mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge remote-tracking branch 'origin/main' into claude/nip88-polls-quartz-p5fBa
This commit is contained in:
+33
-2
@@ -46,8 +46,7 @@ amethyst/
|
||||
│ └── jvmAndroid/ # Opus encode/decode, AudioRecord/AudioTrack
|
||||
├── desktopApp/ # Desktop JVM application (layouts, navigation)
|
||||
├── amethyst/ # Android app (layouts, navigation)
|
||||
├── cli/ # Amy — non-interactive CLI (JVM only, no Compose)
|
||||
└── ammolite/ # Support module (unused)
|
||||
└── cli/ # Amy — non-interactive CLI (JVM only, no Compose)
|
||||
```
|
||||
|
||||
**Sharing Philosophy:**
|
||||
@@ -99,6 +98,28 @@ Specialized skills provide domain expertise with bundled resources and patterns:
|
||||
| `find-missing-translations` | Utility | Extract untranslated Android strings |
|
||||
| `find-non-lambda-logs` | Utility | Audit Log calls for lambda overloads |
|
||||
|
||||
### Technique-layer skills (Compose / Kotlin best practices)
|
||||
|
||||
These are general Compose/Kotlin decision-framework skills (vendored from
|
||||
`chrisbanes/skills`). The skills above are **codebase-oriented** ("where is X in
|
||||
Amethyst, what pattern do we use"); these are **technique-oriented** ("what is
|
||||
the correct Compose/Kotlin design here"). They complement — not replace — the
|
||||
codebase skills: e.g. `compose-expert` tells you where shared composables live,
|
||||
`compose-slot-api-pattern` tells you how to shape their public API.
|
||||
|
||||
| Skill | Expertise | Complements |
|
||||
|-------|-----------|-------------|
|
||||
| `compose-recomposition-performance` | Router: which recomposition axis is the problem | `compose-expert` |
|
||||
| `compose-stability-diagnostics` | Compiler reports, strong skipping, `ImmutableList` at UI boundaries | `compose-expert`, `kotlin-expert` |
|
||||
| `compose-state-deferred-reads` | Phase-aware state reads, block-form modifiers, provider lambdas | `compose-expert` |
|
||||
| `compose-slot-api-pattern` | `@Composable` slot design for reusable components | `compose-expert` |
|
||||
| `compose-modifier-and-layout-style` | `modifier` parameter conventions, chain construction, conditional hoisting | `compose-expert` |
|
||||
| `compose-side-effects` | `LaunchedEffect`/`DisposableEffect`/`SideEffect`, keys, `rememberUpdatedState` | `compose-expert` |
|
||||
| `compose-state-holder-ui-split` | State-holder vs plain-UI composable split | `compose-expert`, `feed-patterns` |
|
||||
| `kotlin-flow-state-event-modeling` | StateFlow/SharedFlow/Channel choice, sentinels, `stateIn`, `update {}` | `kotlin-expert` |
|
||||
| `kotlin-coroutines-structured-concurrency` | Stored-scope anti-pattern, `suspend` boundaries, `runBlocking`, cancellation | `kotlin-coroutines` |
|
||||
| `kotlin-types-value-class` | `@JvmInline value class` vs `data class`, Compose stability | `kotlin-expert` |
|
||||
|
||||
## Workflow
|
||||
|
||||
**When you ask for a feature:**
|
||||
@@ -292,6 +313,16 @@ After completing any task that modifies Kotlin files, always run:
|
||||
```
|
||||
Do this before considering the task complete.
|
||||
|
||||
### Kotlin Style
|
||||
|
||||
- **Never write fully-qualified class names inline in function bodies.** Add an
|
||||
`import` for the class and reference it by its simple name. Write
|
||||
`Event` (with `import com.vitorpamplona.quartz...Event`), not
|
||||
`com.vitorpamplona.quartz...Event` in the middle of code.
|
||||
- The only acceptable inline fully-qualified names are: a genuine name
|
||||
collision (prefer `import ... as Alias` instead), or where the language
|
||||
requires it. Comments, KDoc, and string literals are exempt.
|
||||
|
||||
### Navigation Shell
|
||||
- **Desktop**: Sidebar + main content area
|
||||
- **Android**: Bottom navigation
|
||||
|
||||
@@ -745,8 +745,8 @@ android {
|
||||
applicationId = "com.vitorpamplona.amethyst"
|
||||
minSdk = 26 // Android 8.0 (Oreo)
|
||||
targetSdk = 36 // Android 15
|
||||
versionCode = 442
|
||||
versionName = "1.08.0"
|
||||
versionCode = 444
|
||||
versionName = "1.09.2"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
---
|
||||
name: compose-modifier-and-layout-style
|
||||
description: Use when writing or reviewing Jetpack Compose layout APIs, modifier parameters, modifier chain construction, hardcoded root layout decisions, or layout wrappers around a single conditional. Technique-layer skill — complements the codebase-specific compose-expert.
|
||||
---
|
||||
|
||||
# Compose modifier and layout style
|
||||
|
||||
## Core principle
|
||||
|
||||
A composable that emits layout is a leaf the *parent* places — the parent decides position, size, alignment, padding. The composable's job is structure (what's inside), not placement (where it goes). Three rules follow:
|
||||
|
||||
- **Declare a `modifier` parameter and apply it to the root**, so the parent can actually do its job. Hardcoding `.fillMaxWidth()` on a composable's root takes that decision away from every future caller.
|
||||
- **Construct modifier chains as one fluent expression**, not stepwise reassignments. Both compile to the same thing, but the chain *reads* as intent in one pass.
|
||||
- **Conditional rendering belongs where the condition applies.** A layout call whose only content is one `if` exists solely to hold the condition — push the `if` outside instead.
|
||||
|
||||
These travel together because the same composable usually triggers all three: you declare its parameters (rule 1), the caller constructs a chain to position it (rules 2), and the body has a conditional you might be tempted to wrap (rule 3).
|
||||
|
||||
## When to use this skill
|
||||
|
||||
- You're writing a `@Composable fun` that calls a layout (`Box`, `Column`, `Row`, `LazyColumn`, `Text`, `Image`, `Surface`, `Card`, `Layout { … }`, anything from `compose.foundation.layout` or `compose.material*`) and its signature has no `modifier` parameter, or has one that isn't applied to the root, or has a hardcoded `.fillMaxWidth()`/`.padding(...)` on the root.
|
||||
- You see `var m = Modifier` followed by `m = m.padding(…)`, `m = m.background(…)`, etc.
|
||||
- A `modifier = …` argument has three or more chained calls on a single line.
|
||||
- A composable's body is `Layout { if (cond) Content() }` — one conditional, nothing else.
|
||||
|
||||
## 1. Declare a `modifier` parameter
|
||||
|
||||
For composables that emit layout, prefer a `modifier` parameter after required parameters and before content/lambda parameters, with a default of `Modifier`. The name is exactly `modifier` — not `mod`, not `m`, not `wrapperModifier`.
|
||||
|
||||
```kotlin
|
||||
// ❌ BAD — no modifier param; caller can't position, size, or constrain this
|
||||
@Composable
|
||||
fun HomeScreenHeader(title: String, subtitle: String) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.headlineLarge)
|
||||
Text(subtitle, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// ✅ GOOD — parent decides width and padding; the composable describes structure only
|
||||
@Composable
|
||||
fun HomeScreenHeader(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.headlineLarge)
|
||||
Text(subtitle, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The caller now writes `HomeScreenHeader(title, subtitle, Modifier.fillMaxWidth().padding(horizontal = 16.dp))` once, at the home screen — the only place that knows the layout actually wants those.
|
||||
|
||||
## 2. Apply the caller's modifier to the root, and apply it first
|
||||
|
||||
When the root layout already takes other arguments (alignment, arrangement, padding *that's intrinsic to the composable*), the caller-provided modifier still goes on the root layout's `modifier` parameter — and the composable's local chain is appended after.
|
||||
|
||||
```kotlin
|
||||
// ❌ BAD — modifier accepted but never applied
|
||||
@Composable
|
||||
fun Avatar(url: String, modifier: Modifier = Modifier) {
|
||||
Image(painter = rememberAsyncImagePainter(url), contentDescription = null)
|
||||
}
|
||||
|
||||
// ❌ BAD — applied to a child, not the root; caller's size/position changes don't take
|
||||
@Composable
|
||||
fun Avatar(url: String, modifier: Modifier = Modifier) {
|
||||
Box {
|
||||
Image(
|
||||
painter = rememberAsyncImagePainter(url),
|
||||
contentDescription = null,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ BAD — caller's modifier ends up last, so the composable's own size wins
|
||||
@Composable
|
||||
fun Avatar(url: String, modifier: Modifier = Modifier) {
|
||||
Image(
|
||||
painter = rememberAsyncImagePainter(url),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.size(48.dp)
|
||||
.then(modifier),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// ✅ GOOD — caller's modifier first, then the composable's intrinsic chain
|
||||
@Composable
|
||||
fun Avatar(url: String, modifier: Modifier = Modifier) {
|
||||
Image(
|
||||
painter = rememberAsyncImagePainter(url),
|
||||
contentDescription = null,
|
||||
modifier = modifier
|
||||
.clip(CircleShape)
|
||||
.size(48.dp),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Order matters: in a modifier chain, the *earlier* segment is the outer wrapper. The caller's modifier should be the outermost so caller-provided `.size(...)` or `.padding(...)` can override the composable's defaults rather than being overridden by them.
|
||||
|
||||
## 3. Don't hardcode layout decisions on the root
|
||||
|
||||
If the composable's root has `.fillMaxWidth()`, `.padding(horizontal = 16.dp)`, `.height(56.dp)`, etc., the caller can't *not* have them. Those are layout choices the parent should own.
|
||||
|
||||
```kotlin
|
||||
// ❌ BAD — every caller now fills max width whether they want to or not
|
||||
@Composable
|
||||
fun PrimaryButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = modifier.fillMaxWidth(), // ← hardcoded
|
||||
) { Text(text) }
|
||||
}
|
||||
|
||||
// ✅ GOOD — caller adds .fillMaxWidth() if (and only if) they want it
|
||||
@Composable
|
||||
fun PrimaryButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Button(onClick = onClick, modifier = modifier) { Text(text) }
|
||||
}
|
||||
```
|
||||
|
||||
The carve-out is for modifiers that are part of the **identity** of the composable — what makes an `Avatar` an avatar (the `.clip(CircleShape)` and a default `.size(48.dp)`), not where it sits on the screen. Test: can you imagine a caller wanting a version of this composable *without* that modifier? If yes, push it out. If no (an avatar without `clip(CircleShape)` isn't an avatar), keep it — but put it *after* the caller's modifier in the chain (see §2).
|
||||
|
||||
## 4. Construct modifier chains as one fluent expression
|
||||
|
||||
Recomposition re-runs the composable body — every modifier expression is re-evaluated. Reassigning `var modifier =` step-by-step looks plausible but breaks the visual flow, invites further mutation, and produces nothing a chain doesn't.
|
||||
|
||||
```kotlin
|
||||
// ❌ BAD — visual flow broken into reassignments; `var` invites more mutation
|
||||
@Composable
|
||||
fun Demo() {
|
||||
var m = Modifier
|
||||
m = m.padding(16.dp)
|
||||
m = m.fillMaxSize()
|
||||
Box(m) { }
|
||||
}
|
||||
|
||||
// ❌ ALSO BAD — same shape, dressed up with .then()
|
||||
@Composable
|
||||
fun Demo() {
|
||||
var m = Modifier
|
||||
m = m.padding(16.dp)
|
||||
m = m.then(Modifier.fillMaxSize())
|
||||
Box(m) { }
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// ✅ GOOD
|
||||
@Composable
|
||||
fun Demo() {
|
||||
val m = Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxSize()
|
||||
Box(m) { }
|
||||
}
|
||||
```
|
||||
|
||||
`val`, not `var`: once the chain is built, nothing should re-bind it. The reassignment shape is what makes `var` look necessary; the chain shape doesn't need it.
|
||||
|
||||
### Inline at the call site is fine for short chains
|
||||
|
||||
For one or two calls, build the modifier inline. The "extract to a `val`" rule only earns its keep when the chain is long enough to be worth naming, or when the same chain repeats.
|
||||
|
||||
```kotlin
|
||||
// ✅ GOOD — short chain inline
|
||||
Box(modifier = Modifier.fillMaxWidth()) { … }
|
||||
Box(modifier = Modifier.padding(8.dp).background(Color.Red)) { … }
|
||||
```
|
||||
|
||||
### Conditional segments stay on the chain
|
||||
|
||||
A common reason to reach for `var` is "the modifier depends on a condition." It doesn't — splice the condition inline:
|
||||
|
||||
```kotlin
|
||||
// ✅ GOOD — conditional inside the chain, still one expression
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (selected) Modifier.background(Color.Red) else Modifier),
|
||||
)
|
||||
```
|
||||
|
||||
`Modifier` (the empty modifier) is the identity element for `.then` — it lets you keep the chain shape when one branch contributes nothing.
|
||||
|
||||
## 5. Multiline formatting at the call site
|
||||
|
||||
When a `modifier` argument's chain has **three or more** calls, format multiline with one call per line. Indent the chain so the dotted calls align beneath the value.
|
||||
|
||||
```kotlin
|
||||
// ❌ BAD — three+ calls on one line; hard to scan
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize().padding(16.dp).weight(1f),
|
||||
)
|
||||
|
||||
// ✅ GOOD
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
.weight(1f),
|
||||
)
|
||||
```
|
||||
|
||||
One or two calls stay on a single line — the threshold is the call count, not the character count. If a single call has very long arguments, that's a different problem (extract a `val`, or shorten the arguments).
|
||||
|
||||
This applies *only* to a parameter named `modifier`. Other fluent-style arguments aren't covered here.
|
||||
|
||||
## 6. Hoist single conditionals out of the layout
|
||||
|
||||
When a layout's *only* content is one `if`, the layout exists solely to "hold" the conditional. Move the `if` outside — the layout will only exist when it has something to show.
|
||||
|
||||
```kotlin
|
||||
// ❌ BAD — Column always emitted; only its inner content is conditional
|
||||
@Composable
|
||||
fun A() {
|
||||
Column {
|
||||
if (showHeader) {
|
||||
Text("Title")
|
||||
Text("Subtitle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ GOOD — Column only exists when it has content
|
||||
@Composable
|
||||
fun A() {
|
||||
if (showHeader) {
|
||||
Column {
|
||||
Text("Title")
|
||||
Text("Subtitle")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The benefit isn't a performance win — the runtime handles both fine — it's that the second form *reads* as "header section, conditionally." The first reads as "always-on column that may or may not have content."
|
||||
|
||||
### The carve-outs (and why)
|
||||
|
||||
- **Layout carries visual semantics that aren't conditional.** When the layout call passes `modifier`, `contentAlignment`, `horizontalArrangement`, or `verticalAlignment`, those arguments describe the *container*, not the content. Hoisting the conditional either loses those (the container collapses with the content) or duplicates them into both branches. Leave it.
|
||||
|
||||
```kotlin
|
||||
// ✅ KEEP AS-IS — modifier on the container is doing visible work
|
||||
@Composable
|
||||
fun A(modifier: Modifier = Modifier) {
|
||||
Box(modifier = modifier) {
|
||||
if (something) {
|
||||
Text("Bleh1")
|
||||
Text("Bleh2")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **There are siblings to the `if`.** The layout has other content; the `if` is just one piece. Hoisting either pulls the siblings out (changing the layout) or leaves a different shape behind. Leave it.
|
||||
|
||||
- **`if … else …` with both branches contributing composables.** Both branches do work; nothing to hoist; the layout *is* the shared container.
|
||||
|
||||
```kotlin
|
||||
// ✅ KEEP AS-IS — both branches contribute to the layout
|
||||
Box {
|
||||
if (something) Text("Hint") else innerTextField()
|
||||
}
|
||||
```
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Symptom | Diagnosis | Fix |
|
||||
|---|---|---|
|
||||
| `@Composable fun Foo(text: String)` with `Column`/`Box`/`Text` in body | No `modifier` param (§1) | Add `modifier: Modifier = Modifier`; pass to root |
|
||||
| `modifier: Modifier = Modifier` declared but never referenced | Param ignored (§2) | Apply to root layout's `modifier` arg |
|
||||
| `modifier` passed to a child, not the root | Wrong target (§2) | Move to the outermost layout's `modifier` |
|
||||
| `modifier = Modifier.x().y().then(modifier)` | Caller's modifier last (§2) | Reorder: `modifier = modifier.x().y()` |
|
||||
| `modifier = modifier.fillMaxWidth().padding(...)` on a general-purpose component | Layout hardcoded (§3) | Remove the hardcoded calls; let callers add them |
|
||||
| Sibling composables in the file don't have `modifier` either | Spreading anti-pattern | Fix this one; fix siblings opportunistically |
|
||||
| `mod: Modifier = Modifier` or `wrapperModifier: Modifier = Modifier` | Wrong name (§1) | Rename to exactly `modifier` |
|
||||
| `var m = Modifier` followed by `m = m.xxx()` reassignments | Stepwise modifier construction (§4) | One fluent chain on a `val`, or build inline |
|
||||
| `var m = Modifier; m = m.then(Modifier.xxx())` | Same shape via `.then` (§4) | Collapse `.then(Modifier.x())` to `.x()` in the chain |
|
||||
| Modifier branch needs a condition | Reaching for `var` (§4) | `.then(if (c) Modifier.x() else Modifier)` inside the chain |
|
||||
| `modifier = modifier.a().b().c()` on one line | Long chain not formatted (§5) | One call per line, indented under the value |
|
||||
| `Layout { if (cond) X() }` with no other content and no layout-tuning args | Hoist (§6) | Move the `if` outside the layout |
|
||||
| `Box(modifier = …) { if (cond) X() }` | Layout carries semantics — leave (§6 carve-out) | Keep as-is |
|
||||
| `Box { if (cond) X() else Y() }` | Both branches contribute — leave (§6 carve-out) | Keep as-is |
|
||||
|
||||
## When NOT to apply
|
||||
|
||||
- **Composables that don't emit layout.** A `@Composable fun computeColor(): Color` or a `@Composable @ReadOnlyComposable` accessor doesn't emit a layout node. No `modifier` parameter needed (and a `@ReadOnlyComposable` couldn't accept one anyway).
|
||||
- **`@Preview` functions.** Previews are throwaway entry points; the framework calls them with no caller. A `modifier` parameter would be unused dead weight.
|
||||
- **Test-only composables** inside `*Test` sources whose only caller is `composeTestRule.setContent { … }`. Same reasoning as previews.
|
||||
- **Internal layout primitives that take a `modifier` as their *first required* parameter** (very rare; framework-level). The rule is "first *optional* param"; some private utilities legitimately have `modifier` upfront as required.
|
||||
- **Modifier assembled imperatively from animation state.** A modifier built by appending values from `Animatable` or other procedural sources may legitimately need intermediate variables. The chain isn't the goal; readability is. If the chain becomes a worse expression, write the imperative form.
|
||||
- **Slot APIs that store modifiers** in a data class or builder (rare; usually framework-level code). The fluent-chain idea is about user-site construction.
|
||||
- **Test composables** pinning specific recomposition shapes — usually fine either way; don't refactor test composables purely for style.
|
||||
|
||||
The declaration-side rules (§1–§3) should not be skipped merely because "this composable is internal", "only used in one place", "I'd rather not have the extra parameter on the signature", or "we know all the callers already". Those are exactly the rationalisations that produce composables that become single-use the day someone wants to call them twice.
|
||||
|
||||
## Red flags during review
|
||||
|
||||
| Thought | Reality |
|
||||
|---|---|
|
||||
| "This composable is internal-only — adding `modifier` is over-engineering" | The parameter is eight characters and a default. It's not over-engineering; it's the convention. Skipping it is the over-engineering — it's a custom decision against the grain of every Compose API. |
|
||||
| "It's only used in one place, so I know the layout requirements" | "Only used in one place" describes today. The cost of the parameter is paid once; the cost of refactoring callers when the second use site appears is paid per caller. |
|
||||
| "The sibling composables in this file don't have `modifier` either, so I'm matching style" | Spreading an anti-pattern isn't matching style. Fix this one. Fix the siblings opportunistically. |
|
||||
| "The parent always wants `.fillMaxWidth()` here" | Then the parent passes `.fillMaxWidth()`. The composable doesn't decide that for callers it hasn't met yet. |
|
||||
| "I'll add it when someone needs it" | You're someone. You need it now (for the convention). The next caller won't add it either — they'll work around its absence. |
|
||||
| "It's a tiny composable — the modifier param is noise" | The param is eight characters at the declaration and zero characters at any call site that doesn't need it. The "noise" is imagined. |
|
||||
| "I added `modifier` but kept `.fillMaxWidth()` on the root so the home screen doesn't have to" | Then the *not*-home-screen caller can't unset it. Move the `.fillMaxWidth()` to the caller. |
|
||||
| "I need `var` for the modifier because the chain depends on a condition" | A conditional segment is `.then(if (c) Modifier.x() else Modifier)`, still on one chain. No `var` needed. |
|
||||
| "Three lines is too few to make multiline" | Three chained calls *is* the threshold. Below three, one line. At or above three, multiline. |
|
||||
| "The Column adds nothing but I'll keep it for symmetry" | Then hoist the conditional and keep the Column inside the consequent — symmetry preserved, no always-on container. |
|
||||
| "I'll put the `if` inside because the layout already exists" | "Already exists" is the bug. The layout shouldn't exist when the condition is false. |
|
||||
|
||||
## Related
|
||||
|
||||
- [`compose-slot-api-pattern`](../compose-slot-api-pattern/SKILL.md) — the other half of declaring a reusable composable's public API: take `@Composable () -> Unit` slots for variable content. A reusable component takes both a `modifier` parameter *and* slots — caller owns placement *and* what to place.
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: compose-recomposition-performance
|
||||
description: Use when investigating Jetpack Compose recomposition performance, skippable/restartable composables, composables.txt or compiler reports, Layout Inspector recomposition counts, or frame-rate State reads in composition vs layout/draw, and it is not yet clear whether the cause is parameter stability or deferred reads. Technique-layer skill — complements the codebase-specific compose-expert.
|
||||
---
|
||||
|
||||
# Compose recomposition performance
|
||||
|
||||
Router only — deep fixes live in [`compose-stability-diagnostics`](../compose-stability-diagnostics/SKILL.md) and [`compose-state-deferred-reads`](../compose-state-deferred-reads/SKILL.md).
|
||||
|
||||
## Two axes
|
||||
|
||||
1. **Parameter stability / skipping** — can Compose skip this restartable composable; are arguments stable and comparable?
|
||||
2. **Where `State` is read** — is frame-rate `State` read during composition vs layout/draw?
|
||||
|
||||
Either axis can dominate; they combine independently.
|
||||
|
||||
## Route here → focused skill
|
||||
|
||||
| Primary suspicion | Next skill |
|
||||
|---|---|
|
||||
| Skipping, unstable params, compiler/`composables.txt` churn | [`compose-stability-diagnostics`](../compose-stability-diagnostics/SKILL.md) |
|
||||
| Frame-rate `State` read phase (composition vs layout/draw) | [`compose-state-deferred-reads`](../compose-state-deferred-reads/SKILL.md) |
|
||||
| Evidence for both | Apply both skills in parallel |
|
||||
|
||||
## Review order
|
||||
|
||||
1. Decide which axis fits the evidence; open the matching skill.
|
||||
2. If unclear, sample both — stability churn vs composition-phase reads of fast `State`.
|
||||
3. Re-measure after changes.
|
||||
|
||||
## When NOT to apply
|
||||
|
||||
- Recomposition tracks real data changes, or the bug is correctness not cost.
|
||||
- No profiler / compiler signal suggests a problem.
|
||||
@@ -0,0 +1,173 @@
|
||||
---
|
||||
name: compose-side-effects
|
||||
description: Use when writing or reviewing Jetpack Compose code with LaunchedEffect, DisposableEffect, SideEffect, rememberCoroutineScope, rememberUpdatedState, snapshotFlow, snackbar, navigation, focus requests, analytics, or event Flow collection. Technique-layer skill — complements the codebase-specific compose-expert.
|
||||
---
|
||||
|
||||
# Compose: side effects
|
||||
|
||||
## Core principle
|
||||
|
||||
Composable bodies describe UI. They can be recomposed, skipped, or abandoned. Work that changes the outside world belongs in an effect API whose lifecycle matches the work.
|
||||
|
||||
## Pick the smallest effect
|
||||
|
||||
| Need | API |
|
||||
|---|---|
|
||||
| Publish Compose state to non-Compose code after every successful recomposition | `SideEffect` |
|
||||
| Register/unregister a listener, callback, observer, or resource | `DisposableEffect(keys...)` |
|
||||
| Run suspending, deferred, or keyed one-shot work | `LaunchedEffect(keys...)` |
|
||||
| Launch suspending work from a user event callback | `rememberCoroutineScope()` |
|
||||
| Convert Compose snapshot reads into a Flow inside a coroutine | `snapshotFlow { ... }` inside `LaunchedEffect` |
|
||||
|
||||
## Effect keys
|
||||
|
||||
Keys define restart identity. When any key changes, the old effect is cancelled/disposed and a new one starts.
|
||||
|
||||
```kotlin
|
||||
// ✅ Restart collection when userId changes
|
||||
LaunchedEffect(userId) {
|
||||
repository.events(userId).collect { event -> handle(event) }
|
||||
}
|
||||
|
||||
// ❌ Unit hides a changing input; collection keeps using the first userId
|
||||
LaunchedEffect(Unit) {
|
||||
repository.events(userId).collect { event -> handle(event) }
|
||||
}
|
||||
```
|
||||
|
||||
Use stable, semantic keys:
|
||||
|
||||
- Use the thing whose lifecycle the effect follows: `userId`, `screenId`, `lifecycleOwner`, `focusRequester`.
|
||||
- Do not use broad objects (`state`, `viewModel`) when only one property matters.
|
||||
- Do not add changing lambdas as keys unless you really want restarts on every lambda change.
|
||||
|
||||
## Avoid stale captures
|
||||
|
||||
For long-running effects that should not restart but need the latest callback or value, use `rememberUpdatedState`.
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun Timeout(onTimeout: () -> Unit) {
|
||||
val latestOnTimeout by rememberUpdatedState(onTimeout)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(1_000)
|
||||
latestOnTimeout()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use this when the lifecycle is "start once" but the invoked lambda should stay fresh. Common cases:
|
||||
|
||||
- A timeout or splash effect should not restart when `onTimeout` changes, but it should call the latest callback.
|
||||
- A lifecycle observer should stay registered to the same owner, but invoke the latest `onStart` / `onStop` lambdas.
|
||||
- A long-running collector should keep its collection lifecycle, but call the latest event handler.
|
||||
|
||||
Do not use `rememberUpdatedState` to avoid choosing proper keys. If the changed value should restart the work, make it a key instead:
|
||||
|
||||
```kotlin
|
||||
// BAD: userId changes should restart the collection, not update a captured value.
|
||||
val latestUserId by rememberUpdatedState(userId)
|
||||
LaunchedEffect(Unit) {
|
||||
repository.events(latestUserId).collect { event -> handle(event) }
|
||||
}
|
||||
|
||||
// GOOD: the collection lifecycle follows userId.
|
||||
LaunchedEffect(userId) {
|
||||
repository.events(userId).collect { event -> handle(event) }
|
||||
}
|
||||
```
|
||||
|
||||
`rememberUpdatedState` also does not make render state "non-recomposing." If the UI needs to display a changing value, read normal `State` in composition or use the deferred-read patterns in [`compose-state-deferred-reads`](../compose-state-deferred-reads/SKILL.md) for frame-rate values.
|
||||
|
||||
## Collecting Flow
|
||||
|
||||
Use `LaunchedEffect` for **side-effect/event flows**: snackbars, navigation events, analytics events, focus commands, or other streams where each emission triggers imperative work.
|
||||
|
||||
```kotlin
|
||||
LaunchedEffect(events) {
|
||||
events.collect { event ->
|
||||
snackbarHostState.showSnackbar(event.message)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Do not collect render state imperatively just to mutate local state. For UI state, collect near the state holder and pass plain values into the UI composable—the **state-holder vs UI split**, `collectAsStateWithLifecycle()` / `collectAsState()`, and preview-friendly wiring are covered in [`compose-state-holder-ui-split`](../compose-state-holder-ui-split/SKILL.md). Do not duplicate that architecture here.
|
||||
|
||||
On Android, prefer lifecycle-aware collection where available; use `collectAsState()` on targets without lifecycle-aware APIs.
|
||||
|
||||
For Compose state reads, use `snapshotFlow`:
|
||||
|
||||
```kotlin
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow { listState.firstVisibleItemIndex }
|
||||
.distinctUntilChanged()
|
||||
.collect { index -> analytics.visibleIndex(index) }
|
||||
}
|
||||
```
|
||||
|
||||
`snapshotFlow { ... }.map { ... }` without a terminal `collect` does nothing.
|
||||
|
||||
## User events
|
||||
|
||||
Use `rememberCoroutineScope()` when a click or gesture starts suspending work:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun SaveButton(snackbarHostState: SnackbarHostState) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar("Saved")
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Avoid "event flag" state just to trigger a `LaunchedEffect`. The click already is the event.
|
||||
|
||||
## Registration and cleanup
|
||||
|
||||
Use `DisposableEffect` for paired setup/teardown:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ObserveLifecycle(owner: LifecycleOwner, observer: LifecycleObserver) {
|
||||
DisposableEffect(owner, observer) {
|
||||
owner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
owner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Every registration path should have a matching `onDispose` cleanup path.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
| Mistake | Fix |
|
||||
|---|---|
|
||||
| Network request directly in the composable body | Usually move to a ViewModel/state holder; use `LaunchedEffect` only for UI-owned keyed work |
|
||||
| Analytics property written from the composable body | Use `SideEffect` when it should publish after every successful recomposition |
|
||||
| Impression/event logged from the composable body | Use `LaunchedEffect(key)` when it should run once for that key |
|
||||
| `LaunchedEffect(Unit)` captures changing `id` | Key by `id`, or use `rememberUpdatedState` if it must not restart |
|
||||
| `rememberUpdatedState(id)` used so `LaunchedEffect(Unit)` keeps running after `id` changes | Hidden lifecycle bug | Key the effect by `id` |
|
||||
| Long-lived effect invokes an old callback after recomposition | Stale capture | Wrap the callback with `rememberUpdatedState` and call the wrapper inside the effect |
|
||||
| `LaunchedEffect(state) { ... }` restarts too often | Key by the specific property |
|
||||
| `LaunchedEffect(...) { nonSuspendSetter() }` | Usually `SideEffect`; keep `LaunchedEffect` only for keyed one-shot/deferred work |
|
||||
| Listener added in `LaunchedEffect` with no cleanup | Use `DisposableEffect` |
|
||||
| Launching from click by setting `shouldShowSnackbar = true` | Use `rememberCoroutineScope()` in the click callback |
|
||||
|
||||
## Red flags during review
|
||||
|
||||
- "This only runs once" about code in a composable body.
|
||||
- `LaunchedEffect(Unit)` in a function with changing parameters.
|
||||
- A flow chain inside an effect with no terminal collection.
|
||||
- Effects whose keys are chosen to silence lint instead of model lifecycle.
|
||||
- Callback lambdas used from long-lived effects without either a key or `rememberUpdatedState`.
|
||||
@@ -0,0 +1,198 @@
|
||||
---
|
||||
name: compose-slot-api-pattern
|
||||
description: Use when designing or reviewing a reusable Jetpack Compose component whose visual regions vary by caller, or when primitive content parameters and boolean shape flags are accumulating. Technique-layer skill — complements the codebase-specific compose-expert.
|
||||
---
|
||||
|
||||
# Compose: slot API pattern
|
||||
|
||||
## Core principle
|
||||
|
||||
A reusable Compose component's job is to lay things out, not to enumerate what it lays out. The moment you write `title: String, subtitle: String?, leadingIcon: ImageVector?, trailingIcon: ImageVector?, trailingText: String?, showSwitch: Boolean, switchValue: Boolean, onSwitchChange: (Boolean) -> Unit?, badge: String?, …`, the component has stopped describing a layout and started enumerating call sites — and the next call site will need a parameter the component doesn't have.
|
||||
|
||||
The fix is to **delegate content to the caller** via `@Composable` lambda parameters. The component contributes structure (where the leading bit, headline, supporting bit, trailing bit go). The caller contributes everything that goes *in* those slots.
|
||||
|
||||
Material 3's `ListItem` is the canonical example: every visual piece is a slot (`headlineContent`, `supportingContent`, `leadingContent`, `trailingContent`, `overlineContent`), not a primitive. That's not over-engineering — it's the design that scales to every list-item shape the design system needs without ever editing `ListItem` again.
|
||||
|
||||
## When to use this skill
|
||||
|
||||
You're designing or reviewing a Compose component intended for reuse (more than one call site, now or planned), its visual content varies by caller, and any of these is true:
|
||||
|
||||
- Its signature has `title: String`, `icon: ImageVector`, `actionText: String?`, etc. — primitive types describing *content*.
|
||||
- It has multiple optional-content parameters that vary by call site (`subtitle: String?`, `leadingIcon: ImageVector?`, `trailingText: String?`).
|
||||
- It has boolean flags whose only purpose is to switch between content shapes (`showChevron: Boolean`, `showSwitch: Boolean`, `mode: Mode.Text | Mode.Switch | …`).
|
||||
- It accepts a `String` parameter where one caller would want a `Text` with custom style, a second caller a `Text` with a `Badge`, a third caller a row of icons.
|
||||
- It already has *one* slot (often `trailing` or `content`) and the rest of the parameters are still primitives.
|
||||
|
||||
## 1. Replace primitive content with `@Composable` slots
|
||||
|
||||
Where the component asks for caller-controlled *content*, prefer a `@Composable () -> Unit` slot. Where the slot is structurally required, leave it non-nullable with no default. Where it's optional, make it nullable with a `null` default.
|
||||
|
||||
```kotlin
|
||||
// ❌ BAD — primitive parameters; trailing area is the only slot; everything else is locked
|
||||
@Composable
|
||||
fun SettingsRow(
|
||||
title: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
subtitle: String? = null,
|
||||
leadingIcon: ImageVector? = null,
|
||||
trailing: (@Composable () -> Unit)? = null,
|
||||
) { … }
|
||||
```
|
||||
|
||||
This shape *seems* fine because the call sites today fit (`title` is always single-line text, `leadingIcon` is always an `ImageVector`). The problem is the *next* call site: a row with a `Badge` next to the title, a leading slot that's a circular avatar (not an `ImageVector`), a subtitle that's a row of chips. Each forces either a new parameter, a new flag, or a workaround.
|
||||
|
||||
```kotlin
|
||||
// ✅ GOOD — every visual region is a slot; the row describes structure, not content
|
||||
@Composable
|
||||
fun SettingsRow(
|
||||
headlineContent: @Composable () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
supportingContent: (@Composable () -> Unit)? = null,
|
||||
leadingContent: (@Composable () -> Unit)? = null,
|
||||
trailingContent: (@Composable () -> Unit)? = null,
|
||||
) { … }
|
||||
```
|
||||
|
||||
Call sites stay short because the typical content is a one-liner:
|
||||
|
||||
```kotlin
|
||||
SettingsRow(
|
||||
headlineContent = { Text("Account") },
|
||||
leadingContent = { Icon(Icons.Default.Person, contentDescription = null) },
|
||||
trailingContent = { SettingsRowDefaults.Chevron() },
|
||||
onClick = { … },
|
||||
)
|
||||
```
|
||||
|
||||
And the awkward cases that *would* have required new primitive parameters now don't:
|
||||
|
||||
```kotlin
|
||||
SettingsRow(
|
||||
headlineContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Inbox")
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Badge { Text("3") }
|
||||
}
|
||||
},
|
||||
onClick = { … },
|
||||
)
|
||||
```
|
||||
|
||||
### Slot naming
|
||||
|
||||
- Use `xxxContent` for free-form `@Composable () -> Unit` slots (`headlineContent`, `supportingContent`, `trailingContent`) — matches Material 3.
|
||||
- Use a singular noun (`title`, `icon`, `actions`) when the slot is semantically constrained and the component name disambiguates (`Scaffold(topBar = { … }, bottomBar = { … }, floatingActionButton = { … })`).
|
||||
- Don't use `content` *and* other `xxxContent` slots together — pick one convention per component.
|
||||
|
||||
## 2. Scope receivers when the slot emits into a layout
|
||||
|
||||
If the slot's content will sit inside a `Row`/`Column`/`Box` whose layout features (`Modifier.weight`, `BoxScope.matchParentSize`, alignment) should be available to the caller, declare the slot as a receiver lambda: `@Composable RowScope.() -> Unit`.
|
||||
|
||||
```kotlin
|
||||
// ❌ BAD — actions render inside a Row, but callers can't use RowScope.weight()
|
||||
@Composable
|
||||
fun MyTopBar(
|
||||
title: @Composable () -> Unit,
|
||||
actions: @Composable () -> Unit = {}, // ← caller has no Row scope
|
||||
)
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// ✅ GOOD — caller gets RowScope; .weight() and alignment-by works inside
|
||||
@Composable
|
||||
fun MyTopBar(
|
||||
title: @Composable () -> Unit,
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
)
|
||||
```
|
||||
|
||||
This is what makes `TopAppBar(actions = { IconButton(…); IconButton(…) })` work — the caller is implicitly inside a `RowScope`.
|
||||
|
||||
Don't bolt a scope receiver onto every slot reflexively. The receiver should match the actual parent layout the slot emits into. If the slot is rendered inside a `Box`, use `BoxScope`. If it's inside a `Column`, use `ColumnScope`. If the parent is not a standard layout (or none of its scope APIs are useful in slot content), no receiver.
|
||||
|
||||
## 3. Optional slots — nullable with `null` default
|
||||
|
||||
For slots that may be absent, prefer `(@Composable () -> Unit)? = null` over `@Composable () -> Unit = {}`:
|
||||
|
||||
```kotlin
|
||||
// ❌ BAD — empty default; "no leading content" is the empty lambda
|
||||
leadingContent: @Composable () -> Unit = {}
|
||||
|
||||
// ✅ GOOD — null means "no slot"; the component can omit space/padding when absent
|
||||
leadingContent: (@Composable () -> Unit)? = null
|
||||
```
|
||||
|
||||
Why: with a nullable slot, the *component* can branch on `leadingContent != null` and skip the slot's container, spacing, padding entirely. With an empty default, the layout still allocates the slot — sometimes you see a stray padding or spacer around content that turned out to be nothing. The nullable form makes the "absent" case structurally distinct, which is almost always what you want.
|
||||
|
||||
The trade-off: callers who pass an explicit empty `{}` to silence a slot now have to pass `null` or omit the argument. That's the right answer either way — they shouldn't be passing `{}`.
|
||||
|
||||
## 4. Defaults live in `XxxDefaults`
|
||||
|
||||
When you find yourself documenting "the trailing slot should usually be a chevron" or "pass `MaterialTheme.colorScheme.surface` for the default background", co-locate the helpers in a `XxxDefaults` object next to the component:
|
||||
|
||||
```kotlin
|
||||
object SettingsRowDefaults {
|
||||
@Composable
|
||||
fun Chevron() = Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun TrailingValue(text: String) = Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Call sites stay declarative for the common cases and the slot is still fully open for one-offs:
|
||||
|
||||
```kotlin
|
||||
SettingsRow(
|
||||
headlineContent = { Text("Notifications") },
|
||||
trailingContent = { SettingsRowDefaults.Chevron() },
|
||||
onClick = { … },
|
||||
)
|
||||
```
|
||||
|
||||
This matches Material 3's `ButtonDefaults`, `TopAppBarDefaults`, etc. — defaults that are themselves composable belong here, not as new component parameters with `MaterialTheme.x.y` defaults expanded inline.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Symptom | Diagnosis | Fix |
|
||||
|---|---|---|
|
||||
| `title: String, subtitle: String?, leadingIcon: ImageVector?` on a reusable component | Primitive content params (§1) | Convert to `xxxContent: (@Composable () -> Unit)?` slots |
|
||||
| Multiple boolean flags (`showChevron`, `showSwitch`) selecting trailing shapes | Enumerating shapes (§1) | One `trailingContent: (@Composable () -> Unit)?` slot |
|
||||
| A `mode: Mode.Sealed` parameter listing variants | Same as flag soup (§1) | Slot it |
|
||||
| `actions: @Composable () -> Unit = {}` inside a `Row` body | Missing scope receiver (§2) | `actions: @Composable RowScope.() -> Unit = {}` |
|
||||
| `slot: @Composable () -> Unit = {}` for an optional area | Empty-lambda default (§3) | `slot: (@Composable () -> Unit)? = null` and branch on it |
|
||||
| Component param `defaultColor: Color = MaterialTheme.colorScheme.surface` | Defaults inlined (§4) | Move to `XxxDefaults.color` and reference it |
|
||||
| Common trailing content repeats at every call site | Missing default helper (§4) | Add `XxxDefaults.Chevron()` etc. |
|
||||
|
||||
## When NOT to apply
|
||||
|
||||
- **Single-use components.** A composable used in exactly one place, with no plan to reuse, doesn't benefit from slot flexibility — and the slot indirection makes the code harder to read for the one reader. Primitive params + inline content is fine. (As soon as a second call site appears, slot it.)
|
||||
- **Design-system primitives where every caller must look identical.** A `Heading2(text: String)` exists *because* you want every H2 to look the same; making it `headlineContent: @Composable () -> Unit` invites callers to break the rule. Keep it primitive. (Conversely: if `Heading2` ever needs a badge inline, slot it.)
|
||||
- **Semantic parameters the component intentionally owns.** If the component owns typography, iconography, accessibility wording, or product consistency, a primitive parameter may be the constraint you want.
|
||||
- **Constrained-type parameters that genuinely are constrained.** A `Switch(checked: Boolean, onCheckedChange: ...)` doesn't need its checked indicator to be a slot. Booleans-with-callbacks are not "content."
|
||||
- **Performance-critical fast paths** (rare in app code; common in framework primitives). A slot is an allocated lambda. In the deepest LazyList item layer, sometimes primitives win. If you're not writing the framework, this doesn't apply.
|
||||
|
||||
## Red flags during review
|
||||
|
||||
| Thought | Reality |
|
||||
|---|---|
|
||||
| "Title is *always* a String — making it a slot is over-engineering" | "Always today" is the trap. Material's `ListItem.headlineContent` exists because tomorrow someone wants a `Text + Badge`. The slot is `8` characters of extra wrapping at every call site (`{ Text(…) }`); the refactor to add a slot later edits every existing call site. |
|
||||
| "Lambdas are heavier than strings" | At the scale of typical Compose UI, this isn't measurable — and the framework's own components (`Button`, `ListItem`, `TopAppBar`, `Scaffold`) all slot. If your component is in the hottest of hot paths, see "When NOT to apply." |
|
||||
| "I'll add a slot later if someone asks" | The slot turns one parameter into two parameters (the slot itself + maybe an internal flag) and edits every call site. The shape change isn't a "later" change. |
|
||||
| "I'll model the variants with a sealed `Trailing` type instead" | Sealed enumeration is bounded; slots are unbounded. A sealed type works *until* the day someone needs a variant you didn't anticipate — at which point you're back to editing the component. The slot avoids the cycle. |
|
||||
| "The leading area is *always* an icon, the trailing area varies — I'll slot only the trailing" | This is the partial-slot trap. The "always-an-icon" assumption breaks the first time a row needs an avatar or a flag emoji or a coloured shape. Slot leading too. |
|
||||
| "There's only one call site today" | If there's only one call site, you're probably not designing a reusable component yet. See "When NOT to apply" — primitives are fine for a true single-use. The moment you copy-paste it, slot it. |
|
||||
|
||||
## Related
|
||||
|
||||
- [`compose-modifier-and-layout-style`](../compose-modifier-and-layout-style/SKILL.md) — the modifier-parameter rule (§1–§3 there) travels with slot APIs. A reusable component takes a `modifier` parameter *and* slots its content; the caller owns both placement and what to place.
|
||||
@@ -0,0 +1,140 @@
|
||||
---
|
||||
name: compose-stability-diagnostics
|
||||
description: Use when writing or reviewing Jetpack Compose parameter stability, compiler reports, skippability, unstable UI state classes, collection parameters, or Kotlin 2.0+ strong skipping behavior. Technique-layer skill — complements the codebase-specific compose-expert and kotlin-expert.
|
||||
---
|
||||
|
||||
# Compose stability diagnostics
|
||||
|
||||
## Core principle
|
||||
|
||||
Compose performance problems from parameters are about **whether inputs compare cheaply and predictably across recompositions**. With Kotlin 2.0.20+ strong skipping is enabled by default, so unstable parameters no longer automatically make restartable composables non-skippable. That does not make stability irrelevant: unstable parameters are compared by instance identity (`===`), stable parameters by equality (`equals`), and churny instances can still defeat skipping.
|
||||
|
||||
First identify the compiler mode you are on, then read reports in that context.
|
||||
|
||||
## When to use this skill
|
||||
|
||||
- A composable or screen recomposes more than expected and parameter churn is suspected.
|
||||
- A UI-state/model class is passed to composables and contains `List`, `Set`, `Map`, ranges, Java time/money types, or third-party types.
|
||||
- `composables.txt` / `classes.txt` shows unstable parameters or non-skippable composables.
|
||||
- A project uses Kotlin < 2.0.20, disables strong skipping, or has old Compose compiler report guidance.
|
||||
|
||||
## 1. Start with strong skipping
|
||||
|
||||
On Kotlin 2.0.20+, strong skipping is enabled by default. In that mode:
|
||||
|
||||
- Restartable composables are skippable even when parameters are unstable, unless explicitly opted out.
|
||||
- Stable parameters compare with `equals`.
|
||||
- Unstable parameters compare with instance equality (`===`).
|
||||
- Lambdas inside composables are automatically remembered based on captures.
|
||||
|
||||
That means the question changes from "is this composable skippable at all?" to "will these parameters compare the way I expect, and are callers creating new unstable instances every frame?"
|
||||
|
||||
For older compiler setups or strong skipping disabled, the legacy rule still matters: a restartable composable with unstable parameters may be restartable but not skippable.
|
||||
|
||||
## 2. Generate compiler reports
|
||||
|
||||
With Kotlin 2.0+ the Compose Compiler is configured through the Kotlin Gradle plugin:
|
||||
|
||||
```kotlin
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) // or android.library / jvm
|
||||
alias(libs.plugins.kotlin.android) // or kotlin.multiplatform / kotlin.jvm
|
||||
alias(libs.plugins.compose.compiler)
|
||||
}
|
||||
|
||||
if (providers.gradleProperty("composeReports").orNull == "true") {
|
||||
composeCompiler {
|
||||
reportsDestination = layout.buildDirectory.dir("compose_compiler")
|
||||
metricsDestination = layout.buildDirectory.dir("compose_compiler")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then build the variant whose compiler configuration you care about, for example:
|
||||
|
||||
```bash
|
||||
./gradlew :app:assembleRelease -PcomposeReports=true
|
||||
```
|
||||
|
||||
Use release/non-debuggable builds for runtime profiling. Compiler reports are build-time outputs, so the important thing is matching the variant and compiler flags you ship.
|
||||
|
||||
Key files:
|
||||
|
||||
| File | What it tells you |
|
||||
|---|---|
|
||||
| `<module>-classes.txt` | Stability of classes and properties |
|
||||
| `<module>-composables.txt` | Restartable/skippable status and parameter stability |
|
||||
| `<module>-composables.csv` | Same data in sortable form |
|
||||
| `<module>-module.json` | Aggregate metrics |
|
||||
|
||||
## 3. Fix stability where semantics need it
|
||||
|
||||
Pick the lightest fix that makes the type's immutability or equality semantics true.
|
||||
|
||||
### Immutable collections
|
||||
|
||||
`kotlin.collections.List` is an interface; Compose cannot know the runtime implementation is immutable. Prefer `kotlinx.collections.immutable` at UI-state boundaries:
|
||||
|
||||
```kotlin
|
||||
// Before: unstable collection interfaces
|
||||
data class UiState(val items: List<Item>, val tags: Set<String>)
|
||||
|
||||
// After: immutable collection contracts
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
|
||||
data class UiState(val items: ImmutableList<Item>, val tags: ImmutableSet<String>)
|
||||
```
|
||||
|
||||
Producers convert once at the boundary with `.toImmutableList()` / `.toImmutableSet()`.
|
||||
|
||||
### `@Immutable` / `@Stable`
|
||||
|
||||
- Use `@Immutable` when every property is effectively immutable and equality describes all observable state.
|
||||
- Use `@Stable` for types whose mutable state is observable by Compose, typically via `MutableState`.
|
||||
|
||||
Do not annotate to silence a report. A false stability promise can produce stale UI.
|
||||
|
||||
### Third-party immutable types
|
||||
|
||||
For types you cannot annotate, use `stabilityConfigurationFiles`:
|
||||
|
||||
```kotlin
|
||||
composeCompiler {
|
||||
stabilityConfigurationFiles.add(
|
||||
rootProject.layout.projectDirectory.file("compose_stability.conf"),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
```text
|
||||
java.math.BigDecimal
|
||||
java.math.BigInteger
|
||||
java.time.*
|
||||
kotlinx.datetime.*
|
||||
```
|
||||
|
||||
Only list types you are willing to promise are immutable. Do not list mutable types such as `java.util.Date`.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Symptom | Diagnosis | Fix |
|
||||
|---|---|---|
|
||||
| Kotlin 2.0.20+ but old docs say unstable means non-skippable | Strong skipping changed the default | Check comparison semantics and instance churn instead |
|
||||
| `unstable val items: List<Item>` | Interface collection | Use `ImmutableList<Item>` or another true immutable wrapper |
|
||||
| `unstable val price: BigDecimal` | External immutable type | Add to stability config |
|
||||
| `@Immutable` on a type with mutable internals | False promise | Fix the model or remove the annotation |
|
||||
| Composable skips poorly despite strong skipping | New unstable instance each recomposition | Remember, hoist, or make the type stable/equality-based |
|
||||
| Reports not generated | Compose compiler plugin missing or flag not set | Apply `org.jetbrains.kotlin.plugin.compose` and enable destinations |
|
||||
|
||||
## When NOT to apply
|
||||
|
||||
- The issue is a fast-changing `State` read in composition, such as scroll or animation. Use `compose-state-deferred-reads`.
|
||||
- The recomposition count matches real data changes.
|
||||
- The bug is wrong data or stale state, not excess work.
|
||||
- The code is test-only and readability is more important than report cleanliness.
|
||||
|
||||
## Related
|
||||
|
||||
- [`compose-state-deferred-reads`](../compose-state-deferred-reads/SKILL.md) - frame-rate state should often be read in layout/draw rather than composition.
|
||||
- [`compose-recomposition-performance`](../compose-recomposition-performance/SKILL.md) - entry point when you are not sure which recomposition axis is involved.
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
name: compose-state-deferred-reads
|
||||
description: Use when Jetpack Compose code reads scroll, animation, gesture, or other frame-rate State in composition, passes changing values across composable boundaries, or uses value-form layout/draw modifiers. Technique-layer skill — complements the codebase-specific compose-expert.
|
||||
---
|
||||
|
||||
# Compose state deferred reads
|
||||
|
||||
## Core principle
|
||||
|
||||
State reads invalidate the phase that reads them. If a `State<T>` is read in a composable body, changes invalidate composition. If it is read in layout or draw, changes can invalidate only layout or draw. Frame-rate state such as scroll offsets, animations, and drag positions usually belongs in layout/draw, not composition.
|
||||
|
||||
The fix is structural: keep the `State<T>` or a provider lambda, and read the value inside a layout/draw callback.
|
||||
|
||||
## When to use this skill
|
||||
|
||||
- `val x by animate*AsState(...)` is passed to `Modifier.offset(x = ...)`, `Modifier.size(...)`, `Modifier.graphicsLayer(...)`, or another value-form modifier.
|
||||
- `LazyListState.firstVisibleItemScrollOffset`, `ScrollState.value`, `Animatable.value`, or gesture state is read in a composable body.
|
||||
- A composable takes `scrollOffset: Int`, `progress: Float`, `dragOffset: Offset`, or similar frame-rate values.
|
||||
- Recomposition counters climb during scroll, animation, or gestures even when data is stable.
|
||||
|
||||
## 1. Prefer block-form modifiers
|
||||
|
||||
Several modifiers have value forms and block forms. The value form receives values already read in composition; the block form can read during layout or draw.
|
||||
|
||||
```kotlin
|
||||
// Before: animated value read in composition by the `by` delegate
|
||||
@Composable
|
||||
fun SelectionPill(selectedIndex: Int) {
|
||||
val offsetX by animateDpAsState(120.dp * selectedIndex)
|
||||
Box(Modifier.offset(x = offsetX))
|
||||
}
|
||||
|
||||
// After: State is kept, value is read in the layout-phase offset block
|
||||
@Composable
|
||||
fun SelectionPill(selectedIndex: Int) {
|
||||
val offsetX = animateDpAsState(120.dp * selectedIndex)
|
||||
Box(
|
||||
Modifier.offset {
|
||||
IntOffset(offsetX.value.roundToPx(), 0)
|
||||
},
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Common replacements:
|
||||
|
||||
| Composition read | Deferred read |
|
||||
|---|---|
|
||||
| `Modifier.offset(x = animatedX)` | `Modifier.offset { IntOffset(animatedX.value.roundToPx(), 0) }` |
|
||||
| `Modifier.graphicsLayer(translationY = y)` | `Modifier.graphicsLayer { translationY = yProvider() }` |
|
||||
| `val radius by animateFloatAsState(...); drawBehind { drawCircle(radius = radius) }` | `val radius = animateFloatAsState(...); drawBehind { drawCircle(radius = radius.value) }` |
|
||||
|
||||
The `drawBehind` block is already draw-phase; the important part is that the `State.value` read also happens inside that block.
|
||||
|
||||
## 2. Pass providers across composable boundaries
|
||||
|
||||
If the fast-changing value would cross a composable boundary, pass a provider lambda instead of a snapshot value:
|
||||
|
||||
```kotlin
|
||||
// Before: HomeScreen reads scroll offset in composition and passes the value down
|
||||
@Composable
|
||||
fun HomeScreen() {
|
||||
val listState = rememberLazyListState()
|
||||
LazyColumn(state = listState) {
|
||||
item { HeroImage(scrollOffset = listState.firstVisibleItemScrollOffset) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HeroImage(scrollOffset: Int, modifier: Modifier = Modifier) {
|
||||
AsyncImage(
|
||||
model = "...",
|
||||
modifier = modifier.graphicsLayer(translationY = -scrollOffset / 2f),
|
||||
)
|
||||
}
|
||||
|
||||
// After: the only read happens inside graphicsLayer
|
||||
@Composable
|
||||
fun HomeScreen() {
|
||||
val listState = rememberLazyListState()
|
||||
LazyColumn(state = listState) {
|
||||
item {
|
||||
HeroImage(
|
||||
scrollOffsetProvider = {
|
||||
if (listState.firstVisibleItemIndex == 0) {
|
||||
listState.firstVisibleItemScrollOffset
|
||||
} else {
|
||||
0
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HeroImage(scrollOffsetProvider: () -> Int, modifier: Modifier = Modifier) {
|
||||
AsyncImage(
|
||||
model = "...",
|
||||
modifier = modifier.graphicsLayer {
|
||||
translationY = -scrollOffsetProvider() / 2f
|
||||
},
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Suffix provider parameters with `Provider` when that clarifies the deferred-read contract.
|
||||
|
||||
## 3. Other layout/draw read sites
|
||||
|
||||
State reads can also be deferred inside:
|
||||
|
||||
- `Modifier.layout { measurable, constraints -> ... }`
|
||||
- Custom `Alignment.align(...)`
|
||||
- `drawWithContent`, `drawBehind`, and other draw modifiers
|
||||
- Block-form layer/layout modifiers such as `graphicsLayer { ... }` and `offset { ... }`
|
||||
|
||||
Use these when the state changes where something is placed or painted. If the state decides *which composables exist*, it belongs in composition.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Symptom | Diagnosis | Fix |
|
||||
|---|---|---|
|
||||
| `val x by animateFloatAsState(...)` then `Modifier.offset(...)` | `by` reads in composition | Keep `State<Float>` and read `.value` in `offset {}` |
|
||||
| `Modifier.graphicsLayer(translationY = animatedY)` | Property-argument form uses composition values | Use `graphicsLayer { translationY = ... }` |
|
||||
| `Child(scrollOffset = listState.firstVisibleItemScrollOffset)` | Fast-changing value crosses boundary | `Child(scrollOffsetProvider = { ... })` |
|
||||
| Draw block still recomposes every frame | Value was read before draw block | Move the `State.value` read inside the draw block |
|
||||
| State chooses between different UI branches | Composition decision | Keep the read in composition |
|
||||
|
||||
## When NOT to apply
|
||||
|
||||
- The state controls which composables are emitted.
|
||||
- The animation is one-shot, cheap, and clarity wins.
|
||||
- You are writing tests where direct value assertions are simpler.
|
||||
- Runtime evidence shows recomposition is not the bottleneck.
|
||||
|
||||
## Related
|
||||
|
||||
- [`compose-state-holder-ui-split`](../compose-state-holder-ui-split/SKILL.md) - where state-holder vs plain UI split applies when passing providers/lambdas across boundaries.
|
||||
- [`compose-stability-diagnostics`](../compose-stability-diagnostics/SKILL.md) - parameter stability and compiler reports.
|
||||
- [`compose-modifier-and-layout-style`](../compose-modifier-and-layout-style/SKILL.md) - child composables need a normal `modifier` parameter before callers can move visual reads into modifiers.
|
||||
@@ -0,0 +1,157 @@
|
||||
---
|
||||
name: compose-state-holder-ui-split
|
||||
description: Use when a Jetpack Compose screen-level composable takes a ViewModel/component/controller, collects state or effects, handles navigation/snackbars, or wires callbacks while also rendering layout. Technique-layer skill — complements the codebase-specific compose-expert and feed-patterns.
|
||||
---
|
||||
|
||||
# Compose: state holder/UI split
|
||||
|
||||
## Core principle
|
||||
|
||||
Separate state-holder wiring from UI rendering. The state-holder composable talks to ViewModels, components, flows, navigation, and side effects. The UI composable takes plain immutable UI state plus callbacks and describes layout.
|
||||
|
||||
This keeps screens previewable, testable, and easier to reuse across Android, Desktop, TV, and KMP/CMP targets.
|
||||
|
||||
## When to use this skill
|
||||
|
||||
Use this when a Compose screen:
|
||||
|
||||
- Takes a ViewModel, component, controller, navigator, repository, or service directly.
|
||||
- Collects app/business state or side effects in the same function that lays out most UI.
|
||||
- Passes a whole state holder into child composables instead of explicit state and callbacks.
|
||||
- Is hard to preview because it needs dependency injection, navigation, lifecycle, or fake services.
|
||||
- Has UI tests that must construct a full app stack to verify a simple layout branch.
|
||||
|
||||
## The pattern
|
||||
|
||||
Use a small public state-holder composable:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ProfileScreen(component: ProfileComponent, modifier: Modifier = Modifier) {
|
||||
val state by component.state.collectAsStateWithLifecycle()
|
||||
|
||||
ProfileScreen(
|
||||
state = state,
|
||||
onNameChange = component::onNameChange,
|
||||
onSaveClick = component::save,
|
||||
onBackClick = component::back,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Then put UI in a plain composable that knows nothing about the state holder:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ProfileScreen(
|
||||
state: ProfileUiState,
|
||||
onNameChange: (String) -> Unit,
|
||||
onSaveClick: () -> Unit,
|
||||
onBackClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
ProfileContent(
|
||||
name = state.name,
|
||||
isSaving = state.isSaving,
|
||||
canSave = state.canSave,
|
||||
onNameChange = onNameChange,
|
||||
onSaveClick = onSaveClick,
|
||||
onBackClick = onBackClick,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Private content functions can break up layout:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
private fun ProfileContent(
|
||||
name: String,
|
||||
isSaving: Boolean,
|
||||
canSave: Boolean,
|
||||
onNameChange: (String) -> Unit,
|
||||
onSaveClick: () -> Unit,
|
||||
onBackClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// Layout only.
|
||||
}
|
||||
```
|
||||
|
||||
## Rules of thumb
|
||||
|
||||
| Concern | State-holder composable | UI composable |
|
||||
|---|---|---|
|
||||
| Collect ViewModel/component state | Yes | No |
|
||||
| Collect one-shot effects | Yes, or a tiny sibling effect handler | Usually no |
|
||||
| Hold dependency-injected objects | Yes | No |
|
||||
| Accept immutable UI state | Usually passes it through | Yes |
|
||||
| Accept lambdas for user events | Wires them | Calls them |
|
||||
| Own layout, modifiers, semantics, test tags | No/minimal | Yes |
|
||||
| Own UI-local state like scroll, focus, text input, animation, interaction | Sometimes seeds it | Yes |
|
||||
| Preview/screenshot friendly | Not necessarily | Yes |
|
||||
|
||||
The "no collection in UI composables" rule is about app/business state and side-effect streams. Plain UI composables can still own UI-local framework state: `rememberScrollState`, `rememberLazyListState`, `FocusRequester`, focus state, animation state, `TextFieldState`, `MutableInteractionSource.collectIsPressedAsState()`, and similar behavior that belongs to the rendered widget.
|
||||
|
||||
If that UI-local state grows into coordinated behavior with multiple related fields and operations, consult `compose-expert` (state hoisting section) to decide whether it should become a plain state holder class remembered in composition.
|
||||
|
||||
## What to pass
|
||||
|
||||
Pass the smallest useful UI contract:
|
||||
|
||||
- Prefer a dedicated `UiState`/`State` object over many unrelated primitives when the screen has real state.
|
||||
- Prefer explicit lambdas (`onRetryClick`, `onItemSelected`) over passing a whole component.
|
||||
- Keep domain models out of the UI composable if they force business rules into UI. Map to UI models when the UI needs a different shape.
|
||||
- Keep navigation as callbacks. The UI composable says "user clicked back", not "navigate to route X".
|
||||
- Frame-rate or UI-local values that should not force whole-tree recomposition when they change: prefer provider lambdas and deferred reads per [`compose-state-deferred-reads`](../compose-state-deferred-reads/SKILL.md).
|
||||
|
||||
## Side effects
|
||||
|
||||
[`compose-side-effects`](../compose-side-effects/SKILL.md) covers effect APIs (`LaunchedEffect`, `DisposableEffect`, `SideEffect`), keys, cleanup, and `rememberUpdatedState`.
|
||||
|
||||
Handle effects near the state holder, where the effect source and imperative target are both available:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ProfileScreen(component: ProfileComponent, snackbarHostState: SnackbarHostState) {
|
||||
val state by component.state.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(component) {
|
||||
component.effects.collect { effect ->
|
||||
when (effect) {
|
||||
ProfileEffect.Saved -> snackbarHostState.showSnackbar("Saved")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ProfileScreen(state = state, onSaveClick = component::save)
|
||||
}
|
||||
```
|
||||
|
||||
If effect handling grows, extract `ProfileEffects(component, snackbarHostState)` rather than pushing the component into the UI composable.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
| Mistake | Why it hurts | Fix |
|
||||
|---|---|---|
|
||||
| `fun Screen(viewModel: MyViewModel)` contains all layout | Hard to preview/test without Android lifecycle and DI | Add a plain UI overload that takes `state` and callbacks |
|
||||
| Child composables take `component` | Dependencies leak through the tree | Pass only the state/callbacks that child needs |
|
||||
| UI composable launches navigation | UI becomes coupled to app routing | Expose `onBackClick`, `onItemClick`, etc. |
|
||||
| UI composable collects app/business flows | Collection lifecycle is hidden in layout | Collect near the state holder and pass values down |
|
||||
| UI-local state is hoisted into the state holder for no reason | State holder starts owning layout mechanics | Keep scroll/focus/animation/text-field interaction state in the UI composable when it is only UI behavior |
|
||||
| Every tiny composable gets a state-holder overload | Too much ceremony | Split at screen/section boundaries, not every `Row` |
|
||||
|
||||
## When NOT to apply
|
||||
|
||||
- Tiny one-off composables that already take plain values and callbacks.
|
||||
- Design-system primitives such as `Button`, `Card`, or `ListItem`; those should expose slots and modifiers, not state holders.
|
||||
- Cases where the state-holder composable would only forward one primitive and add no isolation.
|
||||
|
||||
## Related
|
||||
|
||||
- [`compose-expert`](../compose-expert/SKILL.md) — Amethyst's shared-UI patterns, including state hoisting for UI element state and plain state holder classes.
|
||||
- [`compose-side-effects`](../compose-side-effects/SKILL.md) — effect keys and cleanup in Compose.
|
||||
- [`compose-state-deferred-reads`](../compose-state-deferred-reads/SKILL.md) — deferred reads for frame-rate / UI-local values passed across boundaries.
|
||||
- [`kotlin-multiplatform`](../kotlin-multiplatform/SKILL.md) — platform services, native views, and expect/interface boundaries when shared UI meets platform-specific leaves.
|
||||
@@ -8,13 +8,13 @@
|
||||
│ (Amethyst) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌────────────────┼────────────────┬────────────┐
|
||||
│ │ │ │
|
||||
▼ ▼ ▼ ▼
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌──────────┐
|
||||
│ :amethyst │ │ :desktopApp │ │ :benchmark │ │:ammolite │
|
||||
│ (Android) │ │ (JVM) │ │ (Android) │ │ (Support)│
|
||||
└─────────────┘ └─────────────┘ └─────────────┘ └──────────┘
|
||||
┌────────────────┼────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ :amethyst │ │ :desktopApp │ │ :benchmark │
|
||||
│ (Android) │ │ (JVM) │ │ (Android) │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
│ │ │
|
||||
│ │ │
|
||||
└────────────────┼────────────────┘
|
||||
@@ -77,7 +77,7 @@
|
||||
**Type:** Android Application
|
||||
**Targets:** Android
|
||||
**Dependencies:**
|
||||
- Modules: `:commons`, `:quartz`, `:ammolite`
|
||||
- Modules: `:commons`, `:quartz`, `:nestsClient`
|
||||
- External: Android SDK, AndroidX, Firebase, Tor
|
||||
|
||||
**Role:** Android-specific navigation, layouts, and entry point
|
||||
@@ -91,13 +91,6 @@
|
||||
|
||||
**Role:** Performance benchmarking for Android builds
|
||||
|
||||
### :ammolite (Support Module)
|
||||
**Type:** Android Library
|
||||
**Targets:** Android
|
||||
**Dependencies:** Android-specific utilities
|
||||
|
||||
**Role:** Android support utilities for amethyst
|
||||
|
||||
## Dependency Flow Patterns
|
||||
|
||||
### Desktop Build Chain
|
||||
@@ -112,10 +105,10 @@
|
||||
### Android Build Chain
|
||||
```
|
||||
:amethyst → :commons (androidMain) → :quartz (androidMain)
|
||||
↓ ↓
|
||||
:ammolite jvmAndroid
|
||||
↓
|
||||
commonMain
|
||||
↓
|
||||
jvmAndroid
|
||||
↓
|
||||
commonMain
|
||||
```
|
||||
|
||||
## Source Set Dependencies
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
---
|
||||
name: kotlin-coroutines-structured-concurrency
|
||||
description: Use when writing or reviewing Kotlin code that stores CoroutineScope, launches from init/non-suspending APIs, calls runBlocking, or catches broad exceptions around suspend calls. Technique-layer skill — complements the codebase-specific kotlin-coroutines.
|
||||
---
|
||||
|
||||
# Kotlin coroutines: structured concurrency
|
||||
|
||||
## Core principle
|
||||
|
||||
A well-structured coroutine is a self-contained unit of asynchronous work — single entry, single exit, scoped to a lifecycle known at the call site.
|
||||
|
||||
**Scopes should usually be tied to the caller's lifecycle, not stored as a property on the callee.** A stored `CoroutineScope` is a strong review signal: the class must prove it owns cancellation, error reporting, restart behavior, and lifecycle. Most repositories, managers, use cases, and data sources cannot prove that, so they should expose `suspend` APIs instead.
|
||||
|
||||
The fix is almost always the same: **make the API `suspend` and let the caller own the scope.**
|
||||
|
||||
## When to use this skill
|
||||
|
||||
You're writing or reviewing Kotlin code and you see any of these:
|
||||
|
||||
- A class with `private val scope: CoroutineScope` (constructor param stored as a property)
|
||||
- An `init { scope.launch { ... } }` block
|
||||
- A non-suspending public function whose body is `scope.launch { ... }`
|
||||
- `runBlocking { ... }` in suspend-capable application code, or in tests where `runTest` should apply
|
||||
- `runCatching { suspendCall() }` or a `catch` on `Exception` / `Throwable` around a `suspend` call without rethrowing `CancellationException`
|
||||
- A `catch (e: CancellationException)` (or equivalent) around suspension that does not rethrow
|
||||
|
||||
## The silent-cancellation bug
|
||||
|
||||
The reason an unowned `CoroutineScope` property is so dangerous: "once a scope is cancelled, every future `launch` on it silently completes as cancelled — no exception, no log, nothing." The work just doesn't happen. This is one of the hardest coroutine bugs to diagnose, and it appears when a class holds a long-lived reference to a lifecycle it does not own.
|
||||
|
||||
If APIs are `suspend`, this can't happen: the caller's scope is either alive (work runs) or the call site cancels (the caller knows).
|
||||
|
||||
## Anti-patterns and fixes
|
||||
|
||||
### 1. CoroutineScope stored as a property
|
||||
|
||||
```kotlin
|
||||
// ❌ BAD
|
||||
@Inject
|
||||
class UserRepository(
|
||||
private val scope: CoroutineScope,
|
||||
private val api: UserApi,
|
||||
) {
|
||||
fun refresh() {
|
||||
scope.launch { _state.value = api.fetchUser() }
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ GOOD
|
||||
@Inject
|
||||
class UserRepository(
|
||||
private val api: UserApi,
|
||||
) {
|
||||
suspend fun refresh(): User = api.fetchUser()
|
||||
}
|
||||
```
|
||||
|
||||
The repository no longer needs to know about coroutines at all. The caller (a ViewModel, a use case) decides on what scope, with what error handling, with what cancellation semantics.
|
||||
|
||||
### 2. init-block launches
|
||||
|
||||
```kotlin
|
||||
// ❌ BAD: construction-time side effect, unbounded work
|
||||
class UserSession(private val scope: CoroutineScope, private val api: Api) {
|
||||
init { scope.launch { _user.value = api.load() } }
|
||||
}
|
||||
```
|
||||
|
||||
The constructor returns immediately. The caller can't `await` the load, can't see errors, can't cancel. The class is "alive" but its state is undefined.
|
||||
|
||||
```kotlin
|
||||
// ✅ GOOD: explicit bootstrap, caller owns the suspension
|
||||
class UserSession(private val api: Api) {
|
||||
private var _user: User? = null
|
||||
val user: User get() = checkNotNull(_user) { "Call init() first" }
|
||||
|
||||
suspend fun init() { _user = api.load() }
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Fire-and-forget from non-UI classes
|
||||
|
||||
A non-suspending public function on a **non-UI class** (repository, manager, use case, data source) that launches into a class-owned scope. The caller gets no result, no error, no cancellation, and no guarantee the work ever ran.
|
||||
|
||||
```kotlin
|
||||
// ❌ BAD — repository with stored scope and fire-and-forget public API
|
||||
class AnalyticsClient(private val scope: CoroutineScope, private val api: Api) {
|
||||
fun track(event: Event) {
|
||||
scope.launch { api.send(event) } // caller has no idea what happens
|
||||
}
|
||||
fun signOut() {
|
||||
scope.launch { api.signOut() } // silent failure if scope cancelled
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// ✅ GOOD
|
||||
class AnalyticsClient(private val api: Api) {
|
||||
suspend fun track(event: Event) = api.send(event)
|
||||
suspend fun signOut() = api.signOut()
|
||||
}
|
||||
```
|
||||
|
||||
#### Carve-out: the UI ↔ state-holder boundary
|
||||
|
||||
UI frameworks are non-suspending. A Composable's `onClick`, a Fragment's `onKeyEvent`, an Activity's `onNewIntent` — none can `suspend`. The state holder (ViewModel, Decompose Component, feature model, etc. — anything whose role is to absorb UI events and hold UI state) **is** the boundary that translates one-shot UI events into asynchronous work bound to the UI lifecycle. That's its job.
|
||||
|
||||
```kotlin
|
||||
// ✅ GOOD — state holder absorbs a non-suspending UI event onto its scope
|
||||
class FavouritesViewModel(private val repo: FavouritesRepository) : ViewModel() {
|
||||
fun onToggleFavourite(item: Item) {
|
||||
viewModelScope.launch { repo.toggleFavourite(item) }
|
||||
}
|
||||
}
|
||||
|
||||
// in Compose:
|
||||
ListItem(onClick = { viewModel.onToggleFavourite(item) })
|
||||
```
|
||||
|
||||
This is **not** the fire-and-forget anti-pattern. All three conditions must hold:
|
||||
|
||||
1. **State holder for a UI surface** — a ViewModel, Decompose Component, feature model, or equivalent UI state holder. Not a repository, manager, use case, or data source.
|
||||
2. **Lifecycle-bound scope** — `viewModelScope`, a Component's `coroutineScope` that's cancelled on destroy, a Composable's `rememberCoroutineScope()`. Not `AppScope`, not an injected long-lived scope, not an ad-hoc `CoroutineScope(...)`.
|
||||
3. **Caller really is a UI event** — Composable callback, key handler, lifecycle hook. Not another business-logic class calling through the state holder.
|
||||
|
||||
The repository / use case / data source layers underneath still expose `suspend` APIs. The state holder is the *only* layer where the non-suspending → suspending translation belongs.
|
||||
|
||||
"It feels like a state holder" isn't enough. The question is "does the UI directly bind to this?" If no, the carve-out doesn't apply.
|
||||
|
||||
### 4. Stored scopes that aren't injected
|
||||
|
||||
The same anti-pattern, without an injected scope:
|
||||
|
||||
```kotlin
|
||||
// ❌ BAD — same problem, scope is constructed in-class instead of injected
|
||||
class FooManager {
|
||||
private val scope = MainScope()
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
}
|
||||
```
|
||||
|
||||
Lifecycle is now owned by nothing and lives forever. Replace with `suspend` APIs.
|
||||
|
||||
The same is true if the instantiation is nested inside a function body — `fun foo() { CoroutineScope(...).launch { … } }` is just a stored scope with extra steps. Each call leaks a new uncancellable scope; bundling it into a `by lazy` property doesn't fix the underlying issue (the scope shouldn't exist at all).
|
||||
|
||||
### 5. DI-bound singletons / initializers that launch
|
||||
|
||||
A specific pattern that is hard to spot: a DI-bound class (`@SingleIn(AppScope)`, `@Singleton`, an `Initializer.initialize()`) launches a coroutine from its constructor / `init` block / `initialize()`. The launched work then has:
|
||||
|
||||
- **A non-deterministic start time** — whenever the graph realizes the binding. Cold-start ordering is invisible.
|
||||
- **No observable lifecycle.** Nothing else in the codebase can see whether it's running or has crashed.
|
||||
- **No `stop()` / restart path.** If upstream enters a bad state, the loop is uncancellable.
|
||||
- **No calling code to grep for.** Readers can't find "who starts this and when".
|
||||
|
||||
§1 says scopes should be tied to the caller's lifecycle. The DI-bound variant violates this indirectly: the *scope* may be injected, but the *launch* is hidden inside construction — same effect, harder to see.
|
||||
|
||||
```kotlin
|
||||
// ❌ BAD — singleton boots work as a side effect of being constructed
|
||||
@SingleIn(AppScope::class)
|
||||
@Inject
|
||||
class TokenRefresher(
|
||||
@ForScope(AppScope::class) private val scope: CoroutineScope,
|
||||
private val auth: AuthService,
|
||||
) {
|
||||
init {
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
delay(5.minutes)
|
||||
auth.refreshIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ ALSO BAD — Initializer.initialize() that *launches*, not just registers
|
||||
class TokenInvalidatorInitializer @Inject constructor(
|
||||
@ForScope(AppScope::class) private val scope: CoroutineScope,
|
||||
private val store: AuthStore,
|
||||
private val invalidator: TokenInvalidator,
|
||||
) : Initializer {
|
||||
override fun initialize() {
|
||||
scope.launch { store.tokenChanges.collect { invalidator.invalidate() } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Both look like "application-scoped singletons", but the **When NOT to apply** carve-out is *not* permission to launch from `init` / `initialize()`. It's permission for a singleton to own a scope when its API is suspending.
|
||||
|
||||
#### First ask: does this background-loop class need to exist at all?
|
||||
|
||||
Most background-loop classes exist only because no one inverted the observation. Three answers, in order of preference:
|
||||
|
||||
**Pattern 1 — invert into the consumer.** The class observes state forever to react when it changes. But *someone* mutates the state — sign-out flow, profile switch, flag-update handler. That mutation site is already in a coroutine context and is the natural place to do the work directly.
|
||||
|
||||
```kotlin
|
||||
// ✅ GOOD — no background loop, no scope, no class. The mutation site does the work.
|
||||
class Authenticator(
|
||||
private val authStore: AuthStore,
|
||||
private val tokenInvalidator: TokenInvalidator,
|
||||
) {
|
||||
suspend fun signOut() {
|
||||
authStore.clearTokens()
|
||||
tokenInvalidator.invalidate() // direct call at the mutation site
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The background-loop class is **deleted**. The work happens where the state changes.
|
||||
|
||||
When this applies: the consumer of the state has a clear lifecycle (a use case, an Authenticator, a service handler) and can perform the reaction inline.
|
||||
|
||||
**Pattern 2 — scheduled work.** Genuinely periodic or deferred. Use WorkManager / BGTaskScheduler. The enqueue is one-shot; make it suspending and call it once from an orchestrator that already runs at startup.
|
||||
|
||||
**Pattern 3 — explicit named launch site.** Sometimes the consumer is a synchronous API with no observable lifecycle (e.g., OpenTelemetry's `Sampler.shouldSample(...)`, an AIDL stub fanout, a broadcast receiver bridge). The observation has to live somewhere coroutine-aware, but it must live at an *explicit named call site* — not in the class's own `init`.
|
||||
|
||||
```kotlin
|
||||
// ✅ GOOD — work is named; an explicit call site owns the launch
|
||||
@SingleIn(AppScope::class)
|
||||
class OtelConfigurableSampler(...) : Sampler {
|
||||
@Volatile private var delegate: Sampler = ...
|
||||
|
||||
suspend fun observeRate(featureFlags: FeatureFlags) {
|
||||
featureFlags.observe(OTEL_SAMPLING_RATE).collect { rate ->
|
||||
delegate = Sampler.traceIdRatioBased(rate.coerceIn(0.0, 1.0))
|
||||
}
|
||||
}
|
||||
|
||||
override fun shouldSample(...) = delegate.shouldSample(...)
|
||||
}
|
||||
|
||||
// wired explicitly at the OTel SDK init module:
|
||||
applicationScope.launch { otelSampler.observeRate(featureFlags) }
|
||||
```
|
||||
|
||||
When this applies: the consumer is a synchronous API that calls *into* you with no observable lifecycle. The launch can't be invertible, but it must still be visible at a named call site.
|
||||
|
||||
#### Test for which pattern fits
|
||||
|
||||
"Is the consumer's lifecycle observable to me?"
|
||||
|
||||
- **Yes, and they're already in a coroutine context** → Pattern 1. Push the subscription into them; delete the background-loop class.
|
||||
- **The work is periodic / deferred** → Pattern 2. Suspend enqueue called once.
|
||||
- **No, they're a synchronous API with no observable lifecycle** → Pattern 3. Explicit launch site, not `init`.
|
||||
|
||||
If a fourth answer seems to fit — e.g., "I want a `Bootable` interface that launches everything for me" — that's the same anti-pattern with an extra layer of abstraction. The whole point is that launches be *visible*; auto-discovery by interface defeats it.
|
||||
|
||||
#### Initializers are still fine — *if they only register*
|
||||
|
||||
The `Initializer` pattern is correct when `initialize()` *registers* a listener or hook. The bug is when `initialize()` *launches* a coroutine.
|
||||
|
||||
```kotlin
|
||||
// ✅ GOOD Initializer — registers a contributor, doesn't launch
|
||||
class FavouritesContributorInitializer @Inject constructor(
|
||||
private val registry: ContributorRegistry,
|
||||
private val favouritesContributor: FavouritesContributor,
|
||||
) : Initializer {
|
||||
override fun initialize() {
|
||||
registry.register(favouritesContributor)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`Initializer.initialize()` must not `launch` a coroutine.** If yours does, it's a Pattern 1/2/3 candidate.
|
||||
|
||||
#### Diagnostic for review
|
||||
|
||||
- Where is the start moment defined? If "wherever DI realizes me", bad.
|
||||
- Who can observe whether the work is running? If "no one", bad.
|
||||
- Who can stop or restart it? If "no one", bad.
|
||||
- Can a reader grep for the launch site? If no, bad.
|
||||
|
||||
If the answers are "the consumer / the orchestrator / the named call site" — you're good.
|
||||
|
||||
### 6. Swallowing `CancellationException`
|
||||
|
||||
A `catch` clause around a `suspend` call that matches `CancellationException` — directly, or through `Exception` / `Throwable` — and doesn't rethrow usually turns cancellation into silent success. The parent coroutine thinks the child finished; the child keeps running (or its side effects do); the cancellation contract is broken.
|
||||
|
||||
Same failure shape as §1's stored-scope bug, viewed from the other end: §1 hides the work *from* the caller's lifecycle; this hides cancellation *from* the work.
|
||||
|
||||
```kotlin
|
||||
// ❌ BAD — catches CancellationException, never rethrows
|
||||
suspend fun fetch() {
|
||||
try {
|
||||
api.load()
|
||||
} catch (e: Exception) { // matches CancellationException too
|
||||
logger.warn("load failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ ALSO BAD — runCatching has the same problem
|
||||
suspend fun fetch() {
|
||||
runCatching { api.load() }
|
||||
.onFailure { logger.warn("load failed", it) }
|
||||
}
|
||||
```
|
||||
|
||||
The acceptable shapes:
|
||||
|
||||
```kotlin
|
||||
// ✅ Separate catch first
|
||||
try { api.load() }
|
||||
catch (e: CancellationException) { throw e }
|
||||
catch (e: Exception) { logger.warn("load failed", e) }
|
||||
|
||||
// ✅ Conditional rethrow inside the broad catch
|
||||
try { api.load() }
|
||||
catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
logger.warn("load failed", e)
|
||||
}
|
||||
|
||||
// ✅ ensureActive() — good when the catch handles ordinary failures and you only need
|
||||
// to rethrow if the current coroutine is cancelled
|
||||
try { api.load() }
|
||||
catch (e: Exception) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
logger.warn("load failed", e)
|
||||
}
|
||||
|
||||
// ✅ runCatching with explicit guard
|
||||
runCatching { api.load() }
|
||||
.onFailure {
|
||||
if (it is CancellationException) throw it
|
||||
logger.warn("load failed", it)
|
||||
}
|
||||
|
||||
// ✅ runCatching terminated with getOrThrow (cancellation flows back out)
|
||||
runCatching { api.load() }.getOrThrow()
|
||||
```
|
||||
|
||||
The trigger is "a suspend call inside the `try`", not "the enclosing function is declared `suspend`". This applies inside any suspending body — `suspend fun`, a `launch { … }` lambda, a Flow `collect { … }`, etc.
|
||||
|
||||
The common carve-out is an intentionally local timeout: catching `TimeoutCancellationException` from your own `withTimeout` and converting it to a domain result can be correct. Keep that catch narrow and close to the timeout. Do not use it as permission to swallow arbitrary cancellation.
|
||||
|
||||
Catching a non-cancellation subtype (`IOException`, your own exception types) is fine — they don't extend `CancellationException`.
|
||||
|
||||
### 7. `runBlocking`
|
||||
|
||||
`runBlocking` parks the current thread until the lambda finishes. Inside suspend-capable or lifecycle-scoped application paths it is wrong: a thread that meant to be async is now blocked, structured concurrency is broken, and any cancellation upstream has no effect. It is the "callee makes a structural decision for the caller" anti-pattern at its most direct.
|
||||
|
||||
```kotlin
|
||||
// ❌ BAD — bridging to suspend by blocking the calling thread
|
||||
fun saveUser(user: User) {
|
||||
runBlocking { repository.save(user) }
|
||||
}
|
||||
```
|
||||
|
||||
Three fixes, by context:
|
||||
|
||||
**Suspend-capable application code** — make the function `suspend`:
|
||||
|
||||
```kotlin
|
||||
// ✅ GOOD
|
||||
suspend fun saveUser(user: User) = repository.save(user)
|
||||
```
|
||||
|
||||
If the immediate caller can't suspend either (a non-suspending UI callback, a `BroadcastReceiver` hook), use the existing lifecycle-bound scope at the boundary — see §3's UI ↔ state-holder carve-out. The fix is at the boundary, not inside `saveUser`.
|
||||
|
||||
Legitimate blocking boundaries exist: `main` in a CLI tool, Java interop APIs that must return synchronously, framework callbacks with no suspending alternative, and migration shims. Keep `runBlocking` at that outer boundary, keep the body small, and call suspending code immediately.
|
||||
|
||||
**Tests** — use `runTest`:
|
||||
|
||||
```kotlin
|
||||
// ❌ BAD — real time, slow tests, no virtual delay
|
||||
@Test fun loadsUser() = runBlocking {
|
||||
assertThat(repository.load().name).isEqualTo("Alice")
|
||||
}
|
||||
|
||||
// ✅ GOOD
|
||||
@Test fun loadsUser() = runTest {
|
||||
assertThat(repository.load().name).isEqualTo("Alice")
|
||||
}
|
||||
```
|
||||
|
||||
`runTest` gives you virtual time (`delay()` returns immediately), `TestDispatcher` integration, and proper coroutine cleanup. Real-time `runBlocking` in tests makes them slow and flaky.
|
||||
|
||||
**`ContentProvider` carve-out** — Android's `ContentProvider` methods (`query`, `insert`, `update`, `delete`, `onCreate`, `call`) are synchronous from outside the process. There is no way to suspend them. Inside *member functions* of a `ContentProvider` subclass (direct or indirect — not companion objects), `runBlocking` is the unavoidable bridge. Keep the body as short as possible and call into suspending code immediately:
|
||||
|
||||
```kotlin
|
||||
// ✅ Acceptable in ContentProvider members only
|
||||
class MyProvider : ContentProvider() {
|
||||
override fun query(...): Cursor? = runBlocking { dao.query(...) }
|
||||
}
|
||||
```
|
||||
|
||||
This carve-out is for `android.content.ContentProvider` subclasses *only*. "It's like a `ContentProvider`" doesn't apply, and a `runBlocking` in a `ContentProvider`'s companion object is still a regular violation — the helper isn't part of the framework's synchronous surface.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Symptom | Anti-pattern | Fix |
|
||||
|---|---|---|
|
||||
| Class has `private val scope: CoroutineScope` | Stored scope on the callee | Remove. Make public APIs `suspend`. |
|
||||
| `init { scope.launch { ... } }` | Construction-time launch | Move to `suspend fun init()` / `login()` |
|
||||
| `fun foo() { scope.launch { ... } }` on a repository/manager/use case | Fire-and-forget from non-UI class | `suspend fun foo()`, let UI state holder pick the scope |
|
||||
| `fun onClick() { viewModelScope.launch { ... } }` on a state holder, called from UI | UI ↔ state-holder boundary — fine | Keep as-is (see §3 carve-out) |
|
||||
| `private val scope = MainScope()` | Internally-constructed stored scope | Same — remove, make APIs `suspend` |
|
||||
| `@SingleIn(AppScope) class X(scope) { init { scope.launch { … } } }` | DI-bound opaque launch (§5) | Expose `suspend fun run()`, launch from startup orchestrator |
|
||||
| `class Y : Initializer { override fun initialize() { scope.launch { … } } }` | Initializer that launches, not registers (§5) | Same — `suspend fun run()`, orchestrator owns lifecycle |
|
||||
| `try { suspendCall() } catch (e: Exception\|Throwable\|CancellationException) { … }` with no rethrow | Swallowed cancellation (§6) | Prefer `catch (e: CancellationException) { throw e }`; use `ensureActive()` only when that matches the intent |
|
||||
| `runCatching { suspendCall() }.onFailure { … }` with no cancellation guard | Same shape as above (§6) | Add `if (it is CancellationException) throw it`, or terminate with `.getOrThrow()` |
|
||||
| `runBlocking { … }` inside suspend-capable app code | Thread-blocking bridge (§7) | Make caller `suspend`; or use a lifecycle scope at the boundary |
|
||||
| `runBlocking { … }` in a test | Same — real-time bridging (§7) | Use `runTest { … }` |
|
||||
| `runBlocking { … }` inside a `ContentProvider.query`/`insert`/… member | Carve-out (§7) | Acceptable; keep the body minimal |
|
||||
|
||||
## Refactoring guidance
|
||||
|
||||
Removing an existing offender:
|
||||
|
||||
1. **Start at the leaf.** Pick the class farthest from any UI — usually a repository or data source. Its public surface should be the easiest to convert.
|
||||
2. **Convert public functions to `suspend`** one at a time. The compiler will surface every caller.
|
||||
3. **At each caller, choose the scope deliberately:** `viewModelScope`, `lifecycleScope`, `coroutineScope { }`, or an explicit job. This is the choice that was missing before.
|
||||
4. **Delete the `CoroutineScope` constructor parameter** once nothing uses it. Remove the injection binding.
|
||||
|
||||
Don't try to fix every class in one MR. Removing an anti-pattern is incremental work.
|
||||
|
||||
## When NOT to apply
|
||||
|
||||
- **UI state holders absorbing UI events.** A ViewModel/Component/feature model with `fun onClick(...) { viewModelScope.launch { ... } }` is correct — that's the boundary the framework needs. See §3 carve-out.
|
||||
- **Lifecycle owners with explicit cancellation and error policy.** Actors/services, app infrastructure, or application-scoped singletons may own a scope when they expose clear `close`/`cancel`/restart behavior or otherwise map directly to an application lifecycle. Inject `Application.applicationScope` explicitly rather than creating one ad-hoc. **This is not permission to launch from `init` / `initialize()`** — see §5.
|
||||
- **Already-suspending APIs** don't need any of this work.
|
||||
- **Tests** sometimes use `TestScope` as a deliberate ambient scope — that's a different pattern with explicit virtual-time control.
|
||||
|
||||
## Red flags during review
|
||||
|
||||
These thoughts mean the anti-pattern is back:
|
||||
|
||||
| Thought | Reality |
|
||||
|---|---|
|
||||
| "I'll just add a `CoroutineExceptionHandler` to the scope" | The problem isn't error handling. The problem is the scope shouldn't exist. |
|
||||
| "I need to launch from `init` so the data's ready when consumers arrive" | Consumers reading state that isn't ready is the bug. Use phasing. |
|
||||
| "The caller doesn't want to deal with `suspend`" | Then the caller chooses fire-and-forget at their scope. Don't decide for them. |
|
||||
| "It's just a small fire-and-forget call" | Silent cancellation makes every fire-and-forget a potential silent failure. |
|
||||
| "We caught and logged the exception, so we're fine" | Did the catch rethrow `CancellationException`? If no, the coroutine is silently un-cancelled. (§6) |
|
||||
| "It's just one `runBlocking`, in a non-critical path" | Every `runBlocking` asserts the caller has no async option. If they do, it's the wrong primitive. (§7) |
|
||||
| "Tests are simpler with `runBlocking`" | They run in real time, can't fast-forward `delay`, and lose `TestDispatcher` semantics. Use `runTest`. (§7) |
|
||||
|
||||
## Related
|
||||
|
||||
- [`kotlin-flow-state-event-modeling`](../kotlin-flow-state-event-modeling/SKILL.md) — `StateFlow`, `SharedFlow`, `Channel`, `stateIn`, one-shot events, and related modeling.
|
||||
- [`kotlin-coroutines`](../kotlin-coroutines/SKILL.md) — Amethyst's relay-pool / callbackFlow / testing async patterns.
|
||||
@@ -0,0 +1,183 @@
|
||||
---
|
||||
name: kotlin-flow-state-event-modeling
|
||||
description: Use when writing or reviewing Kotlin StateFlow/SharedFlow/Channel choices, sentinel default values, stateIn placement, WhileSubscribed staleness, or MutableStateFlow update patterns. Technique-layer skill — complements the codebase-specific kotlin-expert.
|
||||
---
|
||||
|
||||
# Kotlin Flow: state and event modeling
|
||||
|
||||
## Core principle
|
||||
|
||||
**Pick the primitive that matches replay, fan-out, and synchronous-read requirements.** `StateFlow`, `SharedFlow`, `Channel`-backed flows, and cold `Flow` differ in buffering, who sees each emission, and whether `.value` exists. Wrong choices drop events, leak sharing coroutines, or force fake domain sentinels into state.
|
||||
|
||||
## When to use this skill
|
||||
|
||||
You're writing or reviewing Kotlin code involving:
|
||||
|
||||
- `MutableStateFlow<T>(SomeSentinel)` — `NoUser`, `Empty`, `Loading`, etc. — because the real value is async
|
||||
- `.stateIn(...)` called inside a function rather than assigned to a property
|
||||
- `SharingStarted.WhileSubscribed(...)` on a flow whose `.value` is read synchronously and must stay fresh
|
||||
- `MutableSharedFlow` for navigation events, snackbars, or other one-shot emissions where loss would be a bug
|
||||
- `.map { }` on a `StateFlow` when consumers still need synchronous `.value`
|
||||
- `MutableStateFlow.value = _state.value.copy(...)` or update code that builds expensive objects inside `update { ... }`
|
||||
|
||||
## SharedFlow for single-consumer fire-once events
|
||||
|
||||
`SharedFlow` defaults have no replay buffer. If nothing is collecting at the exact instant of emission, the event is gone. For a **single UI consumer** handling exactly-once events such as navigation or snackbars, a buffered `Channel` exposed as a `Flow` often matches the semantics better:
|
||||
|
||||
```kotlin
|
||||
// ❌ BAD
|
||||
private val _navEvents = MutableSharedFlow<NavigationEvent>()
|
||||
val navEvents: SharedFlow<NavigationEvent> = _navEvents.asSharedFlow()
|
||||
|
||||
// ✅ GOOD
|
||||
private val _navEvents = Channel<NavigationEvent>(Channel.BUFFERED)
|
||||
val navEvents: Flow<NavigationEvent> = _navEvents.receiveAsFlow()
|
||||
```
|
||||
|
||||
`Channel.receiveAsFlow()` is **fan-out, not broadcast**: with multiple collectors, each event is delivered to **one** collector. `Channel.BUFFERED` is bounded, so sends can suspend and `trySend` can fail. If multiple observers must all see the same event, use explicit state, durable storage, or a deliberately configured `SharedFlow` instead.
|
||||
|
||||
## StateFlow polluted with invalid sentinel defaults
|
||||
|
||||
`StateFlow` forces an initial value. When the real value is async, developers sometimes invent fake domain values — `NoUser`, `EmptyUser`, placeholder IDs — and every consumer is forced to treat that sentinel as real data.
|
||||
|
||||
```kotlin
|
||||
// ❌ BAD — sentinel leaks into the type
|
||||
class UserSession(private val db: Db) {
|
||||
private val _user = MutableStateFlow<User>(NoUser)
|
||||
val user: StateFlow<User> = _user.asStateFlow()
|
||||
init { scope.launch { _user.value = db.load() } }
|
||||
}
|
||||
```
|
||||
|
||||
One fix is **phasing**: don't expose the `StateFlow` until the real value exists.
|
||||
|
||||
```kotlin
|
||||
// ✅ GOOD — bootstrap suspends; observers only see real users
|
||||
class UserSession(private val db: Db) {
|
||||
private var _user: MutableStateFlow<User>? = null
|
||||
val user: StateFlow<User>
|
||||
get() = checkNotNull(_user) { "Call login() first" }
|
||||
|
||||
suspend fun login() {
|
||||
_user = MutableStateFlow(db.load())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If absence, loading, or error is a real state, model it explicitly (`User?`, `sealed interface UserUiState`, `Result`, etc.). The bug is a fake domain value masquerading as real data, not every initial value.
|
||||
|
||||
## Mutate MutableStateFlow with `update { ... }`
|
||||
|
||||
Prefer `MutableStateFlow.update { current -> ... }` over reading `.value` and writing it back. `update` applies the transform atomically against the latest state, which avoids lost updates when multiple coroutines mutate the same state.
|
||||
|
||||
```kotlin
|
||||
// BAD — read/modify/write can lose concurrent updates.
|
||||
_state.value = _state.value.copy(
|
||||
selectedId = id,
|
||||
details = details,
|
||||
)
|
||||
|
||||
// GOOD — transform starts from the latest state.
|
||||
_state.update { current ->
|
||||
current.copy(
|
||||
selectedId = id,
|
||||
details = details,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Keep object creation outside the `update` block unless it needs the current state. The update lambda can be retried, so expensive work or side effects inside it may run more than once:
|
||||
|
||||
```kotlin
|
||||
// GOOD — details does not depend on current state, so build it once.
|
||||
val details = Details.from(response)
|
||||
_state.update { current ->
|
||||
current.copy(details = details)
|
||||
}
|
||||
|
||||
// GOOD — derived value depends on current state, so compute it inside.
|
||||
_state.update { current ->
|
||||
val nextItems = current.items.replaceById(updatedItem)
|
||||
current.copy(items = nextItems)
|
||||
}
|
||||
```
|
||||
|
||||
The block should be a pure, fast state transformation: no network calls, database writes, logging side effects, random IDs, or time reads unless those values were captured before the block.
|
||||
|
||||
## `stateIn()` inside a function
|
||||
|
||||
```kotlin
|
||||
// ❌ BAD — new sharing coroutine every call
|
||||
fun getPreferences(): StateFlow<Prefs> =
|
||||
repo.prefsFlow.stateIn(scope, SharingStarted.Eagerly, Prefs.Default)
|
||||
```
|
||||
|
||||
Every call to `getPreferences()` launches a fresh coroutine on `scope` that never completes. Performance dies fast under repeated reads.
|
||||
|
||||
```kotlin
|
||||
// ✅ GOOD — one shared instance, computed once
|
||||
val preferences: StateFlow<Prefs> =
|
||||
repo.prefsFlow.stateIn(viewModelScope, SharingStarted.Eagerly, Prefs.Default)
|
||||
```
|
||||
|
||||
## `WhileSubscribed` with synchronous `.value`
|
||||
|
||||
`SharingStarted.WhileSubscribed(timeout)` disconnects the upstream when there are no active collectors. While disconnected, `.value` returns the last cached value, which may be stale or still the initial value.
|
||||
|
||||
**Rule:** if `.value` must be fresh or initialized without an active collector, use `SharingStarted.Eagerly` or explicit initialization. `WhileSubscribed` is fine when stale/cached values are acceptable and consumers primarily collect asynchronously.
|
||||
|
||||
## `.map` on `StateFlow` loses `.value`
|
||||
|
||||
```kotlin
|
||||
// ❌ BAD — `name.value` won't compile; it's now a plain Flow
|
||||
val name: Flow<String> = userState.map { it.name }
|
||||
```
|
||||
|
||||
If you need synchronous `.value`, terminate the chain with `.stateIn(...)`:
|
||||
|
||||
```kotlin
|
||||
// ✅ GOOD
|
||||
val name: StateFlow<String> = userState
|
||||
.map { it.name }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, userState.value.name)
|
||||
```
|
||||
|
||||
Community "derived state flow" utilities run the transform on every `.value` read — only acceptable for fast, idempotent transforms. Default to `.stateIn(...)`.
|
||||
|
||||
## Decision: which Flow type?
|
||||
|
||||
| Need | Primitive |
|
||||
|------|-----------|
|
||||
| State that always has a value, read by both async collectors **and** synchronous code | `StateFlow`, often with `SharingStarted.Eagerly` when `.value` matters |
|
||||
| Hot stream, multiple subscribers, **no** requirement for synchronous `.value` | `SharedFlow` |
|
||||
| Discrete events for **one** consumer, exactly-once handoff | Consider `Channel(BUFFERED).receiveAsFlow()` |
|
||||
| Cold stream, one consumer per collection | Plain `Flow` |
|
||||
|
||||
If you're tempted to reach for `SharedFlow`, ask: would dropping an emission be a bug, and how many consumers must see it? If one consumer must handle it exactly once, a `Channel` may fit. If every observer must see it, model durable state or configure a broadcast stream deliberately.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Symptom | Problem | Fix |
|
||||
|---------|---------|-----|
|
||||
| `MutableStateFlow<X>(FakeDomainValue)` | Invalid placeholder default | Model absence explicitly or use phase initialization |
|
||||
| `MutableSharedFlow<Event>` for single-consumer nav/snackbar | Lossy default event stream | Consider `Channel(BUFFERED).receiveAsFlow()` |
|
||||
| `fun foo() = flow.stateIn(...)` | Per-call sharing coroutine | Make it a `val` / shared instance |
|
||||
| `WhileSubscribed` + `.value` must be fresh/initialized | Stale or initial data | `SharingStarted.Eagerly` or explicit initialization |
|
||||
| `stateFlow.map { ... }` consumed as state | Lost `.value` | Terminate with `.stateIn(...)` |
|
||||
| `_state.value = _state.value.copy(...)` | Non-atomic read/modify/write | `_state.update { it.copy(...) }` |
|
||||
| Expensive object creation inside `update { ... }` that doesn't use current state | Work can repeat if update retries | Build before `update`; keep only current-state transforms inside |
|
||||
|
||||
## Red flags during review
|
||||
|
||||
| Thought | Reality |
|
||||
|---------|---------|
|
||||
| "We need `SharedFlow` because there are multiple subscribers" | Multiple subscribers change the semantics. `Channel.receiveAsFlow()` is not broadcast; choose the event model deliberately. |
|
||||
| "We'll use `WhileSubscribed` to save resources" | Only if stale/initial `.value` reads are acceptable. Verify before applying. |
|
||||
| "I'll use a sentinel until real data loads" | Consumers treat it as real domain; prefer explicit UI/state modeling or phasing. |
|
||||
| "I'll construct the new object inside `update` because it's convenient" | The lambda may retry. Construct outside unless it depends on the current state. |
|
||||
|
||||
## Related
|
||||
|
||||
- [`kotlin-coroutines-structured-concurrency`](../kotlin-coroutines-structured-concurrency/SKILL.md) — scope ownership, init launches, fire-and-forget boundaries, cancellation, `runBlocking`
|
||||
- [`compose-side-effects`](../compose-side-effects/SKILL.md) — collecting event flows and wiring side effects in Compose
|
||||
- [`compose-state-holder-ui-split`](../compose-state-holder-ui-split/SKILL.md) — where state holders expose flows to UI
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
name: kotlin-types-value-class
|
||||
description: Use when writing or reviewing Kotlin type declarations to choose @JvmInline value class over data class where appropriate, including Compose stability implications. Technique-layer skill — complements the codebase-specific kotlin-expert.
|
||||
---
|
||||
|
||||
# Kotlin value class vs data class
|
||||
|
||||
## Core principle
|
||||
|
||||
Prefer `@JvmInline value class` for single-field types that carry domain meaning. Data classes are for aggregating multiple fields. A value class gives you type safety (you can't mix up `UserId` and `String`) without the allocation overhead of a data class.
|
||||
|
||||
## When to use this skill
|
||||
|
||||
- Writing a new Kotlin type that wraps a single value
|
||||
- Reviewing a data class that has only one property
|
||||
- Seeing primitive types (`String`, `Long`, `Int`, etc.) used where a domain type would prevent misuse
|
||||
- Compose compiler reports showing unstable parameters that could be value classes
|
||||
|
||||
## Decision flow
|
||||
|
||||
| Situation | Prefer |
|
||||
|---|---|
|
||||
| Single field + domain-meaningful (`UserId`, `EmailAddress`, `Percentage`) | `@JvmInline value class` |
|
||||
| Single field + no domain meaning (just grouping) | Type alias or keep the primitive |
|
||||
| Multiple fields | Data class |
|
||||
| Needs custom `equals`/`hashCode`/`toString` beyond the wrapped value | Data class (value classes delegate to the underlying type) |
|
||||
| Used as a generic type argument or nullable in hot paths | Data class or primitive (autoboxing cost) |
|
||||
|
||||
```kotlin
|
||||
// GOOD: domain-meaningful single field
|
||||
@JvmInline value class UserId(val value: String)
|
||||
@JvmInline value class EmailAddress(val value: String)
|
||||
@JvmInline value class Percentage(val value: Float)
|
||||
|
||||
// BAD: data class wrapping a single field
|
||||
data class UserId(val value: String) // unnecessary allocation
|
||||
data class EmailAddress(val value: String) // type safety without the overhead is available
|
||||
|
||||
// BAD: value class with no domain meaning
|
||||
@JvmInline value class Wrapper(val value: String) // just use the String, or a type alias
|
||||
|
||||
// BAD: value class needing custom equality
|
||||
@JvmInline value class CaseInsensitiveString(val value: String)
|
||||
// value class equals delegates to String equals, which IS case-sensitive
|
||||
// Use a data class if you need different equality semantics
|
||||
```
|
||||
|
||||
## Compose stability
|
||||
|
||||
`@JvmInline value class` is treated as `Stable` by the Compose compiler when its underlying type is stable (primitives, `String`, and other stable types). This means:
|
||||
|
||||
- Value classes passed as composable parameters avoid "unstable parameter" warnings
|
||||
- No need for `@Immutable` annotations at Compose boundaries when wrapping primitives or strings
|
||||
- Replacing single-field data classes with value classes at UI boundaries improves skippability
|
||||
|
||||
```kotlin
|
||||
// Before: data class wrapping a single field
|
||||
data class UiState(val userId: String) // works, but allocates a wrapper object
|
||||
|
||||
// After: value class is stable and zero-allocation at runtime
|
||||
@JvmInline value class UserId(val value: String)
|
||||
data class UiState(val userId: UserId)
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Autoboxing**: Value classes are unboxed at compile time but boxed (allocated) when used as nullable (`UserId?`), generic type arguments (`List<UserId>`), or vararg parameters. In hot paths these allocations matter; in most code they don't.
|
||||
- **No backing fields**: You cannot use `init` blocks, `lateinit`, or delegated properties like `by lazy`. The class body is extremely constrained — only the single constructor parameter exists.
|
||||
- **No data-class conveniences**: No `copy()`, no `component1()` for destructuring, and no way to customize `toString()`. If you need any of these, use a data class.
|
||||
- **No custom equals/hashCode/toString**: These always delegate to the underlying type. Need custom equality → use a data class.
|
||||
- **when exhaustiveness**: Sealed hierarchies of value classes work differently than data class hierarchies. Test `when` branches carefully.
|
||||
- **Serialization semantics**: With kotlinx.serialization, a `@Serializable data class A(val value: String)` serializes as `{"value":"..."}`, but a `@Serializable value class A(val value: String)` serializes as the underlying value (`"..."`). Replacing a single-field data class with a value class is a breaking change for your API/JSON contract.
|
||||
- **Serialization**: Some serialization frameworks need explicit support for value classes (e.g., kotlinx.serialization's `@Serializable` works, but Jackson may need configuration).
|
||||
- **Interoperability**: From Java, value classes appear as their underlying type. Java callers bypass the type-safety wrapper.
|
||||
- **Reflection and runtime erasure**: When passed as `Any` or used in generic contexts, value classes box into a synthetic wrapper class. Java reflection sees mangled method signatures, and frameworks that rely on raw runtime types (some ORMs, DI containers, or serializers) may see the underlying type rather than the value class.
|
||||
|
||||
## Packing multiple values
|
||||
|
||||
A value class can only declare one field, but Compose provides `packFloats`, `packInts`, and matching `unpack*` functions in `androidx.compose.ui.util` to store multiple primitives in a single `Long`. This lets you represent composite values (e.g., a 2D point, size, or padding) as a zero-allocation value class instead of a multi-field data class.
|
||||
|
||||
```kotlin
|
||||
@JvmInline value class Offset(val packedValue: Long)
|
||||
|
||||
fun Offset(x: Float, y: Float): Offset = Offset(packFloats(x, y))
|
||||
val Offset.x: Float get() = unpackFloat1(packedValue)
|
||||
val Offset.y: Float get() = unpackFloat2(packedValue)
|
||||
```
|
||||
|
||||
- **Only use this in performance-critical paths** — manual bit-packing is error-prone. A data class is simpler and safer for most UI types.
|
||||
- **Available in `androidx.compose.ui.util`** — `packFloats`, `packInts`, `unpackFloat1`, `unpackFloat2`, `unpackInt1`, `unpackInt2`.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
| Mistake | Fix |
|
||||
|---|---|
|
||||
| Data class wrapping a single domain field | Replace with `@JvmInline value class` |
|
||||
| Value class with no domain meaning (just a wrapper) | Use a type alias or the primitive directly |
|
||||
| Value class needing custom equality | Use a data class instead |
|
||||
| Value class as generic type argument in hot path | Accept autoboxing cost or use the primitive |
|
||||
| `@Immutable` annotation on a type that could be a value class | Replace with value class — it's Stable by default |
|
||||
| Forgetting `@JvmInline` annotation | Always pair `value class` with `@JvmInline` for single-field classes |
|
||||
|
||||
## Red flags during review
|
||||
|
||||
- A data class with exactly one property
|
||||
- A `String`, `Long`, or `Int` used where different values should not be interchangeable (e.g., `fun transfer(from: String, to: String, amount: Long)`)
|
||||
- An `@Immutable` annotation on a single-field wrapper
|
||||
- A type alias used for domain distinction where value-class semantics are needed (type aliases are type-erased, no runtime protection)
|
||||
|
||||
## When NOT to apply
|
||||
|
||||
- The type needs multiple fields → data class
|
||||
- The type needs custom `equals`/`hashCode`/`toString` → data class
|
||||
- The type is used heavily as a nullable or generic in performance-critical code → measure autoboxing cost first
|
||||
- The project does not need the type-safety distinction → a type alias or primitive is sufficient
|
||||
|
||||
## Related
|
||||
|
||||
- [`compose-stability-diagnostics`](../compose-stability-diagnostics/SKILL.md) — diagnose unstable Compose parameters; value classes are one fix
|
||||
@@ -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.08.0` (Maven Central)
|
||||
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.09.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.08.0"
|
||||
quartz = "1.09.2"
|
||||
|
||||
[libraries]
|
||||
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
|
||||
@@ -41,7 +41,7 @@ kotlin {
|
||||
|
||||
```kotlin
|
||||
dependencies {
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.08.0")
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.09.2")
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Current version
|
||||
|
||||
```
|
||||
com.vitorpamplona.quartz:quartz:1.08.0
|
||||
com.vitorpamplona.quartz:quartz:1.09.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.08.0"
|
||||
quartz = "1.09.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.08.0")
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.09.2")
|
||||
}
|
||||
```
|
||||
|
||||
@@ -70,7 +70,7 @@ plugins {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.08.0")
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.09.2")
|
||||
// JNA needed for libsodium (NIP-44) on JVM
|
||||
implementation("net.java.dev.jna:jna:5.18.1")
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ The Quartz library was successfully converted from Android-only to full KMP supp
|
||||
## Current artifact
|
||||
|
||||
```
|
||||
com.vitorpamplona.quartz:quartz:1.08.0
|
||||
com.vitorpamplona.quartz:quartz:1.09.2
|
||||
```
|
||||
|
||||
See `.claude/skills/quartz-integration/SKILL.md` for full integration guide.
|
||||
@@ -21,7 +21,6 @@ TASKS=(
|
||||
:commons:jvmTest
|
||||
:nestsClient:jvmTest
|
||||
:quic:jvmTest
|
||||
:ammolite:testDebugUnitTest
|
||||
:amethyst:testPlayDebugUnitTest
|
||||
:cli:test
|
||||
)
|
||||
|
||||
@@ -218,7 +218,6 @@ jobs:
|
||||
:quartz:jvmTest \
|
||||
:commons:jvmTest \
|
||||
:nestsClient:jvmTest \
|
||||
:ammolite:testDebugUnitTest \
|
||||
:amethyst:testFdroidDebugUnitTest \
|
||||
:amethyst:testPlayDebugUnitTest \
|
||||
:amethyst:assembleBenchmark \
|
||||
|
||||
@@ -21,9 +21,13 @@ permissions:
|
||||
env:
|
||||
# Asset naming contract: amethyst-desktop-<version>-<family>-<arch>.<ext>
|
||||
# Single source of truth in scripts/asset-name.sh.
|
||||
# linuxdeploy pinned release — bump via Dependabot, verify SHA256 via env var below.
|
||||
LINUXDEPLOY_URL: https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20240109-1/linuxdeploy-x86_64.AppImage
|
||||
LINUXDEPLOY_SHA256: c86d6540f1df31061f02f539a2d3445f8d7f85cc3994eee1e74cd1ac97b76df0
|
||||
# appimagetool pinned release — bump via Dependabot, verify SHA256 via env var below.
|
||||
# We used to use linuxdeploy here, but it auto-walks the AppDir with ldd to
|
||||
# bundle deps — that fights jpackage's self-contained JRE (libjvm.so RPATH
|
||||
# mismatch) and the UPX-compressed VLC plugins. appimagetool only embeds the
|
||||
# AppDir as-is, which is what we actually want.
|
||||
APPIMAGETOOL_URL: https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage
|
||||
APPIMAGETOOL_SHA256: 46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -35,7 +39,6 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { os: macos-13, arch: x64, family: macos, tasks: "packageReleaseDmg" }
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "packageReleaseDmg" }
|
||||
- { os: windows-latest, arch: x64, family: windows, tasks: "packageReleaseMsi createReleaseDistributable" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" }
|
||||
@@ -84,17 +87,19 @@ jobs:
|
||||
if: matrix.family == 'linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y rpm fakeroot
|
||||
|
||||
- name: Fetch linuxdeploy (linux-portable only, SHA-verified)
|
||||
- name: Fetch appimagetool (linux-portable only, SHA-verified)
|
||||
if: matrix.family == 'linux-portable'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL --retry 3 "$LINUXDEPLOY_URL" -o packaging/appimage/linuxdeploy-x86_64.AppImage
|
||||
actual=$(sha256sum packaging/appimage/linuxdeploy-x86_64.AppImage | awk '{print $1}')
|
||||
if [[ "$actual" != "$LINUXDEPLOY_SHA256" ]]; then
|
||||
echo "::error::linuxdeploy SHA256 mismatch. Expected $LINUXDEPLOY_SHA256, got $actual"
|
||||
# appimagetool 1.9.0 validates the .desktop file via desktop-file-validate.
|
||||
sudo apt-get update && sudo apt-get install -y desktop-file-utils
|
||||
curl -fsSL --retry 3 "$APPIMAGETOOL_URL" -o desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
|
||||
actual=$(sha256sum desktopApp/packaging/appimage/appimagetool-x86_64.AppImage | awk '{print $1}')
|
||||
if [[ "$actual" != "$APPIMAGETOOL_SHA256" ]]; then
|
||||
echo "::error::appimagetool SHA256 mismatch. Expected $APPIMAGETOOL_SHA256, got $actual"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x packaging/appimage/linuxdeploy-x86_64.AppImage
|
||||
chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
|
||||
|
||||
- name: Build desktop artifacts
|
||||
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
|
||||
@@ -204,7 +209,6 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { os: macos-13, arch: x64, family: macos, tasks: "amyImage" }
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
+2
-3
@@ -167,9 +167,8 @@ desktopApp/src/jvmMain/appResources/macos/
|
||||
desktopApp/src/jvmMain/appResources/windows/
|
||||
|
||||
# CI-fetched AppImage tooling (downloaded by create-release workflow; not committed)
|
||||
packaging/appimage/linuxdeploy-x86_64.AppImage
|
||||
packaging/appimage/linuxdeploy-extracted/
|
||||
packaging/appimage/squashfs-root/
|
||||
desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
|
||||
desktopApp/packaging/appimage/squashfs-root/
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
||||
+21
-17
@@ -27,7 +27,8 @@ Platform-specific:
|
||||
|
||||
- **macOS**: Xcode Command Line Tools (`xcode-select --install`)
|
||||
- **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset`
|
||||
- **Linux (all)**: nothing extra for `.deb`; `rpm` + `fakeroot` for `.rpm`; `linuxdeploy` for AppImage
|
||||
- **Linux (all)**: nothing extra for `.deb`; `rpm` + `fakeroot` for `.rpm`;
|
||||
`appimagetool` + `desktop-file-utils` for AppImage
|
||||
|
||||
Install Linux RPM tooling:
|
||||
|
||||
@@ -39,12 +40,15 @@ sudo apt-get install -y rpm fakeroot
|
||||
sudo dnf install -y rpm-build
|
||||
```
|
||||
|
||||
Install linuxdeploy locally (CI fetches its own — SHA-verified):
|
||||
Install appimagetool locally (CI fetches its own — SHA-verified):
|
||||
|
||||
```bash
|
||||
curl -fsSL -o packaging/appimage/linuxdeploy-x86_64.AppImage \
|
||||
https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20240109-1/linuxdeploy-x86_64.AppImage
|
||||
chmod +x packaging/appimage/linuxdeploy-x86_64.AppImage
|
||||
# Debian/Ubuntu — appimagetool calls desktop-file-validate on the .desktop entry
|
||||
sudo apt-get install -y desktop-file-utils
|
||||
|
||||
curl -fsSL -o desktopApp/packaging/appimage/appimagetool-x86_64.AppImage \
|
||||
https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage
|
||||
chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
|
||||
```
|
||||
|
||||
---
|
||||
@@ -107,11 +111,11 @@ amethyst-desktop-<version>-<family>-<arch>.<ext>
|
||||
|
||||
Where:
|
||||
|
||||
| Field | Values |
|
||||
|---|---|
|
||||
| `<version>` | Tag stripped of leading `v` (e.g. `1.08.0`) |
|
||||
| `<family>` | `macos`, `windows`, `linux` |
|
||||
| `<arch>` | `x64`, `arm64` |
|
||||
| Field | Values |
|
||||
|---|---------------------------------------------------------|
|
||||
| `<version>` | Tag stripped of leading `v` (e.g. `1.09.2`) |
|
||||
| `<family>` | `macos`, `windows`, `linux` |
|
||||
| `<arch>` | `x64`, `arm64` |
|
||||
| `<ext>` | `dmg`, `msi`, `zip`, `deb`, `rpm`, `AppImage`, `tar.gz` |
|
||||
|
||||
Single source of truth: [`scripts/asset-name.sh`](scripts/asset-name.sh).
|
||||
@@ -120,10 +124,10 @@ any change is a breaking contract.
|
||||
|
||||
Examples:
|
||||
|
||||
- `amethyst-desktop-1.08.0-macos-x64.dmg`
|
||||
- `amethyst-desktop-1.08.0-macos-arm64.dmg`
|
||||
- `amethyst-desktop-1.08.0-windows-x64.msi`
|
||||
- `amethyst-desktop-1.08.0-linux-x64.AppImage`
|
||||
- `amethyst-desktop-1.09.2-macos-x64.dmg`
|
||||
- `amethyst-desktop-1.09.2-macos-arm64.dmg`
|
||||
- `amethyst-desktop-1.09.2-windows-x64.msi`
|
||||
- `amethyst-desktop-1.09.2-linux-x64.AppImage`
|
||||
|
||||
---
|
||||
|
||||
@@ -219,8 +223,8 @@ or equivalent issue tracker. On rotation, paste new token and run
|
||||
|
||||
```bash
|
||||
brew bump-cask-pr amethyst-nostr \
|
||||
--version 1.08.0 \
|
||||
--url "https://github.com/vitorpamplona/amethyst/releases/download/v1.08.0/amethyst-desktop-1.08.0-macos-arm64.dmg"
|
||||
--version 1.09.2 \
|
||||
--url "https://github.com/vitorpamplona/amethyst/releases/download/v1.09.2/amethyst-desktop-1.09.2-macos-arm64.dmg"
|
||||
```
|
||||
|
||||
The cask filename is `amethyst-nostr` (not `amethyst` — that's taken by a
|
||||
@@ -231,7 +235,7 @@ auto-submits new version bumps on each stable release.
|
||||
|
||||
```bash
|
||||
wingetcreate new \
|
||||
https://github.com/vitorpamplona/amethyst/releases/download/v1.08.0/amethyst-desktop-1.08.0-windows-x64.msi
|
||||
https://github.com/vitorpamplona/amethyst/releases/download/v1.09.2/amethyst-desktop-1.09.2-windows-x64.msi
|
||||
```
|
||||
|
||||
Set `PackageIdentifier = VitorPamplona.Amethyst`. After the first manifest is
|
||||
|
||||
+508
@@ -1,3 +1,511 @@
|
||||
<a id="v1.09.2"></a> Fixes
|
||||
|
||||
- Fix Blossom blob detection to reject non-compliant filenames by @greenart7c3 in #2919
|
||||
- fix(desktop): add ProGuard keep rules so desktop builds actually launch by @mstrofnone in #2921
|
||||
- fix(desktop): mirror Android ProGuard strategy for release builds by @mstrofnone in #2922
|
||||
- fix(video): pause playback when app goes to background by @vitorpamplona in #2925
|
||||
- feat(desktop): wire NamecoinSettingsSection into Settings screen by @mstrofnone in #2923
|
||||
- feat(desktop): wire Import Follow List dialog into UI by @mstrofnone in #2924
|
||||
- Fix crash when toggling home tabs with persisted pager state by @vitorpamplona in #2930
|
||||
- Dedupe public channels in known chat list by channel id by @vitorpamplona in #2932
|
||||
- Route NIP-43 relay join/leave through launchSigner by @vitorpamplona in #2933
|
||||
- Migrate DNS cache from SharedPreferences to cacheDir by @vitorpamplona in #2935
|
||||
- Filter DNS poison (loopback / any-local) from non-loopback hosts by @vitorpamplona in #2936
|
||||
- perf(dns-cache): hand-rolled binary persistence format for SurgeDnsStore by @vitorpamplona in #2937
|
||||
- Ignore duplicated hashtags in different char cases when processing hashtag spam by @vitorpamplona
|
||||
- New Crowdin translations
|
||||
|
||||
<a id="v1.09.1"></a> Activates Nest, Git Screens and Fixes
|
||||
|
||||
- Activates Nest option in the Left Drawer
|
||||
- Rename onRefresh callback to avoid shadowing parameter by @vitorpamplona in #2912
|
||||
- Fix desktop app ProGuard build with Compose 1.11.0 by @vitorpamplona in #2914
|
||||
- Fix pull-to-refresh indicator positioning in DisappearingScaffold by @vitorpamplona in #2916
|
||||
- Remove collectAsStateWithLifecycle from BottomBarSettingsContent by @vitorpamplona in #2918
|
||||
- Add Git repository detail screen with issues/patches tabs by @vitorpamplona in #2917
|
||||
|
||||
<a id="v1.09.0"></a>
|
||||
# [Release v1.09.0: We are going crazy](https://github.com/vitorpamplona/amethyst/releases/tag/v1.09.0) - 2026-05-14
|
||||
|
||||
What's New?
|
||||
- Go live on audio spaces (Nests)
|
||||
- Marmot Group chats (WhiteNoise)
|
||||
- Voice and Video calls (Noscall)
|
||||
- PDF reader
|
||||
- Favorite algo feeds
|
||||
- HLS Video Uploads
|
||||
- Schedule posts for later
|
||||
- Cast videos to your TV (Chromecast)
|
||||
- Multiple accounts on Desktop
|
||||
- Mute a whole conversation thread
|
||||
- Tap any timestamp to see the exact date and time
|
||||
- Pull Notification (internal Pokey)
|
||||
- Local LLM helpers (Pixel 9+, Samsung 25+, Xiaomi 15+)
|
||||
- `amy`, a command-line Nostr client
|
||||
|
||||
## Features
|
||||
|
||||
- Voice and Video Calls (NIP-AC) — one-on-one and group WebRTC calls.
|
||||
- Full-screen incoming-call UI over the lock screen.
|
||||
- PiP, ringtone and vibration.
|
||||
- Proximity sensor support.
|
||||
- Bluetooth headset routing.
|
||||
- Camera switch.
|
||||
- Network resilience and default TURN servers.
|
||||
- Mid-call peer invites with a 30s timeout and per-peer status.
|
||||
- Settings toggle to disable calls.
|
||||
- Audio Rooms / Nests (NIP-53) — a full revamp.
|
||||
- Live chat panel.
|
||||
- Reactions overlay and picker.
|
||||
- Listener counter.
|
||||
- Presence with publishing/onstage tags.
|
||||
- Hand-raise queue.
|
||||
- Host actions: kick, promote, demote, edit, close room.
|
||||
- Per-participant context sheet.
|
||||
- Scheduled rooms with a TimePicker and SCHEDULED badge.
|
||||
- "Listen to recording" CTA for closed rooms.
|
||||
- Share a room as `naddr1`.
|
||||
- Custom room themes and fonts (kind 30312).
|
||||
- Home live-bubble row showing follows broadcasting.
|
||||
- Host-leave confirmation and default-server prompt.
|
||||
- In-app lobby with a chat composer, gating room re-entry.
|
||||
- PiP that focuses active speakers.
|
||||
- Feed bucketed into Live / Scheduled / Recently ended, with live status verified by current presence.
|
||||
- Live audio-level speaker ring.
|
||||
- Keeps the screen on while connected.
|
||||
- Audio plays through the media volume stream.
|
||||
- Marmot Encrypted Group Chats (MLS over Nostr / NIP-EE)
|
||||
- Create, join and leave groups.
|
||||
- Inline group rendering in Messages.
|
||||
- Member management with user search.
|
||||
- Admin grant/revoke.
|
||||
- Group info screen with picture, member list and per-relay freshness.
|
||||
- Auto-publish KeyPackage.
|
||||
- Reset Marmot State safety valve in Settings.
|
||||
- Full RFC 9420 compliance pass (P0/P1/P2).
|
||||
- External Commit flow.
|
||||
- Retained-epoch decryption for offline catch-up.
|
||||
- Required-capabilities advertised on groups; interop fixes for other Marmot clients.
|
||||
- Popup notifications for group messages (kind:445).
|
||||
- Multi-account on Desktop
|
||||
- Account switcher dropdown in the sidebar and single-pane layout.
|
||||
- Add Account dialog and per-account logout.
|
||||
- View-only (npub-only) accounts.
|
||||
- Account removal switches to another account or logs out cleanly.
|
||||
- Encrypted local account storage (AES-256-GCM).
|
||||
- Display names and middle-truncated npubs.
|
||||
- Schedule posts for later
|
||||
- Date/time picker and toolbar toggle in the post composer.
|
||||
- Dedicated screen and drawer entry to view, push or delete scheduled posts.
|
||||
- Background worker that publishes at the scheduled time.
|
||||
- Warning when scheduling without always-on notifications.
|
||||
- Cast videos to your TV
|
||||
- Chromecast casting (Google Play build only).
|
||||
- Stop-from-phone button; the local player pauses while casting.
|
||||
- Cast button backfilled for accounts that already had video settings.
|
||||
- Mute a whole thread
|
||||
- Mute thread entry in the long-press dropdown and quick-action sheet.
|
||||
- Muted threads listed in Security Filters with an unmute action.
|
||||
- Muted threads dropped from feeds, notifications and push delivery.
|
||||
- Configurable home tabs
|
||||
- Choose between New Threads, Conversations and Everything.
|
||||
- Visibility toggles persist across restarts.
|
||||
- Configurable bottom navigation bar
|
||||
- Pick which screens appear in the bottom bar.
|
||||
- Restore-default button in settings.
|
||||
- Reply and Mention notifications (NIP-10 / NIP-22)
|
||||
- Dedicated Mentions channel.
|
||||
- Per-thread grouping.
|
||||
- Inline reply.
|
||||
- All content-event citations routed to Mentions.
|
||||
- Opt-in Following / Everyone tab split.
|
||||
- Filter the home feed in place by hashtag, community, geohash and relay (no navigation away)
|
||||
- Hashtag and geohash top-nav filters on Pictures, Shorts, Articles, Polls and Products
|
||||
- NIP-22 comments on external content (hashtags, geohashes, URLs) render a typed reply-context chip and land in the conversations feed
|
||||
- Interest Sets (NIP-51, kind 30015)
|
||||
- List, create, rename, delete and clone interest sets.
|
||||
- Public/private hashtag toggle.
|
||||
- TopNav filter chips.
|
||||
- NIP-9A Community Rules
|
||||
- Structured rules editor in the new-community flow.
|
||||
- Post validation against community rules in the composer.
|
||||
- Opt-in moderation feed filter.
|
||||
- PDF reader
|
||||
- Inline PDF previews in feeds.
|
||||
- Double-tap to toggle zoom.
|
||||
- Zoom-aware hi-res re-render for crisp pinch-zoom.
|
||||
- Download and save PDFs to Downloads/Amethyst.
|
||||
- Multi-wallet NWC
|
||||
- Multiple wallets with a balance view.
|
||||
- Default picker, rename and reorder.
|
||||
- Dedicated Add Wallet screen with Connect Wallet / paste / QR scan.
|
||||
- Favorite Algo Feeds filter in the Top Nav Bar
|
||||
- Custom Post creation on Polls / Pictures / Shorts / Longs
|
||||
- Custom Emoji Packs (NIP-30)
|
||||
- Browse Emoji Sets screen for discovering kind 30030 packs.
|
||||
- My Emoji List screen for managing your kind 10030 selection.
|
||||
- Modernized pack metadata screen with hero image and inline emoji/cover upload.
|
||||
- Public/private toggle when adding emoji.
|
||||
- Decrypted private emojis surfaced end-to-end.
|
||||
- Dedicated drawer screens for more content types.
|
||||
- Standalone Articles, Products, Public Chats, Communities (NIP-72), Live Streams and Follow Packs screens.
|
||||
- Products screen defaults to "Around Me".
|
||||
- Richer live stream chat.
|
||||
- Inline clips (kind 1313) and raids (kind 1312).
|
||||
- Inline zap receipts.
|
||||
- NIP-75 zap goal pinned at the top.
|
||||
- Top zappers leaderboard.
|
||||
- Stream clips surfaced in the profile gallery tab.
|
||||
- Content warnings on media.
|
||||
- Grid-level content warnings with distinct reasons.
|
||||
- Warning overlaid on the blurhash at media size.
|
||||
- YouTube-style video quality picker.
|
||||
- Feed and PiP default to the lowest HLS resolution.
|
||||
- Fullscreen defaults to auto.
|
||||
- HLS Video uploads (NIP-71)
|
||||
- Pick which renditions to upload.
|
||||
- See which file is currently uploading.
|
||||
- Optional cross-post as a kind-1 note.
|
||||
- Generated poster JPEG.
|
||||
- Blurhash and thumbhash on every video imeta.
|
||||
- ThumbHash support alongside BlurHash
|
||||
- Used across events, uploads and the UI.
|
||||
- Forwarded when adding media to the gallery.
|
||||
- Upload failures to generate a blurhash/thumbhash are now surfaced.
|
||||
- NIP-A3 Payment Targets (kind 10133)
|
||||
- Pay action on the note reactions row.
|
||||
- Payment-targets button on the profile.
|
||||
- Lightning address moved to the wallet setup screen.
|
||||
- Alt-text on payment-target events.
|
||||
- Search power tools
|
||||
- Scope, source, follows and sort toggles.
|
||||
- Paste an `npub1…`, `nprofile1…`, `nevent1…`, `naddr1…` or `note1…` to jump straight to it.
|
||||
- Markdown renderer — improved typography, blockquote gutter, table styling
|
||||
- Polls
|
||||
- Single-screen creation with a poll-type selector.
|
||||
- Open/Closed tabs.
|
||||
- "View results" option (prevents voting after viewing).
|
||||
- Dismiss button on active-poll cards.
|
||||
- Badge support Redesigned — You can now create, grant, manage and add/remove badges from your profile.
|
||||
- Settings revamp
|
||||
- Modernized Settings screen.
|
||||
- Dedicated Profile UI settings page.
|
||||
- Compose Settings screen (auto-draft toggle).
|
||||
- Security Filters split into a hub with per-category screens.
|
||||
- Tap a timestamp to toggle between relative ("2h ago") and absolute date/time, driven by a single shared ticker
|
||||
- Copy raw JSON of a note from the dropdown menu
|
||||
- Stale-relay hint on replaceable events, using the NIP-66 relay cache
|
||||
- Two-stage zap progress on the zap action
|
||||
- Bulk-remove for blocked users and hidden words
|
||||
- Jump-to-parent icon on replies in Full UI mode
|
||||
- Configurable report-warning threshold
|
||||
- `.f4a` audio playback
|
||||
|
||||
## In AI-Ready phones (Pixel 9+, Samsung 25+, Xiaomi 15+):
|
||||
- AI Writing Help — assistant in the new-post screen.
|
||||
- Tone suggestions, precomputed in parallel.
|
||||
- Auto language detection.
|
||||
- On-device option in Application Preferences.
|
||||
- AI Alt-Text for images — on-device image description / labeling.
|
||||
- Suggestions appear in the upload sheet (Google Play build).
|
||||
|
||||
## Desktop
|
||||
|
||||
- Tor Support — full Tor support on the desktop app.
|
||||
- kmp-tor daemon and settings UI.
|
||||
- Per-relay routing.
|
||||
- `.onion` badge.
|
||||
- Restart-on-toggle.
|
||||
- Image loading via Tor.
|
||||
- Multi-account
|
||||
- Sidebar account switcher.
|
||||
- Add Account dialog and per-account logout.
|
||||
- Encrypted account storage.
|
||||
- Embedded local relay — an in-process relay with SQLite event persistence
|
||||
- Custom feeds system
|
||||
- Create, pin and inline-switch between custom feeds.
|
||||
- Author search in the feed builder (relay NIP-50 + avatars).
|
||||
- App Drawer with a categorized screen launcher
|
||||
- Workspace management
|
||||
- Save, switch and restore workspaces.
|
||||
- Tabs, an editor and unified search.
|
||||
- Pin/unpin syncs to the active workspace.
|
||||
- Namecoin name resolution
|
||||
- Namecoin lookups now resolve and surface in search.
|
||||
- Follows the `import` field of name objects (ifa-0001).
|
||||
- Added `relay.testls.bit` ElectrumX endpoints (clearnet TLS, Tor, bare IP).
|
||||
- Native theming for macOS, GNOME, KDE and Windows (matches platform look and accent colors)
|
||||
- Relay power tools
|
||||
- Dashboard and config editors.
|
||||
- Per-screen relay picker.
|
||||
- Persistent configuration.
|
||||
- Correct counts.
|
||||
- Messages
|
||||
- Draggable divider.
|
||||
- Alignment polish and centered empty states.
|
||||
- Typography hierarchy and refined dividers.
|
||||
- macOS polish
|
||||
- Dock / Cmd+Tab icon via the Taskbar API.
|
||||
- Apple-HIG squircle margins.
|
||||
- Transparent window icon.
|
||||
- Light-mode primary contrast.
|
||||
- Content extends correctly under the title bar.
|
||||
- Reading layout — width-capped reading column with comfortable side margins for wide windows
|
||||
- Compact UI
|
||||
- Search/Chat/Profile inputs.
|
||||
- Settings hierarchy normalized.
|
||||
- Tabs-first headers across Home / Reads / Notifications.
|
||||
- Whole-card hover on notes.
|
||||
- Per-OS theming preview flag for testing macOS/GNOME/KDE/Windows looks locally
|
||||
- Selectable error messages.
|
||||
- Scrollable single-pane navigation rail.
|
||||
- Fixes feed loading, repost rendering and Profile back-navigation visibility.
|
||||
|
||||
## Amy (CLI)
|
||||
|
||||
- New `amy`, a non-interactive CLI Nostr client.
|
||||
- Drives the same Quartz + Commons engine as the apps.
|
||||
- Available on macOS and Linux from the GitHub Release.
|
||||
- Subcommands:
|
||||
- `account` / `use`, `profile`, `post`, `feed`, `notes`.
|
||||
- `dm send | list | await | send-file` (NIP-17, kind:14 + kind:15).
|
||||
- `marmot …`.
|
||||
- `store stat | sweep-expired | scrub | compact`.
|
||||
- Cache-first reads from a local file-backed event store.
|
||||
- `relays.json` is gone — `kind:10002 / 10050 / 10051` events in the store *are* the config.
|
||||
- Secure key storage.
|
||||
- Private keys move out of `identity.json` into the OS keychain or a NIP-49 encrypted file.
|
||||
- On-disk data restricted to owner-only.
|
||||
- Color, human-readable output by default; `--json` opts in.
|
||||
|
||||
## Quartz
|
||||
|
||||
- Adds NIP-AC — WebRTC call signaling events (offer / answer / ICE / hangup / reject / renegotiate) over EphemeralGiftWrap, multi-device, group calls
|
||||
- Adds EphemeralGiftWrapEvent (kind 21059) — replaces 20s expiration GiftWraps for call signaling
|
||||
- Adds NIP-A3 Payment Targets (kind 10133)
|
||||
- Adds NIP-82 Software Applications (experimental)
|
||||
- Adds the AdminCommandEvent for audio-room kick (kind 4312)
|
||||
- Adds the NIP-9A community rules parser + validator (kind:34551)
|
||||
- Expands NIP-34 git collaboration coverage.
|
||||
- Repository State (kind 30618).
|
||||
- Pull Requests and PR updates (kinds 1618 / 1619).
|
||||
- Git Status events (open / closed / draft / applied).
|
||||
- Adds the rest of NIP-51 list event kinds and full NIP-53 live-activity rendering
|
||||
- Adds MLS / Marmot event types and a pure-Kotlin MLS engine with IETF RFC 9420 interop test vectors (no native deps)
|
||||
- Adds an async SQLite event persistence layer.
|
||||
- NIP-09 / NIP-50 / NIP-62 compliance.
|
||||
- Room-style connection pool.
|
||||
- Adds a file-backed event store.
|
||||
- flock + transactions.
|
||||
- scrub/compact.
|
||||
- NIP-50 full-text search.
|
||||
- NIP-62 Right-to-Vanish.
|
||||
- NIP-01 tiebreaker.
|
||||
- NIP-09 created_at window.
|
||||
- Deletion-author check.
|
||||
- Adds a reactive `ObservableEventStore` layer.
|
||||
- A façade that wraps any event store — SQLite-backed, file-backed, or in-memory.
|
||||
- Publishes a `StoreChange` on every accepted insert, delete and expiration sweep.
|
||||
- Projections stay in sync without re-querying the store.
|
||||
- Ephemeral events (kinds 20000-29999) emit without being persisted.
|
||||
- `EventStoreProjection` turns the change stream into a cold `Flow` of sealed `ProjectionState`.
|
||||
- Per-filter limits and per-projection NIP-62 vanish scoping.
|
||||
- Promotes the relay toolkit into the new `geode` module — a real Nostr relay.
|
||||
- Implements NIP-01 and NIP-45.
|
||||
- NIP-77 negentropy reconciliation (strfry parity).
|
||||
- NIP-86 management API.
|
||||
- TOML config and graceful drain.
|
||||
- Adaptive connection pooling for 10k+ connections.
|
||||
- Adds an EventInterner so deserialized events share canonical instances, with an interning event store that interns on insert
|
||||
- Adds Ktor KMP HTTP implementations alongside OkHttp
|
||||
- Adds macOS (Apple Silicon), iOS and Linux native targets.
|
||||
- Pure-Kotlin Ed25519 and X25519 for the MLS crypto on those platforms.
|
||||
- `commonMain` now compiles for Kotlin/Native.
|
||||
|
||||
## Crypto and Performance
|
||||
|
||||
- Custom secp256k1 implementation, starting to replace `fr.acinq.secp256k1`
|
||||
- Pure-Kotlin core for KMP native targets (macOS / iOS / Linux).
|
||||
- C + inline-ASM accelerated path on Android via a JNI bridge.
|
||||
- Hardware SHA-256 (SHA-NI on x86_64, ARMv8 CE on ARM64).
|
||||
- Comb method for G multiplication → 3× faster sign/keygen.
|
||||
- Same-pubkey batch Schnorr verify (5–6× throughput).
|
||||
- `verifySchnorrFast` for Nostr (skips y-parity inversion).
|
||||
- 4×64-bit limb representation with lazy field ops and ARM64 assembly.
|
||||
- Standalone `libsecp256k1-nostr` / `libschnorr256k1` C project, with Android benchmarks.
|
||||
- Concurrent caching DNS resolver (SurgeDns)
|
||||
- Lock-free DNS cache.
|
||||
- 24h positive TTL.
|
||||
- Stale-while-revalidate.
|
||||
- Persisted across process restarts.
|
||||
- Smoother video playback
|
||||
- Warm ExoPlayer pool and retained warm players.
|
||||
- Tuned LoadControl.
|
||||
- VideoCache warmup 10s → 1.5s.
|
||||
- Stable controller-overlay tree.
|
||||
- Faster icons — shared FontFamily and TextMeasurer across all Material Symbols
|
||||
- Faster chat lists — stable list keys and reduced recomposition
|
||||
- Faster note rendering — cached event-derived values, fewer per-item allocations during feed scroll
|
||||
- Faster Quartz queries
|
||||
- Direct-slot driver for replaceable + addressable lookups.
|
||||
- Streaming k-way merge.
|
||||
- Smallest-first FTS intersect.
|
||||
- Parallel Schnorr verify in the ingest queue.
|
||||
- Index-driven fan-out for cache observers.
|
||||
- Faster rich-text translation
|
||||
- Thumbnail disk cache for profile pictures; Coil disk-cache eviction moved off the write path to prevent scroll stalls
|
||||
- Paginated GiftWrap loading for the DM chat list
|
||||
- Bookmark events preloaded for faster access
|
||||
- Lifecycle-aware screen subscriptions
|
||||
- Feed/screen relay subscriptions pause on background and resume on foreground.
|
||||
- 30s grace delay so brief app switches don't churn subscriptions.
|
||||
- Adaptive video disk cache — sized to 20% of free disk (256 MB–4 GB) instead of a fixed 1 GB, with on-demand HLS videos cached in SimpleCache
|
||||
- Tuned image/video OkHttp dispatcher and connection pool (16 in-flight per host) to de-serialize feed loading
|
||||
- Streaming image hashing — computes image hashes without loading the whole file into memory; SHA-256 hasher moved off the thread pool
|
||||
- GeoHash library rewritten from scratch for performance, dropping an external dependency
|
||||
|
||||
## QUIC + nestsClient (foundation)
|
||||
|
||||
- New pure-Kotlin QUIC v1 + HTTP/3 + WebTransport client (no JNI, no native deps).
|
||||
- Powers the NIP-53 audio-rooms over MoQ-transport path.
|
||||
- Full RFC coverage and stabilization:
|
||||
- RFC 9002 loss recovery and retransmission.
|
||||
- 0-RTT early data.
|
||||
- 1-RTT key update.
|
||||
- TLS 1.3 session resumption (PSK).
|
||||
- ECN.
|
||||
- Connection migration with path validation.
|
||||
- Retry and Version Negotiation packet handling.
|
||||
- Stateless-reset detection.
|
||||
- Broad DoS-hardening / RFC-compliance stabilization sweep.
|
||||
- Passes the quic-interop-runner test matrix against picoquic and quic-go.
|
||||
- Covers handshake, transfer, multiplexing, retry, 0-RTT, key-update, ECN, http3.
|
||||
- Includes qlog diagnostics.
|
||||
- Multiple security and correctness audits.
|
||||
- RFC 9001 test vectors.
|
||||
- Live interop against aioquic and picoquic.
|
||||
- `nestsClient` module
|
||||
- MoQ-transport (IETF) reference implementation.
|
||||
- Production moq-lite Lite-03/04 codec with version-aware ALPN negotiation.
|
||||
- `catalog.json` publishing aligned with kixelated/hang.
|
||||
- Opus + AudioRecord/AudioTrack.
|
||||
- Reconnection policy with proactive JWT refresh.
|
||||
- Cross-stack (Amethyst ↔ Rust ↔ browser) interop harness in CI.
|
||||
|
||||
## Improvements and Fixes
|
||||
- WakeUp Push Notification events — Starting to migrate to a better Push/Loading system
|
||||
- Pinned notes moved to their own screen
|
||||
- Left drawer reorganized into collapsible You / Feeds / Create / System sections, with clearer names
|
||||
- Article writing redesign — banner, tags, slug
|
||||
- Redesigned long-form article cards
|
||||
- GIF support
|
||||
- Playback controls and autoplay.
|
||||
- GIF→MP4 upload conversion option in the upload screen.
|
||||
- GIF / image keyboard support in the short post screen and in Marmot, DM and public-channel chat fields.
|
||||
- Configurable video player buttons in Account Settings
|
||||
- Autoplay Videos setting (Always / Never), separate from the video-loading toggle
|
||||
- Drag-and-drop reordering for some relay list settings
|
||||
- 3-dot options menu on video / picture / file feed cards
|
||||
- Zoomable media grows from its source bounds, and loads the full-resolution source in the image dialog
|
||||
- Favorite relays can now be added to the Global Feed
|
||||
- Configurable max-hashtag spam filter
|
||||
- Account setting to forward kind 0 events to a local relay
|
||||
- Relay Sync UI replaced with visual indicators
|
||||
- Account Settings
|
||||
- Split broadcast tracker visibility from Complete UI mode.
|
||||
- Hide payment-targets icon by default and place it after Zap.
|
||||
- Float the broadcast banner as a rounded card.
|
||||
- Danger Zone section in settings
|
||||
- NIP-89 client tag
|
||||
- Per-account toggle to disable it, synced via NIP-78 security settings.
|
||||
- On by default and moved into Compose settings.
|
||||
- Local Blossom cache — image and profile-picture fetches route through a local Blossom cache
|
||||
- Mention preservation in compose:
|
||||
- Survives keyboard auto-correction.
|
||||
- Partial-overlap edits delete the whole mention.
|
||||
- Cursor snaps to mention boundaries.
|
||||
- Chat cursor jumping fixed
|
||||
- Avatar zoom-in keeps aspect ratio during the animation
|
||||
- Profile pictures center-cropped to prevent squashing
|
||||
- HLS video fixes:
|
||||
- Playback routed to the right MediaSource.
|
||||
- Multi-rendition videos collapse to a single gallery tile.
|
||||
- Render with artwork and a graceful fallback.
|
||||
- Broken "Pause" action removed from the always-on background notification
|
||||
- Hand-raise button in audio rooms now has a visible toggled state
|
||||
- GiftWrap unwrapping for all writable accounts when always-on is enabled
|
||||
- Search bar bech32 paste navigates instead of running a search
|
||||
- Top and bottom bars stay visible on non-scrollable lists
|
||||
- Rich-text translation:
|
||||
- Bug, performance and jitter overhaul.
|
||||
- `{N}` placeholders so URLs survive CJK translation.
|
||||
- Swipe-to-dismiss containers fixed on newer Compose
|
||||
- Right-to-Vanish settings observe toggles reactively, preserve prior behavior on upgrade
|
||||
- Relay reconnection:
|
||||
- Auto-reconnect after a server-initiated disconnect.
|
||||
- Periodic keep-alive to revive relays stuck in long backoff.
|
||||
- Account settings (profile, follow list, mutes, relay lists, KeyPackages) are republished to newly-selected relays so accounts aren't lost on fresh relays
|
||||
- Broadcasting relays:
|
||||
- Kept out of personal & channel sends.
|
||||
- Always included in non-private sends.
|
||||
- Fixed an infinite loop in the broadcast-relay computation.
|
||||
- Tor now falls back to clearnet when bootstrap is stuck
|
||||
- Android Arti reliability: stale Arti cache cleared on init with retry, SOCKS proxy default port moved with busy-port retry, relay-over-Tor connectivity fixes
|
||||
- Chess game challenges filtered out of the home feed (ended games only); chess cards show user picture and name instead of hex pubkeys
|
||||
- Expired polls re-evaluated and removed from notification cards
|
||||
- NIP-39 external identity claims without a platform separator are rejected
|
||||
- Dismissible cleanup banner across Pinned Notes, Bookmarks and Bookmark Sets, flagging author-deleted items with a "Remove from list" action
|
||||
- Bogus Content-Type rejected when saving downloaded media, with URL-extension fallback validation
|
||||
- NIP-46 bunker decrypt/encrypt response parsing fixed, with a longer timeout
|
||||
- Hidden DMs no longer counted toward the unread message badge
|
||||
- Profile header hides the `_@` prefix on NIP-05 names
|
||||
- Foreground-service-not-allowed exception from the background handled gracefully
|
||||
- Fixes Samsung crash on outgoing call
|
||||
- Foreground service starts earlier to prevent call death on Android 14+
|
||||
- Stop ringtone and call notification when rejecting consecutive calls
|
||||
|
||||
## UI Refresh
|
||||
|
||||
- Migrates the icon set from Material Icons to Material Symbols (thin weight) for a more consistent, modern look across the app
|
||||
- Drops unused legacy drawables
|
||||
- Bottom-bar icon size bumped to compensate for Material Symbols padding
|
||||
|
||||
## Build & Documentation
|
||||
|
||||
- CI restructure:
|
||||
- Splits Android into its own CI job.
|
||||
- Adds Android Lint as the first step.
|
||||
- Merges test+build to eliminate duplicate compilation.
|
||||
- Drops `assembleDebug` APK uploads.
|
||||
- Adds a `:nestsClient:test` step to the desktop CI leg
|
||||
- Adds a quic-interop-runner CI workflow and a browser-side cross-stack interop workflow
|
||||
- Broadens `libicu` Depends so the `.deb` installs across Debian and Ubuntu
|
||||
- Adds `SECURITY.md` with private vulnerability reporting policy
|
||||
- Moves desktop packaging / AppImage tooling into the `desktopApp` module
|
||||
- AGP and dependencies bumped
|
||||
|
||||
## Contributors
|
||||
|
||||
- @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z
|
||||
- @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
|
||||
- @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7y76c
|
||||
- @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
|
||||
- @npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k
|
||||
- @npub1dn0tej4a5806qk9ts56j572sndvjk27l5qmsxf0z3mquknccve7s4k8tfp
|
||||
|
||||
## Translations
|
||||
|
||||
- Czech, German, Swedish, and Portuguese by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
|
||||
- Hungarian by @npub1dnvslq0vvrs8d603suykc4harv94yglcxwna9sl2xu8grt2afm3qgfh0tp
|
||||
- French by @npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz
|
||||
- Polish by @npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm
|
||||
- Hindi by @npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6
|
||||
- Slovenian by @npub1qqqqqqz7nhdqz3uuwmzlflxt46lyu7zkuqhcapddhgz66c4ddynswreecw
|
||||
- Bengali by @npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t
|
||||
- Spanish by @npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903
|
||||
- Chinese by hypnotichemionus4 and @npub1gd8e0xfkylc7v8c5a6hkpj4gelwwcy99jt90lqjseqjj2t253s2s6ch58h
|
||||
- Russian by Anton Zhao
|
||||
|
||||
<a id="v1.08.0"></a>
|
||||
# [Release v1.08.0: Arti](https://github.com/vitorpamplona/amethyst/releases/tag/v1.08.0) - 2026-04-01
|
||||
|
||||
|
||||
@@ -164,7 +164,6 @@ Modules:
|
||||
- `amethyst/` — Android app: Activity, layouts, navigation.
|
||||
- `desktopApp/` — Desktop JVM app: Window, sidebar, keyboard shortcuts.
|
||||
- `cli/` — `amy`, a non-interactive JVM CLI over `quartz` + `commons`.
|
||||
- `ammolite/` — Legacy support module.
|
||||
|
||||
Per-module design docs live in `<module>/plans/YYYY-MM-DD-<slug>.md`. The
|
||||
global `docs/plans/` folder is frozen.
|
||||
|
||||
@@ -54,9 +54,9 @@ android {
|
||||
applicationId = "com.vitorpamplona.amethyst"
|
||||
minSdk = libs.versions.android.minSdk.get().toInteger()
|
||||
targetSdk = libs.versions.android.targetSdk.get().toInteger()
|
||||
versionCode = 442
|
||||
versionCode = 445
|
||||
versionName = generateVersionName(libs.versions.app.get())
|
||||
buildConfigField "String", "RELEASE_NOTES_ID", "\"be99e8c8d4df0f54b44eb6c96976ccb38baeea0192436a1c6fc8bc5e930da6b0\""
|
||||
buildConfigField "String", "RELEASE_NOTES_ID", "\"b1b91d7ee0c5da9d081d1a53470248ee4585b058b11aa34fe28c0e3e07ac1e0a\""
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -270,7 +270,6 @@ dependencies {
|
||||
|
||||
implementation project(path: ':quartz')
|
||||
implementation project(path: ':commons')
|
||||
implementation project(path: ':ammolite')
|
||||
implementation project(path: ':nestsClient')
|
||||
implementation libs.androidx.core.ktx
|
||||
implementation libs.androidx.activity.compose
|
||||
|
||||
Vendored
-1
@@ -62,7 +62,6 @@
|
||||
# JSON parsing
|
||||
-keep class com.vitorpamplona.quartz.** { *; }
|
||||
-keep class com.vitorpamplona.amethyst.** { *; }
|
||||
-keep class com.vitorpamplona.ammolite.** { *; }
|
||||
|
||||
# Room generates *_Impl subclasses instantiated reflectively via no-arg constructor.
|
||||
# -keepnames preserves the name but R8 still strips the unused <init>().
|
||||
|
||||
@@ -219,8 +219,8 @@ class AppModules(
|
||||
|
||||
// Persists [surgeDns]'s positive cache across process restarts so cold starts don't pay
|
||||
// ~700 sync getaddrinfo calls. Restored entries fall through to the stale-while-revalidate
|
||||
// path on first lookup.
|
||||
val dnsStore = SurgeDnsStore(appContext, surgeDns)
|
||||
// path on first lookup. Stored in cacheDir — pure perf data, OK if the OS evicts it.
|
||||
val dnsStore = SurgeDnsStore(File(appContext.safeCacheDir(), SurgeDnsStore.FILE_NAME), surgeDns)
|
||||
|
||||
// manages all the other connections separately from relays.
|
||||
val okHttpClients: DualHttpClientManager =
|
||||
@@ -542,7 +542,7 @@ class AppModules(
|
||||
// image cache in disk for coil
|
||||
val diskCache: DiskCache by lazy {
|
||||
Log.d("AppModules", "ImageCacheFactory Init")
|
||||
ImageCacheFactory.newDisk(appContext)
|
||||
ImageCacheFactory.newDisk(appContext, applicationIOScope)
|
||||
}
|
||||
|
||||
// image cache in memory for coil
|
||||
|
||||
@@ -161,7 +161,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.countHashtags
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasMoreHashtagsThan
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds
|
||||
@@ -548,8 +548,8 @@ class Account(
|
||||
return false
|
||||
}
|
||||
|
||||
suspend fun updateDisableClientTag(disable: Boolean): Boolean {
|
||||
if (settings.updateDisableClientTag(disable)) {
|
||||
suspend fun updateAddClientTag(add: Boolean): Boolean {
|
||||
if (settings.updateAddClientTag(add)) {
|
||||
sendNewAppSpecificData()
|
||||
return true
|
||||
}
|
||||
@@ -999,7 +999,15 @@ class Account(
|
||||
return true
|
||||
}
|
||||
|
||||
fun computeRelayListToBroadcast(event: Event): Set<NormalizedRelayUrl> {
|
||||
fun computeRelayListToBroadcast(event: Event): Set<NormalizedRelayUrl> = computeRelayListToBroadcast(event, mutableSetOf())
|
||||
|
||||
private fun computeRelayListToBroadcast(
|
||||
event: Event,
|
||||
visited: MutableSet<HexKey>,
|
||||
): Set<NormalizedRelayUrl> {
|
||||
// a-tagged events can form cycles; without this the two recursive descents stack-overflow.
|
||||
if (!visited.add(event.id)) return emptySet()
|
||||
|
||||
if (event is GiftWrapEvent) {
|
||||
val receiver = event.recipientPubKey()
|
||||
return if (receiver != null) {
|
||||
@@ -1078,7 +1086,7 @@ class Account(
|
||||
}
|
||||
|
||||
linkedNote.event?.let { linkedEvent ->
|
||||
relayList.addAll(computeRelayListToBroadcast(linkedEvent))
|
||||
relayList.addAll(computeRelayListToBroadcast(linkedEvent, visited))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1099,7 +1107,7 @@ class Account(
|
||||
}
|
||||
|
||||
linkedNote.event?.let { linkedEvent ->
|
||||
relayList.addAll(computeRelayListToBroadcast(linkedEvent))
|
||||
relayList.addAll(computeRelayListToBroadcast(linkedEvent, visited))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3061,7 +3069,7 @@ class Account(
|
||||
|
||||
private fun hasExcessiveHashtags(note: Note): Boolean {
|
||||
val limit = settings.syncedSettings.security.maxHashtagLimit.value
|
||||
return limit > 0 && (note.event?.countHashtags() ?: 0) > limit
|
||||
return limit > 0 && note.event?.hasMoreHashtagsThan(limit) == true
|
||||
}
|
||||
|
||||
override fun isAcceptable(note: Note): Boolean {
|
||||
|
||||
@@ -440,8 +440,8 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun updateDisableClientTag(disable: Boolean): Boolean =
|
||||
if (syncedSettings.security.updateDisableClientTag(disable)) {
|
||||
fun updateAddClientTag(add: Boolean): Boolean =
|
||||
if (syncedSettings.security.updateAddClientTag(add)) {
|
||||
saveAccountSettings()
|
||||
true
|
||||
} else {
|
||||
|
||||
@@ -56,7 +56,7 @@ class AccountSyncedSettings(
|
||||
MutableStateFlow(internalSettings.security.filterSpamFromStrangers),
|
||||
MutableStateFlow(internalSettings.security.maxHashtagLimit),
|
||||
MutableStateFlow(internalSettings.security.sendKind0EventsToLocalRelay),
|
||||
MutableStateFlow(internalSettings.security.disableClientTag),
|
||||
MutableStateFlow(internalSettings.security.addClientTag),
|
||||
)
|
||||
val videoPlayer =
|
||||
AccountVideoPlayerPreferences(
|
||||
@@ -85,7 +85,7 @@ class AccountSyncedSettings(
|
||||
security.filterSpamFromStrangers.value,
|
||||
security.maxHashtagLimit.value,
|
||||
security.sendKind0EventsToLocalRelay.value,
|
||||
security.disableClientTag.value,
|
||||
security.addClientTag.value,
|
||||
),
|
||||
videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value),
|
||||
)
|
||||
@@ -146,8 +146,8 @@ class AccountSyncedSettings(
|
||||
security.sendKind0EventsToLocalRelay.tryEmit(syncedSettingsInternal.security.sendKind0EventsToLocalRelay)
|
||||
}
|
||||
|
||||
if (security.disableClientTag.value != syncedSettingsInternal.security.disableClientTag) {
|
||||
security.disableClientTag.tryEmit(syncedSettingsInternal.security.disableClientTag)
|
||||
if (security.addClientTag.value != syncedSettingsInternal.security.addClientTag) {
|
||||
security.addClientTag.tryEmit(syncedSettingsInternal.security.addClientTag)
|
||||
}
|
||||
|
||||
val newVideoPlayerButtonItems =
|
||||
@@ -247,7 +247,7 @@ class AccountSecurityPreferences(
|
||||
var filterSpamFromStrangers: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
val maxHashtagLimit: MutableStateFlow<Int> = MutableStateFlow(5),
|
||||
var sendKind0EventsToLocalRelay: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||
val disableClientTag: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||
val addClientTag: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
) {
|
||||
fun updateShowSensitiveContent(show: Boolean?): Boolean {
|
||||
if (showSensitiveContent.value != show) {
|
||||
@@ -297,9 +297,9 @@ class AccountSecurityPreferences(
|
||||
false
|
||||
}
|
||||
|
||||
fun updateDisableClientTag(disable: Boolean): Boolean =
|
||||
if (disable != disableClientTag.value) {
|
||||
disableClientTag.tryEmit(disable)
|
||||
fun updateAddClientTag(add: Boolean): Boolean =
|
||||
if (add != addClientTag.value) {
|
||||
addClientTag.tryEmit(add)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
|
||||
+1
-1
@@ -161,5 +161,5 @@ class AccountSecurityPreferencesInternal(
|
||||
var filterSpamFromStrangers: Boolean = true,
|
||||
val maxHashtagLimit: Int = 5,
|
||||
var sendKind0EventsToLocalRelay: Boolean = false,
|
||||
var disableClientTag: Boolean = false,
|
||||
var addClientTag: Boolean = true,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Cross-screen index of the most recent NIP-34 status event (kinds
|
||||
* 1630-1633) per target id, kept up to date from
|
||||
* [LocalCache.live.newEventBundles]. Status events are not tracked in
|
||||
* `Note.replies` (see `LocalCache.computeReplyTo`), so the only way to
|
||||
* find them otherwise would be a full cache scan per row.
|
||||
*/
|
||||
object GitStatusIndex {
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val started = AtomicBoolean(false)
|
||||
|
||||
private val mutableLatestByTarget = MutableStateFlow<Map<HexKey, GitStatusEvent>?>(null)
|
||||
val latestByTarget: StateFlow<Map<HexKey, GitStatusEvent>?> = mutableLatestByTarget.asStateFlow()
|
||||
|
||||
fun startIfNeeded() {
|
||||
if (!started.compareAndSet(false, true)) return
|
||||
scope.launch {
|
||||
// Subscribe to bundle updates BEFORE the initial scan via onStart, so any
|
||||
// events that arrive between scan start and collector attach are picked up.
|
||||
LocalCache.live.newEventBundles
|
||||
.onStart {
|
||||
val initial = HashMap<HexKey, GitStatusEvent>()
|
||||
LocalCache.notes.forEach { _, note ->
|
||||
val event = note.event as? GitStatusEvent ?: return@forEach
|
||||
val target = event.rootEventId() ?: return@forEach
|
||||
val current = initial[target]
|
||||
if (current == null || event.createdAt > current.createdAt) {
|
||||
initial[target] = event
|
||||
}
|
||||
}
|
||||
mutableLatestByTarget.value = initial
|
||||
}.collect { bundle -> processBundle(bundle) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun processBundle(bundle: Set<Note>) {
|
||||
val snapshot = mutableLatestByTarget.value ?: emptyMap()
|
||||
var modified: HashMap<HexKey, GitStatusEvent>? = null
|
||||
for (note in bundle) {
|
||||
val event = note.event as? GitStatusEvent ?: continue
|
||||
val target = event.rootEventId() ?: continue
|
||||
val map = modified ?: snapshot
|
||||
val current = map[target]
|
||||
if (current == null || event.createdAt > current.createdAt) {
|
||||
if (modified == null) modified = HashMap(snapshot)
|
||||
modified[target] = event
|
||||
}
|
||||
}
|
||||
modified?.let { mutableLatestByTarget.value = it }
|
||||
}
|
||||
}
|
||||
@@ -135,10 +135,15 @@ import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.grasp.UserGraspListEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.state.GitRepositoryStateEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent
|
||||
import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
|
||||
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
@@ -2676,7 +2681,10 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
nip19.relay.forEach { relayHint ->
|
||||
relayHints.addEvent(nip19.hex, relayHint)
|
||||
}
|
||||
getOrCreateNote(nip19.hex)
|
||||
val note = getOrCreateNote(nip19.hex)
|
||||
if (note.author == null) {
|
||||
nip19.author?.let { note.author = checkGetOrCreateUser(it) }
|
||||
}
|
||||
}
|
||||
|
||||
is NEmbed -> {
|
||||
@@ -3097,10 +3105,30 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is GitPullRequestEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is GitPullRequestUpdateEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is GitStatusEvent -> {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is GitRepositoryEvent -> {
|
||||
consumeBaseReplaceable(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is GitRepositoryStateEvent -> {
|
||||
consumeBaseReplaceable(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is UserGraspListEvent -> {
|
||||
consumeBaseReplaceable(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is RootSiteEvent -> {
|
||||
consumeBaseReplaceable(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
+1
-1
@@ -139,7 +139,7 @@ class AccountCacheState(
|
||||
NostrSignerWithClientTag(
|
||||
inner = signer,
|
||||
clientName = CLIENT_TAG_NAME,
|
||||
disabled = { accountSettings.syncedSettings.security.disableClientTag.value },
|
||||
disabled = { !accountSettings.syncedSettings.security.addClientTag.value },
|
||||
)
|
||||
|
||||
val accountDir = File(rootFilesDir(), "accounts/${signer.pubKey}").apply { mkdirs() }
|
||||
|
||||
@@ -306,7 +306,7 @@ class CallAudioManager(
|
||||
.any {
|
||||
it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO ||
|
||||
it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP ||
|
||||
it.type == AudioDeviceInfo.TYPE_BLE_HEADSET
|
||||
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && it.type == AudioDeviceInfo.TYPE_BLE_HEADSET)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
|
||||
@@ -27,8 +27,8 @@ import coil3.annotation.ExperimentalCoilApi
|
||||
import coil3.fetch.FetchResult
|
||||
import coil3.fetch.Fetcher
|
||||
import coil3.network.CacheStrategy
|
||||
import coil3.network.ConcurrentRequestStrategy
|
||||
import coil3.network.ConnectivityChecker
|
||||
import coil3.network.DeDupeConcurrentRequestStrategy
|
||||
import coil3.network.NetworkFetcher
|
||||
import coil3.network.okhttp.asNetworkClient
|
||||
import coil3.request.Options
|
||||
@@ -74,7 +74,7 @@ class BlossomFetcher(
|
||||
diskCache = lazy { imageLoader.diskCache },
|
||||
cacheStrategy = lazy { CacheStrategy.DEFAULT },
|
||||
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
|
||||
concurrentRequestStrategy = lazy { ConcurrentRequestStrategy.UNCOORDINATED },
|
||||
concurrentRequestStrategy = lazy { DeDupeConcurrentRequestStrategy() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.images
|
||||
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.yield
|
||||
import okio.FileHandle
|
||||
import okio.FileSystem
|
||||
import okio.ForwardingFileSystem
|
||||
import okio.Path
|
||||
import okio.Sink
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* okio [FileSystem] wrapper that moves Coil's eviction `unlink()` out from
|
||||
* under the `DiskLruCache` lock.
|
||||
*
|
||||
* Verified against Coil 3.4.0's `coil3.disk.DiskLruCache`: eviction is not
|
||||
* inline on the commit thread — `completeEdit()` calls `launchCleanup()`,
|
||||
* which `launch`es on a limited-parallelism IO scope. But the cleanup
|
||||
* coroutine runs `trimToSize()` *while holding the global `DiskLruCache`
|
||||
* lock*, and `trimToSize()` → `removeEntry()` → `fileSystem.delete()` does
|
||||
* the unlink syscalls under that lock. Every `openSnapshot()` (read) and
|
||||
* `openEditor()` (write) contends on the same lock. So on a saturated
|
||||
* cache, each cleanup pass blocks all feed-scroll reads and writes for the
|
||||
* duration of a burst of `delete()` syscalls — that is the scroll stall
|
||||
* users see go away after a storage wipe.
|
||||
*
|
||||
* This wrapper intercepts `delete()` — exactly the call `removeEntry()`
|
||||
* makes — and, instead of unlinking inline, enqueues the path and unlinks
|
||||
* it later on a background coroutine, one path at a time with a `yield()`
|
||||
* between each so the IO dispatcher stays responsive. `delete()` returns in
|
||||
* microseconds, so `trimToSize()` releases the lock almost immediately;
|
||||
* Coil keeps full ownership of LRU order, keys, and `size` accounting, and
|
||||
* only the syscall is rescheduled off-lock.
|
||||
*
|
||||
* Re-create race: if Coil re-fetches a URL whose entry was just evicted, it
|
||||
* writes a new file at the same path. [atomicMove], [sink], [appendingSink],
|
||||
* [openReadWrite] and [createDirectory] therefore cancel any pending delete
|
||||
* of the path they are about to (re)create, so the drainer can never unlink
|
||||
* a freshly written file.
|
||||
*
|
||||
* The drainer unlinks each path while holding [lock], so a concurrent
|
||||
* write-path call that needs to cancel a delete waits at most one in-flight
|
||||
* `unlink()` — O(1), never the O(N) burst this class exists to remove.
|
||||
*
|
||||
* This instance is dedicated to Coil's image disk cache, so every path it
|
||||
* sees is a cache file and deferring all `delete()` calls is safe.
|
||||
*/
|
||||
class DeferredDeleteFileSystem(
|
||||
delegate: FileSystem,
|
||||
scope: CoroutineScope,
|
||||
) : ForwardingFileSystem(delegate) {
|
||||
companion object {
|
||||
private const val TAG = "DeferredDeleteFileSystem"
|
||||
}
|
||||
|
||||
private val lock = Any()
|
||||
private val pendingDeletes = LinkedHashSet<Path>()
|
||||
private val wakeUp = Channel<Unit>(Channel.CONFLATED)
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
for (signal in wakeUp) {
|
||||
drainPending()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun delete(
|
||||
path: Path,
|
||||
mustExist: Boolean,
|
||||
) {
|
||||
synchronized(lock) { pendingDeletes.add(path) }
|
||||
wakeUp.trySend(Unit)
|
||||
}
|
||||
|
||||
override fun atomicMove(
|
||||
source: Path,
|
||||
target: Path,
|
||||
) {
|
||||
synchronized(lock) {
|
||||
pendingDeletes.remove(source)
|
||||
pendingDeletes.remove(target)
|
||||
}
|
||||
super.atomicMove(source, target)
|
||||
}
|
||||
|
||||
override fun sink(
|
||||
file: Path,
|
||||
mustCreate: Boolean,
|
||||
): Sink {
|
||||
synchronized(lock) { pendingDeletes.remove(file) }
|
||||
return super.sink(file, mustCreate)
|
||||
}
|
||||
|
||||
override fun appendingSink(
|
||||
file: Path,
|
||||
mustExist: Boolean,
|
||||
): Sink {
|
||||
synchronized(lock) { pendingDeletes.remove(file) }
|
||||
return super.appendingSink(file, mustExist)
|
||||
}
|
||||
|
||||
override fun openReadWrite(
|
||||
file: Path,
|
||||
mustCreate: Boolean,
|
||||
mustExist: Boolean,
|
||||
): FileHandle {
|
||||
synchronized(lock) { pendingDeletes.remove(file) }
|
||||
return super.openReadWrite(file, mustCreate, mustExist)
|
||||
}
|
||||
|
||||
override fun createDirectory(
|
||||
dir: Path,
|
||||
mustCreate: Boolean,
|
||||
) {
|
||||
synchronized(lock) { pendingDeletes.remove(dir) }
|
||||
super.createDirectory(dir, mustCreate)
|
||||
}
|
||||
|
||||
/** Visible for tests: number of paths waiting to be unlinked. */
|
||||
fun pendingCount(): Int = synchronized(lock) { pendingDeletes.size }
|
||||
|
||||
/** Visible for tests: unlink everything currently queued, synchronously. */
|
||||
fun drainNow() {
|
||||
while (true) {
|
||||
synchronized(lock) {
|
||||
val iterator = pendingDeletes.iterator()
|
||||
if (!iterator.hasNext()) return
|
||||
val path = iterator.next()
|
||||
iterator.remove()
|
||||
deleteUnderLock(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun drainPending() {
|
||||
while (true) {
|
||||
synchronized(lock) {
|
||||
val iterator = pendingDeletes.iterator()
|
||||
if (!iterator.hasNext()) return
|
||||
val path = iterator.next()
|
||||
iterator.remove()
|
||||
deleteUnderLock(path)
|
||||
}
|
||||
yield()
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteUnderLock(path: Path) {
|
||||
try {
|
||||
super.delete(path, mustExist = false)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "deferred delete failed: $path", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -24,14 +24,22 @@ import android.content.Context
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.memory.MemoryCache
|
||||
import com.vitorpamplona.amethyst.service.safeCacheDir
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import okio.FileSystem
|
||||
import okio.Path.Companion.toOkioPath
|
||||
|
||||
class ImageCacheFactory {
|
||||
companion object {
|
||||
fun newDisk(app: Context): DiskCache =
|
||||
fun newDisk(
|
||||
app: Context,
|
||||
scope: CoroutineScope,
|
||||
): DiskCache =
|
||||
DiskCache
|
||||
.Builder()
|
||||
.directory(app.safeCacheDir().resolve("image_cache").toOkioPath())
|
||||
// Defers Coil's inline eviction unlink() onto [scope] so a
|
||||
// saturated cache doesn't stall feed-scroll IO on the write path.
|
||||
.fileSystem(DeferredDeleteFileSystem(FileSystem.SYSTEM, scope))
|
||||
.maxSizePercent(0.2)
|
||||
.maximumMaxSizeBytes(1024 * 1024 * 1024) // 1GB
|
||||
.build()
|
||||
|
||||
+2
-2
@@ -33,8 +33,8 @@ import coil3.gif.AnimatedImageDecoder
|
||||
import coil3.gif.GifDecoder
|
||||
import coil3.memory.MemoryCache
|
||||
import coil3.network.CacheStrategy
|
||||
import coil3.network.ConcurrentRequestStrategy
|
||||
import coil3.network.ConnectivityChecker
|
||||
import coil3.network.DeDupeConcurrentRequestStrategy
|
||||
import coil3.network.NetworkFetcher
|
||||
import coil3.network.okhttp.asNetworkClient
|
||||
import coil3.request.Options
|
||||
@@ -149,7 +149,7 @@ class OkHttpFactory(
|
||||
diskCache = lazy { imageLoader.diskCache },
|
||||
cacheStrategy = cacheStrategyLazy,
|
||||
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
|
||||
concurrentRequestStrategy = lazy { ConcurrentRequestStrategy.UNCOORDINATED },
|
||||
concurrentRequestStrategy = lazy { DeDupeConcurrentRequestStrategy() },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -30,8 +30,8 @@ import coil3.fetch.Fetcher
|
||||
import coil3.fetch.ImageFetchResult
|
||||
import coil3.key.Keyer
|
||||
import coil3.network.CacheStrategy
|
||||
import coil3.network.ConcurrentRequestStrategy
|
||||
import coil3.network.ConnectivityChecker
|
||||
import coil3.network.DeDupeConcurrentRequestStrategy
|
||||
import coil3.network.NetworkFetcher
|
||||
import coil3.network.okhttp.asNetworkClient
|
||||
import coil3.request.Options
|
||||
@@ -113,7 +113,7 @@ class ProfilePictureFetcher(
|
||||
diskCache = diskCacheLazy,
|
||||
cacheStrategy = lazy { CacheStrategy.DEFAULT },
|
||||
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
|
||||
concurrentRequestStrategy = lazy { ConcurrentRequestStrategy.UNCOORDINATED },
|
||||
concurrentRequestStrategy = lazy { DeDupeConcurrentRequestStrategy() },
|
||||
)
|
||||
|
||||
return ProfilePictureFetcher(
|
||||
|
||||
+2
-2
@@ -22,9 +22,9 @@ package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import androidx.core.net.toUri
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
@@ -54,7 +54,7 @@ object BatteryOptimizationHelper {
|
||||
try {
|
||||
val intent =
|
||||
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = Uri.parse("package:${context.packageName}")
|
||||
data = "package:${context.packageName}".toUri()
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
|
||||
+9
-8
@@ -72,16 +72,17 @@ class LocalBlossomCacheRedirectInterceptor(
|
||||
}
|
||||
|
||||
private fun findSha256AndExtensionInPath(url: HttpUrl): Triple<Int, String, String>? {
|
||||
// Per Blossom (BUD-01) the blob is always the last path segment. If the
|
||||
// last segment isn't a sha256, this isn't a Blossom URL and the bridge
|
||||
// must leave it alone — even if an earlier path segment happens to be
|
||||
// a 64-char hex (e.g. a per-user cache prefix). The prefix segments
|
||||
// are preserved verbatim via `buildServerBase`.
|
||||
// Per Blossom (BUD-01) the last path segment must be exactly
|
||||
// `<sha256>` or `<sha256>.<ext>`. URLs whose filename merely embeds a
|
||||
// 64-char hex (e.g. "nostr.build_<sha>.jpg") aren't Blossom blobs and
|
||||
// the bridge must leave them alone — rewriting them would point the
|
||||
// local cache at a fallback `xs=` server that doesn't host the blob.
|
||||
// Prefix segments are preserved verbatim via `buildServerBase`.
|
||||
val lastIndex = url.pathSegments.lastIndex
|
||||
if (lastIndex < 0) return null
|
||||
val segment = url.pathSegments[lastIndex]
|
||||
val match = SHA256_SEGMENT_REGEX.find(segment) ?: return null
|
||||
val sha = match.value.lowercase()
|
||||
val match = BLOSSOM_LAST_SEGMENT_REGEX.matchEntire(segment) ?: return null
|
||||
val sha = match.groupValues[1].lowercase()
|
||||
val ext = guessExtensionFrom(segment, sha) ?: "bin"
|
||||
return Triple(lastIndex, sha, ext)
|
||||
}
|
||||
@@ -119,6 +120,6 @@ class LocalBlossomCacheRedirectInterceptor(
|
||||
const val LOCAL_CACHE_HOST = "127.0.0.1"
|
||||
const val LOCAL_CACHE_PORT = 24242
|
||||
const val LOCAL_CACHE_BASE = "http://$LOCAL_CACHE_HOST:$LOCAL_CACHE_PORT"
|
||||
private val SHA256_SEGMENT_REGEX = Regex("(?<![0-9a-fA-F])[0-9a-fA-F]{64}(?![0-9a-fA-F])")
|
||||
private val BLOSSOM_LAST_SEGMENT_REGEX = Regex("^([0-9a-fA-F]{64})(?:\\.[^./]+)?$")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +139,15 @@ class SurgeDns(
|
||||
|
||||
private fun lookupAndCache(host: String): List<InetAddress> =
|
||||
try {
|
||||
val addresses = delegate.lookup(host).ifEmpty { throw UnknownHostException(host) }
|
||||
val raw = delegate.lookup(host).ifEmpty { throw UnknownHostException(host) }
|
||||
val addresses =
|
||||
if (isLoopbackHostname(host)) {
|
||||
raw
|
||||
} else {
|
||||
raw
|
||||
.filterNot { it.isLoopbackAddress || it.isAnyLocalAddress }
|
||||
.ifEmpty { throw UnknownHostException(host) }
|
||||
}
|
||||
putPositive(host, addresses)
|
||||
addresses
|
||||
} catch (e: UnknownHostException) {
|
||||
@@ -220,12 +228,10 @@ class SurgeDns(
|
||||
for ((host, entry) in cache) {
|
||||
if (entry.addresses.isEmpty()) continue
|
||||
if (entry.expiresAtMillis <= now) continue
|
||||
// hostAddress is platform-typed (String!); mapNotNull narrows to String and stays
|
||||
// defensive against the rare case where it might be null.
|
||||
val ips = entry.addresses.mapNotNull { it.hostAddress }
|
||||
if (ips.isNotEmpty()) {
|
||||
out += DnsCacheRecord(host, ips, entry.expiresAtMillis)
|
||||
}
|
||||
// Raw bytes from getAddress() — no string formatting / re-parsing on either side
|
||||
// of the persistence boundary.
|
||||
val ips = entry.addresses.map { it.address }
|
||||
out += DnsCacheRecord(host, ips, entry.expiresAtMillis)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -233,22 +239,35 @@ class SurgeDns(
|
||||
/**
|
||||
* Restore from a previously taken snapshot. Entries already expired on disk are dropped.
|
||||
* Existing in-memory entries are NOT overwritten (so a fresh lookup that completes before
|
||||
* restore lands keeps its newer answer).
|
||||
* restore lands keeps its newer answer). Loopback / any-local addresses (127/8, ::1, 0.0.0.0)
|
||||
* are filtered out for non-loopback hostnames — they're poison left over from a captive
|
||||
* portal, ad-blocker DNS, or VPN hiccup at snapshot time. When such addresses are dropped
|
||||
* the cache is marked dirty so the next persist re-writes the snapshot without them.
|
||||
*/
|
||||
fun restore(records: List<DnsCacheRecord>) {
|
||||
val now = System.currentTimeMillis()
|
||||
var droppedPoisoned = false
|
||||
for (record in records) {
|
||||
if (record.expiresAtMillis <= now) continue
|
||||
val addresses =
|
||||
record.addresses.mapNotNull { literal ->
|
||||
// getByName on a numeric literal parses without doing DNS.
|
||||
runCatching { InetAddress.getByName(literal) }.getOrNull()
|
||||
val key = record.hostname.lowercase(Locale.ROOT)
|
||||
val allowLoopback = isLoopbackHostname(key)
|
||||
val addresses = ArrayList<InetAddress>(record.addresses.size)
|
||||
for (bytes in record.addresses) {
|
||||
// getByAddress wraps the raw bytes without re-parsing a string literal.
|
||||
val addr = runCatching { InetAddress.getByAddress(bytes) }.getOrNull() ?: continue
|
||||
if (!allowLoopback && (addr.isLoopbackAddress || addr.isAnyLocalAddress)) {
|
||||
droppedPoisoned = true
|
||||
continue
|
||||
}
|
||||
addresses += addr
|
||||
}
|
||||
if (addresses.isNotEmpty()) {
|
||||
val key = record.hostname.lowercase(Locale.ROOT)
|
||||
cache.putIfAbsent(key, Entry(addresses, record.expiresAtMillis))
|
||||
}
|
||||
}
|
||||
// Dirty the cache so the next save rewrites the snapshot without the poisoned entries
|
||||
// — otherwise they'd be re-restored on every cold start.
|
||||
if (droppedPoisoned) dirty.set(true)
|
||||
}
|
||||
|
||||
/** True if the cache has changed since the last [tryClearDirty] / [markDirty] / construction. */
|
||||
@@ -267,6 +286,39 @@ class SurgeDns(
|
||||
dirty.set(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* True if [host] is itself a loopback identifier — the user-configured `localhost`-style relay
|
||||
* case where 127.0.0.1 / ::1 in the answer is legitimate and must not be filtered as poison.
|
||||
* Accepts the DNS name `localhost` and any `.localhost` subdomain (RFC 6761), and IP literals
|
||||
* that parse to a loopback or any-local address (127.0.0.0/8, ::1, 0.0.0.0). [host] is assumed
|
||||
* already lowercased.
|
||||
*/
|
||||
private fun isLoopbackHostname(host: String): Boolean {
|
||||
// RFC 1034: a trailing dot is the FQDN form (e.g. `localhost.`), still the same name.
|
||||
val name = host.trimEnd('.')
|
||||
if (name == "localhost" || name.endsWith(".localhost")) return true
|
||||
val literal = parseIpLiteral(name) ?: return false
|
||||
return literal.isLoopbackAddress || literal.isAnyLocalAddress
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses [host] as a numeric IP literal without ever triggering a DNS lookup. Returns null for
|
||||
* anything that doesn't structurally look like an IPv4 or IPv6 literal — we must not hand a
|
||||
* plain hostname to [InetAddress.getByName] from inside the resolver.
|
||||
*/
|
||||
private fun parseIpLiteral(host: String): InetAddress? {
|
||||
val candidate =
|
||||
if (host.startsWith("[") && host.endsWith("]")) {
|
||||
host.substring(1, host.length - 1)
|
||||
} else {
|
||||
host
|
||||
}
|
||||
val looksLikeIpv4 = candidate.isNotEmpty() && candidate.all { it.isDigit() || it == '.' } && candidate.contains('.')
|
||||
val looksLikeIpv6 = candidate.contains(':')
|
||||
if (!looksLikeIpv4 && !looksLikeIpv6) return null
|
||||
return runCatching { InetAddress.getByName(candidate) }.getOrNull()
|
||||
}
|
||||
|
||||
private class Entry(
|
||||
val addresses: List<InetAddress>,
|
||||
val expiresAtMillis: Long,
|
||||
@@ -286,9 +338,17 @@ class SurgeDns(
|
||||
}
|
||||
}
|
||||
|
||||
/** Persistable record. Public so [SurgeDnsStore] can serialize it via Jackson. */
|
||||
data class DnsCacheRecord(
|
||||
/**
|
||||
* Persistable record. Addresses are stored as raw bytes (4 or 16) so [SurgeDnsStore] can write
|
||||
* them straight into the binary blob and round-trip through [InetAddress.getByAddress] without
|
||||
* formatting/parsing a string literal on either side.
|
||||
*
|
||||
* Not a `data class`: the auto-generated `equals`/`hashCode` would compare `addresses`
|
||||
* (List<ByteArray>) by reference identity, which is a footgun. Nothing in this codebase needs
|
||||
* structural equality on records, so the methods are simply not provided.
|
||||
*/
|
||||
class DnsCacheRecord(
|
||||
val hostname: String,
|
||||
val addresses: List<String>,
|
||||
val addresses: List<ByteArray>,
|
||||
val expiresAtMillis: Long,
|
||||
)
|
||||
|
||||
+118
-20
@@ -20,43 +20,64 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.okhttp
|
||||
|
||||
import android.content.Context
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.DataInputStream
|
||||
import java.io.DataOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
|
||||
/**
|
||||
* Persists [SurgeDns]'s positive cache to a small `SharedPreferences` blob so the resolver
|
||||
* starts hot after a process restart.
|
||||
* Persists [SurgeDns]'s positive cache to a small binary blob under the app's cache directory
|
||||
* so the resolver starts hot after a process restart.
|
||||
*
|
||||
* Cold starts are the resolver's worst case — every host is a sync `getaddrinfo`. With a
|
||||
* persisted snapshot, every previously-seen host falls into the stale-while-revalidate path on
|
||||
* first lookup: the cached IP is served immediately, and a background refresh updates it. That
|
||||
* turns ~700 blocking system calls at app start into zero.
|
||||
*
|
||||
* The blob is plain (not encrypted) — hostnames are already exposed in the user's signed relay
|
||||
* list, Coil's image cache, and the system resolver's own state. ~700 entries × ~80 bytes ≈
|
||||
* ~55 KB of JSON.
|
||||
* Stored under `cacheDir` because the snapshot is pure perf — if the OS evicts it under storage
|
||||
* pressure, the resolver just falls back to sync `getaddrinfo` and rebuilds the cache as
|
||||
* lookups happen. The blob is plain (not encrypted) — hostnames are already exposed in the
|
||||
* user's signed relay list, Coil's image cache, and the system resolver's own state.
|
||||
*
|
||||
* Format (big-endian, no padding):
|
||||
* ```
|
||||
* header:
|
||||
* [u32 magic = 0x534E5343] // 'SNSC'
|
||||
* [u16 version = 1]
|
||||
* [u32 record_count]
|
||||
* record (repeated record_count times):
|
||||
* [u8 host_len] // hostname ASCII, < 256 bytes
|
||||
* [host_len bytes] // UTF-8 hostname
|
||||
* [u8 ip_count]
|
||||
* per ip:
|
||||
* [u8 ip_byte_len] // 4 or 16
|
||||
* [ip_byte_len bytes] // raw, from InetAddress.address
|
||||
* [i64 expiresAtMillis]
|
||||
* ```
|
||||
*
|
||||
* ~700 hosts × ~36 bytes ≈ ~25 KB.
|
||||
*/
|
||||
class SurgeDnsStore(
|
||||
private val context: Context,
|
||||
private val file: File,
|
||||
private val dns: SurgeDns,
|
||||
) {
|
||||
private val prefs by lazy { context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) }
|
||||
|
||||
/**
|
||||
* Read the persisted snapshot and merge it into the resolver. Existing in-memory entries
|
||||
* are preserved (see [SurgeDns.restore]). Safe to call once at app start. Blocking I/O —
|
||||
* call from a background thread.
|
||||
*/
|
||||
fun load() {
|
||||
val json = prefs.getString(KEY_SNAPSHOT, null) ?: return
|
||||
if (!file.exists()) return
|
||||
val records =
|
||||
try {
|
||||
MAPPER.readValue<List<DnsCacheRecord>>(json)
|
||||
readRecords(file)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG) { "Dropping corrupt DNS cache blob: ${t.message}" }
|
||||
prefs.edit().remove(KEY_SNAPSHOT).apply()
|
||||
file.delete()
|
||||
return
|
||||
}
|
||||
// restore() uses putIfAbsent and never marks dirty, so we deliberately do NOT clear the
|
||||
@@ -75,27 +96,104 @@ class SurgeDnsStore(
|
||||
// captured by the next save instead of being silently lost. compareAndSet ensures two
|
||||
// concurrent saves don't both proceed.
|
||||
if (!dns.tryClearDirty()) return
|
||||
// Write atomically: write to a sibling tmp file then rename so a crash mid-write
|
||||
// can't leave a half-written blob that load() would have to throw away.
|
||||
val tmp = File(file.parentFile, "${file.name}.tmp")
|
||||
try {
|
||||
val records = dns.snapshot()
|
||||
val json = MAPPER.writeValueAsString(records)
|
||||
prefs.edit().putString(KEY_SNAPSHOT, json).apply()
|
||||
file.parentFile?.mkdirs()
|
||||
writeRecords(tmp, records)
|
||||
if (!tmp.renameTo(file)) {
|
||||
file.delete()
|
||||
if (!tmp.renameTo(file)) {
|
||||
tmp.copyTo(file, overwrite = true)
|
||||
}
|
||||
}
|
||||
Log.d(TAG) { "Persisted ${records.size} DNS cache entries" }
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG) { "Failed to persist DNS cache: ${t.message}" }
|
||||
// Restore the dirty signal so the next save retries.
|
||||
dns.markDirty()
|
||||
} finally {
|
||||
// Cleans up after both happy paths (copyTo fallback) and failure paths (writeRecords
|
||||
// crashed partway, leaving a partial blob) so a corrupt tmp can't accumulate.
|
||||
if (tmp.exists()) tmp.delete()
|
||||
}
|
||||
}
|
||||
|
||||
/** Force-clear the on-disk cache. Useful for diagnostics or when the user wipes data. */
|
||||
fun clear() {
|
||||
prefs.edit().remove(KEY_SNAPSHOT).apply()
|
||||
file.delete()
|
||||
}
|
||||
|
||||
private fun writeRecords(
|
||||
target: File,
|
||||
records: List<DnsCacheRecord>,
|
||||
) {
|
||||
DataOutputStream(BufferedOutputStream(FileOutputStream(target))).use { out ->
|
||||
out.writeInt(MAGIC)
|
||||
out.writeShort(VERSION)
|
||||
out.writeInt(records.size)
|
||||
for (record in records) {
|
||||
val hostBytes = record.hostname.toByteArray(Charsets.UTF_8)
|
||||
require(hostBytes.size <= MAX_HOST_LEN) { "hostname too long: ${hostBytes.size}" }
|
||||
require(record.addresses.size <= MAX_IPS_PER_HOST) { "too many IPs: ${record.addresses.size}" }
|
||||
out.writeByte(hostBytes.size)
|
||||
out.write(hostBytes)
|
||||
out.writeByte(record.addresses.size)
|
||||
for (ip in record.addresses) {
|
||||
require(ip.size == 4 || ip.size == 16) { "bad ip byte length: ${ip.size}" }
|
||||
out.writeByte(ip.size)
|
||||
out.write(ip)
|
||||
}
|
||||
out.writeLong(record.expiresAtMillis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun readRecords(source: File): List<DnsCacheRecord> =
|
||||
DataInputStream(BufferedInputStream(FileInputStream(source))).use { input ->
|
||||
val magic = input.readInt()
|
||||
if (magic != MAGIC) throw IllegalStateException("bad magic: 0x${Integer.toHexString(magic)}")
|
||||
val version = input.readUnsignedShort()
|
||||
if (version != VERSION) throw IllegalStateException("unsupported version: $version")
|
||||
val count = input.readInt()
|
||||
if (count < 0 || count > MAX_RECORDS) throw IllegalStateException("bad record count: $count")
|
||||
val out = ArrayList<DnsCacheRecord>(count)
|
||||
repeat(count) {
|
||||
val hostLen = input.readUnsignedByte()
|
||||
// 0-length hostnames are nonsense; treat as corruption rather than restoring them.
|
||||
if (hostLen == 0) throw IllegalStateException("empty hostname")
|
||||
val hostBytes = ByteArray(hostLen)
|
||||
input.readFully(hostBytes)
|
||||
val hostname = String(hostBytes, Charsets.UTF_8)
|
||||
val ipCount = input.readUnsignedByte()
|
||||
if (ipCount == 0 || ipCount > MAX_IPS_PER_HOST) throw IllegalStateException("bad ip count: $ipCount")
|
||||
val ips = ArrayList<ByteArray>(ipCount)
|
||||
repeat(ipCount) {
|
||||
val ipLen = input.readUnsignedByte()
|
||||
if (ipLen != 4 && ipLen != 16) throw IllegalStateException("bad ip byte length: $ipLen")
|
||||
val ipBytes = ByteArray(ipLen)
|
||||
input.readFully(ipBytes)
|
||||
ips += ipBytes
|
||||
}
|
||||
val expiresAt = input.readLong()
|
||||
out += DnsCacheRecord(hostname, ips, expiresAt)
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SurgeDnsStore"
|
||||
private const val PREFS_NAME = "amethyst_dns_cache"
|
||||
private const val KEY_SNAPSHOT = "dns_cache_v1"
|
||||
private val MAPPER = jacksonObjectMapper()
|
||||
const val FILE_NAME = "dns_cache_v1.bin"
|
||||
|
||||
// 'SNSC' — Surge dNS Cache.
|
||||
private const val MAGIC = 0x534E5343
|
||||
private const val VERSION = 1
|
||||
private const val MAX_HOST_LEN = 255
|
||||
private const val MAX_IPS_PER_HOST = 255
|
||||
|
||||
// Sane cap so a corrupt header can't trick load() into allocating gigabytes.
|
||||
private const val MAX_RECORDS = 100_000
|
||||
}
|
||||
}
|
||||
|
||||
+41
@@ -26,6 +26,9 @@ import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.media3.common.Player
|
||||
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
|
||||
|
||||
@@ -75,6 +78,44 @@ fun ControlWhenPlayerIsActive(
|
||||
listener.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
// Pause when the host activity leaves the foreground; resume the visible
|
||||
// video when it comes back. The scroll-based mutex above only reacts to
|
||||
// on-screen position, which doesn't change on background — so without
|
||||
// this, videos play forever behind another app.
|
||||
// Skip the explicit BackgroundMedia (PiP) instance: the user opted that
|
||||
// one into keep-playing.
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner, mediaControllerState) {
|
||||
val observer =
|
||||
LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_PAUSE -> {
|
||||
if (controller.isPlaying && !BackgroundMedia.isMutex(mediaControllerState)) {
|
||||
controller.pause()
|
||||
}
|
||||
}
|
||||
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
if (automaticallyStartPlayback &&
|
||||
isClosestToTheCenterOfTheScreen.value &&
|
||||
!controller.isPlaying
|
||||
) {
|
||||
if (BackgroundMedia.hasBackgroundButNot(mediaControllerState)) {
|
||||
controller.volume = 0f
|
||||
}
|
||||
controller.play()
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
}
|
||||
|
||||
class PlayerEventListener(
|
||||
|
||||
+6
-3
@@ -35,6 +35,7 @@ import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
|
||||
import java.lang.ref.WeakReference
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
@@ -58,8 +59,10 @@ private var winner: TrackedVideo? = null
|
||||
|
||||
// Cached result of view.getGlobalVisibleRect. The compose root rarely changes
|
||||
// across a scroll burst; caching avoids N native calls per frame.
|
||||
// The view is held weakly: it is only used for an identity check, never
|
||||
// dereferenced, and a strong static reference would leak the Activity.
|
||||
private val cachedRootRect = Rect()
|
||||
private var cachedRootRectView: View? = null
|
||||
private var cachedRootRectView: WeakReference<View>? = null
|
||||
private var cachedRootRectVisible: Boolean = false
|
||||
private var cachedRootRectTimeNs: Long = 0L
|
||||
private const val ROOT_RECT_CACHE_TTL_NS = 8_000_000L // ~half a frame at 60fps
|
||||
@@ -191,11 +194,11 @@ private fun electNewWinner() {
|
||||
*/
|
||||
private fun cachedGlobalVisibleRect(view: View): Rect? {
|
||||
val now = System.nanoTime()
|
||||
if (cachedRootRectView === view && (now - cachedRootRectTimeNs) < ROOT_RECT_CACHE_TTL_NS) {
|
||||
if (cachedRootRectView?.get() === view && (now - cachedRootRectTimeNs) < ROOT_RECT_CACHE_TTL_NS) {
|
||||
return if (cachedRootRectVisible) cachedRootRect else null
|
||||
}
|
||||
cachedRootRectVisible = view.getGlobalVisibleRect(cachedRootRect)
|
||||
cachedRootRectView = view
|
||||
cachedRootRectView = WeakReference(view)
|
||||
cachedRootRectTimeNs = now
|
||||
return if (cachedRootRectVisible) cachedRootRect else null
|
||||
}
|
||||
|
||||
+6
-2
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.playback.pip
|
||||
import android.app.ActivityOptions
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Rect
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -68,8 +69,11 @@ class PipVideoActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode)
|
||||
override fun onPictureInPictureModeChanged(
|
||||
isInPictureInPictureMode: Boolean,
|
||||
newConfig: Configuration,
|
||||
) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
||||
if (!isInPictureInPictureMode) {
|
||||
// User dismissed PiP (swiped away or expanded).
|
||||
finishAndRemoveTask()
|
||||
|
||||
+3
@@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasourc
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource.FollowPackFeedFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.list.datasource.FollowPacksFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.datasource.RepositoryFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.datasource.LiveStreamsFilterAssembler
|
||||
@@ -96,6 +97,7 @@ class RelaySubscriptionsCoordinator(
|
||||
val channel = ChannelFilterAssembler(client)
|
||||
val chatroom = ChatroomFilterAssembler(client)
|
||||
val community = CommunityFilterAssembler(client)
|
||||
val gitRepository = RepositoryFilterAssembler(client)
|
||||
val thread = ThreadFilterAssembler(client)
|
||||
val profile = UserProfileFilterAssembler(client)
|
||||
val hashtags = HashtagFilterAssembler(client)
|
||||
@@ -156,6 +158,7 @@ class RelaySubscriptionsCoordinator(
|
||||
channel,
|
||||
chatroom,
|
||||
community,
|
||||
gitRepository,
|
||||
thread,
|
||||
profile,
|
||||
hashtags,
|
||||
|
||||
+2
@@ -34,6 +34,8 @@ fun potentialRelaysToFindEvent(note: Note): Set<NormalizedRelayUrl> {
|
||||
|
||||
set.addAll(LocalCache.relayHints.hintsForEvent(note.idHex))
|
||||
|
||||
note.author?.outboxRelays()?.let { set.addAll(it) }
|
||||
|
||||
LocalCache.getAnyChannel(note)?.relays()?.let { set.addAll(it) }
|
||||
|
||||
note.replyTo?.forEach { parentNote ->
|
||||
|
||||
@@ -27,6 +27,7 @@ import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -36,6 +37,7 @@ import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.call.CallState
|
||||
@@ -217,8 +219,11 @@ class CallActivity : AppCompatActivity() {
|
||||
enterPipIfActive()
|
||||
}
|
||||
|
||||
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode)
|
||||
override fun onPictureInPictureModeChanged(
|
||||
isInPictureInPictureMode: Boolean,
|
||||
newConfig: Configuration,
|
||||
) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
||||
isInPipMode.value = isInPictureInPictureMode
|
||||
}
|
||||
|
||||
@@ -285,7 +290,6 @@ class CallActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun enterPipIfActive() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val callManager = CallSessionBridge.callManager ?: return
|
||||
val state = callManager.state.value
|
||||
val isActive =
|
||||
@@ -388,7 +392,7 @@ class CallActivity : AppCompatActivity() {
|
||||
addAction(ACTION_PIP_HANGUP)
|
||||
addAction(ACTION_PIP_TOGGLE_MUTE)
|
||||
}
|
||||
registerReceiver(pipActionReceiver, filter, RECEIVER_NOT_EXPORTED)
|
||||
ContextCompat.registerReceiver(this, pipActionReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
}
|
||||
|
||||
private fun unregisterPipReceiver() {
|
||||
|
||||
@@ -97,6 +97,7 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
|
||||
import com.vitorpamplona.amethyst.service.CachedRichTextParser
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent
|
||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||
@@ -661,6 +662,11 @@ fun BechLink(
|
||||
|
||||
val baseNote = loadedLink?.baseNote
|
||||
|
||||
// Preload the placeholder's author NIP-65 outbox so a missing quoted event can be fetched from there.
|
||||
baseNote?.author?.let { author ->
|
||||
UserFinderFilterAssemblerSubscription(author, accountViewModel)
|
||||
}
|
||||
|
||||
if (canPreview && quotesLeft > 0 && baseNote != null) {
|
||||
Row {
|
||||
DisplayFullNote(
|
||||
|
||||
+6
-13
@@ -45,10 +45,10 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.createBitmap
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
|
||||
@@ -107,17 +107,10 @@ private fun LoadedPdfPreviewCard(
|
||||
) {
|
||||
val sharePopupExpanded = remember { mutableStateOf(false) }
|
||||
|
||||
val density = LocalDensity.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val containerWidthPx = LocalWindowInfo.current.containerSize.width
|
||||
val targetWidthPx =
|
||||
remember(density, configuration) {
|
||||
val screenPx =
|
||||
with(density) {
|
||||
configuration.screenWidthDp.dp
|
||||
.toPx()
|
||||
.toInt()
|
||||
}
|
||||
screenPx.coerceAtMost(THUMBNAIL_MAX_DIM_PX).coerceAtLeast(1)
|
||||
remember(containerWidthPx) {
|
||||
containerWidthPx.coerceAtMost(THUMBNAIL_MAX_DIM_PX).coerceAtLeast(1)
|
||||
}
|
||||
|
||||
@Suppress("ProduceStateDoesNotAssignValue")
|
||||
@@ -256,7 +249,7 @@ private fun renderFirstPage(
|
||||
renderer.openPage(0).use { page ->
|
||||
val (renderW, renderH) = cappedRenderSize(page.width, page.height, targetWidthPx)
|
||||
// PdfRenderer requires ARGB_8888; RGB_565 silently produces blank output.
|
||||
val bitmap = Bitmap.createBitmap(renderW, renderH, Bitmap.Config.ARGB_8888)
|
||||
val bitmap = createBitmap(renderW, renderH)
|
||||
bitmap.eraseColor(android.graphics.Color.WHITE)
|
||||
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||||
PdfLoadState.Ready(
|
||||
|
||||
+2
-1
@@ -60,6 +60,7 @@ import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.graphics.createBitmap
|
||||
import coil3.disk.DiskCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
@@ -398,7 +399,7 @@ private suspend fun renderPageCatching(
|
||||
val (width, height) = cappedRenderSize(page.width, page.height, maxDim)
|
||||
// PdfRenderer requires ARGB_8888 bitmaps — RGB_565 is silently
|
||||
// rejected and produces blank output.
|
||||
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val bmp = createBitmap(width, height)
|
||||
bmp.eraseColor(android.graphics.Color.WHITE)
|
||||
page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||||
bmp
|
||||
|
||||
@@ -30,7 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.countHashtags
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasMoreHashtagsThan
|
||||
import com.vitorpamplona.quartz.nip10Notes.threadRootIdOrSelf
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
|
||||
@@ -51,7 +51,7 @@ class FilterByListParams(
|
||||
|
||||
fun isNotInTheFuture(noteEvent: Event) = noteEvent.createdAt <= now
|
||||
|
||||
fun hasExcessiveHashtags(noteEvent: Event) = hiddenLists.maxHashtagLimit > 0 && noteEvent.countHashtags() > hiddenLists.maxHashtagLimit
|
||||
fun hasExcessiveHashtags(noteEvent: Event) = hiddenLists.maxHashtagLimit > 0 && noteEvent.hasMoreHashtagsThan(hiddenLists.maxHashtagLimit)
|
||||
|
||||
fun isAuthorInFollows(author: HexKey): Boolean {
|
||||
if (followLists == null) return false
|
||||
|
||||
@@ -23,43 +23,31 @@ package com.vitorpamplona.amethyst.ui.feeds
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
|
||||
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.vitorpamplona.amethyst.ui.layouts.LocalDisappearingScaffoldPadding
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RefresheableBox(
|
||||
invalidateableContent: InvalidatableContent,
|
||||
enablePullRefresh: Boolean = true,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val onRefresh: () -> Unit = {
|
||||
isRefreshing = true
|
||||
scope.launch {
|
||||
invalidateableContent.invalidateData()
|
||||
delay(500)
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
if (enablePullRefresh) {
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = onRefresh,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
content = content,
|
||||
)
|
||||
RefresheableBox(onRefresh = invalidateableContent::invalidateData, content = content)
|
||||
} else {
|
||||
Box(Modifier.fillMaxSize(), content = content)
|
||||
}
|
||||
@@ -73,19 +61,35 @@ fun RefresheableBox(
|
||||
) {
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val onRefresh: () -> Unit = {
|
||||
isRefreshing = true
|
||||
scope.launch {
|
||||
onRefresh()
|
||||
delay(500)
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
val state = rememberPullToRefreshState()
|
||||
val topPadding = LocalDisappearingScaffoldPadding.current.calculateTopPadding()
|
||||
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = onRefresh,
|
||||
onRefresh = {
|
||||
isRefreshing = true
|
||||
scope.launch {
|
||||
onRefresh()
|
||||
delay(500)
|
||||
isRefreshing = false
|
||||
}
|
||||
},
|
||||
state = state,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
// Anchor the indicator below the scaffold's top bar. Inside a DisappearingScaffold
|
||||
// the content fills the full screen height (feed items scroll behind the bar),
|
||||
// so the default top-aligned indicator would sit behind the top bar. Outside a
|
||||
// scaffold the local default is 0.dp and behaviour is unchanged.
|
||||
indicator = {
|
||||
PullToRefreshDefaults.Indicator(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = topPadding),
|
||||
isRefreshing = isRefreshing,
|
||||
state = state,
|
||||
)
|
||||
},
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.FollowPack
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.list.FollowPacksScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashPostScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagPostScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.HomeScreen
|
||||
@@ -356,6 +357,7 @@ fun BuildNavigation(
|
||||
composableFromEndArgs<Route.RelayManagement> { RelayManagementScreen(it.url, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.RelayMembers> { RelayMembersScreen(it.url, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.Community> { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.GitRepository> { GitRepositoryScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.FollowPack> { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
|
||||
|
||||
composableFromEndArgs<Route.Room> { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) }
|
||||
|
||||
+9
-8
@@ -29,7 +29,6 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material3.BottomAppBarDefaults.windowInsets
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -49,10 +48,12 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.HorzPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size0dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size24dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size27dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size25Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size27Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.onSurface65
|
||||
|
||||
/** Content height of the [AppBottomBar] (the 50.dp Column inside [RenderBottomMenu]),
|
||||
* exclusive of the system navigation-bar inset. Used by FAB callers that want to
|
||||
@@ -110,6 +111,7 @@ private fun RenderBottomMenu(
|
||||
thickness = DividerThickness,
|
||||
)
|
||||
NavigationBar(
|
||||
modifier = HorzPadding,
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
tonalElevation = Size0dp,
|
||||
) {
|
||||
@@ -151,13 +153,12 @@ private fun NotifiableIcon(
|
||||
destination: Route,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
Box(Modifier.size(Size27dp)) {
|
||||
val iconSizeModifier = Modifier.size(Size24dp)
|
||||
val description = stringRes(def.labelRes)
|
||||
Box(Size27Modifier, contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
symbol = def.icon,
|
||||
contentDescription = description,
|
||||
modifier = iconSizeModifier,
|
||||
contentDescription = stringRes(def.labelRes),
|
||||
modifier = Size25Modifier,
|
||||
tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65,
|
||||
)
|
||||
|
||||
AddNotifIconIfNeeded(destination, accountViewModel, Modifier.align(Alignment.TopEnd))
|
||||
|
||||
+1
-2
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.navigation.bottombars
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.isDebug
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.serialization.Serializable
|
||||
@@ -323,7 +322,7 @@ val DrawerFeedsItems: List<NavBarItem> =
|
||||
NavBarItem.PUBLIC_CHATS,
|
||||
NavBarItem.FOLLOW_PACKS,
|
||||
NavBarItem.LIVE_STREAMS,
|
||||
if (isDebug) NavBarItem.NESTS else null,
|
||||
NavBarItem.NESTS,
|
||||
NavBarItem.LONGS,
|
||||
NavBarItem.POLLS,
|
||||
NavBarItem.BADGES,
|
||||
|
||||
+2
-2
@@ -22,11 +22,11 @@ package com.vitorpamplona.amethyst.ui.navigation.navs
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.material3.DrawerState
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.core.net.toUri
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
|
||||
@@ -119,7 +119,7 @@ class BouncingIntentNav(
|
||||
|
||||
private fun bounce(uri: String) {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse(uri))
|
||||
Intent(Intent.ACTION_VIEW, uri.toUri())
|
||||
.setClass(context, MainActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
runCatching { context.startActivity(intent) }
|
||||
|
||||
@@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||
@@ -144,6 +145,10 @@ fun routeForInner(
|
||||
Route.Community(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag())
|
||||
}
|
||||
|
||||
is GitRepositoryEvent -> {
|
||||
Route.GitRepository(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag())
|
||||
}
|
||||
|
||||
is GiftWrapEvent -> {
|
||||
noteEvent.innerEventId?.let {
|
||||
routeFor(LocalCache.getOrCreateNote(it), loggedIn)
|
||||
|
||||
@@ -329,6 +329,18 @@ sealed class Route {
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable data class GitRepository(
|
||||
val kind: Int,
|
||||
val pubKeyHex: HexKey,
|
||||
val dTag: String,
|
||||
) : Route() {
|
||||
constructor(address: Address) : this(
|
||||
kind = address.kind,
|
||||
pubKeyHex = address.pubKeyHex,
|
||||
dTag = address.dTag,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable data class FollowPack(
|
||||
val kind: Int,
|
||||
val pubKeyHex: HexKey,
|
||||
|
||||
@@ -24,7 +24,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -35,7 +35,7 @@ private const val TICK_INTERVAL_MS = 30_000L
|
||||
// every TimeAgo on screen reads from it. Because TimeAgo wraps the formatted string in
|
||||
// `derivedStateOf`, the Text only recomposes when the displayed string actually changes
|
||||
// (e.g. crossing 1m → 2m) — not on every tick.
|
||||
val LocalNowSeconds = compositionLocalOf<State<Long>> { mutableStateOf(TimeUtils.now()) }
|
||||
val LocalNowSeconds = compositionLocalOf<State<Long>> { mutableLongStateOf(TimeUtils.now()) }
|
||||
|
||||
@Composable
|
||||
fun NowProvider(content: @Composable () -> Unit) {
|
||||
|
||||
+93
-1
@@ -20,12 +20,37 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.nip22Comments
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.LinkInteractionListener
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId
|
||||
|
||||
@Composable
|
||||
fun DisplayExternalId(
|
||||
@@ -42,6 +67,73 @@ fun DisplayExternalId(
|
||||
DisplayHashtagExternalId(externalId, accountViewModel, nav)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
is UrlId -> {
|
||||
DisplayUrlExternalId(externalId)
|
||||
}
|
||||
|
||||
else -> {
|
||||
DisplayGenericExternalId(externalId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayUrlExternalId(externalId: UrlId) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
DisplayExternalIdChip(
|
||||
symbol = MaterialSymbols.Link,
|
||||
contentDescription = stringRes(id = R.string.external_url_scope),
|
||||
label = externalId.url,
|
||||
linkInteractionListener = { runCatching { uriHandler.openUri(externalId.url) } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayGenericExternalId(externalId: ExternalId) {
|
||||
DisplayExternalIdChip(
|
||||
symbol = MaterialSymbols.Public,
|
||||
contentDescription = stringRes(id = R.string.external_id_scope),
|
||||
label = externalId.toScope(),
|
||||
linkInteractionListener = null,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayExternalIdChip(
|
||||
symbol: MaterialSymbol,
|
||||
contentDescription: String,
|
||||
label: String,
|
||||
linkInteractionListener: LinkInteractionListener?,
|
||||
) {
|
||||
Row(modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
symbol = symbol,
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
|
||||
Spacer(StdHorzSpacer)
|
||||
|
||||
Text(
|
||||
text =
|
||||
if (linkInteractionListener != null) {
|
||||
buildAnnotatedString {
|
||||
withLink(
|
||||
LinkAnnotation.Clickable("externalId", null, linkInteractionListener),
|
||||
) {
|
||||
append(label)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
buildAnnotatedString { append(label) }
|
||||
},
|
||||
style =
|
||||
LocalTextStyle.current.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,13 +20,19 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -34,12 +40,18 @@ import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
@@ -49,19 +61,22 @@ import com.vitorpamplona.amethyst.ui.components.ClickableUrl
|
||||
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font12SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size16dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size8dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
@@ -70,6 +85,101 @@ import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
|
||||
private val CardShape = QuoteBorder
|
||||
private val ChipShape = RoundedCornerShape(8.dp)
|
||||
private val CardPadding = PaddingValues(Size10dp)
|
||||
private val HeaderSpacing = Arrangement.spacedBy(Size8dp)
|
||||
private val LinkRowSpacing = Arrangement.spacedBy(Size8dp)
|
||||
|
||||
@Composable
|
||||
private fun GitCardContainer(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val border = MaterialTheme.colorScheme.subtleBorder
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.clip(CardShape)
|
||||
.border(1.dp, border, CardShape)
|
||||
.padding(CardPadding),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IconBadge(
|
||||
symbol: MaterialSymbol,
|
||||
contentDescription: String?,
|
||||
background: Color,
|
||||
tint: Color,
|
||||
size: Dp = 28.dp,
|
||||
iconSize: Dp = Size16dp,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(size)
|
||||
.clip(CircleShape)
|
||||
.background(background),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(
|
||||
symbol = symbol,
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier.size(iconSize),
|
||||
tint = tint,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TypeChip(
|
||||
text: String,
|
||||
background: Color,
|
||||
contentColor: Color,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = contentColor,
|
||||
maxLines = 1,
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(ChipShape)
|
||||
.background(background)
|
||||
.padding(horizontal = Size8dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LinkRow(
|
||||
symbol: MaterialSymbol,
|
||||
contentDescription: String?,
|
||||
url: String,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = LinkRowSpacing,
|
||||
) {
|
||||
Icon(
|
||||
symbol = symbol,
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier.size(Size16dp),
|
||||
tint = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
ClickableUrl(
|
||||
url = url,
|
||||
urlText = url.removePrefix("https://").removePrefix("http://"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderGitPatchEvent(
|
||||
baseNote: Note,
|
||||
@@ -101,26 +211,46 @@ private fun RenderShortRepositoryHeader(
|
||||
nav: INav,
|
||||
) {
|
||||
val noteEvent by observeNoteEvent<GitRepositoryEvent>(baseNote, accountViewModel)
|
||||
val title = noteEvent?.name() ?: baseNote.dTag()
|
||||
val summary = noteEvent?.description()
|
||||
|
||||
Column(
|
||||
modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp),
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(ChipShape)
|
||||
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.04f))
|
||||
.clickable { nav.nav(Route.GitRepository(baseNote.address)) }
|
||||
.padding(horizontal = Size10dp, vertical = Size8dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = HeaderSpacing,
|
||||
) {
|
||||
val title = noteEvent?.name() ?: baseNote.dTag()
|
||||
Text(
|
||||
text = stringRes(id = R.string.git_repository, title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
IconBadge(
|
||||
symbol = MaterialSymbols.Code,
|
||||
contentDescription = null,
|
||||
background = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
size = 24.dp,
|
||||
iconSize = 14.dp,
|
||||
)
|
||||
|
||||
noteEvent?.description()?.let {
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
Column(modifier = Modifier.weight(1f, fill = true)) {
|
||||
Text(
|
||||
text = it,
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
summary?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,72 +266,98 @@ private fun RenderGitPatchEvent(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val repository = remember(noteEvent) { noteEvent.repositoryAddress() }
|
||||
GitCardContainer {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = HeaderSpacing,
|
||||
) {
|
||||
TypeChip(
|
||||
text = stringRes(id = R.string.kind_git_patch),
|
||||
background = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.15f),
|
||||
contentColor = MaterialTheme.colorScheme.tertiary,
|
||||
)
|
||||
|
||||
if (repository != null) {
|
||||
LoadAddressableNote(repository, accountViewModel) {
|
||||
if (it != null) {
|
||||
RenderShortRepositoryHeader(it, accountViewModel, nav)
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
GitStatusPill(targetIdHex = note.idHex, defaultIfMissing = StatusKind.OPEN)
|
||||
|
||||
val commit = remember(noteEvent) { noteEvent.commit() }
|
||||
commit?.takeIf { it.isNotBlank() }?.let { hash ->
|
||||
Text(
|
||||
text = hash.take(7),
|
||||
style = MaterialTheme.typography.labelMedium.copy(fontSize = Font12SP),
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LoadDecryptedContent(note, accountViewModel) { body ->
|
||||
val eventContent by
|
||||
remember(note.event) {
|
||||
derivedStateOf {
|
||||
val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null }
|
||||
|
||||
if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) {
|
||||
"### $subject\n$body"
|
||||
} else {
|
||||
body
|
||||
}
|
||||
val repository = remember(noteEvent) { noteEvent.repositoryAddress() }
|
||||
if (repository != null) {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
LoadAddressableNote(repository, accountViewModel) {
|
||||
if (it != null) {
|
||||
RenderShortRepositoryHeader(it, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val isAuthorTheLoggedUser =
|
||||
remember(note.event) { accountViewModel.isLoggedUser(note.author) }
|
||||
Spacer(modifier = HalfDoubleVertSpacer)
|
||||
|
||||
if (makeItShort && isAuthorTheLoggedUser) {
|
||||
Text(
|
||||
text = eventContent,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
} else {
|
||||
val callbackUri = remember(note) { note.toNostrUri() }
|
||||
LoadDecryptedContent(note, accountViewModel) { body ->
|
||||
val eventContent by
|
||||
remember(note.event) {
|
||||
derivedStateOf {
|
||||
val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null }
|
||||
|
||||
SensitivityWarning(
|
||||
note = note,
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
val tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }
|
||||
if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) {
|
||||
"### $subject\n$body"
|
||||
} else {
|
||||
body
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TranslatableRichTextViewer(
|
||||
content = eventContent,
|
||||
canPreview = canPreview && !makeItShort,
|
||||
quotesLeft = quotesLeft,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tags = tags,
|
||||
backgroundColor = backgroundColor,
|
||||
id = note.idHex,
|
||||
callbackUri = callbackUri,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
val isAuthorTheLoggedUser =
|
||||
remember(note.event) { accountViewModel.isLoggedUser(note.author) }
|
||||
|
||||
if (makeItShort && isAuthorTheLoggedUser) {
|
||||
Text(
|
||||
text = eventContent,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val callbackUri = remember(note) { note.toNostrUri() }
|
||||
|
||||
if (note.event?.hasHashtags() == true) {
|
||||
DisplayUncitedHashtags(
|
||||
event = noteEvent,
|
||||
content = eventContent,
|
||||
callbackUri = callbackUri,
|
||||
SensitivityWarning(
|
||||
note = note,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
) {
|
||||
val tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }
|
||||
|
||||
TranslatableRichTextViewer(
|
||||
content = eventContent,
|
||||
canPreview = canPreview && !makeItShort,
|
||||
quotesLeft = quotesLeft,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tags = tags,
|
||||
backgroundColor = backgroundColor,
|
||||
id = note.idHex,
|
||||
callbackUri = callbackUri,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
if (note.event?.hasHashtags() == true) {
|
||||
DisplayUncitedHashtags(
|
||||
event = noteEvent,
|
||||
content = eventContent,
|
||||
callbackUri = callbackUri,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -242,67 +398,83 @@ private fun RenderGitIssueEvent(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val repository = remember(noteEvent) { noteEvent.repositoryAddress() }
|
||||
GitCardContainer {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = HeaderSpacing,
|
||||
) {
|
||||
TypeChip(
|
||||
text = stringRes(id = R.string.kind_git_issue),
|
||||
background = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f),
|
||||
contentColor = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
|
||||
if (repository != null) {
|
||||
LoadAddressableNote(repository, accountViewModel) {
|
||||
if (it != null) {
|
||||
RenderShortRepositoryHeader(it, accountViewModel, nav)
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
}
|
||||
GitStatusPill(targetIdHex = note.idHex, defaultIfMissing = StatusKind.OPEN)
|
||||
}
|
||||
}
|
||||
|
||||
LoadDecryptedContent(note, accountViewModel) { body ->
|
||||
val eventContent by
|
||||
remember(note.event) {
|
||||
derivedStateOf {
|
||||
val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null }
|
||||
|
||||
if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) {
|
||||
"### $subject\n$body"
|
||||
} else {
|
||||
body
|
||||
}
|
||||
val repository = remember(noteEvent) { noteEvent.repositoryAddress() }
|
||||
if (repository != null) {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
LoadAddressableNote(repository, accountViewModel) {
|
||||
if (it != null) {
|
||||
RenderShortRepositoryHeader(it, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val isAuthorTheLoggedUser =
|
||||
remember(note.event) { accountViewModel.isLoggedUser(note.author) }
|
||||
Spacer(modifier = HalfDoubleVertSpacer)
|
||||
|
||||
if (makeItShort && isAuthorTheLoggedUser) {
|
||||
Text(
|
||||
text = eventContent,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
} else {
|
||||
val callbackUri = remember(note) { note.toNostrUri() }
|
||||
LoadDecryptedContent(note, accountViewModel) { body ->
|
||||
val eventContent by
|
||||
remember(note.event) {
|
||||
derivedStateOf {
|
||||
val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null }
|
||||
|
||||
SensitivityWarning(
|
||||
note = note,
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
val tags =
|
||||
remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }
|
||||
if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) {
|
||||
"### $subject\n$body"
|
||||
} else {
|
||||
body
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TranslatableRichTextViewer(
|
||||
content = eventContent,
|
||||
canPreview = canPreview && !makeItShort,
|
||||
quotesLeft = quotesLeft,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tags = tags,
|
||||
backgroundColor = backgroundColor,
|
||||
id = note.idHex,
|
||||
callbackUri = callbackUri,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
val isAuthorTheLoggedUser =
|
||||
remember(note.event) { accountViewModel.isLoggedUser(note.author) }
|
||||
|
||||
if (makeItShort && isAuthorTheLoggedUser) {
|
||||
Text(
|
||||
text = eventContent,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val callbackUri = remember(note) { note.toNostrUri() }
|
||||
|
||||
if (note.event?.hasHashtags() == true) {
|
||||
DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, accountViewModel, nav)
|
||||
SensitivityWarning(
|
||||
note = note,
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
val tags =
|
||||
remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }
|
||||
|
||||
TranslatableRichTextViewer(
|
||||
content = eventContent,
|
||||
canPreview = canPreview && !makeItShort,
|
||||
quotesLeft = quotesLeft,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tags = tags,
|
||||
backgroundColor = backgroundColor,
|
||||
id = note.idHex,
|
||||
callbackUri = callbackUri,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
if (note.event?.hasHashtags() == true) {
|
||||
DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -331,62 +503,62 @@ private fun RenderGitRepositoryEvent(
|
||||
val web = noteEvent.web()
|
||||
val clone = noteEvent.clone()
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(shape = QuoteBorder)
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.subtleBorder,
|
||||
QuoteBorder,
|
||||
).padding(Size10dp),
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = stringRes(id = R.string.git_repository, title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
GitCardContainer {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = HeaderSpacing,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
IconBadge(
|
||||
symbol = MaterialSymbols.Code,
|
||||
contentDescription = null,
|
||||
background = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
|
||||
summary?.let {
|
||||
Column(modifier = Modifier.weight(1f, fill = true)) {
|
||||
Text(
|
||||
text = it,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = Size5dp),
|
||||
maxLines = 3,
|
||||
text = stringRes(id = R.string.kind_git_repo),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
maxLines = 1,
|
||||
)
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
summary?.let {
|
||||
Spacer(modifier = HalfDoubleVertSpacer)
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
web?.let {
|
||||
Row(Modifier.fillMaxWidth().padding(top = Size5dp)) {
|
||||
Text(
|
||||
text = stringRes(id = R.string.git_web_address),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
ClickableUrl(
|
||||
if (web != null || clone != null) {
|
||||
Spacer(modifier = HalfDoubleVertSpacer)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(Size5dp)) {
|
||||
web?.let {
|
||||
LinkRow(
|
||||
symbol = MaterialSymbols.Public,
|
||||
contentDescription = stringRes(id = R.string.git_web_address),
|
||||
url = it,
|
||||
urlText = it.removePrefix("https://").removePrefix("http://"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
clone?.let {
|
||||
Row(Modifier.fillMaxWidth().padding(top = Size5dp)) {
|
||||
Text(
|
||||
text = stringRes(id = R.string.git_clone_address),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
ClickableUrl(
|
||||
clone?.let {
|
||||
LinkRow(
|
||||
symbol = MaterialSymbols.AutoMirrored.OpenInNew,
|
||||
contentDescription = stringRes(id = R.string.git_clone_address),
|
||||
url = it,
|
||||
urlText = it.removePrefix("https://").removePrefix("http://"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.model.GitStatusIndex
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
|
||||
|
||||
private val PillShape = RoundedCornerShape(8.dp)
|
||||
|
||||
enum class StatusKind {
|
||||
OPEN,
|
||||
APPLIED,
|
||||
CLOSED,
|
||||
DRAFT,
|
||||
}
|
||||
|
||||
private fun GitStatusEvent.statusKind(): StatusKind =
|
||||
when (this) {
|
||||
is GitStatusAppliedEvent -> StatusKind.APPLIED
|
||||
is GitStatusClosedEvent -> StatusKind.CLOSED
|
||||
is GitStatusDraftEvent -> StatusKind.DRAFT
|
||||
is GitStatusOpenEvent -> StatusKind.OPEN
|
||||
else -> StatusKind.OPEN
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GitStatusPill(
|
||||
targetIdHex: String,
|
||||
modifier: Modifier = Modifier,
|
||||
defaultIfMissing: StatusKind? = null,
|
||||
) {
|
||||
LaunchedEffect(Unit) { GitStatusIndex.startIfNeeded() }
|
||||
val index by GitStatusIndex.latestByTarget.collectAsStateWithLifecycle()
|
||||
val map = index ?: return // hide pill until the initial scan completes — avoids a default-then-real flicker
|
||||
val kind = map[targetIdHex]?.statusKind() ?: defaultIfMissing ?: return
|
||||
|
||||
GitStatusPill(kind, modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GitStatusPill(
|
||||
kind: StatusKind,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val (label, symbol, container, content) =
|
||||
when (kind) {
|
||||
StatusKind.OPEN -> {
|
||||
StatusStyle(
|
||||
label = stringRes(id = R.string.git_status_open),
|
||||
symbol = MaterialSymbols.RadioButtonChecked,
|
||||
container = Color(0xFF1F883D),
|
||||
content = Color.White,
|
||||
)
|
||||
}
|
||||
|
||||
StatusKind.APPLIED -> {
|
||||
StatusStyle(
|
||||
label = stringRes(id = R.string.git_status_merged),
|
||||
symbol = MaterialSymbols.Check,
|
||||
container = Color(0xFF8250DF),
|
||||
content = Color.White,
|
||||
)
|
||||
}
|
||||
|
||||
StatusKind.CLOSED -> {
|
||||
StatusStyle(
|
||||
label = stringRes(id = R.string.git_status_closed),
|
||||
symbol = MaterialSymbols.Cancel,
|
||||
container = Color(0xFFCF222E),
|
||||
content = Color.White,
|
||||
)
|
||||
}
|
||||
|
||||
StatusKind.DRAFT -> {
|
||||
StatusStyle(
|
||||
label = stringRes(id = R.string.git_status_draft),
|
||||
symbol = MaterialSymbols.EditNote,
|
||||
container = MaterialTheme.colorScheme.surfaceVariant,
|
||||
content = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.clip(PillShape)
|
||||
.background(container)
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = symbol,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = content,
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class StatusStyle(
|
||||
val label: String,
|
||||
val symbol: MaterialSymbol,
|
||||
val container: Color,
|
||||
val content: Color,
|
||||
)
|
||||
@@ -45,6 +45,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.net.toUri
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
@@ -203,7 +204,7 @@ private fun ListenToRecordingButton(
|
||||
runCatching {
|
||||
context.startActivity(
|
||||
android.content
|
||||
.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url))
|
||||
.Intent(android.content.Intent.ACTION_VIEW, url.toUri())
|
||||
.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
}.isSuccess
|
||||
|
||||
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent
|
||||
import com.vitorpamplona.amethyst.ui.note.ReplyNoteComposition
|
||||
import com.vitorpamplona.amethyst.ui.note.ReplyToLabel
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
|
||||
import com.vitorpamplona.amethyst.ui.note.nip22Comments.DisplayExternalId
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
@@ -54,7 +55,9 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.hasAnyTaggedUser
|
||||
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip14Subject.subject
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.scope
|
||||
|
||||
enum class ReplyRenderType {
|
||||
FULL,
|
||||
@@ -119,6 +122,14 @@ fun RenderTextEvent(
|
||||
Spacer(modifier = HalfVertSpacer)
|
||||
}
|
||||
}
|
||||
} else if (!makeItShort && noteEvent is CommentEvent) {
|
||||
// A comment scoped to an external identifier (`I` tag) has no in-cache parent
|
||||
// note. Show the scope itself as the reply context.
|
||||
val scope = remember(note) { noteEvent.scope() }
|
||||
if (scope != null) {
|
||||
DisplayExternalId(scope, accountViewModel, nav)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -1193,7 +1193,7 @@ class AccountViewModel(
|
||||
|
||||
fun updateReportWarningThreshold(threshold: Int) = launchSigner { account.updateReportWarningThreshold(threshold) }
|
||||
|
||||
fun updateDisableClientTag(disable: Boolean) = launchSigner { account.updateDisableClientTag(disable) }
|
||||
fun updateAddClientTag(add: Boolean) = launchSigner { account.updateAddClientTag(add) }
|
||||
|
||||
fun updateFilterSpam(filterSpam: Boolean) =
|
||||
launchSigner {
|
||||
@@ -2091,6 +2091,7 @@ class AccountViewModel(
|
||||
}
|
||||
|
||||
is NEvent -> {
|
||||
LocalCache.consume(parsed)
|
||||
LocalCache.checkGetOrCreateNote(parsed.hex)?.let { note ->
|
||||
returningNote = note
|
||||
}
|
||||
|
||||
+2
-1
@@ -46,6 +46,7 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -76,7 +77,7 @@ fun MarmotGroupListScreen(
|
||||
nav: INav,
|
||||
) {
|
||||
var groupList by remember { mutableStateOf(listOf<Pair<HexKey, MarmotGroupChatroom>>()) }
|
||||
var selectedTab by remember { mutableStateOf(0) }
|
||||
var selectedTab by remember { mutableIntStateOf(0) }
|
||||
|
||||
// Load group list
|
||||
LaunchedEffect(Unit) {
|
||||
|
||||
+3
-3
@@ -55,10 +55,10 @@ class ChatroomListKnownFeedFilter(
|
||||
|
||||
val publicChannels =
|
||||
account
|
||||
.publicChatList.flow.value
|
||||
.mapNotNull { it ->
|
||||
.publicChatList.flowSet.value
|
||||
.mapNotNull { channelId ->
|
||||
LocalCache
|
||||
.getOrCreatePublicChatChannel(it.eventId)
|
||||
.getOrCreatePublicChatChannel(channelId)
|
||||
.notes
|
||||
.filter { _, it -> account.isAcceptable(it) && it.event != null }
|
||||
.sortedWith(DefaultFeedOrder)
|
||||
|
||||
+162
-49
@@ -25,6 +25,7 @@ import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
@@ -37,21 +38,29 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Alignment.Companion.BottomStart
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
@@ -75,6 +84,8 @@ import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val STAR_INLINE_ID = "star"
|
||||
|
||||
@Composable
|
||||
fun FavoriteAlgoFeedsListScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
@@ -82,6 +93,8 @@ fun FavoriteAlgoFeedsListScreen(
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val favorites by accountViewModel.account.favoriteAlgoFeedsList.flowNotes
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
DisappearingScaffold(
|
||||
isInvertedLayout = false,
|
||||
@@ -113,65 +126,67 @@ fun FavoriteAlgoFeedsListScreen(
|
||||
bottom = padding.calculateBottomPadding(),
|
||||
).consumeWindowInsets(padding),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.favorite_dvms_explainer),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
|
||||
FavoriteAlgoFeedList(listState, accountViewModel, nav)
|
||||
if (favorites.isEmpty()) {
|
||||
Text(
|
||||
text = stringRes(R.string.favorite_dvms_explainer),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 24.dp, bottom = 12.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
FavoriteAlgoFeedsEmptyState(nav)
|
||||
} else {
|
||||
FavoriteAlgoFeedList(favorites, listState, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FavoriteAlgoFeedList(
|
||||
private fun FavoriteAlgoFeedsEmptyState(nav: INav) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AutoAwesome,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(72.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.favorite_dvms_empty_headline),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
FavoriteAlgoFeedsEmptySteps(
|
||||
ctaLabel = stringRes(R.string.favorite_dvms_empty_cta),
|
||||
modifier = Modifier.widthIn(max = 320.dp),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
BrowseAlgosButton(
|
||||
label = stringRes(R.string.favorite_dvms_empty_cta),
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.FavoriteAlgoFeedList(
|
||||
favorites: List<AddressableNote>,
|
||||
listState: LazyListState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val favorites by accountViewModel.account.favoriteAlgoFeedsList.flowNotes
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
if (favorites.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AutoAwesome,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(72.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.favorite_dvms_empty_headline),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.favorite_dvms_empty_subtitle),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
modifier = Modifier.widthIn(max = 280.dp),
|
||||
)
|
||||
Button(onClick = { nav.nav(Route.Discover(initialTab = DiscoverTab.ALGOS)) }) {
|
||||
Text(text = stringRes(R.string.favorite_dvms_empty_cta))
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
state = listState,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
contentPadding = FeedPadding,
|
||||
@@ -188,6 +203,25 @@ private fun FavoriteAlgoFeedList(
|
||||
)
|
||||
}
|
||||
}
|
||||
BrowseAlgosButton(
|
||||
label = stringRes(R.string.favorite_dvms_add_more),
|
||||
nav = nav,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally).padding(vertical = 32.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BrowseAlgosButton(
|
||||
label: String,
|
||||
nav: INav,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Button(
|
||||
onClick = { nav.nav(Route.Discover(initialTab = DiscoverTab.ALGOS)) },
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(text = label)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@@ -267,3 +301,82 @@ private fun FavoriteAlgoFeedRow(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FavoriteAlgoFeedsEmptySteps(
|
||||
ctaLabel: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val step2Template = stringRes(R.string.favorite_dvms_empty_step2)
|
||||
val primaryColor = MaterialTheme.colorScheme.primary
|
||||
|
||||
val step2Text =
|
||||
remember(step2Template) {
|
||||
val parts = step2Template.split("%1\$s", limit = 2)
|
||||
buildAnnotatedString {
|
||||
append(parts.first())
|
||||
appendInlineContent(STAR_INLINE_ID, "star")
|
||||
if (parts.size > 1) append(parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
val starInlineContent =
|
||||
remember(primaryColor) {
|
||||
mapOf(
|
||||
STAR_INLINE_ID to
|
||||
InlineTextContent(
|
||||
Placeholder(
|
||||
width = 18.sp,
|
||||
height = 18.sp,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.StarBorder,
|
||||
contentDescription = null,
|
||||
tint = primaryColor,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
NumberedStep(number = "1.") {
|
||||
Text(
|
||||
text = stringRes(R.string.favorite_dvms_empty_step1, ctaLabel),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
}
|
||||
NumberedStep(number = "2.") {
|
||||
Text(
|
||||
text = step2Text,
|
||||
inlineContent = starInlineContent,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NumberedStep(
|
||||
number: String,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.semantics(mergeDescendants = true) {},
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Text(
|
||||
text = number,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* 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.gitRepo
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font12SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size16dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
|
||||
private val SectionCardShape = RoundedCornerShape(15.dp)
|
||||
private val SectionSpacing = Arrangement.spacedBy(12.dp)
|
||||
private val LinkSpacing = Arrangement.spacedBy(8.dp)
|
||||
|
||||
@Composable
|
||||
fun GitRepositoryOverview(
|
||||
event: GitRepositoryEvent,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 12.dp, vertical = 16.dp),
|
||||
verticalArrangement = SectionSpacing,
|
||||
) {
|
||||
TitleHeader(event)
|
||||
|
||||
val description = event.description()
|
||||
if (!description.isNullOrBlank()) {
|
||||
SectionCard(title = stringRes(id = R.string.git_repo_section_about)) {
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val webs = event.webs()
|
||||
val clones = event.clones()
|
||||
if (webs.isNotEmpty() || clones.isNotEmpty()) {
|
||||
SectionCard(title = stringRes(id = R.string.git_repo_section_links)) {
|
||||
Column(verticalArrangement = LinkSpacing) {
|
||||
webs.forEach { LinkLine(symbol = MaterialSymbols.Public, url = it) }
|
||||
clones.forEach { LinkLine(symbol = MaterialSymbols.AutoMirrored.OpenInNew, url = it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val topics = event.hashtags()
|
||||
if (topics.isNotEmpty()) {
|
||||
SectionCard(title = stringRes(id = R.string.git_repo_section_topics)) {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
topics.forEach { TopicChip(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val maintainers = listOfNotNull(event.pubKey).plus(event.maintainers()).distinct()
|
||||
if (maintainers.isNotEmpty()) {
|
||||
SectionCard(title = stringRes(id = R.string.git_repo_section_maintainers)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
maintainers.forEach { hex ->
|
||||
MaintainerRow(pubKeyHex = hex, accountViewModel = accountViewModel, nav = nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TitleHeader(event: GitRepositoryEvent) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Code,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = event.name() ?: event.dTag(),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (event.isPersonalFork()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
TopicChip(stringRes(id = R.string.git_repo_personal_fork))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionCard(
|
||||
title: String,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(SectionCardShape)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LinkLine(
|
||||
symbol: MaterialSymbol,
|
||||
url: String,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = symbol,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(Size16dp),
|
||||
tint = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
ClickableUrl(
|
||||
url = url,
|
||||
urlText = url.removePrefix("https://").removePrefix("http://"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopicChip(label: String) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = Font12SP),
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f))
|
||||
.padding(horizontal = 10.dp, vertical = 3.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MaintainerRow(
|
||||
pubKeyHex: HexKey,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val user = LocalCache.checkGetOrCreateUser(pubKeyHex) ?: return
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ClickableUserPicture(
|
||||
baseUser = user,
|
||||
size = 32.dp,
|
||||
accountViewModel = accountViewModel,
|
||||
onClick = { nav.nav(Route.Profile(it.pubkeyHex)) },
|
||||
)
|
||||
UsernameDisplay(
|
||||
baseUser = user,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* 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.gitRepo
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SecondaryTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
|
||||
import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState
|
||||
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TitleIconModifier
|
||||
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.dal.RepositoryIssuesFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.dal.RepositoryPatchesFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.datasource.RepositoryFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun GitRepositoryScreen(
|
||||
address: Address,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
LoadAddressableNote(address, accountViewModel) { note ->
|
||||
note?.let {
|
||||
PrepareGitRepositoryScreen(
|
||||
note = it,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PrepareGitRepositoryScreen(
|
||||
note: AddressableNote,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val issuesViewModel: RepositoryIssuesFeedViewModel =
|
||||
viewModel(
|
||||
key = note.idHex + "GitRepoIssues",
|
||||
factory = RepositoryIssuesFeedViewModel.Factory(note, accountViewModel.account),
|
||||
)
|
||||
|
||||
val patchesViewModel: RepositoryPatchesFeedViewModel =
|
||||
viewModel(
|
||||
key = note.idHex + "GitRepoPatches",
|
||||
factory = RepositoryPatchesFeedViewModel.Factory(note, accountViewModel.account),
|
||||
)
|
||||
|
||||
GitRepositoryScreen(
|
||||
note = note,
|
||||
issuesViewModel = issuesViewModel,
|
||||
patchesViewModel = patchesViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun GitRepositoryScreen(
|
||||
note: AddressableNote,
|
||||
issuesViewModel: RepositoryIssuesFeedViewModel,
|
||||
patchesViewModel: RepositoryPatchesFeedViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
WatchLifecycleAndUpdateModel(issuesViewModel)
|
||||
WatchLifecycleAndUpdateModel(patchesViewModel)
|
||||
|
||||
val event by observeNoteEvent<GitRepositoryEvent>(note, accountViewModel)
|
||||
|
||||
// RepositoryContentSubAssembler.updateFilter reads note.event and bails out if it isn't
|
||||
// a GitRepositoryEvent yet. The compose subscription manager doesn't re-run updateFilter
|
||||
// when note.event later mutates, so subscribing before the event has arrived (cold-start
|
||||
// / deep-link case) leaves an empty filter forever. Gate the subscription composable on
|
||||
// event presence so it enters composition (and fires DisposableEffect → subscribe → fresh
|
||||
// updateFilter) only once the repo event is loaded.
|
||||
if (event != null) {
|
||||
RepositoryFilterAssemblerSubscription(note, accountViewModel.dataSources().gitRepository)
|
||||
}
|
||||
|
||||
val pagerState = rememberForeverPagerState(note.idHex + "GitRepoScreenPagerState") { 3 }
|
||||
|
||||
DisappearingScaffold(
|
||||
isInvertedLayout = false,
|
||||
topBar = {
|
||||
Column {
|
||||
ShorterTopAppBar(
|
||||
title = {
|
||||
TopBarTitle(event = event, fallback = note.dTag())
|
||||
},
|
||||
navigationIcon = {
|
||||
Row(TitleIconModifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = nav::popBack) { ArrowBackIcon() }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
SecondaryTabRow(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = TabRowHeight,
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
Tab(
|
||||
selected = pagerState.currentPage == 0,
|
||||
text = { Text(stringRes(R.string.git_repo_tab_overview)) },
|
||||
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } },
|
||||
)
|
||||
Tab(
|
||||
selected = pagerState.currentPage == 1,
|
||||
text = { Text(stringRes(R.string.git_repo_tab_issues)) },
|
||||
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } },
|
||||
)
|
||||
Tab(
|
||||
selected = pagerState.currentPage == 2,
|
||||
text = { Text(stringRes(R.string.git_repo_tab_patches)) },
|
||||
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) { page ->
|
||||
when (page) {
|
||||
0 -> {
|
||||
val currentEvent = event
|
||||
if (currentEvent != null) {
|
||||
GitRepositoryOverview(currentEvent, accountViewModel, nav)
|
||||
} else {
|
||||
EmptyMessage(stringRes(R.string.loading_feed))
|
||||
}
|
||||
}
|
||||
|
||||
1 -> {
|
||||
RefresheableFeedView(
|
||||
viewModel = issuesViewModel,
|
||||
routeForLastRead = null,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
2 -> {
|
||||
RefresheableFeedView(
|
||||
viewModel = patchesViewModel,
|
||||
routeForLastRead = null,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopBarTitle(
|
||||
event: GitRepositoryEvent?,
|
||||
fallback: String,
|
||||
) {
|
||||
Text(
|
||||
text = event?.name() ?: fallback,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyMessage(text: String) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(24.dp),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
|
||||
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
|
||||
class RepositoryIssuesFeedFilter(
|
||||
val repositoryNote: AddressableNote,
|
||||
val account: Account,
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
private val repositoryAddressId = repositoryNote.address.toValue()
|
||||
|
||||
override fun feedKey(): String = account.userProfile().pubkeyHex + "-issues-" + repositoryNote.idHex
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val result =
|
||||
LocalCache.notes.mapNotNullIntoSet { _, note ->
|
||||
if (matches(note)) note else null
|
||||
}
|
||||
return sort(result)
|
||||
}
|
||||
|
||||
override fun applyFilter(newItems: Set<Note>): Set<Note> = newItems.filterTo(HashSet()) { matches(it) }
|
||||
|
||||
private fun matches(note: Note): Boolean {
|
||||
val event = note.event as? GitIssueEvent ?: return false
|
||||
return event.repositoryHex() == repositoryAddressId
|
||||
}
|
||||
|
||||
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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.gitRepo.dal
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
|
||||
|
||||
class RepositoryIssuesFeedViewModel(
|
||||
val note: AddressableNote,
|
||||
val account: Account,
|
||||
) : AndroidFeedViewModel(RepositoryIssuesFeedFilter(note, account)) {
|
||||
class Factory(
|
||||
val note: AddressableNote,
|
||||
val account: Account,
|
||||
) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = RepositoryIssuesFeedViewModel(note, account) as T
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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.gitRepo.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
|
||||
|
||||
class RepositoryPatchesFeedFilter(
|
||||
val repositoryNote: AddressableNote,
|
||||
val account: Account,
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
private val repositoryAddressId = repositoryNote.address.toValue()
|
||||
|
||||
override fun feedKey(): String = account.userProfile().pubkeyHex + "-patches-" + repositoryNote.idHex
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val result =
|
||||
LocalCache.notes.mapNotNullIntoSet { _, note ->
|
||||
if (matches(note)) note else null
|
||||
}
|
||||
return sort(result)
|
||||
}
|
||||
|
||||
override fun applyFilter(newItems: Set<Note>): Set<Note> = newItems.filterTo(HashSet()) { matches(it) }
|
||||
|
||||
private fun matches(note: Note): Boolean {
|
||||
val event = note.event ?: return false
|
||||
return when (event) {
|
||||
is GitPatchEvent -> event.repositoryAddress()?.toValue() == repositoryAddressId
|
||||
is GitPullRequestEvent -> event.repositoryAddress()?.toValue() == repositoryAddressId
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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.gitRepo.dal
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
|
||||
|
||||
class RepositoryPatchesFeedViewModel(
|
||||
val note: AddressableNote,
|
||||
val account: Account,
|
||||
) : AndroidFeedViewModel(RepositoryPatchesFeedFilter(note, account)) {
|
||||
class Factory(
|
||||
val note: AddressableNote,
|
||||
val account: Account,
|
||||
) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = RepositoryPatchesFeedViewModel(note, account) as T
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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.gitRepo.datasource
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
|
||||
|
||||
val RepositoryContentKinds =
|
||||
listOf(
|
||||
GitIssueEvent.KIND,
|
||||
GitPatchEvent.KIND,
|
||||
GitPullRequestEvent.KIND,
|
||||
GitStatusOpenEvent.KIND,
|
||||
GitStatusAppliedEvent.KIND,
|
||||
GitStatusClosedEvent.KIND,
|
||||
GitStatusDraftEvent.KIND,
|
||||
)
|
||||
|
||||
fun filterRepositoryContent(
|
||||
relay: NormalizedRelayUrl,
|
||||
repository: GitRepositoryEvent,
|
||||
since: Long?,
|
||||
): RelayBasedFilter =
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
tags = mapOf("a" to listOf(repository.addressTag())),
|
||||
kinds = RepositoryContentKinds,
|
||||
limit = 500,
|
||||
since = since,
|
||||
),
|
||||
)
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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.gitRepo.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
|
||||
class RepositoryContentSubAssembler(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<RepositoryQueryState>,
|
||||
) : SingleSubEoseManager<RepositoryQueryState>(client, allKeys) {
|
||||
override fun updateFilter(
|
||||
keys: List<RepositoryQueryState>,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> {
|
||||
if (keys.isEmpty()) return emptyList()
|
||||
|
||||
return keys.flatMap { key ->
|
||||
val repoEvent = key.repository.event as? GitRepositoryEvent ?: return@flatMap emptyList()
|
||||
|
||||
val announcedRelays = repoEvent.relays().mapNotNull(RelayUrlNormalizer::normalizeOrNull)
|
||||
val hintRelays =
|
||||
if (announcedRelays.isEmpty()) {
|
||||
LocalCache.relayHints.hintsForAddress(repoEvent.addressTag())
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
val repoRelays = (announcedRelays + hintRelays + key.repository.relayUrls()).toSet()
|
||||
|
||||
repoRelays.map { relay ->
|
||||
filterRepositoryContent(relay, repoEvent, since?.get(relay)?.time)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun distinct(key: RepositoryQueryState) = key.repository.idHex
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
|
||||
class RepositoryQueryState(
|
||||
var repository: AddressableNote,
|
||||
)
|
||||
|
||||
class RepositoryFilterAssembler(
|
||||
client: INostrClient,
|
||||
) : ComposeSubscriptionManager<RepositoryQueryState>() {
|
||||
val group =
|
||||
listOf(
|
||||
RepositoryContentSubAssembler(client, ::allKeys),
|
||||
)
|
||||
|
||||
override fun invalidateKeys() = invalidateFilters()
|
||||
|
||||
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
|
||||
|
||||
override fun destroy() = group.forEach { it.destroy() }
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.datasource
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
|
||||
@Composable
|
||||
fun RepositoryFilterAssemblerSubscription(
|
||||
repository: AddressableNote,
|
||||
filterAssembler: RepositoryFilterAssembler,
|
||||
) {
|
||||
val state =
|
||||
remember(repository) {
|
||||
RepositoryQueryState(repository)
|
||||
}
|
||||
|
||||
LifecycleAwareKeyDataSourceSubscription(state, filterAssembler)
|
||||
}
|
||||
+8
-3
@@ -227,16 +227,21 @@ private fun HomePages(
|
||||
Column {
|
||||
HomeTopBar(accountViewModel, nav)
|
||||
if (tabs.size > 1) {
|
||||
// Clamp to a valid index: rememberForeverPagerState persists
|
||||
// currentPage across tab-count changes, so toggling off a home
|
||||
// tab can leave currentPage > tabs.lastIndex for a frame, which
|
||||
// crashes Material3's TabIndicatorOffsetNode.
|
||||
val selectedTab = pagerState.currentPage.coerceIn(0, tabs.lastIndex)
|
||||
SecondaryTabRow(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = TabRowHeight,
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
selectedTabIndex = selectedTab,
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
tabs.forEachIndexed { index, tab ->
|
||||
Tab(
|
||||
selected = pagerState.currentPage == index,
|
||||
selected = selectedTab == index,
|
||||
text = { Text(text = stringRes(tab.resource)) },
|
||||
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
|
||||
)
|
||||
@@ -248,7 +253,7 @@ private fun HomePages(
|
||||
bottomBar = {
|
||||
AppBottomBar(Route.Home, nav, accountViewModel) { route ->
|
||||
if (route == Route.Home) {
|
||||
tabs[pagerState.currentPage].feedState.sendToTop()
|
||||
tabs.getOrNull(pagerState.currentPage)?.feedState?.sendToTop()
|
||||
} else {
|
||||
nav.navBottomBar(route)
|
||||
}
|
||||
|
||||
+3
-1
@@ -29,7 +29,9 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -94,7 +96,7 @@ fun CreateNestSheet(
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
|
||||
+7
-5
@@ -38,6 +38,7 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
|
||||
/**
|
||||
* Per-room state for every wire subscription scoped to a single
|
||||
@@ -56,11 +57,11 @@ class NestRoomQueryState(
|
||||
* Single per-room sub-assembler. Issues two [Filter]s per outbox
|
||||
* relay on every key update:
|
||||
*
|
||||
* 1. `kinds=[1311, 10312, 7]`, `#a=[roomATag]` — the chat, presence
|
||||
* and reaction streams the room consumes. Bundling them into
|
||||
* one Filter is safe: a single Filter requires (kind ∈ kinds)
|
||||
* AND (every tag dimension matches), and these three streams
|
||||
* share the same `#a` gate.
|
||||
* 1. `kinds=[1311, 10312, 7, 9735]`, `#a=[roomATag]` — the chat,
|
||||
* presence, reaction and zap-receipt streams the room consumes.
|
||||
* Bundling them into one Filter is safe: a single Filter
|
||||
* requires (kind ∈ kinds) AND (every tag dimension matches),
|
||||
* and these streams all share the same `#a` gate.
|
||||
*
|
||||
* 2. `kinds=[4312]`, `#a=[roomATag]`, `#p=[localPubkey]` — admin
|
||||
* commands. Stays separate from (1) because the additional
|
||||
@@ -94,6 +95,7 @@ class NestRoomFilterSubAssembler(
|
||||
LiveActivitiesChatMessageEvent.KIND,
|
||||
MeetingRoomPresenceEvent.KIND,
|
||||
ReactionEvent.KIND,
|
||||
LnZapEvent.KIND,
|
||||
),
|
||||
tags = mapOf("a" to listOf(key.note.idHex)),
|
||||
since = since?.get(relay)?.time,
|
||||
|
||||
+26
-2
@@ -27,6 +27,7 @@ import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -38,6 +39,8 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayAuthSubscription
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.StringResSetup
|
||||
import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.screen.ManageRelayServices
|
||||
import com.vitorpamplona.amethyst.ui.screen.ManageWebOkHttp
|
||||
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||
@@ -190,6 +193,24 @@ class NestActivity : AppCompatActivity() {
|
||||
onLeave = { finish() },
|
||||
onMinimize = { enterPip() },
|
||||
)
|
||||
|
||||
// Mirror MainActivity's `AppNavigation` → render toasts
|
||||
// inside *this* Activity's Compose tree so promote /
|
||||
// demote / kick / room-edit results actually surface in
|
||||
// front of the room UI. Without this, every
|
||||
// `toastManager.toast(...)` from nest code queues into a
|
||||
// StateFlow whose only collector lives in MainActivity —
|
||||
// the dialog only becomes visible after the user PIPs out.
|
||||
// EmptyNav is the documented stub for hosts that have no
|
||||
// navigation graph; the only consumer that reads `nav`
|
||||
// is `MultiErrorToastMsg` (zap-error aggregator) — nest
|
||||
// call sites emit `ResourceToastMsg` / `StringToastMsg`
|
||||
// which don't touch nav.
|
||||
DisplayErrorMessages(
|
||||
toastManager = accountViewModel.toastManager,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = EmptyNav(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -244,8 +265,11 @@ class NestActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode)
|
||||
override fun onPictureInPictureModeChanged(
|
||||
isInPictureInPictureMode: Boolean,
|
||||
newConfig: Configuration,
|
||||
) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
||||
isInPipMode.value = isInPictureInPictureMode
|
||||
}
|
||||
|
||||
|
||||
+21
@@ -251,6 +251,14 @@ private fun NestActivityBody(
|
||||
participants = event.participants(),
|
||||
presences = presences,
|
||||
hostPubkey = event.pubKey,
|
||||
// Treat presence as authoritative only when fresher
|
||||
// than the role grant. nostrnests audience members
|
||||
// emit kind-10312 with `onstage=0` and never flip
|
||||
// it back on after a promotion — without this gate,
|
||||
// a freshly-promoted speaker would render in the
|
||||
// audience tab with role=SPEAKER, looking unchanged
|
||||
// to the host who just promoted them.
|
||||
roleGrantSec = event.createdAt,
|
||||
)
|
||||
}
|
||||
val onStageKeys =
|
||||
@@ -282,6 +290,19 @@ private fun NestActivityBody(
|
||||
}
|
||||
}
|
||||
|
||||
// Re-sync the optimistic local intent flag with the role-derived
|
||||
// on-stage state whenever the user becomes on stage. Symmetric to
|
||||
// the auto-stop above: when `isOnStageMe` flips to true (initial
|
||||
// promotion or a re-promotion after a previous voluntary leave),
|
||||
// also flip `ui.onStageNow` back to true so [StageControlsBar]'s
|
||||
// `!ui.onStageNow` gate doesn't hide the talk button when their
|
||||
// role says they CAN talk. Without this, a promoted speaker
|
||||
// appeared on the stage grid but saw no speaker controls
|
||||
// — confirmed during testing on 2026-05-13.
|
||||
LaunchedEffect(isOnStageMe) {
|
||||
if (isOnStageMe) viewModel.setOnStage(true)
|
||||
}
|
||||
|
||||
// Single REQ per relay covering chat, presence, reactions, admin
|
||||
// commands. See NestRoomFilterAssembler for the filter shape.
|
||||
NestRoomFilterAssemblerSubscription(roomNote, accountViewModel)
|
||||
|
||||
+72
-49
@@ -26,9 +26,12 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -108,60 +111,80 @@ fun EditNestSheet(
|
||||
)
|
||||
}
|
||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
|
||||
// Two-region layout so the keyboard never squashes the action
|
||||
// row: the form scrolls inside its own viewport, the bottom
|
||||
// buttons stay anchored. `imePadding()` on the outer Column
|
||||
// lifts the whole sheet above the keyboard, so the anchored
|
||||
// row sits just above the IME instead of behind it.
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth().imePadding(),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.nest_edit_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.roomName,
|
||||
onValueChange = viewModel::setRoomName,
|
||||
label = { Text(stringRes(R.string.nest_create_field_room)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.summary,
|
||||
onValueChange = viewModel::setSummary,
|
||||
label = { Text(stringRes(R.string.nest_create_field_summary)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.serviceUrl,
|
||||
onValueChange = viewModel::setServiceUrl,
|
||||
label = { Text(stringRes(R.string.nest_create_field_service)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.endpointUrl,
|
||||
onValueChange = viewModel::setEndpointUrl,
|
||||
label = { Text(stringRes(R.string.nest_create_field_endpoint)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.imageUrl,
|
||||
onValueChange = viewModel::setImageUrl,
|
||||
label = { Text(stringRes(R.string.nest_create_field_image)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
state.error?.let { error ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f, fill = false)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
text = stringRes(R.string.nest_edit_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
OutlinedTextField(
|
||||
value = state.roomName,
|
||||
onValueChange = viewModel::setRoomName,
|
||||
label = { Text(stringRes(R.string.nest_create_field_room)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.summary,
|
||||
onValueChange = viewModel::setSummary,
|
||||
label = { Text(stringRes(R.string.nest_create_field_summary)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.serviceUrl,
|
||||
onValueChange = viewModel::setServiceUrl,
|
||||
label = { Text(stringRes(R.string.nest_create_field_service)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.endpointUrl,
|
||||
onValueChange = viewModel::setEndpointUrl,
|
||||
label = { Text(stringRes(R.string.nest_create_field_endpoint)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.imageUrl,
|
||||
onValueChange = viewModel::setImageUrl,
|
||||
label = { Text(stringRes(R.string.nest_create_field_image)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
state.error?.let { error ->
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
// Destructive — flips status to CLOSED with the same d-tag
|
||||
// so subscribers see the room go dark. Confirm prompt
|
||||
// gates the actual publish so a misclick on the
|
||||
|
||||
+54
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
@@ -61,6 +62,8 @@ internal fun NestRoomEventCollectors(
|
||||
ChatCollector(viewModel, roomATag)
|
||||
ReactionsCollector(viewModel, roomATag)
|
||||
ReactionsEvictionTicker(viewModel)
|
||||
ZapsCollector(viewModel, roomATag)
|
||||
ZapsEvictionTicker(viewModel)
|
||||
AdminCommandsCollector(viewModel, event, roomATag, localPubkey)
|
||||
}
|
||||
|
||||
@@ -144,6 +147,57 @@ private fun ReactionsCollector(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pump kind-9735 zap receipts into the VM's chat ledger AND the
|
||||
* floating zap-overlay aggregator. The same note flows two places:
|
||||
*
|
||||
* 1. [NestViewModel.onChatEvent] — `ChatroomMessageCompose` routes
|
||||
* `LnZapEvent` notes through `RenderChatZap`, which is the same
|
||||
* card live streams use. Sharing the chat ledger keeps zap
|
||||
* cards interleaved with kind-1311 chat messages in time order.
|
||||
*
|
||||
* 2. [NestViewModel.onZapEvent] — same sliding-window pattern as
|
||||
* reactions; drives the floating "⚡ Nsats" chip over the
|
||||
* targeted participant's avatar (or the room itself).
|
||||
*/
|
||||
@Composable
|
||||
private fun ZapsCollector(
|
||||
viewModel: NestViewModel,
|
||||
roomATag: String,
|
||||
) {
|
||||
LaunchedEffect(viewModel, roomATag) {
|
||||
val filter =
|
||||
Filter(
|
||||
kinds = listOf(LnZapEvent.KIND),
|
||||
tags = mapOf("a" to listOf(roomATag)),
|
||||
)
|
||||
LocalCache.observeNotes(filter).collect { notes ->
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
notes.forEach { note ->
|
||||
viewModel.onChatEvent(note)
|
||||
(note.event as? LnZapEvent)?.let { viewModel.onZapEvent(it, nowSec) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ZapsEvictionTicker(viewModel: NestViewModel) {
|
||||
// Mirrors [ReactionsEvictionTicker] — only tick while there are
|
||||
// zaps to evict. The aggregator's last eviction empties
|
||||
// [NestViewModel.recentZaps], which flips [hasZaps] to false and
|
||||
// cancels the loop until the next zap arrives.
|
||||
val zaps by viewModel.recentZaps.collectAsState()
|
||||
val hasZaps = zaps.values.any { it.isNotEmpty() }
|
||||
LaunchedEffect(viewModel, hasZaps) {
|
||||
if (!hasZaps) return@LaunchedEffect
|
||||
while (isActive) {
|
||||
delay(REACTIONS_TICK_MS)
|
||||
viewModel.evictZaps(System.currentTimeMillis() / 1000 - REACTION_WINDOW_SEC_LOCAL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReactionsEvictionTicker(viewModel: NestViewModel) {
|
||||
// Only tick while there are reactions to evict. The aggregator's
|
||||
|
||||
+2
-2
@@ -48,7 +48,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -435,7 +435,7 @@ private fun RoomHeader(
|
||||
*/
|
||||
@Composable
|
||||
private fun CachedListenerCount(roomATag: String) {
|
||||
var count by remember(roomATag) { mutableStateOf(0) }
|
||||
var count by remember(roomATag) { mutableIntStateOf(0) }
|
||||
LaunchedEffect(roomATag) {
|
||||
val filter =
|
||||
Filter(
|
||||
|
||||
+63
-42
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.participants
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -50,6 +49,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.RoomSpeakerCatalog
|
||||
@@ -71,6 +72,9 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Per-participant context sheet (T2 #2). Long-press on any participant
|
||||
@@ -100,12 +104,12 @@ internal fun ParticipantHostActionsSheet(
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val targetUser = remember(target) { LocalCache.getOrCreateUser(target) }
|
||||
|
||||
// Reactive display name — UsernameDisplay observes this for the
|
||||
// header, and we reuse the same flow for toast text so the header
|
||||
// and toast never disagree (e.g. metadata arriving mid-sheet).
|
||||
// Reactive display name for confirmation-dialog bodies (kick /
|
||||
// force-mute) — `observeUserInfo` lets the dialog text track a
|
||||
// late-arriving metadata event without re-opening the sheet.
|
||||
val userInfo by observeUserInfo(targetUser, accountViewModel)
|
||||
val displayName = userInfo?.info?.bestName() ?: targetUser.pubkeyDisplayHex()
|
||||
val toasts = rememberParticipantToasts(displayName)
|
||||
val toasts = rememberParticipantToasts()
|
||||
|
||||
var openDialog by rememberSaveable(stateSaver = SheetDialogSaver) {
|
||||
mutableStateOf<SheetDialog?>(null)
|
||||
@@ -174,7 +178,6 @@ internal fun ParticipantHostActionsSheet(
|
||||
// so future presence events don't re-render them
|
||||
broadcast(accountViewModel, AdminCommandEvent.kick(roomATag, target))
|
||||
broadcast(accountViewModel, RoomParticipantActions.removeParticipant(event, target))
|
||||
accountViewModel.toastManager.toast(toasts.title, toasts.kickSent)
|
||||
onDismiss()
|
||||
},
|
||||
onDismiss = { openDialog = null },
|
||||
@@ -190,7 +193,6 @@ internal fun ParticipantHostActionsSheet(
|
||||
openDialog = null
|
||||
val roomATag = ATag(event.kind, event.pubKey, event.dTag(), null)
|
||||
broadcast(accountViewModel, AdminCommandEvent.forceMute(roomATag, target))
|
||||
accountViewModel.toastManager.toast(toasts.title, toasts.forceMuteSent)
|
||||
onDismiss()
|
||||
},
|
||||
onDismiss = { openDialog = null },
|
||||
@@ -273,10 +275,8 @@ private fun AudienceActions(
|
||||
) {
|
||||
if (isFollowing) {
|
||||
accountViewModel.unfollow(targetUser)
|
||||
accountViewModel.toastManager.toast(toasts.title, toasts.unfollowSent)
|
||||
} else {
|
||||
accountViewModel.follow(targetUser)
|
||||
accountViewModel.toastManager.toast(toasts.title, toasts.followSent)
|
||||
}
|
||||
onDismiss()
|
||||
}
|
||||
@@ -289,10 +289,8 @@ private fun AudienceActions(
|
||||
) {
|
||||
if (isHidden) {
|
||||
accountViewModel.show(targetUser)
|
||||
accountViewModel.toastManager.toast(toasts.title, toasts.unmuteSent)
|
||||
} else {
|
||||
accountViewModel.hide(targetUser)
|
||||
accountViewModel.toastManager.toast(toasts.title, toasts.muteSent)
|
||||
}
|
||||
onDismiss()
|
||||
}
|
||||
@@ -337,13 +335,22 @@ private fun HostActions(
|
||||
val nestPresences by nestViewModel.presences.collectAsState()
|
||||
val targetIsBroadcasting = nestPresences[target]?.publishing == true
|
||||
|
||||
fun hostAction(
|
||||
template: EventTemplate<out Event>?,
|
||||
toastMsg: String,
|
||||
) {
|
||||
broadcast(accountViewModel, template)
|
||||
accountViewModel.toastManager.toast(toasts.title, toastMsg)
|
||||
fun hostAction(template: EventTemplate<out Event>?) {
|
||||
// Sign+publish runs inside a coroutine that surfaces ANY
|
||||
// failure as a toast. Success is confirmed by the UI moving
|
||||
// the target between stage/audience (or removing them), so
|
||||
// no success toast is needed — the previous "Promoted X"
|
||||
// toast was redundant once the kind-30312 + stale-presence
|
||||
// fixes made the grid update visible. Silent signer failures
|
||||
// (NIP-46 timeout, manual denial, etc.) still need to
|
||||
// surface because they're invisible from the host's POV.
|
||||
if (template == null) {
|
||||
accountViewModel.toastManager.toast(toasts.failedTitle, toasts.failedTemplateNull)
|
||||
onDismiss()
|
||||
return
|
||||
}
|
||||
onDismiss()
|
||||
broadcastWithDiagnostics(accountViewModel, template, toasts.failedTitle)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
@@ -353,12 +360,12 @@ private fun HostActions(
|
||||
// wasted relay traffic and an obvious UX tell.
|
||||
if (targetRole != ROLE.SPEAKER) {
|
||||
ActionRow(stringRes(R.string.nest_promote_speaker)) {
|
||||
hostAction(RoomParticipantActions.setRole(event, target, ROLE.SPEAKER), toasts.promoteSpeakerSent)
|
||||
hostAction(RoomParticipantActions.setRole(event, target, ROLE.SPEAKER))
|
||||
}
|
||||
}
|
||||
if (targetRole != ROLE.MODERATOR) {
|
||||
ActionRow(stringRes(R.string.nest_promote_moderator)) {
|
||||
hostAction(RoomParticipantActions.setRole(event, target, ROLE.MODERATOR), toasts.promoteModeratorSent)
|
||||
hostAction(RoomParticipantActions.setRole(event, target, ROLE.MODERATOR))
|
||||
}
|
||||
}
|
||||
// Demote only when the target actually has a role above listener —
|
||||
@@ -366,7 +373,7 @@ private fun HostActions(
|
||||
// and the visible row sets up a misleading expectation.
|
||||
if (targetRole != null && targetRole != ROLE.PARTICIPANT) {
|
||||
ActionRow(stringRes(R.string.nest_demote_listener)) {
|
||||
hostAction(RoomParticipantActions.demoteToListener(event, target), toasts.demoteListenerSent)
|
||||
hostAction(RoomParticipantActions.demoteToListener(event, target))
|
||||
}
|
||||
}
|
||||
// Force-mute is honor-based: the target's client must honor the
|
||||
@@ -498,6 +505,36 @@ private fun broadcast(
|
||||
accountViewModel.launchSigner { accountViewModel.account.signAndComputeBroadcast(template) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign+publish a host-action template and surface only FAILURES as
|
||||
* a toast. Success is implicit — the UI updates when the kind-30312
|
||||
* round-trips, so a success toast would be redundant. Any throw
|
||||
* (including the silent ones [launchSigner] Log.w-only's —
|
||||
* `TimedOutException`, `ManuallyUnauthorizedException`,
|
||||
* `CouldNotPerformException`,
|
||||
* `RunningOnBackgroundWithoutAutomaticPermissionException`,
|
||||
* `AutomaticallyUnauthorizedException`) becomes a visible error
|
||||
* toast with the exception's class name + message so the host knows
|
||||
* a remote-signer denial happened.
|
||||
*/
|
||||
private fun broadcastWithDiagnostics(
|
||||
accountViewModel: AccountViewModel,
|
||||
template: EventTemplate<out Event>,
|
||||
failedTitle: String,
|
||||
) {
|
||||
accountViewModel.viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
accountViewModel.account.signAndComputeBroadcast(template)
|
||||
} catch (ce: CancellationException) {
|
||||
throw ce
|
||||
} catch (e: Throwable) {
|
||||
val klass = e::class.simpleName ?: "Throwable"
|
||||
val msg = e.message?.takeIf { it.isNotBlank() } ?: "no message"
|
||||
accountViewModel.toastManager.toast(failedTitle, "$klass: $msg")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openProfileInMainActivity(
|
||||
context: Context,
|
||||
target: String,
|
||||
@@ -509,7 +546,7 @@ private fun openProfileInMainActivity(
|
||||
runCatching {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW).apply {
|
||||
data = Uri.parse("nostr:$npub")
|
||||
data = "nostr:$npub".toUri()
|
||||
setClass(context, MainActivity::class.java)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
|
||||
},
|
||||
@@ -545,33 +582,17 @@ private val SheetDialogSaver: Saver<SheetDialog?, String> =
|
||||
* (which are not @Composable) read them by reference.
|
||||
*/
|
||||
private data class ParticipantToasts(
|
||||
val title: String,
|
||||
val followSent: String,
|
||||
val unfollowSent: String,
|
||||
val muteSent: String,
|
||||
val unmuteSent: String,
|
||||
val promoteSpeakerSent: String,
|
||||
val promoteModeratorSent: String,
|
||||
val demoteListenerSent: String,
|
||||
val forceMuteSent: String,
|
||||
val kickSent: String,
|
||||
val failedTitle: String,
|
||||
val failedTemplateNull: String,
|
||||
val noAppMessage: String,
|
||||
val zapSplitUnsupported: String,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun rememberParticipantToasts(displayName: String): ParticipantToasts =
|
||||
private fun rememberParticipantToasts(): ParticipantToasts =
|
||||
ParticipantToasts(
|
||||
title = stringRes(R.string.nest_toast_action_title),
|
||||
followSent = stringRes(R.string.nest_toast_follow_sent, displayName),
|
||||
unfollowSent = stringRes(R.string.nest_toast_unfollow_sent, displayName),
|
||||
muteSent = stringRes(R.string.nest_toast_mute_sent, displayName),
|
||||
unmuteSent = stringRes(R.string.nest_toast_unmute_sent, displayName),
|
||||
promoteSpeakerSent = stringRes(R.string.nest_toast_promote_speaker_sent, displayName),
|
||||
promoteModeratorSent = stringRes(R.string.nest_toast_promote_moderator_sent, displayName),
|
||||
demoteListenerSent = stringRes(R.string.nest_toast_demote_listener_sent, displayName),
|
||||
forceMuteSent = stringRes(R.string.nest_toast_force_mute_sent, displayName),
|
||||
kickSent = stringRes(R.string.nest_toast_kick_sent, displayName),
|
||||
failedTitle = stringRes(R.string.nest_toast_host_action_failed_title),
|
||||
failedTemplateNull = stringRes(R.string.nest_toast_host_action_failed_template_null),
|
||||
noAppMessage = stringRes(R.string.nest_no_app_to_open_link),
|
||||
zapSplitUnsupported = stringRes(R.string.nest_participant_zap_split_unsupported),
|
||||
)
|
||||
|
||||
+14
@@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.summary
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Pure builders for participant-list mutations on a kind-30312
|
||||
@@ -132,12 +133,25 @@ internal object RoomParticipantActions {
|
||||
?: ParticipantTag(original.pubKey, null, ROLE.HOST.code, null)
|
||||
val others = participants.filterNot { it.pubKey == host.pubKey }
|
||||
|
||||
// Strictly monotonic createdAt. NIP-01 replaceable-event
|
||||
// semantics drop a new version whose createdAt is <= the
|
||||
// previous one (with a lower-id tie-break that's effectively
|
||||
// random under signing). Creating a room then immediately
|
||||
// promoting in the same wall-clock second produced the
|
||||
// dreaded silent-no-op: the local cache rejected the new
|
||||
// event in `consumeBaseReplaceable` (strict `>`) and relays
|
||||
// applied the same rule. `+ 1` is enough to win the tie;
|
||||
// `coerceAtLeast(now)` keeps the wire timestamp accurate
|
||||
// when the original is already in the past.
|
||||
val nextCreatedAt = (original.createdAt + 1L).coerceAtLeast(TimeUtils.now())
|
||||
|
||||
return MeetingSpaceEvent.build(
|
||||
room = original.room().orEmpty(),
|
||||
status = status,
|
||||
service = original.service().orEmpty(),
|
||||
host = host,
|
||||
dTag = original.dTag(),
|
||||
createdAt = nextCreatedAt,
|
||||
) {
|
||||
original.endpoint()?.let { endpoint(it) }
|
||||
original.summary()?.takeIf { it.isNotBlank() }?.let { summary(it) }
|
||||
|
||||
-104
@@ -1,104 +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.nests.room.reactions
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
/**
|
||||
* Quick-pick emoji sheet for the room. Six default reactions cover
|
||||
* the common audio-room interactions (clap, fire, laugh, eyes,
|
||||
* heart, lightning); the parent invokes [onPick] with the chosen
|
||||
* emoji which is then sent as a kind-7 reaction tagged for the
|
||||
* current room (and optional speaker target).
|
||||
*
|
||||
* Custom-emoji-pack favourites (NIP-30) are intentionally deferred —
|
||||
* the v1 sheet is a plain hard-coded set so the dependency surface
|
||||
* stays tight; later passes can read the user's
|
||||
* `EmojiPackEvent`s and merge them in.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun RoomReactionPickerSheet(
|
||||
onPick: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||
Text(
|
||||
text = stringRes(R.string.nest_reactions_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
DEFAULT_REACTIONS.forEach { emoji ->
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(48.dp)
|
||||
.clickable {
|
||||
onPick(emoji)
|
||||
onDismiss()
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = emoji,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val DEFAULT_REACTIONS = listOf("👏", "🔥", "😂", "👀", "❤️", "⚡")
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* 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.nests.room.reactions
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.note.ReactionChoicePopupContent
|
||||
import com.vitorpamplona.amethyst.ui.note.popupAnimationEnter
|
||||
import com.vitorpamplona.amethyst.ui.note.popupAnimationExit
|
||||
import com.vitorpamplona.amethyst.ui.note.rememberVisibilityState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
|
||||
/**
|
||||
* Room-scoped reaction picker. Visually identical to
|
||||
* [com.vitorpamplona.amethyst.ui.note.ReactionChoicePopup] — same
|
||||
* pop-up animation, same user-configured reaction set (including
|
||||
* NIP-30 custom emojis), same "change reactions" affordance — but
|
||||
* with two semantic deviations specific to live audio rooms:
|
||||
*
|
||||
* 1. **Repeatable.** A note's heart `ReactionChoicePopup` calls
|
||||
* `reactToOrDelete`, which toggles a `(note, emoji)` pair: a
|
||||
* second tap of the same emoji DELETES the first. For an audio
|
||||
* room every tap is an ephemeral cheer — the user should be
|
||||
* able to fire `🔥` ten times during a great moment without the
|
||||
* second tap nuking the first. We bypass `reactToOrDelete` and
|
||||
* call `account.reactTo` directly, which always emits a fresh
|
||||
* kind-7.
|
||||
*
|
||||
* 2. **No "already reacted" gate.** The standard popup passes a
|
||||
* `toRemove` set so already-reacted buttons render with a
|
||||
* destructive hint (next tap = delete). For us every button is
|
||||
* always "fresh", so `toRemove` is empty.
|
||||
*
|
||||
* Anchoring + animation mirror the original popup so the affordance
|
||||
* feels the same when the user enters/exits the room.
|
||||
*/
|
||||
@Composable
|
||||
fun RoomReactionPopup(
|
||||
roomNote: AddressableNote,
|
||||
iconSize: Dp,
|
||||
accountViewModel: AccountViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
onChangeAmount: () -> Unit,
|
||||
) {
|
||||
val iconSizePx = with(LocalDensity.current) { -iconSize.toPx().toInt() }
|
||||
val visibilityState = rememberVisibilityState(onDismiss)
|
||||
val reactions by accountViewModel.reactionChoicesFlow().collectAsState()
|
||||
|
||||
Popup(
|
||||
alignment = Alignment.BottomCenter,
|
||||
offset = IntOffset(0, iconSizePx),
|
||||
onDismissRequest = { visibilityState.targetState = false },
|
||||
properties = PopupProperties(focusable = true),
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visibleState = visibilityState,
|
||||
enter = popupAnimationEnter,
|
||||
exit = popupAnimationExit,
|
||||
) {
|
||||
ReactionChoicePopupContent(
|
||||
listOfReactions = reactions,
|
||||
// Empty set — never mark a reaction as "already
|
||||
// emitted" so the user can fire the same emoji
|
||||
// repeatedly throughout the room.
|
||||
toRemove = persistentSetOf(),
|
||||
onClick = { reactionType ->
|
||||
accountViewModel.launchSigner {
|
||||
// Bypass `Account.reactTo` (which delegates to
|
||||
// `ReactionAction.reactTo(note, …)` and
|
||||
// short-circuits on `note.hasReacted(by, reaction)`
|
||||
// — line 181 in ReactionAction.kt). For a live
|
||||
// audio room we WANT the second tap of the same
|
||||
// emoji to fire another kind-7. Build the
|
||||
// template directly and hand it to
|
||||
// `signAndComputeBroadcast`, which signs +
|
||||
// publishes + writes to LocalCache without the
|
||||
// duplicate gate.
|
||||
val eventHint = roomNote.toEventHint<Event>()
|
||||
if (eventHint != null) {
|
||||
val template =
|
||||
if (reactionType.startsWith(":")) {
|
||||
val emojiUrl = EmojiUrlTag.decode(reactionType)
|
||||
if (emojiUrl != null) {
|
||||
ReactionEvent.build(emojiUrl, eventHint)
|
||||
} else {
|
||||
ReactionEvent.build(reactionType, eventHint)
|
||||
}
|
||||
} else {
|
||||
ReactionEvent.build(reactionType, eventHint)
|
||||
}
|
||||
accountViewModel.account.signAndComputeBroadcast(template)
|
||||
}
|
||||
}
|
||||
visibilityState.targetState = false
|
||||
},
|
||||
onChangeAmount = onChangeAmount,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+234
-5
@@ -40,6 +40,7 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.FilledTonalIconToggleButton
|
||||
@@ -52,7 +53,10 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -67,7 +71,21 @@ import com.vitorpamplona.amethyst.commons.viewmodels.BroadcastUiState
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.NestUiState
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapAmountChoicePopup
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapCustomDialog
|
||||
import com.vitorpamplona.amethyst.ui.note.payViaIntent
|
||||
import com.vitorpamplona.amethyst.ui.note.zapClick
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.reactions.RoomReactionPopup
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
/**
|
||||
* Sticky bottom action bar for the room screen.
|
||||
@@ -96,7 +114,9 @@ internal fun NestActionBar(
|
||||
isOnStage: Boolean,
|
||||
handRaised: Boolean,
|
||||
onHandRaisedChange: (Boolean) -> Unit,
|
||||
onShowReactionPicker: () -> Unit,
|
||||
roomNote: AddressableNote,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onLeave: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
@@ -121,7 +141,9 @@ internal fun NestActionBar(
|
||||
isConnected = ui.connection is ConnectionUiState.Connected,
|
||||
handRaised = handRaised,
|
||||
onHandRaisedChange = onHandRaisedChange,
|
||||
onShowReactionPicker = onShowReactionPicker,
|
||||
roomNote = roomNote,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onLeave = onLeave,
|
||||
)
|
||||
}
|
||||
@@ -352,7 +374,9 @@ private fun EndCluster(
|
||||
isConnected: Boolean,
|
||||
handRaised: Boolean,
|
||||
onHandRaisedChange: (Boolean) -> Unit,
|
||||
onShowReactionPicker: () -> Unit,
|
||||
roomNote: AddressableNote,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onLeave: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
@@ -364,19 +388,224 @@ private fun EndCluster(
|
||||
if (!isOnStage && isConnected) {
|
||||
HandRaiseToggle(handRaised = handRaised, onToggle = onHandRaisedChange)
|
||||
}
|
||||
// Zap and React both work in any state — even disconnected
|
||||
// users can zap / react via the room note.
|
||||
NestZapButton(
|
||||
roomNote = roomNote,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
// React works in any state — even disconnected users can react
|
||||
// via the room note.
|
||||
FilledTonalIconButton(onClick = onShowReactionPicker) {
|
||||
// via the room note. Reuses the same ReactionChoicePopup
|
||||
// NoteCompose's heart button drives (NIP-30 custom-emoji
|
||||
// support, user-configured reaction set). Earlier hand-rolled
|
||||
// bottom-sheet picker is gone — it only emitted a fixed set
|
||||
// of six unicode emojis with no path to custom packs.
|
||||
var wantsToReact by rememberSaveable { mutableStateOf(false) }
|
||||
FilledTonalIconButton(onClick = { wantsToReact = true }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.EmojiEmotions,
|
||||
contentDescription = stringRes(R.string.nest_reactions_button),
|
||||
)
|
||||
if (wantsToReact) {
|
||||
RoomReactionPopup(
|
||||
roomNote = roomNote,
|
||||
iconSize = 24.dp,
|
||||
accountViewModel = accountViewModel,
|
||||
onDismiss = { wantsToReact = false },
|
||||
// No `Route.UpdateReactionType` from inside the
|
||||
// standalone NestActivity (no NavController). The
|
||||
// "change reactions" button still dismisses the
|
||||
// popup; users edit their reaction set from the
|
||||
// main app's settings.
|
||||
onChangeAmount = { wantsToReact = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
LeaveRoomButton(onClick = onLeave)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Round zap button styled to match [HandRaiseToggle] / the React
|
||||
* button — same `FilledTonalIconButton` shape and tonal palette, but
|
||||
* tinted with [BitcoinOrange] while a zap is in flight so the user
|
||||
* sees the progress without needing a separate amount label.
|
||||
*
|
||||
* Click behavior is delegated to NoteCompose's [zapClick], which
|
||||
* applies the user's configured zap amount choices the same way the
|
||||
* normal note ⚡ button does (single-tap fires the default amount;
|
||||
* multi-choice opens [ZapAmountChoicePopup]; an unconfigured account
|
||||
* opens [ZapCustomDialog]). Long-press routes to the
|
||||
* [Route.UpdateZapAmount] settings screen via the activity's
|
||||
* [BouncingIntentNav] (no-op when the route can't be expressed as a
|
||||
* `nostr:` URI — same fallback as the chat panel uses).
|
||||
*/
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
@Composable
|
||||
private fun NestZapButton(
|
||||
roomNote: AddressableNote,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var wantsToZap by remember { mutableStateOf(false) }
|
||||
var wantsToSetCustomZap by remember { mutableStateOf(false) }
|
||||
|
||||
// 0f when idle. Driven 0f → 1f by the zap flow's progress
|
||||
// callbacks; reset to 0f on error, on dismiss, and when the flow
|
||||
// hands off to an external wallet via `onPayViaIntent` (the
|
||||
// button can't observe payment completion past that handoff, so
|
||||
// it must not leave the spinner up — unlike NoteCompose's
|
||||
// ZapReaction, this button has no ObserveZapIconState fallback).
|
||||
var zappingProgress by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
val isZapping = zappingProgress > 0.00001f && zappingProgress < 0.99999f
|
||||
|
||||
FilledTonalIconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
zapClick(
|
||||
baseNote = roomNote,
|
||||
accountViewModel = accountViewModel,
|
||||
context = context,
|
||||
onZapStarts = {},
|
||||
onZappingProgress = { progress -> scope.launch { zappingProgress = progress } },
|
||||
onMultipleChoices = { scope.launch { wantsToZap = true } },
|
||||
onError = { _, message, user ->
|
||||
scope.launch {
|
||||
zappingProgress = 0f
|
||||
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, message, user)
|
||||
}
|
||||
},
|
||||
onPayViaIntent = {
|
||||
// Handing off to an external wallet (or the
|
||||
// manual-split screen) ends the in-app phase —
|
||||
// clear the spinner since nothing downstream
|
||||
// tracks payment completion for this button.
|
||||
zappingProgress = 0f
|
||||
if (it.size == 1) {
|
||||
val payable = it.first()
|
||||
payViaIntent(payable.invoice, context, { }) { error ->
|
||||
zappingProgress = 0f
|
||||
accountViewModel.toastManager.toast(
|
||||
R.string.error_dialog_zap_error,
|
||||
UserBasedErrorMessage(error, payable.info.user),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val uid = Uuid.random().toString()
|
||||
accountViewModel.tempManualPaymentCache.put(uid, it)
|
||||
nav.nav(Route.ManualZapSplitPayment(uid))
|
||||
}
|
||||
},
|
||||
onCustomAmount = { wantsToSetCustomZap = true },
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
if (isZapping) {
|
||||
CircularProgressIndicator(
|
||||
progress = { zappingProgress },
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Bolt,
|
||||
contentDescription = stringRes(R.string.zap_description),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (wantsToZap) {
|
||||
ZapAmountChoicePopup(
|
||||
baseNote = roomNote,
|
||||
popupYOffset = 48.dp,
|
||||
accountViewModel = accountViewModel,
|
||||
onZapStarts = {},
|
||||
onDismiss = {
|
||||
wantsToZap = false
|
||||
zappingProgress = 0f
|
||||
},
|
||||
onChangeAmount = {
|
||||
scope.launch {
|
||||
wantsToZap = false
|
||||
nav.nav(Route.UpdateZapAmount())
|
||||
}
|
||||
},
|
||||
onError = { _, message, user ->
|
||||
scope.launch {
|
||||
zappingProgress = 0f
|
||||
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, message, user)
|
||||
}
|
||||
},
|
||||
onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } },
|
||||
onPayViaIntent = {
|
||||
// Handing off to an external wallet (or the manual-
|
||||
// split screen) ends the in-app phase — clear the
|
||||
// spinner since nothing downstream tracks payment
|
||||
// completion for this button.
|
||||
zappingProgress = 0f
|
||||
if (it.size == 1) {
|
||||
val payable = it.first()
|
||||
payViaIntent(payable.invoice, context, { }) { error ->
|
||||
zappingProgress = 0f
|
||||
accountViewModel.toastManager.toast(
|
||||
R.string.error_dialog_zap_error,
|
||||
UserBasedErrorMessage(error, payable.info.user),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val uid = Uuid.random().toString()
|
||||
accountViewModel.tempManualPaymentCache.put(uid, it)
|
||||
nav.nav(Route.ManualZapSplitPayment(uid))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (wantsToSetCustomZap) {
|
||||
ZapCustomDialog(
|
||||
onZapStarts = {},
|
||||
onClose = { wantsToSetCustomZap = false },
|
||||
onError = { _, message, user ->
|
||||
scope.launch {
|
||||
zappingProgress = 0f
|
||||
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, message, user)
|
||||
}
|
||||
},
|
||||
onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } },
|
||||
onPayViaIntent = {
|
||||
// Handing off to an external wallet (or the manual-
|
||||
// split screen) ends the in-app phase — clear the
|
||||
// spinner since nothing downstream tracks payment
|
||||
// completion for this button.
|
||||
zappingProgress = 0f
|
||||
if (it.size == 1) {
|
||||
val payable = it.first()
|
||||
payViaIntent(payable.invoice, context, { }) { error ->
|
||||
zappingProgress = 0f
|
||||
accountViewModel.toastManager.toast(
|
||||
R.string.error_dialog_zap_error,
|
||||
UserBasedErrorMessage(error, payable.info.user),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val uid = Uuid.random().toString()
|
||||
accountViewModel.tempManualPaymentCache.put(uid, it)
|
||||
nav.nav(Route.ManualZapSplitPayment(uid))
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
baseNote = roomNote,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reusable affordances ────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
|
||||
+15
-11
@@ -47,6 +47,7 @@ import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -64,13 +65,13 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.NestUiState
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.ParticipantGrid
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.BouncingIntentNav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.chat.NestChatPanel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.edit.EditNestSheet
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.edit.EditNestViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.participants.ParticipantHostActionsSheet
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.reactions.RoomReactionPickerSheet
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.stage.AudienceGrid
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.stage.HandRaiseQueueSection
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.stage.StageGrid
|
||||
@@ -120,7 +121,6 @@ internal fun NestFullScreen(
|
||||
var showEditSheet by rememberSaveable { mutableStateOf(false) }
|
||||
var showHostMenu by rememberSaveable { mutableStateOf(false) }
|
||||
var showHostLeaveConfirm by rememberSaveable { mutableStateOf(false) }
|
||||
var showReactionPicker by rememberSaveable { mutableStateOf(false) }
|
||||
var hostMenuTarget by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
// Summary is collapsed by default; tapping the top-bar title
|
||||
// toggles it so the user can preview the room description without
|
||||
@@ -130,15 +130,21 @@ internal fun NestFullScreen(
|
||||
// Tab selection survives configuration changes and PIP transitions.
|
||||
// Stored as ordinal so rememberSaveable can persist it without a
|
||||
// custom Saver.
|
||||
var selectedTabIndex by rememberSaveable { mutableStateOf(0) }
|
||||
var selectedTabIndex by rememberSaveable { mutableIntStateOf(0) }
|
||||
|
||||
val isHost = accountViewModel.account.signer.pubKey == event.pubKey
|
||||
val myPubkey = accountViewModel.account.signer.pubKey
|
||||
val leaveScope = rememberCoroutineScope()
|
||||
val topBarContext = LocalContext.current
|
||||
// BouncingIntentNav handles the few routes the zap button can reach
|
||||
// (UpdateZapAmount, ManualZapSplitPayment) by bouncing through
|
||||
// MainActivity — same mechanism the NestChatPanel uses for tap-on-
|
||||
// mention nav. Routes that don't map to a `nostr:` URI are no-ops.
|
||||
val actionBarNav = remember(topBarContext, leaveScope) { BouncingIntentNav(topBarContext, leaveScope) }
|
||||
|
||||
val presences by viewModel.presences.collectAsState()
|
||||
val reactionsByPubkey by viewModel.recentReactions.collectAsState()
|
||||
val zapsByPubkey by viewModel.recentZaps.collectAsState()
|
||||
val speakerCatalogs by viewModel.speakerCatalogs.collectAsState()
|
||||
val audioLevels by viewModel.audioLevels.collectAsState()
|
||||
|
||||
@@ -235,6 +241,7 @@ internal fun NestFullScreen(
|
||||
audioLevels = audioLevels,
|
||||
accountViewModel = accountViewModel,
|
||||
reactionsByPubkey = reactionsByPubkey,
|
||||
zapsByPubkey = zapsByPubkey,
|
||||
connectingSpeakers = ui.connectingSpeakers,
|
||||
onLongPressParticipant = onLongPressParticipant,
|
||||
// Tap on a stage avatar opens the same per-participant
|
||||
@@ -268,7 +275,9 @@ internal fun NestFullScreen(
|
||||
isOnStage = isOnStageMe,
|
||||
handRaised = handRaised,
|
||||
onHandRaisedChange = onHandRaisedChange,
|
||||
onShowReactionPicker = { showReactionPicker = true },
|
||||
roomNote = roomNote,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = actionBarNav,
|
||||
onLeave = {
|
||||
if (isHost) {
|
||||
showHostLeaveConfirm = true
|
||||
@@ -310,6 +319,8 @@ internal fun NestFullScreen(
|
||||
onLongPressParticipant = onLongPressParticipant,
|
||||
onTapParticipant = onLongPressParticipant,
|
||||
myPubkey = myPubkey,
|
||||
reactionsByPubkey = reactionsByPubkey,
|
||||
zapsByPubkey = zapsByPubkey,
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
@@ -349,13 +360,6 @@ internal fun NestFullScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (showReactionPicker) {
|
||||
RoomReactionPickerSheet(
|
||||
onPick = { emoji -> accountViewModel.reactToOrDelete(roomNote, emoji) },
|
||||
onDismiss = { showReactionPicker = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showHostLeaveConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showHostLeaveConfirm = false },
|
||||
|
||||
+66
-27
@@ -63,6 +63,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
@@ -70,6 +71,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.RoomMember
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.RoomReaction
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.RoomZap
|
||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -151,6 +153,7 @@ internal fun StageGrid(
|
||||
modifier: Modifier = Modifier,
|
||||
audioLevels: Map<String, Float> = emptyMap(),
|
||||
reactionsByPubkey: Map<String, List<RoomReaction>> = emptyMap(),
|
||||
zapsByPubkey: Map<String, List<RoomZap>> = emptyMap(),
|
||||
connectingSpeakers: ImmutableSet<String> = persistentSetOf(),
|
||||
onLongPressParticipant: ((String) -> Unit)? = null,
|
||||
onTapParticipant: ((String) -> Unit)? = null,
|
||||
@@ -159,15 +162,12 @@ internal fun StageGrid(
|
||||
listenerCount: Int = 0,
|
||||
bottomBar: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
// Float currently-speaking members to the top so the listener can
|
||||
// see who they're hearing without scrolling. sortedBy is stable in
|
||||
// Kotlin — speakers retain relative order among themselves, as do
|
||||
// non-speakers. Memoized on (members, speakingNow) so a 250ms
|
||||
// speaking flip doesn't reallocate when neither input changed.
|
||||
val sortedMembers =
|
||||
remember(members, speakingNow) {
|
||||
members.sortedBy { if (it.pubkey in speakingNow) 0 else 1 }
|
||||
}
|
||||
// Keep members in their stable arrival order. Reordering on the
|
||||
// speaking state made a member jump to the front when they started
|
||||
// talking and slide back ~250ms after they stopped — a constant
|
||||
// shuffle in any active room. The speaking state is already shown
|
||||
// in-place via MemberCell's mic badge / audio-level ring, so no
|
||||
// reordering is needed.
|
||||
// Wrap the strip in a tonal card so the active speakers visually
|
||||
// live in their own zone, separated from the chat / audience tab
|
||||
// below. surfaceContainerLow is a quiet step up from the screen
|
||||
@@ -218,7 +218,7 @@ internal fun StageGrid(
|
||||
horizontalArrangement = Arrangement.spacedBy(GRID_SPACING),
|
||||
verticalArrangement = Arrangement.spacedBy(GRID_SPACING),
|
||||
) {
|
||||
items(items = sortedMembers, key = { it.pubkey }) { member ->
|
||||
items(items = members, key = { it.pubkey }) { member ->
|
||||
val isSelf = myPubkey != null && member.pubkey == myPubkey
|
||||
MemberCell(
|
||||
member = member,
|
||||
@@ -228,6 +228,7 @@ internal fun StageGrid(
|
||||
isConnecting = member.pubkey in connectingSpeakers,
|
||||
showMicBadge = true,
|
||||
reactions = reactionsByPubkey[member.pubkey].orEmpty(),
|
||||
zaps = zapsByPubkey[member.pubkey].orEmpty(),
|
||||
accountViewModel = accountViewModel,
|
||||
onLongPressParticipant = onLongPressParticipant,
|
||||
onTapParticipant = if (isSelf) null else onTapParticipant,
|
||||
@@ -303,6 +304,8 @@ internal fun AudienceGrid(
|
||||
onLongPressParticipant: ((String) -> Unit)? = null,
|
||||
onTapParticipant: ((String) -> Unit)? = null,
|
||||
myPubkey: String? = null,
|
||||
reactionsByPubkey: Map<String, List<RoomReaction>> = emptyMap(),
|
||||
zapsByPubkey: Map<String, List<RoomZap>> = emptyMap(),
|
||||
) {
|
||||
if (members.isEmpty()) {
|
||||
Box(
|
||||
@@ -341,7 +344,8 @@ internal fun AudienceGrid(
|
||||
audioLevel = 0f,
|
||||
isConnecting = false,
|
||||
showMicBadge = false,
|
||||
reactions = emptyList(),
|
||||
reactions = reactionsByPubkey[member.pubkey].orEmpty(),
|
||||
zaps = zapsByPubkey[member.pubkey].orEmpty(),
|
||||
accountViewModel = accountViewModel,
|
||||
onLongPressParticipant = onLongPressParticipant,
|
||||
onTapParticipant = onTapParticipant,
|
||||
@@ -363,6 +367,7 @@ private fun MemberCell(
|
||||
reactions: List<RoomReaction>,
|
||||
accountViewModel: AccountViewModel,
|
||||
onLongPressParticipant: ((String) -> Unit)?,
|
||||
zaps: List<RoomZap> = emptyList(),
|
||||
isSelf: Boolean = false,
|
||||
onTapSelf: (() -> Unit)? = null,
|
||||
onTapParticipant: ((String) -> Unit)? = null,
|
||||
@@ -524,8 +529,56 @@ private fun MemberCell(
|
||||
isConnecting = isConnecting,
|
||||
showMicBadge = showMicBadge,
|
||||
isSpeaking = isSpeaking,
|
||||
reactions = reactions,
|
||||
)
|
||||
// Reactions overlay sits as a SIBLING of AvatarAndBadges
|
||||
// inside this fixed-size outer Box, NOT inside
|
||||
// AvatarAndBadges itself. AvatarAndBadges' inner Box
|
||||
// wraps content, so any change to the overlay's measured
|
||||
// size (chip appears / animates / disappears) would shift
|
||||
// the inner Box's centre and the badges aligned to its
|
||||
// corners would drift with it. The outer Box has an
|
||||
// explicit `.size(outerBoxSize)`, so adding the overlay
|
||||
// here can't change layout dimensions.
|
||||
//
|
||||
// Anchor the chip's bottom-RIGHT to the avatar's
|
||||
// bottom-right corner (small inward nudge from the outer
|
||||
// Box edge so it clears the ring/glow padding). The chip
|
||||
// extends LEFTWARD inside the cell as content widens
|
||||
// (single emoji vs `×N` count). Earlier `BottomStart`
|
||||
// experiment anchored the chip's left edge at the same
|
||||
// corner — looked great for a fixed-width chip but bled
|
||||
// straight into the next column once a real emoji was
|
||||
// rendered, because the cell was only ~100 dp wide and
|
||||
// the chip extended out 40+ dp to the right.
|
||||
if (reactions.isNotEmpty()) {
|
||||
SpeakerReactionOverlay(
|
||||
reactions = reactions,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.offset(x = -ringPadding + 6.dp, y = -ringPadding + 6.dp),
|
||||
)
|
||||
}
|
||||
// Zaps animate just above the avatar, TOP-CENTER — the
|
||||
// only badge-free anchor: the role badge sits TopStart,
|
||||
// the hand-raise badge TopEnd, the mic badge BottomCenter
|
||||
// and the reaction overlay BottomEnd. Anchoring TopEnd
|
||||
// would collide with the hand-raise badge, and now that
|
||||
// zaps float from the *zapper's* avatar (a likely
|
||||
// hand-raiser), that overlap would be common. Center-
|
||||
// anchored so the `⚡ Nsats` chip extends symmetrically
|
||||
// and stays within the cell rather than bleeding into the
|
||||
// neighbouring column. Same outer-Box sibling placement
|
||||
// and layout-stability reasoning as the reaction overlay.
|
||||
if (zaps.isNotEmpty()) {
|
||||
SpeakerZapOverlay(
|
||||
zaps = zaps,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.offset(y = ringPadding - 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
UsernameDisplay(
|
||||
baseUser = user,
|
||||
@@ -548,7 +601,6 @@ private fun AvatarAndBadges(
|
||||
isConnecting: Boolean,
|
||||
showMicBadge: Boolean,
|
||||
isSpeaking: Boolean,
|
||||
reactions: List<RoomReaction>,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
ClickableUserPicture(
|
||||
@@ -591,19 +643,6 @@ private fun AvatarAndBadges(
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
)
|
||||
}
|
||||
// Reactions float over the avatar's bottom-right corner so
|
||||
// a 👏 burst no longer pushes the username down and reflows
|
||||
// neighbouring cells. The mic badge sits at BottomCenter,
|
||||
// so BottomEnd + a small outward offset keeps them clear.
|
||||
if (reactions.isNotEmpty()) {
|
||||
SpeakerReactionOverlay(
|
||||
reactions = reactions,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.offset(x = 6.dp, y = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -631,7 +670,7 @@ private fun HandRaiseBadge(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.offset(x = 2.dp, y = (-2).dp + offsetY.dp)
|
||||
.offset { IntOffset(2.dp.roundToPx(), ((-2).dp + offsetY.dp).roundToPx()) }
|
||||
.size(MAX_BADGE_SIZE)
|
||||
.background(MaterialTheme.colorScheme.tertiary, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
|
||||
+203
-84
@@ -21,43 +21,50 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.stage
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.REACTION_WINDOW_SEC
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.RoomReaction
|
||||
import kotlinx.coroutines.delay
|
||||
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
/**
|
||||
* Floating-emoji overlay drawn under a speaker's avatar. Each chip
|
||||
* is keyed by emoji content; bursts of the same emoji collapse into
|
||||
* a single `🔥 ×3` chip whose count tracks live as new reactions land.
|
||||
* Floating-emoji overlay drawn under a speaker's avatar. Each
|
||||
* incoming `kind-7` becomes its own independent chip — two
|
||||
* reactions arriving in quick succession produce two concurrent
|
||||
* rises, not one shared chip that restarts. The previous
|
||||
* implementation grouped by content and used `youngestSec` as a
|
||||
* `remember` key, which meant a second `🔥` from the same speaker
|
||||
* interrupted the first chip's animation and showed `×2` instead
|
||||
* of two distinct floating emojis.
|
||||
*
|
||||
* Reactions are about what the speaker is saying RIGHT NOW, so each
|
||||
* chip lives for [REACTION_WINDOW_SEC] seconds: the moment its
|
||||
* youngest reaction arrives the chip fades+scales in, and over that
|
||||
* window it slowly drifts upward and fades out before the eviction
|
||||
* tick removes it from the underlying list.
|
||||
* Reactions live for [REACTION_WINDOW_SEC] seconds in the
|
||||
* aggregator; each chip animates over [REACTION_RISE_MS] and then
|
||||
* stays invisible (alpha=0) until the aggregator evicts it. We cap
|
||||
* the visible-chip count at [MAX_VISIBLE_CHIPS] so a flurry of
|
||||
* reactions doesn't bleed the Row into the next column.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SpeakerReactionOverlay(
|
||||
@@ -70,24 +77,37 @@ internal fun SpeakerReactionOverlay(
|
||||
exit = fadeOut() + scaleOut(targetScale = 0.6f),
|
||||
modifier = modifier,
|
||||
) {
|
||||
// Group by content so duplicate emojis collapse into a single
|
||||
// "🔥 ×N" chip. Order by most recent so a fresh reaction lands
|
||||
// at the front of the row.
|
||||
val byContent = reactions.groupBy { it.content }
|
||||
val ordered =
|
||||
byContent.toList().sortedByDescending { (_, list) ->
|
||||
list.maxOf { it.createdAtSec }
|
||||
// Most-recent first; capped so the layered Box doesn't grow
|
||||
// an unbounded set of overlapping chips. Older reactions drop
|
||||
// off as new ones arrive (the aggregator still tracks them
|
||||
// for eviction but they're not drawn once past the cap).
|
||||
val visible =
|
||||
remember(reactions) {
|
||||
reactions
|
||||
.sortedByDescending { it.createdAtSec }
|
||||
.take(MAX_VISIBLE_CHIPS)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
ordered.forEach { (content, list) ->
|
||||
// Newest createdAt drives the chip's lifecycle —
|
||||
// a fresh reaction restarts the upward-drift+fade.
|
||||
val youngestSec = list.maxOf { it.createdAtSec }
|
||||
ReactionChip(
|
||||
content = content,
|
||||
count = list.size,
|
||||
youngestSec = youngestSec,
|
||||
)
|
||||
// Box, not Row — every chip stacks at the same right-anchored
|
||||
// spot so the X position stays fixed throughout the chip's
|
||||
// lifecycle. The previous Row layout slid older chips
|
||||
// leftward each time a new reaction arrived (sortedByDescending
|
||||
// put the newest at index 0). With overlapping chips, each
|
||||
// animates independently in place; the newest is drawn on
|
||||
// top by virtue of being last in the iteration.
|
||||
Box(contentAlignment = androidx.compose.ui.Alignment.BottomEnd) {
|
||||
// Iterate in reverse (oldest first) so the newest reaction
|
||||
// ends up last in the Box and therefore on top of the
|
||||
// paint stack.
|
||||
visible.asReversed().forEach { reaction ->
|
||||
// Per-event-id key — independent animation lifecycle
|
||||
// per reaction. Same-emoji bursts no longer collide
|
||||
// on a shared chip.
|
||||
key(reaction.eventId) {
|
||||
ReactionChip(
|
||||
content = reaction.content,
|
||||
youngestSec = reaction.createdAtSec,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,62 +116,161 @@ internal fun SpeakerReactionOverlay(
|
||||
@Composable
|
||||
private fun ReactionChip(
|
||||
content: String,
|
||||
count: Int,
|
||||
youngestSec: Long,
|
||||
) {
|
||||
// Tick a [progress] state from 0f → 1f over the eviction window
|
||||
// so the chip can drift + fade in lockstep with how close it is
|
||||
// to falling out of the aggregator. Reset whenever a fresher
|
||||
// reaction lands by re-keying on [youngestSec].
|
||||
var progress by remember(youngestSec) { mutableStateOf(0f) }
|
||||
// Manual frame-clock animation. Earlier passes using
|
||||
// `Animatable.animateTo(...)` and `animateFloatAsState(...)` never
|
||||
// produced visible motion in this site — five iterations of
|
||||
// tuning didn't help. Driving the progress State directly from
|
||||
// `withFrameNanos` is the lowest-level Compose animation pattern
|
||||
// there is: each frame, we read the elapsed time, compute a 0→1
|
||||
// fraction, and write it into a MutableFloatState. The
|
||||
// `graphicsLayer` lambda below reads that State, so its block
|
||||
// re-invokes per frame without going through the standard
|
||||
// animation framework. Keyed on [youngestSec] so a fresh
|
||||
// reaction in the same emoji burst restarts the animation
|
||||
// cleanly.
|
||||
var progress by remember(youngestSec) { mutableFloatStateOf(0f) }
|
||||
LaunchedEffect(youngestSec) {
|
||||
// Re-sync against wall-clock so a chip whose window started
|
||||
// before this composable mounted (e.g. user rotated the
|
||||
// device mid-burst) still ages correctly.
|
||||
val ageMs = (System.currentTimeMillis() / 1000L - youngestSec).coerceAtLeast(0L) * 1000L
|
||||
val remaining = (REACTION_WINDOW_MS - ageMs).coerceAtLeast(0L)
|
||||
progress = (ageMs.toFloat() / REACTION_WINDOW_MS).coerceIn(0f, 1f)
|
||||
if (remaining <= 0L) return@LaunchedEffect
|
||||
// 100 ms ticks keep the drift smooth without burning a frame
|
||||
// budget — the avatar is small and the chip's motion is
|
||||
// sub-pixel between ticks anyway.
|
||||
val steps = (remaining / 100L).coerceAtLeast(1L)
|
||||
repeat(steps.toInt()) {
|
||||
delay(100L)
|
||||
progress =
|
||||
((System.currentTimeMillis() / 1000L - youngestSec).toFloat() * 1000f / REACTION_WINDOW_MS)
|
||||
.coerceIn(0f, 1f)
|
||||
val startNanos = withFrameNanos { it }
|
||||
while (true) {
|
||||
val nowNanos = withFrameNanos { it }
|
||||
val elapsedMs = (nowNanos - startNanos) / 1_000_000f
|
||||
val raw = (elapsedMs / REACTION_RISE_MS).coerceIn(0f, 1f)
|
||||
// FastOutSlowInEasing — quick start, slow tail. Pulled
|
||||
// by hand so we don't import the animation-core easing
|
||||
// for one call site.
|
||||
progress = FastOutSlowInEasing.transform(raw)
|
||||
if (raw >= 1f) break
|
||||
}
|
||||
progress = 1f
|
||||
}
|
||||
|
||||
// Drift up by ~16 dp over the window so the chip reads as
|
||||
// "rising and dissipating", and fade out over the second half
|
||||
// so the first half stays solidly readable.
|
||||
val driftDp = (-16f * progress).dp
|
||||
val animatedAlpha by animateFloatAsState(
|
||||
targetValue = (1f - ((progress - 0.5f).coerceAtLeast(0f) * 2f)).coerceIn(0f, 1f),
|
||||
animationSpec = tween(durationMillis = 100, easing = LinearEasing),
|
||||
label = "reaction-chip-alpha",
|
||||
)
|
||||
|
||||
Surface(
|
||||
// The chip drifts up, grows, and fades out — three concurrent
|
||||
// tracks driven by the same `progress` value, all set inside
|
||||
// the graphicsLayer lambda so the read tracks at draw time
|
||||
// (per frame, no full recomposition needed):
|
||||
// * translationY: 0 → -DRIFT_MAX_DP. Capped within the cell's
|
||||
// headroom (otherwise the chip clips the avatar above on a
|
||||
// 75 dp grid cell).
|
||||
// * scale: 1 → SCALE_MAX. Mirrors a "rising bubble" — the
|
||||
// emoji looks closer/larger as it lifts.
|
||||
// * alpha: 1 → 1 → 0. Solid for the first ALPHA_HOLD_FRAC of
|
||||
// progress; fades over the remainder so the chip dissipates
|
||||
// well before the 10 s aggregator-eviction tick.
|
||||
// Fixed-size Box so each chip occupies the same footprint
|
||||
// regardless of which emoji is rendered — "🔥" and "👍" have
|
||||
// different intrinsic glyph widths, and without a fixed box the
|
||||
// Row's right-anchored layout slid the visible content left/right
|
||||
// by a few dp on every new reaction. Centring the Text inside a
|
||||
// [CHIP_SIZE]-square Box pins the emoji's centre to a stable
|
||||
// point. No Surface / background — transparent over the avatar.
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.offset(y = driftDp)
|
||||
.alpha(animatedAlpha),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
tonalElevation = 2.dp,
|
||||
shadowElevation = 1.dp,
|
||||
.size(CHIP_SIZE)
|
||||
.graphicsLayer {
|
||||
val p = progress
|
||||
translationY = -DRIFT_MAX_DP.dp.toPx() * p
|
||||
scaleX = 1f + (SCALE_MAX - 1f) * p
|
||||
scaleY = scaleX
|
||||
alpha =
|
||||
(
|
||||
1f -
|
||||
(
|
||||
(p - ALPHA_HOLD_FRAC).coerceAtLeast(0f) /
|
||||
(1f - ALPHA_HOLD_FRAC)
|
||||
)
|
||||
).coerceIn(0f, 1f)
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val label = if (count > 1) "$content ×$count" else content
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
RenderReactionContent(content)
|
||||
}
|
||||
}
|
||||
|
||||
private const val REACTION_WINDOW_MS = REACTION_WINDOW_SEC * 1000L
|
||||
/**
|
||||
* Render a single reaction's content the same way
|
||||
* `ReactionsRow.RenderReactionType` does, so a kind-7 with a NIP-30
|
||||
* custom emoji shows the image (not the `:shortcode:url` string)
|
||||
* and special "+" / "-" tokens fall back to the standard heart / 👎
|
||||
* conventions.
|
||||
*
|
||||
* - `":<shortcode>:<url>"` → image rendered inline via
|
||||
* [InLineIconRenderer] using [CustomEmoji.ImageUrlType].
|
||||
* - `"+"` → ❤️ (the legacy NIP-25 "like" symbol; the heart icon
|
||||
* vector lives in `ReactionsRow.kt` as a private composable so
|
||||
* here we render the unicode heart at the same size).
|
||||
* - `"-"` → 👎.
|
||||
* - Anything else → rendered verbatim as Text (the common case —
|
||||
* a single unicode emoji).
|
||||
*/
|
||||
@Composable
|
||||
private fun RenderReactionContent(content: String) {
|
||||
if (content.isNotEmpty() && content[0] == ':') {
|
||||
val renderable =
|
||||
remember(content) {
|
||||
persistentListOf(
|
||||
CustomEmoji.ImageUrlType(content.removePrefix(":").substringAfter(":")),
|
||||
)
|
||||
}
|
||||
InLineIconRenderer(
|
||||
wordsInOrder = renderable,
|
||||
style = SpanStyle(color = Color.Unspecified),
|
||||
fontSize = EMOJI_FONT_SIZE,
|
||||
maxLines = 1,
|
||||
)
|
||||
return
|
||||
}
|
||||
val display =
|
||||
when (content) {
|
||||
"+" -> "❤️"
|
||||
"-" -> "👎"
|
||||
else -> content
|
||||
}
|
||||
Text(
|
||||
text = display,
|
||||
style = MaterialTheme.typography.titleLarge.copy(fontSize = EMOJI_FONT_SIZE),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
// 3 s pop-and-drift. Earlier 1.5 s, 4 s, and 6 s passes (when the
|
||||
// animation was broken upstream) didn't read well; 3 s with the
|
||||
// working frame-clock animation lands as a brisk rise — fast enough
|
||||
// to feel like a reaction, slow enough to track visually — and
|
||||
// still finishes inside the 10 s aggregator-eviction window.
|
||||
private const val REACTION_RISE_MS = 3000L
|
||||
|
||||
// Final rise distance has to clear the avatar without crashing into
|
||||
// the cell above. 28 dp lands the chip near the top edge of a 75 dp
|
||||
// avatar — readable, no clip.
|
||||
private const val DRIFT_MAX_DP = 28f
|
||||
|
||||
// 1.6× peak scale gives a clearly visible "growing as it rises"
|
||||
// effect. Applied via graphicsLayer so it's drawing-only and
|
||||
// doesn't reflow neighbours each frame.
|
||||
private const val SCALE_MAX = 1.6f
|
||||
|
||||
// Alpha holds at 1 for the first 50 % of progress, then ramps to 0
|
||||
// over the remaining 50 %. Keeps the reaction readable in its prime
|
||||
// without abrupt removal.
|
||||
private const val ALPHA_HOLD_FRAC = 0.5f
|
||||
|
||||
// Base font for the emoji glyph. labelSmall (default Material caption
|
||||
// ~11 sp) was too small to read at avatar-grid scale; 22 sp gets the
|
||||
// emoji to roughly the same visual size as a stage-grid avatar badge.
|
||||
private val EMOJI_FONT_SIZE = 22.sp
|
||||
|
||||
// Fixed chip footprint. Slightly larger than the emoji glyph so the
|
||||
// glyph (which varies in intrinsic width across emojis) is centred
|
||||
// in a consistent box. With this, the Row's right-anchored layout
|
||||
// produces a perfectly stable left edge per chip count, no matter
|
||||
// which emoji.
|
||||
private val CHIP_SIZE = 30.dp
|
||||
|
||||
// Cap on concurrently-rendered chips per sender. The aggregator
|
||||
// keeps reactions for 10 s; without a cap, a burst of 8+ reactions
|
||||
// would stretch the Row past the cell and into the next column even
|
||||
// after the older chips have faded to alpha=0 (still occupy layout).
|
||||
// 3 reads as "the user is reacting a lot" without overflowing.
|
||||
private const val MAX_VISIBLE_CHIPS = 3
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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.nests.room.stage
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.REACTION_WINDOW_SEC
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.RoomZap
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmountInteger
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* Floating zap-chip overlay drawn over a participant's avatar — the
|
||||
* zap counterpart to [SpeakerReactionOverlay]. The aggregator groups
|
||||
* zaps by sender, so this renders the zaps a single participant has
|
||||
* *sent*: consecutive zaps from that sender stack into a row, each
|
||||
* chip keyed by its event id. The chip life-cycle (`fadeIn + scaleIn`
|
||||
* on arrival, upward drift + `fadeOut` over [REACTION_WINDOW_SEC])
|
||||
* matches reactions so both streams visually feel like the same
|
||||
* animation.
|
||||
*
|
||||
* Renders an "⚡ Nsats" pill in [BitcoinOrange] so zaps are distinct
|
||||
* from emoji reactions at a glance.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SpeakerZapOverlay(
|
||||
zaps: List<RoomZap>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = zaps.isNotEmpty(),
|
||||
enter = fadeIn() + scaleIn(initialScale = 0.6f),
|
||||
exit = fadeOut() + scaleOut(targetScale = 0.6f),
|
||||
modifier = modifier,
|
||||
) {
|
||||
// Newest zap leads the row, matching SpeakerReactionOverlay's
|
||||
// most-recent-first ordering so the two overlays read the same.
|
||||
val ordered = zaps.sortedByDescending { it.createdAtSec }
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
ordered.take(MAX_VISIBLE_ZAPS).forEach { zap ->
|
||||
ZapChip(zap = zap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ZapChip(zap: RoomZap) {
|
||||
// Mirror of [SpeakerReactionOverlay.ReactionChip]: tick a [progress]
|
||||
// state from 0f → 1f over the eviction window so the chip can drift
|
||||
// + fade in lockstep with how close it is to being evicted. Re-key
|
||||
// on the event id so the animation restarts whenever a fresh zap
|
||||
// replaces the value at this slot.
|
||||
var progress by remember(zap.eventId) { mutableFloatStateOf(0f) }
|
||||
LaunchedEffect(zap.eventId) {
|
||||
val ageMs = (System.currentTimeMillis() / 1000L - zap.createdAtSec).coerceAtLeast(0L) * 1000L
|
||||
val remaining = (ZAP_WINDOW_MS - ageMs).coerceAtLeast(0L)
|
||||
progress = (ageMs.toFloat() / ZAP_WINDOW_MS).coerceIn(0f, 1f)
|
||||
if (remaining <= 0L) return@LaunchedEffect
|
||||
val steps = (remaining / 100L).coerceAtLeast(1L)
|
||||
repeat(steps.toInt()) {
|
||||
delay(100L)
|
||||
progress =
|
||||
((System.currentTimeMillis() / 1000L - zap.createdAtSec).toFloat() * 1000f / ZAP_WINDOW_MS)
|
||||
.coerceIn(0f, 1f)
|
||||
}
|
||||
progress = 1f
|
||||
}
|
||||
|
||||
val driftDp = (-16f * progress).dp
|
||||
val animatedAlpha by animateFloatAsState(
|
||||
targetValue = (1f - ((progress - 0.5f).coerceAtLeast(0f) * 2f)).coerceIn(0f, 1f),
|
||||
animationSpec = tween(durationMillis = 100, easing = LinearEasing),
|
||||
label = "zap-chip-alpha",
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.offset(y = driftDp)
|
||||
.alpha(animatedAlpha),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = BitcoinOrange.copy(alpha = 0.95f),
|
||||
// BitcoinOrange is theme-independent, so pin the content color
|
||||
// to white rather than colorScheme.onPrimary (which tracks the
|
||||
// theme's primary, not this fixed orange).
|
||||
contentColor = Color.White,
|
||||
tonalElevation = 2.dp,
|
||||
shadowElevation = 1.dp,
|
||||
) {
|
||||
val amountText =
|
||||
zap.amountSats?.let { showAmountInteger(it.toBigDecimal()) } ?: ""
|
||||
val label = if (amountText.isNotEmpty()) "⚡ $amountText" else "⚡"
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val ZAP_WINDOW_MS = REACTION_WINDOW_SEC * 1000L
|
||||
|
||||
// Cap the row at a small number so a burst of zaps from the same
|
||||
// sender doesn't overflow the avatar's top-center anchor — the
|
||||
// eviction sweep clears them within the window anyway.
|
||||
private const val MAX_VISIBLE_ZAPS = 3
|
||||
+2
-2
@@ -154,13 +154,13 @@ fun RelayMembersScreen(
|
||||
joinRequestSent = joinRequestSent,
|
||||
leaveRequestSent = leaveRequestSent,
|
||||
onJoinRequest = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.launchSigner {
|
||||
sendJoinRequest(normalizedRelayUrl, accountViewModel)
|
||||
joinRequestSent = true
|
||||
}
|
||||
},
|
||||
onLeaveRequest = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.launchSigner {
|
||||
sendLeaveRequest(normalizedRelayUrl, accountViewModel)
|
||||
leaveRequestSent = true
|
||||
}
|
||||
|
||||
+19
-1
@@ -46,6 +46,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
@@ -98,6 +99,7 @@ import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus
|
||||
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker
|
||||
import com.vitorpamplona.amethyst.ui.components.SwipeToDeleteWithConfirmation
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
|
||||
@@ -130,6 +132,8 @@ fun ScheduledPostsScreen(
|
||||
val totalActive by viewModel.totalActive.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
var expandedId by remember { mutableStateOf<String?>(null) }
|
||||
val listState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Tick once per minute so relative-time strings ("publishes in 2h 13m")
|
||||
// refresh on a long-open list instead of being frozen at first composition.
|
||||
@@ -183,10 +187,23 @@ fun ScheduledPostsScreen(
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { nav.popBack() }) { ArrowBackIcon() }
|
||||
// Suppress the back arrow when this is the bottom of the back stack
|
||||
// (i.e. the user landed here via the bottom nav, which clears the stack).
|
||||
if (nav.canPop()) {
|
||||
IconButton(onClick = { nav.popBack() }) { ArrowBackIcon() }
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
AppBottomBar(Route.ScheduledPosts, nav, accountViewModel) { route ->
|
||||
if (route == Route.ScheduledPosts) {
|
||||
scope.launch { listState.animateScrollToItem(0) }
|
||||
} else {
|
||||
nav.navBottomBar(route)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
if (groups.isEmpty()) {
|
||||
EmptyState(onCompose = { nav.nav(Route.NewShortNote()) }, modifier = Modifier.padding(padding))
|
||||
@@ -194,6 +211,7 @@ fun ScheduledPostsScreen(
|
||||
val today = remember(nowSec) { LocalDate.now(ZoneId.systemDefault()) }
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
|
||||
+3
-8
@@ -58,7 +58,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
@@ -104,16 +103,12 @@ fun BottomBarSettingsScreen(
|
||||
|
||||
@Composable
|
||||
fun BottomBarSettingsContent(accountViewModel: AccountViewModel) {
|
||||
val pinned by accountViewModel.settings.uiSettingsFlow.bottomBarItems
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
var items by remember(pinned) {
|
||||
mutableStateOf(initialRows(pinned))
|
||||
}
|
||||
val bottomBarItemsFlow = accountViewModel.settings.uiSettingsFlow.bottomBarItems
|
||||
var items by remember { mutableStateOf(initialRows(bottomBarItemsFlow.value)) }
|
||||
|
||||
fun save(newItems: List<Row>) {
|
||||
items = newItems
|
||||
accountViewModel.settings.uiSettingsFlow.bottomBarItems.tryEmit(
|
||||
bottomBarItemsFlow.tryEmit(
|
||||
newItems.filter { it.pinned }.map { it.item },
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user