Compare commits

...
171 Commits
Author SHA1 Message Date
davotoulaandClaude Opus 5 d9e01c8504 i18n: translate missing amethyst strings for cs, de, sv and pt-BR
Fills the on-disk gaps in the four target locales: channel archive/unarchive,
the new-forum title, the External Content screen title, the archived channel
section header, the three share targets (picture / short / video) and the URL
preview's open-in-browser action.

Wording follows each locale's existing siblings rather than being translated
fresh -- url_preview_open_in_browser reuses the string already in
git_repo_open_in_browser, share_target_as_picture follows new_picture, and
share_target_as_short_video follows home_content_type_shorts.
relay_group_section_archived heads a channel list, so cs/sv/pt take the plural
adjective.

Keys whose translation would be byte-identical to the English source are left
out on purpose: Crowdin strips source-identical entries on export and Android
already falls back to values/strings.xml at runtime. That covers the "workflow"
loanword in cs/de, and Highlights / Podcasts / Reposts / Torrents / Videos /
Name / Backlog in de, plus Podcasts / Torrents / Backlog in pt-BR.

The commons composeResources tree was already complete for all four locales.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WopdqNoZ9tYoMNppJG17BL
2026-07-31 21:42:30 +02:00
Vitor PamplonaandGitHub 5231f5b051 Merge pull request #3826 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-31 14:05:43 -04:00
vitorpamplonaandgithub-actions[bot] 0f4580ab91 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-31 14:58:45 +00:00
Vitor PamplonaandGitHub cdef4e9658 Merge pull request #3830 from vitorpamplona/claude/servicetype-tag-bounds-check-533b4r
Fix ServiceType parsing to handle edge cases safely
2026-07-31 10:56:02 -04:00
Claude 51e388d2a1 fix: bounds-check ServiceType parsing of NIP-85 service tags
ServiceType.parse destructured the result of split(":", limit = 2) into
two components, so any value without a colon (e.g. the "client" of a
["client", "nostria"] tag) threw IndexOutOfBoundsException instead of
returning null. It also accepted "30382:" as an empty service type.

ServiceType.isOfKind had the same class of bug: it read
serviceType[kind.length] after startsWith, which is out of bounds when
the value equals the kind exactly ("30382" vs "30382").

Parse the separator by index and check the length before indexing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112ysMEzSaezH9mXFteNt13
2026-07-31 14:54:13 +00:00
Vitor PamplonaandGitHub 9a5d47004c Merge pull request #3827 from davotoula/fix/location-foreground-gate
Listen only while foregrounded, and fix the meter that measures it
2026-07-31 10:21:07 -04:00
Vitor PamplonaandGitHub 731f4369cb Merge pull request #3828 from vitorpamplona/claude/content-parse-error-a1pf9h
Fix metadata JSON parsing to use lenient parser consistently
2026-07-31 10:14:56 -04:00
davotoula d93f4db451 docs(location): record the clean-install smoke run, closing R1 2026-07-31 15:57:04 +02:00
Claude 50e61901f7 fix: read profile json with the same lenient parser that renders it
contactMetaData() decodes kind-0 content with the lenient JsonMapper, but
contactMetadataJson() ran the strict default parser. Profiles that sit in
that gap (bare keys, unquoted values) rendered fine yet read back as null,
so updateFromPast() started from an empty map and silently dropped every
field Amethyst does not edit itself the next time the owner touched their
profile — the exact thing its "tries to not delete any existing attribute
that we do not work with" contract promises not to do.

Both accessors now use JsonMapper.jsonInstance. The lenient reader tags
unquoted tokens as strings, so re-encoding still emits valid JSON.

Also pins the behaviour of two malformed kind-0s seen in the wild — a
JavaScript object literal and a value truncated by stray quotes. Neither
is recoverable by any parser; the test records that they are dropped with
a warning and never throw.
2026-07-31 13:38:04 +00:00
davotoula 2360829e83 Code review:
- refactor(location): dedupe test fixtures and simplify the gate internals
- test(location): assert the release, not just its ledger side-effect
- test(location): cover the LocationState -> LocationFlow -> RefCountedSession seam
2026-07-31 15:19:44 +02:00
davotoula f09eef1470 feat(amethyst): wire the location foreground gate and refcounted meter 2026-07-31 10:26:28 +02:00
davotoula 78f0583b16 test(amethyst): document LocationStateTest deviations, close profile gap
feat(amethyst): gate location listening on foreground
2026-07-31 10:26:28 +02:00
davotoula 4234c4f45b docs(location): cite the proven mechanism for LocationFlow's try/finally
fix(test): close the seed-after-registration ordering gap in LocationFlow

fix(test): replace non-discriminating LocationFlow cleanup test
2026-07-31 10:26:28 +02:00
davotoula a742cec495 feat(amethyst): register one location provider with a paired listening hook
feat(amethyst): add location provider ladder
feat(amethyst): add RefCountedSession for overlapping ledger holders
2026-07-31 10:24:39 +02:00
davotoula 3fe936bedb docs 2026-07-31 10:24:16 +02:00
Vitor PamplonaandGitHub 7cece7eafb Merge pull request #3825 from vitorpamplona/claude/intent-receivers-feed-sharing-rkwiv1
Add media share targets for Pictures, Shorts, and Video feeds
2026-07-30 12:22:44 -04:00
Claude dcf2807e75 fix: address audit findings on the media share targets
- Correct the KDoc on the Shorts and Longs composers. They claimed
  everything posted from them lands in that feed; VideoPostKind only
  governs videos, and the gallery picker takes images with no mime
  filter, so a picked JPEG still posts as a kind-20 picture that
  neither feed reads. Say so instead of asserting a false invariant.

- Forward the shared text as the composer's caption. The media targets
  dropped EXTRA_TEXT entirely, so sharing a photo with a caption lost
  it — while the DM target kept it. The routes now carry the message
  and NewMediaModel.load() seeds the caption field from it.

- Accept SEND_MULTIPLE on the three media targets. Sharing several
  files at once previously did not offer Amethyst at all, even though
  the picture composer publishes N images as one kind-20 event. Routes
  carry a URI list; other targets take the first.

- Replace, rather than stack, a feed entry when a second share arrives
  while the first is still open. Only entries that carry attachments
  are replaced, so a feed reached from the bottom bar keeps its
  tab-root marker underneath.

- Rename NewImageButton to NewVideoFeedButton: it is the Video feed's
  composer and handles pictures and video alike.

Adds ShareTargetManifestTest, which pins the activity-alias names in
AndroidManifest.xml to the constants ShareIntentRouting matches them
by — in both directions, plus the SEND_MULTIPLE filters. That link is
invisible to the compiler and fails silently at runtime by routing a
share to the wrong composer. Verified the guard bites by renaming an
alias in the manifest alone: three of its four tests go red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4WsYWaNMPD34SBc6Ej6hx
2026-07-30 16:07:52 +00:00
Claude ea2472468b feat: share images, shorts and videos straight into their feeds
Adds three share targets next to "New Post", "Send as DM" and
"New Highlight":

- New Picture (image/*) -> the picture feed's composer, publishing a
  NIP-68 kind 20 picture post
- New Short (video/*)   -> the Shorts feed's composer, publishing a
  NIP-71 kind 22 short
- New Video (video/*)   -> the Video feed's composer, publishing a
  NIP-71 video event

Until now every SEND intent landed in the kind-1 composer, so a shared
picture became a text note with a link instead of a post in the feed
the user was aiming for.

Each alias resolves to MainActivity like the existing ones, so
ShareIntentRouting now maps the launching component class to a
ShareTarget enum instead of a growing chain of isShareAsX() booleans.
The media targets navigate to the destination feed carrying the shared
content URI, and the feed's existing composer button opens on it.

Video kind is no longer guessed from orientation alone in feeds that
only read one of the two kinds: the Shorts composer always publishes
kind 22 and the Longs composer always publishes kind 21, so a post
lands in the feed it was composed from whatever the footage's shape.
The mixed Video feed and the picture feed keep the automatic choice.

Also fixes the New Post launch path passing the literal string "null"
as the attachment when a share carried no media.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4WsYWaNMPD34SBc6Ej6hx
2026-07-30 15:11:56 +00:00
Vitor PamplonaandGitHub 1f4d9728be Merge pull request #3824 from vitorpamplona/claude/event-kind-0-empty-content-josbwa
Handle blank and malformed metadata gracefully
2026-07-29 22:17:41 -04:00
Claude 25c4516270 fix: parse kind 0 with empty or array-wrapped fields instead of erroring
Four kind-0 events collected from relays exposed two content-parsing
problems. Both `MetadataEvent.contactMetaData()` and `contactMetadataJson()`
are shared code, so Jackson (JVM/Android) and kotlinx (native) were equally
affected — verified against both mappers.

Empty content is a valid, empty profile — someone wiping their metadata —
not a parse failure. It logged "Content Parse Error" and returned null,
which made `LocalCache.consume` drop the event, so the stale profile stayed
in place forever even though a newer replaceable event had arrived. Blank
content now decodes to a blank `UserMetadata` (and an empty `JsonObject`).

A string field wrapped in a one-element array (`"nip05":["a@b.com"]`) has
only one possible reading, so `TolerantStringSerializer` now unwraps it
rather than silently dropping the user's NIP-05 verification. Empty,
multi-element, and non-primitive arrays stay ignored.

The other two events already behaved correctly and are pinned as
regressions: `"nip05":{}` with foreign client keys, and Ditto's ambiguous
`"birthday":"10-24"` — both drop just the offending field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGYcqfBKsP9SCvRdPzdkSx
2026-07-30 02:08:04 +00:00
Vitor PamplonaandGitHub d56d0cf725 Merge pull request #3819 from vitorpamplona/fix/log-defaults-and-concord-cross-epoch-floor
fix: quiet the default log, add a boot narrative, and stop pinning Concord entities across a Refounding
2026-07-29 21:56:52 -04:00
Vitor PamplonaandGitHub f7cf257365 Merge pull request #3821 from vitorpamplona/claude/notification-card-pattern-95omqu
Redesign channel invite cards as feed rows with metadata-only observers
2026-07-29 21:32:58 -04:00
Vitor PamplonaandClaude Opus 5 edd36b467c test(commons): deflake FeedMetadataCoordinatorTest
`loadMetadataBatched follows the same retry semantics` failed on the macOS
build-desktop runner (1431 tests in the module, 1 failed). The coordinator is
fine; the test was wall-clock coupled.

It fired call 1 with `timeoutMs = 200`, waited a flat `delay(350)`, then
asserted that call 2 re-subscribed. That leaves a 150ms budget for
`scope.launch` to be scheduled, subscribe, `awaitAll(200)`, unsubscribe and roll
the pubkeys out of `inFlightBatchedMetadata`. On a loaded runner the launch
itself can be queued past the margin, so the roll-back lands late, call 2
short-circuits by design, and the assertion fails on healthy code. Every test in
the file had the same shape.

"Has call 1 finished?" is a question about the job tree, not the clock, and the
test owns the scope — so it can just ask. `awaitCoordinatorIdle()` waits until
no child of the test scope is active; `waitUntil {}` polls the few preconditions
that aren't expressible that way (listener registered before EOSEs are fired).
Both carry a generous deadline and fail with a message rather than hanging. Safe
here because the coordinator launches nothing at construction and
`BatchEoseGate` closes and joins its consumer inside `awaitAll`, so the scope
does reach idle.

Also made the test fake thread-safe. `subscriptions` and `subscribeCalls` were a
plain map and list, written from the coordinator's `Dispatchers.Default`
coroutines (and `Dispatchers.IO` in the concurrent-EOSE test) and read from the
test thread: an unsynchronized `size` read can be stale, and `fireEose`
iterating `subscriptions.values` while a coordinator coroutine calls
`unsubscribe` can throw ConcurrentModificationException. Concurrency is the
thing under test, so the fake shouldn't be the weak link. Now
ConcurrentHashMap + synchronizedList + AtomicInteger, with a snapshot in
`fireEose`.

Verified deterministic rather than just green:
- 3/3 idle, 4/4 under 24 busy loops on 12 cores (the old shape needed the
  margin; the new one is load-independent).
- Still catches its regression: reintroducing the original mark-on-send bug
  (`eosedRelays > 0` -> `>= 0`) fails exactly this test. A deflaked test that no
  longer detects the fault would be worse than the flake.

This is a pre-existing flake (test dates from 5217035f94, 2026-07-07) unrelated
to the rest of this branch, which touches no commons code — fixed here because
it blocks the branch's CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 21:25:20 -04:00
Claude 5b357cd2fc perf: trim the channel-invite row's per-item cost
Three findings from auditing the previous commit.

observeChannel registered a ChannelFinder query per invite row that could
never produce a filter: every assembler under ChannelFinderFilterAssemblyGroup
is gated on `is PublicChatChannel` / `is LiveActivitiesChannel`, so a
RelayGroupChannel contributes nothing and the registration only churned the
app-wide key set (an allKeys() Set copy per bundled invalidation) on every
mount and unmount. Collect the channel's metadata stateFlow directly instead —
same live name updates when the group's kind-39000 lands, none of the churn.

TimeAgo used the default Dotted style inside a row that already spaces its
children, so the variant's own leading " • " doubled the gap. DottedTight is
what the note header uses in exactly this position.

Key each row by channel id. The list is sorted newest-first, so an arriving
invite shifts every row below it; without a key Compose matches children by
call-site position and each shifted row recomposed against a different invite,
re-resolving the actor and reloading their avatar. Also remember the row
modifier rather than rebuilding the chain on every recomposition.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGB1z4VcNfDw5cMt9V5NCG
2026-07-30 01:16:29 +00:00
Vitor PamplonaandGitHub c47afe6427 Merge pull request #3823 from vitorpamplona/claude/buzz-relay-tor-reachability-7lrj4m
Fix Tor→clearnet fallback banner logic with live socket state
2026-07-29 21:10:22 -04:00
Vitor PamplonaandGitHub a2e199bd57 Merge pull request #3822 from vitorpamplona/claude/buzz-notification-relay-ebj12q
Extract RelayNameChip to shared component and add it to relay group message headers
2026-07-29 21:09:43 -04:00
Vitor PamplonaandClaude Opus 5 2d7ef2fd93 feat(logs): give the default log a boot narrative
The INFO default landed a readable log but an incomplete one: only the boot
census came from our own code, so the default read as "warnings + census" with
nothing between process start and the 20s mark. The first four seconds — the
expensive part — were silent.

Adds the missing spine, ten INFO lines a boot:

- `AmethystApp`: version, process and effective log level at start. The
  napplet-sandbox skip is promoted too — it is the one line that explains why
  `instance` is unset and `LocalCache` empty in that process, which otherwise
  reads as a broken app rather than a deliberately secret-free sandbox.
- `LocalPreferences`: how many accounts are saved (nearly every per-account
  subsystem multiplies by this), then each account's load with its elapsed ms.
  Decrypting one account's settings is among the most expensive things a cold
  start does and "which account was slow" is the first question when a boot
  drags. The six intermediate steps stay at DEBUG.
- `TorService`: every status transition, with time since bootstrap started. All
  status writes now go through one `setStatus` helper instead of nine scattered
  assignments, so a transition is logged exactly once; self-transitions are
  skipped because the reset paths reassign `Off` defensively. A stuck
  `Connecting`, an `Active` that took 40s and a silent drop to `Off` are three
  different bugs that used to look identical.
- `BootRelayDiagnostics`: adds a census at 5s. The pool is assembled and
  dialling well before 20s, and the early datapoint is what separates "slow to
  connect" from "connected fine, slow to serve" — on device it reads pool=21
  at 5s against pool=359 at 20s. Paid for by folding the `=====` banners down
  to DEBUG, so a census is 3 INFO lines instead of 5.

Reads on device as:

    I AmethystApp: Amethyst 1.13.1-DEBUG starting in main process (log level INFO)
    I TorService: Off -> Connecting
    I LocalPreferences: Found 4 saved account(s)
    I LocalPreferences: Loaded account npub1gcx… in 394ms
    I LocalPreferences: Loaded account npub142g… in 760ms
    I BootRelayDiag: census @5s pool=21 opened=17 served_events=14 …
    I TorService: Connecting -> Active after 9910ms
    I BootRelayDiag: census @20s pool=359 opened=158 served_events=78 …

Verified by tag on a cold start: +10 INFO lines from our code, no other change
to volume. (Raw totals moved more, but that run happened to autoplay video, so
15 extra INFO lines came from Android's media/codec stack — not from this.)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 18:26:05 -04:00
Claude 21623cfd3f fix: debounce the Tor→clearnet offer on the socket and hide it when inert
Audit follow-ups on the previous commit's gating.

1. The `hasLoadedContent` gate over-suppressed. Cached channels in
   LocalCache are proof the socket answered *once*, not that it answers
   now — so a relay whose circuits died mid-session, leaving a full but
   frozen screen, would never get the offer. Replace it (and the
   one-shot grace timer) with a debounce on the connection itself: the
   countdown restarts on every drop and clears the moment the socket
   returns. That covers the cold-start case, the reconnect blip and the
   mid-session death with one signal, and no longer depends on what
   happens to be cached.

2. Don't offer what can't help. The action adds the relay to the
   kind-10089 Trusted list, which only moves it to clearnet while
   trusted relays are off Tor. Under the Small-Payloads and
   Full-Privacy presets (trustedRelaysViaTor = true) it changed no
   routing at all — the old `relay !in trustedRelays` condition merely
   hid the banner afterwards, so the dead button looked like it worked.
   Withhold the offer under those presets instead of silently flipping
   a global privacy setting from a per-relay banner.

Tests cover both gates plus the preset routing they read.
2026-07-29 22:24:41 +00:00
Claude 08b3cf266b fix: draw the "added you to a channel" prompt as a note row
The invite prompt was a floating Material card with a surfaceVariant fill,
sitting above the Notifications feed and Messages > New Requests as its own
visual language — nothing else on either surface looks like that.

Render it through NoteComposeLayout instead, the same layout every note and
notification card uses: the actor becomes the row's author (picture, name and
time in the usual header), "Added to <channel>" plus the host relay become the
row's content, and the three choices take the reactions slot. So the prompt now
reads like the reply and mention notifications it sits next to, and a divider
closes each row the way the feed does.

The channel name is now read through observeChannel, the metadata-only observer
the channel rows use, so a stranger's add resolves to a name instead of a raw
group id. It does not open the channel's message subscription — holding that
back until the viewer answers is the whole point of the prompt.

Also relabel "Show" to "Add to Messages". acceptChannelInvite is defined as
`= addRelayGroupToMessages(channel)`, literally the call behind the channel top
bar's "Add to Messages" item, so one action had two words for it. Reusing the
existing string drops channel_invite_accept and channel_invite_body (the actor
and the question both live in the row now).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGB1z4VcNfDw5cMt9V5NCG
2026-07-29 22:23:13 +00:00
Claude f6e68f31d4 feat: name the host relay on Buzz notification cards
A Buzz/NIP-29 group id is only unique within its host relay, so a message
notification titled just "#general" doesn't say which #general it came from.
The Notifications card already draws a RelayGroupChannelHeader naming the
channel; add the relay next to it, mirroring what a Concord message gets on
the same cards (ConcordCommunityPill naming its parent community) and what the
Messages row already does for these groups.

Extracts the relay chip out of ChatroomHeaderCompose into a shared
RelayNameChip so the Messages row and the Notifications feed render the same
chip, the way ConcordCommunityPill is already shared between them. The chip
taps through to the relay's channel list; the name/avatar still open the room.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DsoLSb42C3CdjMceJW2Pq
2026-07-29 22:22:11 +00:00
Vitor PamplonaandGitHub 27c863925a Merge pull request #3820 from dmnyc/fix/nip73-scope-prefetch
perf(nip73): prefetch external-content preview URLs
2026-07-29 18:13:30 -04:00
The Daniel c4c8649fcc perf(nip73): prefetch external-content preview URLs
Closes #3817.

collectWarmTargets() harvested link URLs from note content but not from
NIP-73 `I`-tag scopes, so the external-content preview cards popped in
mid-scroll while every other link preview was warmed ahead of view.

Gated on `is CommentEvent` so only kind 1111 walks the tags, keeping the
other kinds off that path. Reuses scope() -- the same accessor the
renderer uses -- so a URL is only warmed when it will actually be shown.
Inherits the existing showUrlPreview() data-saver gate already applied to
targets.links.
2026-07-29 18:03:36 -04:00
Vitor PamplonaandGitHub 112fbb8379 Merge pull request #3818 from vitorpamplona/claude/local-blossom-main-thread-anr-sfcfv9
Confine Blossom cache probe and resolver to IO dispatcher
2026-07-29 18:02:24 -04:00
Vitor PamplonaandClaude Opus 5 6fbcb880dd fix(concord): stop pinning entities whose chain crosses a Refounding
An entity edited after a CORD-06 Refounding was frozen at its pre-Refounding
version and its newer state silently discarded on every refold. Observed on
device: entity e83ee182 of a real community held at v1 across 21 consecutive
refolds while the current epoch offered v2 citing v1's own hash.

`EditionFold.foldEntity` anchored the walk by requiring the offered set to
*contain* the floor edition. But a Refounding re-wraps ONE edition per entity
(CORD-06 §3, "the last Control Plane state is simply rewrapped"), so an entity
edited afterwards has only its successor on the new epoch while the floor
edition stays behind on the old one. `prev == floor.hash` is a stronger proof
the chain connects than mere presence, and it was being ignored. Worse, the
gap made `admissible` — the pre-filter every derived fold shares — keep only
`version < floor.version`, so the newer edition was dropped every refold: a
role edit, channel rename or banlist entry reverting itself for that user.

Checked against Armada (semantics only, AGPLv3) and the canonical spec before
changing consensus behaviour. Armada selects an arm per entity, and Amethyst
was missing both halves:

1. Its chain-walk arm has three anchor branches to our one; the missing
   `versions[0] === floor + 1n -> bytesEq(lo.prevHash, floorHash)` is now
   implemented.
2. Once an entity has been re-wrapped into the epoch being folded it does not
   chain-walk at all — it anchors on version alone (`bootstrapHead`), because
   behind a compaction dangling `prev`s are normal and, since seal signatures
   survive re-wrap, any group-key holder can re-serve a genuine old edition
   under the current group. A re-wrap cannot raise the version inside the
   signed seal, so version is what bounds that. This is the half that fixes
   the observed pin; branch 1 alone would still refuse a floor-v1 entity whose
   only served edition is v3.

Implemented as an optional `snapshot` (the rumor ids of the epoch being
folded), defaulting to null = pure chain walk for every other caller.
`ConcordCommunityState.fold`/`authorizedHeads` capture it from their own
editions argument before `admissible` re-seats older-epoch heads — Amethyst
folds exactly one epoch per call, so the argument is the snapshot. `admissible`
and `candidates` now ask `foldEntity` whether it gapped rather than
re-deriving the test, so the three sites cannot drift apart again.

`LOG_GAP` also dedups on (entity, floor, offered): the same refusal was
re-reported on every refold, 22 byte-identical warnings a boot, which read as
22 attacks rather than one unchanged state. Deduping is what made the two
genuinely distinct refusals visible and led here. Fails open above 4096
distinct refusals — a flood is when the warnings matter most.

Anti-rollback is unchanged: all 12 pre-existing floor tests still pass, and
three new negative tests cover the fork, the below-floor re-serve, and an
entity absent from the snapshot. Device gaps for the pinned entity: 21 -> 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 18:01:14 -04:00
Vitor PamplonaandClaude Opus 5 60e3df72b2 fix(logs): make the default log a boot narrative, not a firehose
A cold start emitted 6,198 app lines in 80s (~77/sec) on a real device.
Six tags produced 83% of it, and the lines that are actually actionable —
relay protocol refusals like `auth-required`, `rate-limited`,
`unsupported: too many filters` — were buried among them.

`Log.minLevel` was `DEBUG` in debug and `ERROR` in release: two settings,
both wrong. Release dropped all 460 `Log.w` call sites, so the field never
saw a single relay refusal. Debug kept everything.

Now debug defaults to INFO and release to WARN, with `Amethyst.VERBOSE_LOGS`
to restore the firehose. 333 files already route through `quartz.utils.Log`
(only 13 use raw `android.util.Log`), so `minLevel` is a real global gate —
which is why three of the four loudest buckets needed no code change at all:
relay socket lifecycle (1,659 lines), `RelaySpeedLogger` (1,308) and Arti's
per-stream SOCKS errors (753) were already `Log.d`.

The rest:
- `RelayLogger.onCannotConnect` E -> D. Under the outbox model a boot dials
  ~400 relays and most fail identically; one line per dead socket is not
  actionable, and its multi-line TLS certificate dumps were 3-4 logcat lines
  each. This one mattered most: at `Log.e` it survived every level.
- `BootRelayDiagnostics` rollup -> INFO, per-relay WASTE/SERVE tables -> DEBUG.
  The census line carries the aggregate the 655 per-socket errors spelled out.
- `Duplicated/SPAM` -> DEBUG: a detection is the filter working, and it is
  already reported via `relayStats.newSpam()` and `flowSpam` to the UI.
- `MarmotDbg` "not a member of group" -> DEBUG: relays serve kind:445 for
  every group they carry, so this is the steady state. It was burying the
  real warning next to it ("Generation N already consumed").

Measured on emulator (4 accounts, Tor on): 6,198 -> 433 lines. Flipping
VERBOSE_LOGS gives 5,574 back, so nothing was deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 18:01:14 -04:00
Claude 14046fd0ce fix: gate the Buzz Tor→clearnet banner on real relay reachability
The "Can't reach this relay over Tor" card on a community's screen was
driven by a bare 6s timer: `connectTimedOut` flipped true after the delay
and never flipped back, and the condition never consulted the relay at
all. On a Buzz relay the banner is an unconditional item in the sectioned
list (the empty-list guard only covers the vanilla NIP-29 branch), so it
appeared six seconds after opening any community and stayed up while the
relay was connected and delivering messages.

The Tor half was wrong too: `torType != OFF` says Tor is enabled somewhere,
not that this relay is routed through it — onion, localhost and the
per-role presets (trusted / DM / new) each decide independently.

Gate the offer on live signals instead, extracted into a pure predicate:

- routed over Tor  — TorRelayEvaluation.useTor, the same predicate the
  relay pool dials with
- Tor is up        — TorManager.status is Active, so a bootstrapping Tor
  isn't misreported as the relay blocking exits
- not connected    — client.connectedRelaysFlow(); a relay that answers is
  reachable by definition
- nothing loaded   — no channels/DMs on screen, so a reconnect blip can't
  flash the banner over a live list
- grace elapsed    — unchanged, so a slow first connect isn't nagged

Adds unit tests for each gate, including the reported regression
(connected and delivering -> banner stays hidden).
2026-07-29 21:43:08 +00:00
Claude 91bd2b44ee fix: re-probe the local Blossom cache when the toggle flips
Flipping either local-cache setting called probe.invalidate(), which only
clears the TTL timestamp — it never re-runs the probe. Nothing else does
either: isAvailable() is reached only from the one-shot startup warm and
from inside findServers().

That leaves the feature dormant when enabled mid-session. The feed image
path gates on `available.value` (AccountViewModel.useLocalBlossomBridge
and shouldBridgeBlossomCache), so while the probe reads false no request
ever routes through the resolver, and the resolver is the only thing that
would have refreshed the probe. The settings "detected" chip reads the
same flow, so it also stays stale.

Re-probe right after invalidating so enabling the toggle activates the
feature — and reports its true state — in the same session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PCgrM83QetNPJTg3Z5VWnF
2026-07-29 21:34:45 +00:00
Vitor PamplonaandGitHub 09902fe470 Merge pull request #3816 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-29 16:17:43 -04:00
Vitor PamplonaandGitHub 14b615b10d Merge pull request #3810 from dmnyc/feat/nip73-comment-previews
feat(nip73): show rich previews for external-content comments
2026-07-29 16:17:30 -04:00
vitorpamplonaandgithub-actions[bot] 8678c88db6 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-29 20:15:13 +00:00
Vitor Pamplona 6fac964d16 Improving the shape of the mention icon 2026-07-29 16:11:40 -04:00
Vitor PamplonaandGitHub 96a896f78e Merge pull request #3814 from vitorpamplona/claude/corcord-buzz-height-difference-8fp108
Remove facepile from channel rows, simplify layout
2026-07-29 15:41:17 -04:00
Vitor PamplonaandGitHub 1c24ba9cf6 Merge pull request #3815 from vitorpamplona/claude/push-notification-icons-branding-dy4egx
Add Nostr badge watermark to notification icons
2026-07-29 15:40:58 -04:00
Claude 9e4aedee05 fix: enlarge the media Amethyst peak another 10%
Gem height 9.68 -> 10.65.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 19:39:11 +00:00
Claude eae0ca814d fix: raise the media Amethyst peak 2%
cy 15.62 -> 15.14.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 19:36:43 +00:00
Claude ec60834046 fix: enlarge the media Amethyst peak 10%
Gem height 8.8 -> 9.68.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 19:31:48 +00:00
Claude 2684d2d4a8 fix: nudge the media Amethyst peak down 1% and right 2%
cx 15.28 -> 15.76, cy 15.38 -> 15.62.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 19:29:55 +00:00
Claude fd6696da82 fix: lower the media Amethyst peak another 1%
cy 15.14 -> 15.38.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 19:28:40 +00:00
Claude 00c46bcc92 fix: nudge the media Amethyst peak slightly down and right
cx 14.8 -> 15.28, cy 14.9 -> 15.14.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 19:26:41 +00:00
Claude 26ad072993 fix: raise the media Amethyst peak 5%
Move the gem back up (cy 16.1 -> 14.9) for a better balance with the mountain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 19:21:32 +00:00
Claude 52643fb48f fix: lower the Amethyst peak in the media icon ~10%
Move the gem down (cy 13.7 -> 16.1) so it sits lower against the mountain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 19:20:22 +00:00
Claude 202163f4b9 perf: keep Local Blossom resolver/probe off the main thread
BlossomServerResolver.findServers and LocalBlossomCacheProbe.probe are
suspend functions that don't confine to Dispatchers.IO. They're reached
from Compose produceState/LaunchedEffect (RichTextViewer,
MarmotGroupIconDisplay, ZoomableContentView), which run on the main
dispatcher, so the pre-suspension work — BlossomUri regex parsing,
LruCache lookups, the probe's OkHttpClient build on TTL expiry, and the
server-list flow setup — executed on the UI thread on every uncached
resolution during feed scroll.

Wrap findServersInner and the probe's client-build + HEAD in
withContext(Dispatchers.IO) so that work stays off the main thread.
Behaviour is unchanged; callers already on IO (Coil BlossomFetcher,
PlaybackService) are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PCgrM83QetNPJTg3Z5VWnF
2026-07-29 19:19:00 +00:00
Claude 097c3e874d feat: media icon uses the Amethyst crystal as a mountain peak
Drop the second mountain and the sun; place the Amethyst gem on the ground
as the second peak beside a single triangular mountain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 19:15:03 +00:00
The Daniel adc36017d1 fix(nip73): honor data-saver gate and fix preview icon touch target
Review feedback from PR #3810:

- Gate both new preview call sites behind settings.showUrlPreview(), so a
  user on WIFI_ONLY over cellular or NEVER no longer triggers an outbound
  OpenGraph request and image download per external-scoped comment. Falls
  back to the plain link chip / URL header.
- Wrap the open-in-browser icon in an IconButton so it gets a real touch
  target instead of a 14dp one nested inside the card's own clickable,
  where a near-miss silently navigated instead.
- Only show that icon when onCardClick is set; without it the icon and the
  card tap did the same thing, so existing note-body link cards stay as-is.
- staticCompositionLocalOf for LocalCurrentExternalScope (constant per
  screen, avoids read tracking per feed row).
- Inset the loading/error URL header to match the card, and use the
  hoisted Size14Modifier.
2026-07-29 15:12:41 -04:00
Claude 65073c351c fix: nudge the mention ostrich 3% back left
Shift the ostrich centre from x=13.2 to x=12.48.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 19:11:08 +00:00
Claude 34633624cc fix: nudge the mention ostrich ~5% right
Shift the ostrich centre from x=12 to x=13.2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 19:09:21 +00:00
Claude 9f367269e6 fix: tuck the mention bridge ends behind the ostrich
Pull the bridge's inner endpoints toward the logo centre so their raw ends
hide under the ostrich (drawn on top) instead of showing as stubs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 19:05:55 +00:00
Claude 9f77245860 fix: enlarge the mention ostrich ~10%
Grows the Amethyst ostrich in the @ from 9.45 to 10.4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 19:02:05 +00:00
Claude b185f58ada fix: keep the @ bridge and enlarge the ostrich 5%
Drop only the inner "a" circle from the @ outline, keeping the bridge
segments that connect the ring to the "a", and size the ostrich up 5%
(9.0 -> 9.45).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 18:59:43 +00:00
Claude f79741e611 fix: remove the @'s inner ring so the ostrich stands alone
The leftover inner "a" ring sat in front of the ostrich, making the logo
look like it was behind the circle. Drop the inner-a segments from the @
outline, leaving just the outer ring + tail, and size the ostrich so it
reads clearly as the "a".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 18:55:25 +00:00
Claude ff40d49b78 fix: shrink the mention ostrich so it reads as the bird
Smaller centered Amethyst gem inside the @ so the ostrich is distinct
rather than a blob filling the a-space.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 18:49:02 +00:00
Claude 439bc5d5f9 feat: mention = Material @ outline with the gem as the "a"
Reuse the original Material @ shape (keeping its native tail and opening),
drop its inner "a" circle, and place the Amethyst gem there instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 18:33:47 +00:00
Claude bde562b4a6 chore(concord): remove now-unused facepile helpers
Delete ConcordAuthorFacepile and the ConcordChannel/RelayGroupChannel
recentAuthorHexes extensions, orphaned after the channel rows dropped the
recent-posters facepile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017x22okBh6HbiCrJN3zf5xn
2026-07-29 18:13:06 +00:00
Claude 1793ee5ffe Merge remote-tracking branch 'origin/main' into claude/push-notification-icons-branding-dy4egx 2026-07-29 18:06:34 +00:00
Vitor PamplonaandGitHub 07267e254b Merge pull request #3813 from vitorpamplona/claude/notecompose-media-rendering-f4k7nh
Consolidate NIP-71 video event types under shared interface
2026-07-29 14:04:45 -04:00
Claude 16030599d9 fix(concord): align top-bar height and simplify channel rows
Route the Concord community screen and hub through ShorterTopAppBar (50dp)
instead of the raw Material3 TopAppBar (64dp default), so the top bar — and
its 3-dot overflow menu — sits at the same height as the Buzz/relay-group
community screen, which already uses the shorter bar.

Also drop the recent-posters facepile from the Concord and Buzz channel
rows, and move the last-message time up to the first line (next to the
channel name), leaving the unread-message badge on the second line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017x22okBh6HbiCrJN3zf5xn
2026-07-29 17:18:29 +00:00
Claude ee8623bd20 refactor: dispatch all NIP-71 video kinds via the VideoEvent interface in NoteCompose
RenderNoteRow matched the four concrete video subtypes (VideoNormalEvent 21,
VideoShortEvent 22, VideoHorizontalEvent 34235, VideoVerticalEvent 34236) with
four identical branches. Collapse them into a single `is VideoEvent` branch,
matching how ThreadFeedView's NoteMaster already dispatches, so any future
VideoEvent subtype renders through VideoDisplay automatically instead of
falling through to the text fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCKP4tD2seoDpr3ZcdPJNx
2026-07-29 17:01:53 +00:00
David KasparandGitHub 7e6170390d Merge pull request #3808 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-29 18:52:04 +02:00
Claude de1c4905cc Revert "fix: make mention connector a solid tapering wedge"
Back to the curved thin connecting stroke.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 16:44:17 +00:00
vitorpamplonaandgithub-actions[bot] 0279938e05 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-29 16:41:58 +00:00
Claude 536d3cf1a5 fix: make mention connector a solid tapering wedge
Fill the connector as a solid region: thick at the gem's right side,
tapering to the gem's bottom tip and merging into the ring's upper opening.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 16:38:52 +00:00
Vitor PamplonaandGitHub 979ce7a96f Merge pull request #3812 from vitorpamplona/claude/buzz-channel-delete-persist-e3lex3
NIP-29: Channel deletion, archival, and Buzz community UI
2026-07-29 12:38:42 -04:00
Vitor PamplonaandGitHub 7cda707380 Merge pull request #3811 from vitorpamplona/claude/draft-note-expansion-threadview-u2u32l
Make draft posts in thread view open edit screen on tap
2026-07-29 12:36:44 -04:00
Claude b58543fb2d fix: curve the mention connecting stroke
Replace the flat bar with a curved stroke that sweeps down from the gem and
up into the ring's upper opening, reading as a natural @.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 16:33:30 +00:00
Claude 707cffec6d fix: connect mention tail to the upper end of the ring opening
The bridge now joins the gem to the ring's upper-right end (~3 o'clock),
leaving the lower-right as the ring's free opening — the correct @ side.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 16:29:15 +00:00
Claude d44264a1ac feat(buzz): fix channel/forum type on the create screen from the caller
The create screen no longer shows a "Forum channel" toggle — the type is decided
by the community's per-section "+" (Channels vs Forums) and a Buzz channel's
channel_type isn't editable anyway (the relay's 9002 has no such key). The screen
loads with the fixed isForum parameter and creates the right type; its top-bar
title reads "New forum" when isForum, "New channel" otherwise (on Buzz).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
2026-07-29 16:22:34 +00:00
Claude 04243d8dfa fix: open edit-draft screen when tapping a draft in ThreadView
Tapping a note in the thread view toggles the collapse/expand state of
its children. For the user's own drafts, tap now opens the edit-draft
screen (via routeEditDraftTo) so the post can be resumed, matching the
behavior everywhere else a draft is tapped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQCNuxdYkfouNN3ujxm3mR
2026-07-29 16:19:17 +00:00
Claude 60161e3fae fix: mention tail connects the gem to the ring
Bridge the tail from the gem's bottom down into the ring's lower opening,
like the inner "a" stem joining the circle in an @.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 16:14:33 +00:00
Claude ca61eb107d fix(buzz): drop deleted channels that leaked as bare UUID rows
A channel deleted elsewhere (or before this device saw its metadata) keeps its
kind-44100 membership — the relay never retracts it — but the relay soft-deletes
its 39000, and it does NOT serve a channel_deleted 40099 for an already-deleted
channel, so the discovery fetch can't catch it. Such a channel arrived with no
metadata and was shown optimistically: a bare UUID row with "No messages yet".

The reliable signal is that very metadata absence. BuzzRelayImportViewModel.discover
now drops channels the relay serves no 39000 for — but only when the fetch clearly
worked (some channel returned a 39000); if none did, the read failed (auth/
connectivity) and the whole set is kept rather than blanking the community. A
genuinely new channel whose 39000 is merely slow returns on the next bind once its
metadata is cached, so the prune is live (not persisted) and self-correcting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
2026-07-29 16:09:11 +00:00
Claude 9d2c5e0cee fix(buzz): show the channel/forum "+" on any community, not gated on an unloaded roster
The "+" was gated on BuzzCommunityMembership (kind-13534), but that roster is only
fetched by the Members screen — nothing on the channel-list screen subscribes to
it, so isMember read false and the "+" hid even from admins. Match the workspace
overflow menu's approach instead: offer create on any Buzz community and let the
relay reject a non-member's kind-9007 (its own doc: "any member sees them, the
relay only serves the owner/admin ones").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
2026-07-29 16:07:07 +00:00
Claude e6bce31249 feat: add connected @ tail to the mention ring
The tail now flows out of the ring's upper opening end and hooks to the
right, connected to the ring rather than floating beside it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 16:05:44 +00:00
Claude 38e5c848a4 feat: add @ opening to the mention ring
Cut a clean gap in the lower-right of the circle for the @ opening, with no
separate tail curve.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 15:54:25 +00:00
The Daniel ac426531f3 feat(nip73): show rich previews for external-content comments
Kind 1111 comments scoped to a NIP-73 external identifier (e.g. a web
article) rendered as a bare URL with no context. Reuse the existing
OpenGraph preview pipeline so feed rows, expanded threads, the
reply-compose banner, and the URL thread screen all show a rich
image/title/description card instead, and give the URL thread screen
a proper scrolling header instead of a bare title.
2026-07-29 11:50:17 -04:00
Claude 967d51b6f9 fix: mention is a gem inside a clean complete circle
Drop the @ tail attempts, which read as a drooping curve. The mention is
now just the centered Amethyst gem with a clean full circle around it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 15:46:21 +00:00
Claude 21329b6dab fix: reshape mention @ tail to flick out to the right
The tail read as a downward-drooping comma; reshape it to flick outward to
the right like a proper @ tail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 15:44:31 +00:00
Claude 240f4e67a6 fix: thicken the mention circle stroke
Widens the @ ring stroke (1.4 -> 2.1) for a bolder circle around the gem.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 15:38:25 +00:00
Claude a41db22030 feat: redo mention as a clean @, bump chess gem 2%
- Mention: gem centered like the "a" with a near-complete circle around it,
  opened at the lower-right with a small tail — a proper @ shape, ring no
  longer touching the gem.
- Chess: enlarge the gem 2%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 15:36:19 +00:00
Claude 417147cfe7 fix: move chess gem to the right side of the box
Positions the Amethyst gem on the right, blending into the knight's base
where they meet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 15:33:28 +00:00
Claude afaf5f582b feat: balance code icon; blend chess gem into the knight
- Code: widen the < > chevrons (0.62 -> 0.85) and shrink the gem (12 -> 10)
  for a more even balance.
- Chess: enlarge the Amethyst gem so it rides over and blends into the
  bottom of the knight.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 15:27:38 +00:00
Claude c13d2840b9 fix: match code gem height to the < > height
Grows the centered Amethyst gem so its height equals the bracket height
(y6..18); the slimmed chevrons leave room for it to sit between them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 15:21:57 +00:00
Claude bdc21fee1e fix: slimmer code brackets, bigger gem
Thins the < and > chevrons horizontally about their own tips (height kept,
still at the far edges) and enlarges the centered Amethyst gem (7.6 -> 8.8).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 15:20:26 +00:00
Claude 22383e7a1c fix: spread code < > to the far left/right edges
Splits the brackets and pushes < to the left edge and > to the right edge,
opening a clean centered gap for the Amethyst gem.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 15:17:19 +00:00
Claude 6ba53918c4 feat: @-style mention, rebalance zap, narrow code brackets
- Mention: thinner ring whose open lower-left end flows into the gem's
  bottom-left facet via a tapered stroke, echoing how an @ is drawn.
- Zap: enlarge the bolt and shrink the top-right gem.
- Code: squeeze the < > brackets narrower (width only, height kept) so they
  hug the centered gem.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 15:15:36 +00:00
Vitor PamplonaandGitHub 2496a7fb3d Merge pull request #3809 from vitorpamplona/claude/flaky-quic-stream-drain-oge3ze
Fix flaky PeerUniStreamDrainTest by confining drainer to producer's event loop
2026-07-29 11:09:18 -04:00
Claude c422a9d022 feat: rework zap layout — bolt left, gem in the top-right corner
Replaces the centered gem-knockout bolt with the bolt moved to the left
and the Amethyst gem as a separate mark in the top-right corner, mirroring
the reply layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 15:09:14 +00:00
Claude 3510296673 fix: make the reply arrow and gem larger still
Grows the reply arrow (0.78 -> 0.9) and the top-right gem (0.045 -> 0.052)
to fill more of the canvas, keeping the diagonal layout without collision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 15:06:49 +00:00
Claude f723310dc7 feat(buzz): archive/unarchive channels + fix visibility edits on Buzz
Two channel-management gaps vs the Buzz interface (both ride kind-9002 tags; no
new protocol), verified against block/buzz:

Archive/Unarchive — the reversible hide-from-the-sidebar the Buzz client has,
distinct from delete. EditMetadataEvent gains an `archived` tag; Account/
AccountViewModel expose archiveRelayGroup; the channel and forum top bars offer
Archive/Unarchive to admins (no confirm — it's reversible). The relay stamps
`["archived","true"]` on the 39000, so GroupMetadataEvent.isArchived() /
RelayGroupChannel.isArchived() read it directly; the community list drops archived
channels out of Channels/Forums into a collapsed "Archived" tail from which they
can be reopened and unarchived.

Visibility-on-edit — a Buzz relay reads its own `visibility` (open/private) tag,
not the NIP-29 `private` status flag, so the edit screen's private toggle was a
silent no-op on Buzz. editRelayGroupMetadata now sends the `visibility` tag on
Buzz relays (status flag still sent for vanilla NIP-29).

Not gaps (checked): topic/purpose/TTL are in the relay's system-message
vocabulary but not extracted on 9002/9007, so there's nothing to mirror.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
2026-07-29 15:05:59 +00:00
Vitor PamplonaandGitHub 662bd87495 Merge pull request #3807 from vitorpamplona/claude/swipe-reply-sensitivity-y0y0v1
Refactor chat bubble swipe gesture to use lower-level pointer APIs
2026-07-29 11:05:32 -04:00
Claude 6e2a8f00f4 fix: enlarge reply arrow and gem
Bumps the reply arrow (0.62 -> 0.78) and the top-right Amethyst gem
(0.035 -> 0.045) so both read larger while keeping the bottom-left /
top-right diagonal layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 15:02:42 +00:00
Claude 21c44cfc36 test(quic): de-flake PeerUniStreamDrainTest slow-consumer overflow
drainPeerInitiatedUniStreamsIntoBlackHole_keeps_connection_alive passed
in isolation but flaked under parallel load with the audit-4 #3
"slow consumer overflowed incoming channel" teardown.

Root cause: the test ran the black-hole drainer on the shared
Dispatchers.Default pool. The drainer only has to keep the 64-deep
per-stream incomingChannel drained, but when the rest of the JVM test
suite saturates every Default worker thread the drainer coroutine can be
denied a scheduling slot long enough for the synchronous feed loop to
push more than 64 chunks ahead of it, tripping the slow-consumer
teardown. The fixed-delay yields (delay(1) every 16 chunks) do not help
when all pool threads are pinned.

Fix: run the drainer on the runBlocking coroutine's own single-threaded
event loop (CoroutineScope(coroutineContext + SupervisorJob())) and pace
the feed loop with cooperative yield() instead of wall-clock delays. Each
yield deterministically runs the drainer's queued channel-receive
continuation before the producer resumes, so the buffer never approaches
its bound regardless of machine load. The drain contract under test
(parser trySend -> incomingChannel -> collect -> discard) is exercised
identically; only the scheduling is pinned.

Verified with a temporary reproduction that pins every Default worker
thread: the old Default-scheduled drainer overflows to CLOSED while the
confined drainer stays CONNECTED under the same saturation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGZt6D298kGv2XgAi3Uuaj
2026-07-29 15:02:16 +00:00
David KasparandGitHub cb648011d3 Merge pull request #3806 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-29 16:59:01 +02:00
Claude 22503b8fe3 fix: tidy article icon lines and drop-cap alignment
Four evenly-spaced text lines (two short up top beside the gem drop-cap,
two full-width at the bottom) with equal spacing, and the drop-cap gem's
top aligned to the top line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 14:58:44 +00:00
Claude 0284c8c86a fix: keep swipe-to-reply from stranding the chat bubble off-screen
Two ways the bubble could get stuck shifted sideways (dragOffset frozen at a
non-zero value, reply icon left visible), both fixed:

1. Cancelling the settle-back animation on awaitFirstDown — which fires for
   every touch, including one that turns out to be a vertical scroll — while
   settleBack() only ran on the horizontal-drag path. A scroll started during
   an in-flight settle froze dragOffset. Now the settle is cancelled only once
   a horizontal drag is actually committed, so every cancel is paired with a
   settleBack().

2. Keying pointerInput on the onSwipeReply lambda. The caller allocates a fresh
   lambda every recomposition (it captures the note), so the detector was torn
   down and restarted on every row update. A restart mid-drag cancelled
   horizontalDrag before settleBack() ran, stranding the bubble; idle restarts
   also churned the gesture coroutine across all visible bubbles. Now keyed on
   the stable isLoggedInUser, with the callback read through rememberUpdatedState
   and the drag wrapped in try/finally so an interrupted drag always settles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016f7dTu8RoJRXjnHM7FKAE2
2026-07-29 14:58:08 +00:00
davotoulaandgithub-actions[bot] d504e43924 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-29 14:58:05 +00:00
Claude 422bf10337 feat: rework reply, zap layout and grow reaction gem
- Reply: keep the arrow at its size but move it to the bottom-left and put
  the Amethyst gem in the top-right corner.
- Zap: scale the bolt up to fill the canvas and knock the gem out of its
  center (inverted), so the logo reads in front of the bolt.
- Reaction: grow the heart's knocked-out gem ~10% and nudge it toward the
  bottom of the heart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 14:57:05 +00:00
davotoula f353389892 update cs,sv,de,pt 2026-07-29 16:54:25 +02:00
Claude 97ff971542 feat(buzz): move per-row channel actions into the opened screen's top bar
The community list rows (channels, forums, DMs) each carried a 3-dot overflow —
Pin/Unpin and Add/Remove-from-Messages. Those are gone; each row is now a clean
tap-to-open target and its actions live in the top-bar overflow of the screen it
opens:

- Channels & DMs open RelayGroupChatScreen (RelayGroupTopBar): add Pin/Unpin
  (Buzz, non-DM) alongside the existing Messages toggle. A DM has no kind-10009
  entry, so its Messages toggle drives the DM-specific hide/unhide (kind-41012 /
  re-open) read from the per-viewer 30622 snapshot; the DM overflow is always
  shown so that action stays reachable.
- Forums open RelayGroupThreadsScreen: give its top bar an overflow with Pin/Unpin
  and Add/Remove-from-Messages.

Shared BuzzPinDropdownItem / RelayGroupMessagesDropdownItem keep the two bars
consistent. AccountViewModel gains hideBuzzDm/unhideBuzzDm wrappers. The
community-list top-bar overflow (Add people, Invite, Agent Console, Add all) is
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
2026-07-29 14:36:42 +00:00
Claude ce25273898 perf: animate swipe-to-reply icon in the layer phase, not composition
The reply icon's fade/scale read dragOffset in composition, and because Box is
an inline composable that read invalidated the whole ChatBubbleLayout on every
drag and settle frame. Gate the icon on a derivedStateOf boolean that flips only
at the 0<->non-0 edges (icon added/removed twice per gesture) and move the
progress math into the icon's graphicsLayer block so it's read in the layer
phase. The bubble translation was already deferred; now the entire swipe and
settle animate with zero recomposition.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016f7dTu8RoJRXjnHM7FKAE2
2026-07-29 14:33:47 +00:00
Vitor PamplonaandGitHub 3cfa2e0efb Merge pull request #3805 from vitorpamplona/chore/bump-winget-manifest-v1.13.1
chore: sync winget manifests to v1.13.1
2026-07-29 10:33:20 -04:00
vitorpamplonaandgithub-actions[bot] bf73e0934a chore: sync winget manifests to v1.13.1 2026-07-29 14:31:13 +00:00
Vitor Pamplona 8a183d1cbd refactor(ci): move the Winget bump out of CI onto a maintainer machine
Mirrors the Homebrew cask change. The old design stored a classic `public_repo`
PAT as WINGET_TOKEN and handed it to the third-party
vedantmgoyal9/winget-releaser action: a token with write access to every public
repo the owning account can reach, given to code we do not control, in a place
any push-access collaborator could read it from (a pushed branch containing a
workflow runs with repo secrets).

- CI (bump-winget.yml, now "Sync Winget Manifest Reference") uses GITHUB_TOKEN
  only: downloads the MSI, computes the sha256, reads the ProductCode from the
  MSI Property table via msitools, and opens an in-repo PR syncing
  desktopApp/packaging/winget/. Runs on ubuntu (1x billing) rather than Windows
  since msitools reads the Property table fine.
- scripts/bump-winget.sh does the upstream PR. It needs NO new token: it drives
  `gh`, which a maintainer already has authenticated, and it does not need
  wingetcreate (Windows-only) because the manifests are plain YAML — so it runs
  from macOS or Linux.

Add the three reference manifests under desktopApp/packaging/winget/, matching
the schema 1.12.0 shape used upstream. All three validate against Microsoft's
published JSON schemas.

Validation caught one real bug worth noting: an all-digit 64-char InstallerSha256
parses as a YAML *integer* and fails the schema's `string` type, so it is written
quoted. ProductCode is re-read every release because jpackage regenerates it per
build; it is the ARP key `winget upgrade` matches on.

Drops the last package-manager PAT from the secret inventory.
2026-07-29 10:30:21 -04:00
Claude 34fa103dbd feat(buzz): gate the channel/forum "+" to community members
Only a Buzz community member may create a channel (the relay makes the creator
its owner; a non-member's kind-9007 is rejected), so the section-label "+" now
shows only when the viewer is in the community's NIP-43 roster. Reactive: the
relay-signed roster snapshot (kind 13534, republished on every membership change)
can arrive after first composition, so BuzzCommunityMembership is re-read whenever
one lands. The section labels themselves still render for everyone; only the "+"
is gated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
2026-07-29 14:26:23 +00:00
Claude b4798331e8 fix: shrink badge gem ~20%, grow heart gem ~10%
Rebalances the two knockout icons: the badge gem drops from 11.6 to 9.3
(more disc margin around the mark) and the reaction heart gem grows from
8.2 to 9.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 14:23:12 +00:00
Claude 0703797bdb feat: bigger repost arrows; invert + maximize gem in badge
- Repost: scale the repeat-arrow glyph up ~15% so it fills the canvas like
  the original, with the centered gem enlarged to match.
- Badge: replace the medal ring + inner-circle-with-logo with a solid disc
  that has the Amethyst gem knocked straight out of it (inverted), sized to
  fill the disc. Ribbon tails kept.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 14:20:16 +00:00
Vitor Pamplona db529444c4 refactor(ci): move the Homebrew cask bump out of CI onto a maintainer machine
`brew bump-cask-pr` forks homebrew-cask into the token owner's account, which
requires a CLASSIC PAT with the `repo` scope. That scope cannot be narrowed: it
grants write to every repository the owning account can reach, including this
one. As an Actions secret it would be usable by anyone with push access here,
because a pushed branch containing a workflow runs with repo secrets — a strict
escalation for a channel that ships one DMG a month.

Split the work so the token never becomes a CI secret:

- CI (bump-homebrew.yml, now "Sync Homebrew Cask Reference") does the
  error-prone bookkeeping with GITHUB_TOKEN only: downloads the DMG, asserts it
  is notarized + stapled, computes the sha256, and opens an in-repo PR syncing
  desktopApp/packaging/homebrew/amethyst-nostr.rb. This makes it a third sibling
  of the amy/geode formula-sync workflows rather than a special case.
- scripts/bump-homebrew-cask.sh does the upstream PR from a maintainer's shell,
  re-verifying sha256 and the notarization ticket against the live asset first,
  and refusing to submit if either fails. Verified against v1.13.1: the sha256
  matches and it correctly refuses on the missing notarization ticket.

Drop HOMEBREW_TOKEN from the secret inventory, and rewrite the two formula
TODO(bootstrap) notes that proposed reintroducing it so a future homebrew-core
submission follows the same local pattern.
2026-07-29 10:19:57 -04:00
Claude f98859afa8 feat: enlarge the Amethyst gem in reaction, repost and code icons
Bumps the gem so the brand mark reads bigger inside each glyph: the heart
knockout (6.7 -> 8.2), the repost loop center (5.4 -> 6.9) and between the
code brackets (6.2 -> 7.6).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 14:16:15 +00:00
Claude 3e583fd425 fix: enlarge DM bubble to fill the canvas
The inverted DM bubble read smaller than the original because its body
stopped short and the tail was a small stub. Grow the bubble to the same
footprint as the original glyph, scale the knocked-out gem and the three
chat lines up to match, and give it a full corner tail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 14:13:15 +00:00
Claude a89aaed9fa feat(buzz): create channels/forums from section-label "+" instead of a FAB
On a Buzz community the FAB (which created a channel) is replaced by a "+" on the
Channels label — matching how Direct Messages already offers a "+" — and a Forums
label gains its own "+" that starts the create flow pre-set to a forum channel.
Both section headers now always render so the "+" is reachable even before any
channel loads; the collapse chevron is shown only when the section is non-empty.

The create route gains an isForum flag (threaded onto RelayGroupCreateScreen →
RelayGroupMetadataViewModel.isForum) so the Forums "+" opens the create screen on
a forum, the Channels "+" on a chat channel. The vanilla NIP-29 relay (a flat
directory with no sections) keeps its FAB. The stale "accept the invite in the
browser" empty text is dropped — an empty community is now a starting point.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
2026-07-29 14:10:11 +00:00
Claude 6035e5f0e7 fix: clean reaction icon knockout — drop stale inner-heart artifact
The inverted reaction icon carried a leftover inner-heart cutout from the
previous outlined design plus a duplicated gem subpath, so the gem rendered
as a solid island inside a heart-shaped hole instead of a clean knockout.
Regenerated as a solid heart with the Amethyst gem punched straight out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 14:08:08 +00:00
Claude 4d21165d7d fix: balance DM icon — gem matched to chat-line height, more gap
Shrinks the knocked-out Amethyst gem in the direct-message icon so its
height matches the three chat lines, and shifts it left to widen the gap
between the logo and the lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 14:05:22 +00:00
Vitor Pamplona 2e47e9cd56 docs: correct the HOMEBREW_TOKEN type and scope
BUILDING.md called for a fine-grained PAT scoped to Homebrew/homebrew-cask.
That token cannot exist: a fine-grained PAT's repository selector only lists
repos owned by the resource owner, so a repo you do not own can never be
selected, and Homebrew's API layer authorises against classic OAuth scopes
(x-oauth-scopes) which fine-grained tokens do not emit.

brew bump-cask-pr forks homebrew-cask into the TOKEN OWNER's account, pushes a
branch there, and opens the PR upstream. Homebrew declares the requirement as
CREATE_ISSUE_FORK_OR_PR_SCOPES = ["repo"] in utils/github.rb, so it is a classic
PAT with the `repo` scope.

Add the create-token link and call out that `repo` grants write to every repo
the account can reach -- including this one -- so the CI secret should belong to
a dedicated bot account whose only asset is the fork.
2026-07-29 10:04:25 -04:00
Vitor PamplonaandGitHub 09f2ec79e7 Merge pull request #3798 from davotoula/fix/code-review-followups
Address buzz CLI review findings; pin the wrapper copies and the amy arg surface
2026-07-29 10:03:11 -04:00
Claude ab5e4116ad feat: invert DM + reaction icons — knock the gem out of a solid shape
Per design feedback, the direct-message and reaction icons now invert:
instead of an outlined shape with a filled gem inside, the shape is solid
and the Amethyst gem is punched out of it (evenOdd knockout).

- Direct message: solid speech bubble; the gem is knocked out full-height
  on the left, with three chat lines knocked out on the right to read
  clearly as a conversation. The inner rectangle cutout is gone.
- Reaction: solid heart with the gem knocked out of its center.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 14:02:01 +00:00
Vitor Pamplona 8a8f58e7ad fix(release): actually notarize the macOS DMG, and verify it
The macOS leg ran only `packageReleaseDmg`, which SIGNS the DMG but does not
notarize it — notarization is a separate Compose task. The `notarization {}`
block in desktopApp/build.gradle.kts only supplies credentials; nothing invoked
it. So every release up to v1.13.1 shipped a signed but unnotarized DMG:
`xcrun stapler validate` reports no ticket on either the .app or the .dmg, and
Gatekeeper blocks it on first launch.

Append `:desktopApp:notarizeReleaseDmg` on the macOS leg. Compose 1.11.1's
AbstractNotarizationTask runs `notarytool submit --wait` and then `stapler
staple` in place, so the asset-collection step still finds the same file. Raise
that leg's inner timeout to 45m since the submit blocks on Apple.

Gate it on the cert AND all three notary secrets, so forks and credential-less
runs keep producing a plain unsigned DMG instead of failing.

Add a verify step that asserts the stapled ticket. The bug survived many
releases precisely because nothing ever checked the outcome.

Also update the reference cask to 1.13.1 and make it pass `brew audit --new
--cask` + `brew style` cleanly: add the missing conflicts_with (the tiling
window manager's cask installs the same Amethyst.app), add depends_on :macos,
fix stanza order, drop the unnecessary `verified:` (url and homepage share a
domain), and widen the zap to the state dirs the source actually uses.

Correct BUILDING.md's macOS state table, which listed a
com.vitorpamplona.amethyst.desktop.plist that does not exist and omitted
~/.amethyst. Note that Java's Preferences API writes to a SHARED
com.apple.java.util.prefs.plist, which is why the cask must not zap it.
2026-07-29 09:59:41 -04:00
Claude 0e48a8c85b feat: brand push-notification icons with the Amethyst gem
Every per-category status-bar icon now carries the Amethyst crystal so a
notification reads as an *Amethyst* reply/zap/mention at a glance, not a
generic Material glyph. Android renders small icons as a flat alpha-only
silhouette, so the gem is composed as a spatially distinct mark rather than
layered behind the glyph:

- Direct message, mention, repost, reaction, code and badge: gem sits in
  the natural cavity of the glyph (bubble, @ ring, repeat loop, heart,
  brackets, medal center).
- Media: gem is the sun rising over the hills.
- Article: gem is the drop-cap starting the first line.
- Chess: switched to the outlined knight from the app's icon set
  (MaterialSymbols.ChessKnight) with a small brand gem.
- Reply and zap: open glyphs that can't hold a centered gem keep a small
  gem corner mark.

Action buttons stay unbranded — a gem on a "Reply"/"Mark read" button is
noise, not identity — so those now use dedicated plain glyphs
(ic_action_reply, ic_action_mark_read).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
2026-07-29 13:58:10 +00:00
Vitor PamplonaandGitHub 37b3d650e0 Merge pull request #3803 from vitorpamplona/claude/feed-kind-toggles-home-4cjyvp
Add Pictures, Videos, Shorts, and Torrents feed types
2026-07-29 09:52:59 -04:00
Vitor PamplonaandGitHub b0c0f12591 Merge pull request #3801 from vitorpamplona/chore/bump-amy-formula-v1.13.1
chore: sync amy Homebrew formula to v1.13.1
2026-07-29 09:45:01 -04:00
Vitor PamplonaandGitHub a5d59a69c3 Merge pull request #3802 from vitorpamplona/chore/bump-geode-formula-v1.13.1
chore: sync geode Homebrew formula to v1.13.1
2026-07-29 09:44:51 -04:00
davotoulaandClaude Opus 5 592fd8f542 test: pin the amy argument surface the buzz-agent path depends on
Flag names and error strings are the contract between amy and the wrapper
scripts (and their operators), but nothing type-checks them: renaming --base-ref
or rewording an error breaks callers while compiling clean. This harness pins the
ones the buzz-agent path drives.

14 cases, all offline against an isolated ~/.amy via the `amy.home` seam the JVM
tests use: the shared `repo-naddr-or-coordinates` positional on git
browse/cat/log, `pass --channel GID` on the workflow verbs, --base-ref accepted
while --base-reff is rejected on both agent up and agent serve, and the
no_relays detail.

Confirmed to discriminate: 14/14 on this branch, 10/14 when built against
main-upstream's BuzzCommands, with the four failures printing the old misleading
"no relays: pass --relays ws://…" for input the user did pass.

Worth recording from writing it: a bare word like `garbage` is *accepted* as a
relay (the normalizer prepends wss://), so the realistic trigger for the empty
set is a pasted http:// url — which is what the cases use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WopdqNoZ9tYoMNppJG17BL
2026-07-29 15:42:48 +02:00
Vitor Pamplona a302b82413 fix(ci): replace the unresolvable homebrew-bump-cask action with brew itself
bump-homebrew.yml pinned macauley/action-homebrew-bump-cask to
ad984534de44a9489a53aefd81eb77f87c70dc60, commented "v4.0.0". That SHA does not
exist — the action's only release is v1 (445c4239) from 2021. GitHub resolves
every `uses:` during "Set up job", so the job died before running a single step,
which no `if:` gate can avoid. It stayed invisible because the workflow had
never been triggered; fixing the trigger surfaced it immediately.

Call `brew bump-cask-pr` directly instead. It is the same command BUILDING.md
already documents for manual recovery, and it removes an unmaintained
third-party action that would hold a PAT with write access to homebrew-cask.

Split the workflow into check (ubuntu) + bump (macOS): cask commands are
macOS-only, but the bump is gated on a cask that does not exist yet, so the
10x-billed macOS job is only created once there is something to bump.
2026-07-29 09:42:20 -04:00
davotoula 42bf2a43d8 fix: address code-review findings on the buzz CLI + wrapper scripts
Three findings from the review of the Sonar literal-extraction pass. None were
introduced by that pass — it renamed or sat next to each one.

no_relays told the wrong story. `relaysFor` returns an empty *non-null* set when
--relays was given but every entry failed `normalizeGroupRelay`, so the elvis to
`outboxRelays()` never fires and the user was told to pass the flag they had just
passed. The message now names the real problem and echoes the rejected value; the
`no_relays` error code is unchanged.

`participants` was string-munged. `arr.toString().trim('[',']').split(",")` turns
any shape other than a flat string array into plausible-looking fake pubkeys, and
splits a comma-bearing string into two entries. Now decoded as a JsonArray, with
non-string elements skipped rather than stringified. Extracted to
`participantsOf` so it is testable; BuzzParticipantsTest covers the shapes and
was confirmed to fail (3 of 5 cases) against the old implementation.

Nothing kept the two copies of the buzz-agent wrappers in sync. They exist as
byte-identical trees under cli/src/main/resources/buzz-agent (what `agent up`
extracts) and tools/buzz-agent (what the README tells people to use), with no
build step or test pinning them — I had to hand-apply the same patch twice this
week. BuzzAgentWrapperSyncTest now asserts it. The tools/ tree is declared as a
`:cli:test` input, without which Gradle calls the task up-to-date after a
tools/-only edit and misses exactly the drift being guarded (verified both ways).
2026-07-29 15:42:20 +02:00
Claude 3ab87dd48e fix: don't hijack vertical scroll with swipe-to-reply in chats
The chat bubble used detectHorizontalDragGestures, which claims the pointer
as soon as horizontal movement crosses touch-slop regardless of how much
vertical movement is happening. A mostly-vertical scroll with a little
diagonal drift got grabbed as a swipe, so the bubble slid sideways while the
user was just trying to scroll the conversation.

Replace it with a custom awaitEachGesture detector that only consumes the
pointer once the motion is clearly more horizontal than vertical
(abs(over.x) > abs(over.y)). A predominantly-vertical drag leaves the pointer
unconsumed, so the enclosing scroll keeps it and the bubble stays put. Once
committed to a horizontal drag, behavior (clamp, haptic tick at threshold,
release-past-threshold to reply, settle-back animation) is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016f7dTu8RoJRXjnHM7FKAE2
2026-07-29 13:39:24 +00:00
vitorpamplonaandgithub-actions[bot] 54b5ff8876 chore: sync geode Homebrew formula to v1.13.1 2026-07-29 13:39:22 +00:00
vitorpamplonaandgithub-actions[bot] 5a665950b8 chore: sync amy Homebrew formula to v1.13.1 2026-07-29 13:38:36 +00:00
Vitor Pamplona 054dffd55d fix(ci): make the Homebrew/Winget bump workflows actually fire
All four bump workflows triggered on `release: types: [released]`, which never
fires here: create-release.yml publishes the release with GITHUB_TOKEN, and
GitHub suppresses workflow-triggering events for GITHUB_TOKEN actions. They had
zero runs across every release up to v1.13.1.

Switch them to `workflow_run` on "Create Release Assets" completion, filtered to
a successful tag push. That also removes a latent race: `released` fired while
the matrix legs were still uploading assets, whereas workflow_run fires after
all of them finish.

The workflow_run payload carries no draft/prerelease flags, so add a
resolve-release composite action that reads them back from the API and feeds
assert-stable-release, keeping the defense-in-depth guard intact instead of
inferring stability from the tag string alone.

Also gate the cask/winget bumps on the package existing upstream. Neither
`amethyst-nostr` nor `VitorPamplona.Amethyst` has been bootstrapped, and
bump-cask-pr/winget-releaser can only update an existing package — without the
gate, fixing the trigger would file a spurious [release-ops] issue on every
release.

Docs: correct the claims this uncovered — Homebrew/Winget are not shipping,
macOS is arm64-only (no Intel DMG), the release carries 31 assets (13 Android,
not 12), Maven Central publishes from a step inside deploy-android and lags
repo1 by tens of minutes, RELEASE_NOTES_ID is minor-releases-only, and note the
git-credential-manager hang that blocks the release push.
2026-07-29 09:37:24 -04:00
Claude 9a5b36d0ba fix(buzz): honor the relay's channel_deleted signal so deleted channels leave the list
The first pass only recorded a deletion when THIS device published the 9008, so a
channel deleted on another Buzz client — or before the fix existed — kept showing
and survived restarts. The Buzz relay's handle_delete_group soft-deletes the
channel and its 39000/39001/39002 discovery events but emits NO member-removed
notification and never retracts the kind-44100 that seeds the browse list; its
only cross-device signal is the relay-signed kind-40099 system message with
type=channel_deleted (verified against block/buzz side_effects.rs).

Consume that signal: LocalCache records a relay-signed 40099 channel_deleted into
RelayGroupDeletions (gated on isRelaySignedGroupEvent so a spoofed one can't hide a
channel), and BuzzRelayImportViewModel.discover now also fetches the #h-scoped
40099 for its candidate channels and drops deleted ones — the relay keeps
re-announcing their 44100 with blank metadata, so this is the only place they
leave the list. Cross-device and retroactive: the relay replays the 40099 on
subscribe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
2026-07-29 13:25:37 +00:00
Claude b666a189ac feat(home): split Videos into long-form Videos and Shorts toggles
Separate the Home feed video toggle into two independent groups,
matching how the app already classifies them (see ShortsFeedFilter):

- Videos (long-form): normal (21) + horizontal (34235)
- Shorts: short (22) + vertical (34236)

Each is its own Settings › Home tile with distinct icon/string. The
relay assembler filters and New Threads DAL already list all four
kinds, so only the HomeFeedType grouping changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198T5VTjA1x2dCfBLhXZaiL
2026-07-29 13:22:12 +00:00
Vitor PamplonaandGitHub 7d26c06fe9 Merge pull request #3799 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-29 09:10:52 -04:00
Vitor PamplonaandGitHub 0ae90dbdc6 Merge pull request #3797 from davotoula/fix/sonar-case-default-clause
Harden workflow-ship.sh and pin it with a regression harness
2026-07-29 09:09:03 -04:00
vitorpamplonaandgithub-actions[bot] 8d877ea70d chore: sync Crowdin translations and seed translator npub placeholders 2026-07-29 12:33:41 +00:00
Vitor PamplonaandGitHub 7ba5631ec3 Merge pull request #3796 from davotoula/fix/hls-strip-ll-tags
Work around a fatal media3 crash on Low-Latency HLS (LL-HLS), eg NoGoodRadio
2026-07-29 08:30:39 -04:00
Vitor PamplonaandGitHub 1133560dd6 Merge pull request #3795 from nrobi144/feat/desktop-profile-parity
Desktop: profile screen parity — tabs, header actions, banner/bio, zaps
2026-07-29 08:30:02 -04:00
David KasparandGitHub 842a01ea2f Merge pull request #3794 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-29 14:28:47 +02:00
davotoula fcbd9d727b test: add a regression harness for workflow-ship.sh 2026-07-29 14:14:39 +02:00
davotoula 30c7dbc2f3 Code review:
1. `title="$(git log -1 … | cut …)"` aborts the whole script under
   `set -euo pipefail` when the worktree has no commits
2. `base_branch` only fell back to `main`
2026-07-29 14:14:28 +02:00
davotoula 4700e51d82 fix: add explicit default case to the workflow-ship branch guard 2026-07-29 14:13:51 +02:00
davotoulaandClaude Opus 5 c26988f399 Revert "TEMP: HlsVerify error-level probes for on-device verification"
This reverts commit 76b3323583. The probes did their job.

Verified on a Pixel 9a / Android 17 against beam.mapboss.co.th — a
third-party LL-HLS packager, unrelated to zap-stream-core, so this is not
tuned to one vendor's output:

  PARSE STRIPPED 2360B of 3426B  chunklist_0_video_..._llhls.m3u8
  PARSE STRIPPED 3002B of 4068B  chunklist_2_audio_..._llhls.m3u8

26 reloads of each rendition over 50s of continuous playback, ~70% of every
playlist removed, and zero DataSpec.subrange occurrences — the crash that
androidx/media#3350 tracks.

Both branches confirmed. Eight ordinary HLS streams logged "no LL tags" and
took the identity path untouched, so the stripper is inert on normal
playlists. Routing was correct on ~15 distinct real URLs:
`hls=true -> HlsMediaSource` for application/x-mpegURL, and
`hls=false -> ProgressiveMediaSource` for video/mp4 — which is the
isHlsMediaItem coverage that unit tests cannot provide.

The 18 ERROR states in the same capture were all unrelated: dead URLs
(403/404/502/504), four UnknownHostException from a network drop, and two
UnrecognizedInputFormatException from a youtu.be link on the progressive
path, which ExoPlayer has never been able to play directly.

The pure predicates keep their unit tests, which are the durable guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uE2Db4pzmhi5cFKKkyq2m
2026-07-29 13:56:15 +02:00
davotoulaandClaude Opus 5 76b3323583 TEMP: HlsVerify error-level probes for on-device verification
DO NOT MERGE — revert this commit before the branch goes anywhere.

PLAYBACK_DIAG_TAG logs at DEBUG and Amethyst.onCreate sets
`Log.minLevel = if (BuildConfig.DEBUG) DEBUG else ERROR`, so nothing on that
tag survives in the benchmark build. Three logcat captures during this work
had zero PlaybackDiag lines for exactly that reason, which left the routing
decision unverifiable — the fix was only ever confirmed indirectly, by the
crash no longer happening.

Adds one ERROR-level tag, HlsVerify, on the three paths unit tests cannot
reach:

  - CustomMediaSourceFactory.createMediaSource — the routing decision, so
    isHlsMediaItem's verdict and the chosen factory are visible. This is the
    predicate with no test coverage, since android.net.Uri stubs to null
    under unitTests.isReturnDefaultValues.
  - LowLatencyStrippingParser.parse — whether the stripper actually engaged
    and how many bytes it removed, so a silent stop-stripping regression is
    visible rather than inferred.
  - HlsLivenessRecorder.maybeRecord — the other consumer of the shared
    predicate, unreachable without a Player.

The pure predicates underneath are already unit-tested and remain the durable
regression guard; this is only for confirming the wiring on a device.

Capture with `adb logcat -s HlsVerify:E`. Remove with `grep -rn HlsVerify` or
by reverting this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uE2Db4pzmhi5cFKKkyq2m
2026-07-29 13:37:16 +02:00
davotoula cabe539e8e Code review:
- retire the `.m3u8` substring predicate across the UI
- cover the preload hint and the byte/charset layer
- share one HLS predicate with the liveness recorder
- unify the HLS predicate and track the media3 bug upstream
2026-07-29 13:30:36 +02:00
davotoula 6f5e0c6a6b fix(playback): strip Low-Latency HLS tags before media3 parses the playlist
media3 crashes fatally on an LL-HLS playlist whose parts are byte ranges —
what zap-stream-core emits (`#EXT-X-PART:...,BYTERANGE="359712@0"`).
Reproduced on media3 1.10.1, Pixel 9a / Android 17, ~0.6s after
ExoPlayerImpl.Init:

    IllegalArgumentException
      at DataSpec.<init>            // checkArgument(length > 0 || length == LENGTH_UNSET)
      at DataSpec.subrange
      at HlsMediaChunk.feedDataToExtractor
      at HlsMediaChunk.loadMedia
2026-07-29 13:30:36 +02:00
nrobi144andClaude Opus 4.8 c5ba33486a feat(desktop): format profile zap amounts compactly (210k, 1.5M)
Use commons showAmount() for the Zaps tab total and each zapper row instead of
raw sats, matching Amethyst Android and other Nostr clients.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 09:39:57 +03:00
nrobi144andClaude Opus 4.8 c204e5b485 fix(desktop): label the profile 'Mutual' tab 'Yours' to match Android
The filter (your own posts that mention this profile) matches Android's
UserProfileMutualFeedFilter, but Android's user-facing string is 'Yours'
(<string name="mutual">Yours</string>). The internal filter name stays
DesktopMutualFeedFilter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 09:39:56 +03:00
nrobi144andClaude Opus 4.8 1b70f0939d fix(desktop): group profile header actions + load Followers/Following metadata
1. Header actions were direct children of a SpaceBetween Row, so the overflow
   (⋮) menu floated to the center. Group Edit/Follow/⋮ in their own Row and put
   the overflow last (after the primary Follow action), per convention. Extract
   the Follow button into FollowButtonRegion for the reorder.
2. Followers/Following rows now subscribe to kind-0 metadata (batched, capped)
   and observe each user's metadata flow so names + avatars render instead of
   raw npubs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 09:39:56 +03:00
nrobi144andClaude Opus 4.8 dd6b779463 test(desktop): DesktopMutualFeedFilter unit tests + profile parity manual sheet
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 09:39:56 +03:00
nrobi144andClaude Opus 4.8 4ab73789a6 feat(desktop): profile Zaps received tab
- Wire real PrivateZapCache(signer) into DesktopIAccount (was a null stub)
- Subscribe to kind 9735 receipts p-tagging the profile; aggregate sats per
  zapper (private zaps decrypt only on your own profile, else fall back to the
  public zap-request pubkey)
- New ProfileZapRow; tab header shows the total sats

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 09:39:56 +03:00
nrobi144andClaude Opus 4.8 0ab1930123 feat(desktop): profile header actions — Message, Add-to-list, Share
- Message opens the Messages column + 1:1 room via a DesktopDmRoute bridge
  (observed by both deck and single-pane layouts)
- Add to list: pack picker from LocalFollowPacksState, appends via
  FollowListEvent.add + broadcast
- Share copies a nostr: profile link to the clipboard
- Actions live in the existing writeable/other-profile overflow menu, so
  self/read-only are already excluded

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 09:39:56 +03:00
nrobi144andClaude Opus 4.8 bc482cb6eb feat(desktop): five profile tabs — Followers, Following, Relays, Bookmarks, Mutual
- Scrollable tab row; Followers/Following render UserSearchCard from live
  contact-list subscriptions; Relays render a new RelayRowCard from kind 10002;
  Bookmarks (kind 10003 → DesktopBookmarkFeedFilter) and Mutual (new
  DesktopMutualFeedFilter) render FeedNoteCard
- Respects the shared mute/block hidden-set via DesktopFeedViewModel

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 09:39:56 +03:00
nrobi144andClaude Opus 4.8 e862b3ecac feat(desktop): profile header polish — banner, rich-text bio, CLINK offer field
- Render kind-0 banner image in the profile card header
- Render bio as rich text (mentions/hashtags/links) via DesktopRichText
- Add CLINK offer (noffer) field to Edit Profile + shared EditProfileFields

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 09:39:55 +03:00
nrobi144andClaude Opus 4.8 b2b5bccec1 docs: desktop profile parity plan + per-phase sub-plans
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 09:39:55 +03:00
Claude 646459e367 fix(buzz): remove a deleted channel from the community channel list
Deleting a channel (kind-9008) published the delete and dropped it from the
user's kind-10009 list, but the community's browse list is built from the
cached kind-39000 metadata and the Buzz membership (kind-44100) set — neither
of which the delete touched — so the channel lingered in the list, and a stale
44100 re-announcement could bring it back after a restart.

Track deleted relay-group channels in a device-global RelayGroupDeletions
registry (keyed by GroupId.toKey, so it stays relay-scoped), persist it via
RelayGroupDeletionPreferences, mark the channel on deleteRelayGroup, and filter
deleted keys out of both the directory channels and the Buzz membership list in
RelayGroupChannelListScreen. The delete now removes the row live and it stays
gone across restarts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
2026-07-29 05:44:28 +00:00
Claude f46534a219 feat(home): add Pictures, Videos and Torrents to Home content-type toggles
Extend the per-content-type Home feed toggles to the remaining root
feed (non-chat) kinds that render as cards but weren't yet part of the
Home feed:

- Pictures (NIP-68, kind 20)
- Videos (NIP-71: normal 21, short 22, horizontal 34235, vertical 34236)
- Torrents (NIP-35, kind 2003)

Each gets a HomeFeedType group (kinds stay disjoint), is added to the
always-on home relay assembler filters, and is accepted by the New
Threads DAL (which also feeds the Everything tab). The addressable
video kinds are registered in the DAL's addressable-kind scan. Wired
into Settings › Home with icons/strings; toggling still drops the kinds
from both the relay REQ and the rendered feed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198T5VTjA1x2dCfBLhXZaiL
2026-07-29 05:33:13 +00:00
vitorpamplonaandgithub-actions[bot] b575f9078d chore: sync Crowdin translations and seed translator npub placeholders 2026-07-29 03:56:46 +00:00
Vitor PamplonaandGitHub 52a8cc338a Merge pull request #3793 from vitorpamplona/claude/highlight-events-browser-ywz5wr
Add NIP-84 highlights support: composer, feed, and share intent
2026-07-28 23:53:41 -04:00
Claude 7927e1de1b feat(highlights): rich comment field + source-event preview in composer
Address composer feedback:
- The annotation field is now the short-note composer's rich MessageField
  (@-mention + custom-emoji autocomplete, inline previews). On publish it becomes
  the highlight's `comment` tag, with the mentions (`p`), emoji, URLs (`r`),
  hashtags and quotes it references emitted as their own tags — via
  NewMessageTagger + a new tag-builder initializer on HighlightEvent.build().
- A nostr source now renders as a reply-style preview card (NoteCompose, quoted
  style) instead of showing a URL field; the URL field appears only for web
  sources. The source note is resolved from the a/e coordinate carried in the
  route.

Verified with :quartz:jvmTest and :amethyst:compileFdroidDebugKotlin.
2026-07-29 03:38:29 +00:00
Claude 55cc4ce9dc feat(highlights): highlight-a-note menu action; drop composer preview
- Add a "Highlight" action to the note action menu (3-dot menu + chat long-press
  sheet). It opens the NIP-84 composer pre-tagged with the note as source — an
  `a` reference for an addressable article, else an `e` reference, plus the
  author `p` — with the passage left for the user to type/paste. Prose kinds
  only (text notes, long-form), and never a private rumor (a public highlight
  would leak an e-tag of the unsigned rumor). This is the in-app entry point for
  "post with the source as an event".
- Remove the live preview from the New Highlight screen per review.
- Add two real-world regression tests for the shared-highlight parser: Chrome
  "copy link to highlight" with a prefix/suffix fragment (incl. an encoded
  hyphen inside the prefix) and a long start-only fragment with encoded commas.

Verified with :quartz:jvmTest and :amethyst:compileFdroidDebugKotlin.
2026-07-29 02:50:53 +00:00
Claude 155ef8e46c feat(highlights): richer composer UI and full NIP-84 source coverage
Redesign the New Highlight composer as a pull-quote you craft: a rounded tonal
hero card with a quotation-mark watermark, a highlighter-amber accent bar, and
the passage in large type, plus a live highlighter-pen preview (reusing the feed's
HighlightedQuote) and icon-led source/note fields.

Also extend HighlightEvent.create()/build() to emit a/e/p tags, so the builder
now covers every NIP-84 source — a web page (r), a nostr article (a), a nostr
note (e), each with optional author attribution (p) and context — not just web
highlights. Route.NewHighlight and the composer ViewModel carry the nostr source
(address/event/author) and context through so a future in-app "highlight this
passage" action can open the composer for a nostr article/note.

Covered by a new builder test for the a/e/p tags; verified with :quartz:jvmTest
and :amethyst:compileFdroidDebugKotlin.
2026-07-29 02:27:49 +00:00
Vitor PamplonaandClaude 9189955155 fix(quartz): drop the paren the URL trim orphans in the passage
`trimUrlEnd` strips a wrapping `)` off the URL token, but the `(` it opened
stayed at the tail of the passage, so sharing
`See this quote (https://example.com/article)` published a highlight whose
content read `See this quote (`.

Trim an unbalanced bracket left at the end of the passage after the URL token
is removed. Only an unbalanced one at the very end goes, so `He said (see
below) https://…` keeps its matched pair, and `.../Mercury_(planet)` — where
nothing was trimmed off the URL — is untouched on both sides.

The existing paren tests only asserted `result.url`, which is why this slipped
through; the new cases assert `result.quote` too, including a regression guard
for the slug-paren direction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 02:22:07 +00:00
Claude 5b07817cf8 feat(android): Highlights feed with top-nav filters and add button
Adds a browsable Highlights feed (NIP-84 kind:9802) modeled file-for-file on
the Git Repositories feed pattern, since highlights are announcement-like
regular events with the same follow-list top-nav filtering.

New feed stack under ui/screen/loggedIn/highlights:
- dal/HighlightsFeedFilter — AdditiveFeedFilter scanning LocalCache.notes for
  kind 9802 (regular events, so notes rather than addressables), gated by the
  selected top-nav follow list via FilterByListParams.
- datasource/ assembler + compose subscription + PerUserAndFollowList sub-
  assembler + makeHighlightsFilter dispatcher, with per-relay subassemblies for
  follows / authors / muted / global / hashtag / geohash. Communities aren't a
  highlight concept, so those sets fall through and aren't offered.
- HighlightsScreen (DisappearingScaffold + shared RenderFeedContentState; the
  existing RenderHighlight card handles rendering) with a HighlightsTopBar
  FeedFilterSpinner and a NewHighlightButton FAB into Route.NewHighlight.

Wiring into the shared plumbing mirrors gitRepositories exactly: Route.Highlights
+ AppNavigation, NavBarItem enum/catalog/drawer lists (FormatQuote icon, already
in the subset font), AccountFeedContentStates (feed state + update/delete/trim),
RelaySubscriptionsCoordinator, TopNavFilterState.highlightsRoutes,
AccountSettings.defaultHighlightsFollowList + changer, Account live follow-list
flows, LocalPreferences persistence, ScrollStateKeys, and the bottom-bar
preloader. Verified with :amethyst:compileFdroidDebugKotlin.
2026-07-29 02:22:07 +00:00
Claude 927cc9449c fix(quartz): keep balanced trailing parens in shared highlight URLs
The URL trailing-punctuation trim now leaves a closing bracket in place when
it is balanced within the token, so a shared Wikipedia link like
`.../wiki/Mercury_(planet)` keeps its `)` while a wrapping `(https://…)` still
loses the paren the surrounding prose added.
2026-07-29 02:22:06 +00:00
Claude 1f35339866 feat(android): share browser selection to a NIP-84 highlight composer
Adds a "New Highlight" share target and composer so a user can select text in
a browser, hit Share → Amethyst, and publish it as a kind:9802 highlight.

- Manifest: a text/plain-only ShareAsHighlightAlias activity-alias, matched at
  runtime by ShareIntentRouting.isShareAsHighlight (mirrors the Send-as-DM
  alias pattern).
- AppNavigation: both the launch-intent and warm onNewIntent parse blocks now
  branch on the highlight alias, run the shared text through
  SharedHighlightParser, and open Route.NewHighlight pre-split into
  quote/url/prefix/suffix.
- NewHighlightScreen + NewHighlightPostViewModel: a trimmed composer (passage,
  source URL, optional note — no polls/zaps/media/scheduling) that publishes via
  account.signAndComputeBroadcast(HighlightEvent.build(...)).

Also adds HighlightEvent.build(), the unsigned EventTemplate counterpart of
create(), for the sign-and-broadcast pipeline. Verified with :quartz:jvmTest
and :amethyst:compileFdroidDebugKotlin.
2026-07-29 02:22:06 +00:00
Claude 69c3e3accc feat(quartz): parse browser shares into NIP-84 highlights
Adds a shared-highlight parsing layer under nip84Highlights/parse that turns
the free-form text a browser hands Amethyst on "Share selection" into the
pieces of a kind:9802 highlight:

- SharedHighlightParser normalises selection-only, selection+URL, URL-only and
  "link to highlight" (#:~:text= fragment) shares into a SharedHighlight.
- TextFragmentParser decodes/strips WICG text-fragment directives (prefix,
  start, end, suffix), leaving literal '+' verbatim.
- UrlTrackerCleaner strips utm_*/fbclid/etc. from the source URL per NIP-84's
  "clean the URL from trackers" guidance, preserving path and fragment.

Also adds a HighlightEvent.create() builder overload that assembles the r,
textquoteselector, context and comment tags from parsed data, centralising
what the desktop publish action does by hand.

Covered by commonTest suites for each piece plus a builder round-trip.
2026-07-29 02:22:06 +00:00
205 changed files with 15292 additions and 1283 deletions
@@ -0,0 +1,73 @@
name: Resolve Release
description: >-
Resolve a bump workflow's target tag and that release's real published state.
Companion to assert-stable-release: this one FETCHES the facts, that one
ENFORCES them. Split so the enforcement stays a pure function of its inputs.
Handles both entry points of the bump workflows:
- workflow_run -> tag comes from the triggering run's head_branch
- workflow_dispatch (manual recovery) -> tag comes from the input
In both cases draft/prerelease are read back from the GitHub API rather than
inferred, so a draft or prerelease can never slip through to a third-party
package repo just because the trigger payload lacked the flags.
inputs:
tag:
description: "Release tag to resolve (e.g. vX.Y.Z)"
required: true
github_token:
description: "Token used to read the release via the GH API"
required: true
outputs:
tag:
description: "The resolved tag, verbatim (e.g. v1.13.1)"
value: ${{ steps.resolve.outputs.tag }}
ver:
description: "The tag with the leading 'v' stripped (e.g. 1.13.1)"
value: ${{ steps.resolve.outputs.ver }}
is_prerelease:
description: "'true' if the GH Release is flagged prerelease"
value: ${{ steps.resolve.outputs.is_prerelease }}
is_draft:
description: "'true' if the GH Release is still a draft"
value: ${{ steps.resolve.outputs.is_draft }}
runs:
using: composite
steps:
- name: Resolve tag and release state
id: resolve
shell: bash
env:
TAG: ${{ inputs.tag }}
GH_TOKEN: ${{ inputs.github_token }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
if [[ -z "$TAG" ]]; then
echo "::error::No tag to resolve (neither workflow_run.head_branch nor the dispatch input was set)"
exit 1
fi
# A missing release here is a real fault, not something to paper over:
# every caller is about to publish this version to an external package
# manager. Fail loudly and let the caller's Report-failure step file it.
if ! META=$(gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease 2>&1); then
echo "::error::No GH Release found for tag $TAG in $REPO -- refusing to bump"
echo "$META"
exit 1
fi
IS_DRAFT=$(echo "$META" | jq -r '.isDraft')
IS_PRERELEASE=$(echo "$META" | jq -r '.isPrerelease')
{
echo "tag=$TAG"
echo "ver=${TAG#v}"
echo "is_draft=$IS_DRAFT"
echo "is_prerelease=$IS_PRERELEASE"
} >> "$GITHUB_OUTPUT"
echo "resolved tag=$TAG ver=${TAG#v} draft=$IS_DRAFT prerelease=$IS_PRERELEASE"
+43 -31
View File
@@ -19,9 +19,14 @@ name: Bump Homebrew Formula (amy CLI)
# auto-bump here (symmetric to the cask action in bump-homebrew.yml) — see the
# "TODO(bootstrap)" note at the bottom of this file.
# Trigger: after "Create Release Assets" succeeds for a tag push. NOT
# `release: types: [released]` — that event never fires, because the release is
# created by create-release.yml under GITHUB_TOKEN and GitHub suppresses
# workflow-triggering events for it. See the full note in bump-homebrew.yml.
on:
release:
types: [released]
workflow_run:
workflows: ["Create Release Assets"]
types: [completed]
workflow_dispatch:
inputs:
tag:
@@ -38,44 +43,49 @@ permissions:
concurrency:
# Serialize per tag; do not cancel in-progress runs.
group: bump-homebrew-formula-${{ github.event.release.tag_name || inputs.tag }}
group: bump-homebrew-formula-${{ github.event.workflow_run.head_branch || inputs.tag }}
cancel-in-progress: false
jobs:
sync-formula:
if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false
# See bump-homebrew.yml for why these three conditions: successful, tag-push
# (not a dry-run dispatch), v-prefixed. Exact format enforced downstream.
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
startsWith(github.event.workflow_run.head_branch, 'v'))
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Resolve release
id: rel
uses: ./.github/actions/resolve-release
with:
tag: ${{ github.event.workflow_run.head_branch || inputs.tag }}
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Re-assert stable release
uses: ./.github/actions/assert-stable-release
with:
tag: ${{ github.event.release.tag_name || inputs.tag }}
is_prerelease: ${{ github.event.release.prerelease || 'false' }}
is_draft: ${{ github.event.release.draft || 'false' }}
- name: Resolve version
id: ver
run: |
set -euo pipefail
TAG="${{ github.event.release.tag_name || inputs.tag }}"
VER="${TAG#v}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "ver=$VER" >> "$GITHUB_OUTPUT"
tag: ${{ steps.rel.outputs.tag }}
is_prerelease: ${{ steps.rel.outputs.is_prerelease }}
is_draft: ${{ steps.rel.outputs.is_draft }}
- name: Download jvm bundle and compute sha256
id: asset
run: |
set -euo pipefail
TAG="${{ steps.ver.outputs.tag }}"
VER="${{ steps.ver.outputs.ver }}"
TAG="${{ steps.rel.outputs.tag }}"
VER="${{ steps.rel.outputs.ver }}"
URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/amy-${VER}-jvm.tar.gz"
echo "Fetching $URL"
# The `released` event can fire a hair before every matrix leg finishes
# uploading; retry with backoff (mirrors the repo's push/pull retry ethos).
# workflow_run fires only after every upload leg has finished, so the
# asset should already be there. Retry anyway for release-CDN
# propagation (mirrors the repo's push/pull retry ethos).
ok=0
for i in 1 2 3 4 5; do
if curl -fsSL -o amy-jvm.tar.gz "$URL"; then ok=1; break; fi
@@ -110,13 +120,13 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
base: main
branch: chore/bump-amy-formula-${{ steps.ver.outputs.tag }}
branch: chore/bump-amy-formula-${{ steps.rel.outputs.tag }}
add-paths: cli/packaging/homebrew/amy.rb
commit-message: 'chore: sync amy Homebrew formula to ${{ steps.ver.outputs.tag }}'
title: 'chore: sync amy Homebrew formula to ${{ steps.ver.outputs.tag }}'
commit-message: 'chore: sync amy Homebrew formula to ${{ steps.rel.outputs.tag }}'
title: 'chore: sync amy Homebrew formula to ${{ steps.rel.outputs.tag }}'
body: |
Auto-synced `cli/packaging/homebrew/amy.rb` to the
`${{ steps.ver.outputs.tag }}` release:
`${{ steps.rel.outputs.tag }}` release:
- `url` -> `${{ steps.asset.outputs.url }}`
- `sha256` -> `${{ steps.asset.outputs.sha256 }}`
@@ -124,19 +134,21 @@ jobs:
Opened by `.github/workflows/bump-homebrew-formula.yml`. Merge to keep
the reference formula ready for the homebrew-core submission/bump.
# TODO(bootstrap): once `amy` is accepted into Homebrew/homebrew-core, add a
# step here that opens the homebrew-core bump PR automatically — symmetric to
# the cask bump in bump-homebrew.yml (a pinned macauley/action-homebrew-bump-
# formula, or `brew bump-formula-pr amy --url=<url> --sha256=<sha>` with a
# HOMEBREW_TOKEN). It is intentionally omitted until then because
# bump-formula-pr errors on a formula that is not yet in the tap.
# TODO(bootstrap): once `amy` is accepted into Homebrew/homebrew-core, add
# a LOCAL script mirroring scripts/bump-homebrew-cask.sh that runs `brew
# bump-formula-pr amy --url=<url> --sha256=<sha>` from a maintainer's
# machine. Do NOT wire that into CI: bump-formula-pr forks homebrew-core
# into the token owner's account, which needs a classic `repo`-scoped PAT,
# and that scope grants write to every repo the account can reach —
# including this one — to anyone with push access here. See
# BUILDING.md § Homebrew cask for the full reasoning.
- name: Report failure
if: failure()
uses: actions/github-script@v9
with:
script: |
const tag = context.payload.release?.tag_name || context.payload.inputs?.tag || 'unknown';
const tag = context.payload.workflow_run?.head_branch || context.payload.inputs?.tag || 'unknown';
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
await github.rest.issues.create({
owner: context.repo.owner,
@@ -17,9 +17,14 @@ name: Bump Homebrew Formula (geode relay)
# human-reviewed new-formula PR (the one-time bootstrap). Once it lands, wire the
# auto-bump here — see the "TODO(bootstrap)" note at the bottom of this file.
# Trigger: after "Create Release Assets" succeeds for a tag push. NOT
# `release: types: [released]` — that event never fires, because the release is
# created by create-release.yml under GITHUB_TOKEN and GitHub suppresses
# workflow-triggering events for it. See the full note in bump-homebrew.yml.
on:
release:
types: [released]
workflow_run:
workflows: ["Create Release Assets"]
types: [completed]
workflow_dispatch:
inputs:
tag:
@@ -36,44 +41,49 @@ permissions:
concurrency:
# Serialize per tag; do not cancel in-progress runs.
group: bump-homebrew-geode-formula-${{ github.event.release.tag_name || inputs.tag }}
group: bump-homebrew-geode-formula-${{ github.event.workflow_run.head_branch || inputs.tag }}
cancel-in-progress: false
jobs:
sync-formula:
if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false
# See bump-homebrew.yml for why these three conditions: successful, tag-push
# (not a dry-run dispatch), v-prefixed. Exact format enforced downstream.
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
startsWith(github.event.workflow_run.head_branch, 'v'))
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Resolve release
id: rel
uses: ./.github/actions/resolve-release
with:
tag: ${{ github.event.workflow_run.head_branch || inputs.tag }}
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Re-assert stable release
uses: ./.github/actions/assert-stable-release
with:
tag: ${{ github.event.release.tag_name || inputs.tag }}
is_prerelease: ${{ github.event.release.prerelease || 'false' }}
is_draft: ${{ github.event.release.draft || 'false' }}
- name: Resolve version
id: ver
run: |
set -euo pipefail
TAG="${{ github.event.release.tag_name || inputs.tag }}"
VER="${TAG#v}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "ver=$VER" >> "$GITHUB_OUTPUT"
tag: ${{ steps.rel.outputs.tag }}
is_prerelease: ${{ steps.rel.outputs.is_prerelease }}
is_draft: ${{ steps.rel.outputs.is_draft }}
- name: Download jvm bundle and compute sha256
id: asset
run: |
set -euo pipefail
TAG="${{ steps.ver.outputs.tag }}"
VER="${{ steps.ver.outputs.ver }}"
TAG="${{ steps.rel.outputs.tag }}"
VER="${{ steps.rel.outputs.ver }}"
URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/geode-${VER}-jvm.tar.gz"
echo "Fetching $URL"
# The `released` event can fire a hair before every matrix leg finishes
# uploading; retry with backoff (mirrors the repo's push/pull retry ethos).
# workflow_run fires only after every upload leg has finished, so the
# asset should already be there. Retry anyway for release-CDN
# propagation (mirrors the repo's push/pull retry ethos).
ok=0
for i in 1 2 3 4 5; do
if curl -fsSL -o geode-jvm.tar.gz "$URL"; then ok=1; break; fi
@@ -108,13 +118,13 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
base: main
branch: chore/bump-geode-formula-${{ steps.ver.outputs.tag }}
branch: chore/bump-geode-formula-${{ steps.rel.outputs.tag }}
add-paths: geode/packaging/homebrew/geode.rb
commit-message: 'chore: sync geode Homebrew formula to ${{ steps.ver.outputs.tag }}'
title: 'chore: sync geode Homebrew formula to ${{ steps.ver.outputs.tag }}'
commit-message: 'chore: sync geode Homebrew formula to ${{ steps.rel.outputs.tag }}'
title: 'chore: sync geode Homebrew formula to ${{ steps.rel.outputs.tag }}'
body: |
Auto-synced `geode/packaging/homebrew/geode.rb` to the
`${{ steps.ver.outputs.tag }}` release:
`${{ steps.rel.outputs.tag }}` release:
- `url` -> `${{ steps.asset.outputs.url }}`
- `sha256` -> `${{ steps.asset.outputs.sha256 }}`
@@ -122,19 +132,19 @@ jobs:
Opened by `.github/workflows/bump-homebrew-geode-formula.yml`. Merge to
keep the reference formula ready for the homebrew-core submission/bump.
# TODO(bootstrap): once `geode` is accepted into Homebrew/homebrew-core, add
# a step here that opens the homebrew-core bump PR automatically (a pinned
# macauley/action-homebrew-bump-formula, or `brew bump-formula-pr geode
# --url=<url> --sha256=<sha>` with a HOMEBREW_TOKEN). It is intentionally
# omitted until then because bump-formula-pr errors on a formula that is not
# yet in the tap.
# TODO(bootstrap): once `geode` is accepted into Homebrew/homebrew-core,
# add a LOCAL script mirroring scripts/bump-homebrew-cask.sh that runs
# `brew bump-formula-pr geode --url=<url> --sha256=<sha>` from a
# maintainer's machine. Do NOT wire that into CI — it needs a classic
# `repo`-scoped PAT, which as a CI secret would hand write access to this
# repo to anyone with push access. See BUILDING.md § Homebrew cask.
- name: Report failure
if: failure()
uses: actions/github-script@v9
with:
script: |
const tag = context.payload.release?.tag_name || context.payload.inputs?.tag || 'unknown';
const tag = context.payload.workflow_run?.head_branch || context.payload.inputs?.tag || 'unknown';
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
await github.rest.issues.create({
owner: context.repo.owner,
+138 -32
View File
@@ -1,75 +1,181 @@
name: Bump Homebrew Cask
name: Sync Homebrew Cask Reference
# Fires when a GH Release is published (not draft, not prerelease).
# `release.types: [released]` event fires only for stable releases — still
# double-checked by .github/actions/assert-stable-release for defense-in-depth.
# Sibling of bump-homebrew-formula.yml (amy) and bump-homebrew-geode-formula.yml
# (geode). Same mechanism, third artifact:
# - this workflow -> Cask `amethyst-nostr` (the desktop GUI app / DMG)
#
# What it does: after a stable release, download the published macOS DMG, assert
# it is notarized + stapled, compute its sha256, and open a PR syncing
# `desktopApp/packaging/homebrew/amethyst-nostr.rb` to that release.
#
# What it does NOT do: open a PR against Homebrew/homebrew-cask. That step is
# deliberately MANUAL and runs on a maintainer's machine —
# `scripts/bump-homebrew-cask.sh`. Reason: `brew bump-cask-pr` forks
# homebrew-cask into the token owner's account, which requires a CLASSIC PAT
# with the `repo` scope; that scope grants write to every repository the account
# can reach, and stored as a CI secret it would be usable by anyone with push
# access to this repo. Keeping it in a maintainer's shell instead of a CI secret
# removes that blast radius entirely, at the cost of one command per release.
# See BUILDING.md § Homebrew cask.
#
# Consequence: this workflow needs NO external token. GITHUB_TOKEN is enough,
# exactly like the two formula workflows.
#
# Trigger: after "Create Release Assets" succeeds for a tag push. NOT
# `release: types: [released]` — that event never fires, because the release is
# created by create-release.yml under GITHUB_TOKEN and GitHub suppresses
# workflow-triggering events for it. See the note in bump-homebrew-formula.yml.
on:
release:
types: [released]
workflow_run:
workflows: ["Create Release Assets"]
types: [completed]
workflow_dispatch:
inputs:
tag:
description: 'Release tag to bump (for manual recovery)'
description: 'Release tag to sync (for manual recovery)'
required: true
type: string
permissions:
contents: read
# The "Report failure" step below opens a [release-ops] issue via
# github.rest.issues.create; that needs issues:write. Without it the failure
# reporter itself 403s and no alert is ever filed.
contents: write
pull-requests: write
# The "Report failure" step opens a [release-ops] issue via
# github.rest.issues.create, which needs issues:write.
issues: write
concurrency:
# Serialize bumps per tag; do not cancel in-progress bumps.
group: bump-homebrew-${{ github.event.release.tag_name || inputs.tag }}
# Serialize per tag; do not cancel in-progress runs.
group: bump-homebrew-${{ github.event.workflow_run.head_branch || inputs.tag }}
cancel-in-progress: false
jobs:
bump:
if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false
runs-on: ubuntu-latest # brew runs on Linux — saves macOS runner quota
timeout-minutes: 30
sync-cask:
# See bump-homebrew-formula.yml for why these three conditions: successful,
# tag-push (not a dry-run dispatch), v-prefixed. Format enforced downstream.
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
startsWith(github.event.workflow_run.head_branch, 'v'))
# macOS runner: `xcrun stapler` is the only way to verify the notarization
# ticket, and shipping an unnotarized DMG to the cask is the failure mode
# this whole channel is most exposed to.
runs-on: macos-latest
timeout-minutes: 20
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Resolve release
id: rel
uses: ./.github/actions/resolve-release
with:
tag: ${{ github.event.workflow_run.head_branch || inputs.tag }}
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Re-assert stable release
uses: ./.github/actions/assert-stable-release
with:
tag: ${{ github.event.release.tag_name || inputs.tag }}
is_prerelease: ${{ github.event.release.prerelease || 'false' }}
is_draft: ${{ github.event.release.draft || 'false' }}
tag: ${{ steps.rel.outputs.tag }}
is_prerelease: ${{ steps.rel.outputs.is_prerelease }}
is_draft: ${{ steps.rel.outputs.is_draft }}
- name: Bump cask (push-or-update PR)
uses: macauley/action-homebrew-bump-cask@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
- name: Download DMG, verify notarization, compute sha256
id: asset
run: |
set -euo pipefail
TAG="${{ steps.rel.outputs.tag }}"
VER="${{ steps.rel.outputs.ver }}"
URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/amethyst-desktop-${VER}-macos-arm64.dmg"
echo "Fetching $URL"
# workflow_run fires only after every upload leg has finished, so the
# asset should already be there. Retry anyway for release-CDN
# propagation (mirrors the repo's push/pull retry ethos).
ok=0
for i in 1 2 3 4 5; do
if curl -fsSL -o amethyst.dmg "$URL"; then ok=1; break; fi
wait=$(( 2 ** i ))
echo "attempt $i failed; retrying in ${wait}s"
sleep "$wait"
done
[[ "$ok" == 1 ]] || { echo "::error::could not download $URL"; exit 1; }
test -s amethyst.dmg
# A cask must point at a notarized+stapled DMG or every user hits a
# Gatekeeper block. Refuse to advertise one that is not.
if ! xcrun stapler validate amethyst.dmg; then
echo "::error::${TAG} DMG has no stapled notarization ticket -- refusing to sync the cask. Check the notarizeReleaseDmg step in create-release.yml."
exit 1
fi
SHA=$(shasum -a 256 amethyst.dmg | awk '{print $1}')
echo "sha256=$SHA" >> "$GITHUB_OUTPUT"
echo "amethyst-desktop-${VER}-macos-arm64.dmg -> $SHA"
- name: Update reference cask
run: |
set -euo pipefail
CASK=desktopApp/packaging/homebrew/amethyst-nostr.rb
VER="${{ steps.rel.outputs.ver }}"
SHA="${{ steps.asset.outputs.sha256 }}"
# Anchor on the 2-space indent so the header comment's example lines
# are never touched.
sed -i '' -E "s|^( version ).*|\1\"${VER}\"|" "$CASK"
sed -i '' -E "s|^( sha256 ).*|\1\"${SHA}\"|" "$CASK"
echo "----- $CASK -----"
grep -E "^ (version|sha256) " "$CASK"
- name: Open or update the cask-sync PR
# peter-evans/create-pull-request is MIT-licensed CI-only tooling (not
# linked into any shipped artifact). It no-ops when there is no diff.
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.HOMEBREW_TOKEN }}
tap: homebrew/cask
cask: amethyst-nostr
tag: ${{ github.event.release.tag_name || inputs.tag }}
token: ${{ secrets.GITHUB_TOKEN }}
base: main
branch: chore/bump-amethyst-cask-${{ steps.rel.outputs.tag }}
add-paths: desktopApp/packaging/homebrew/amethyst-nostr.rb
commit-message: 'chore: sync amethyst-nostr cask to ${{ steps.rel.outputs.tag }}'
title: 'chore: sync amethyst-nostr cask to ${{ steps.rel.outputs.tag }}'
body: |
Auto-synced `desktopApp/packaging/homebrew/amethyst-nostr.rb` to the
`${{ steps.rel.outputs.tag }}` release:
- `version` -> `${{ steps.rel.outputs.ver }}`
- `sha256` -> `${{ steps.asset.outputs.sha256 }}`
The DMG was verified notarized + stapled before this PR was opened.
**Merge this, then push it upstream from a maintainer machine:**
```bash
scripts/bump-homebrew-cask.sh ${{ steps.rel.outputs.tag }}
```
That step is manual on purpose — it needs a classic PAT with the
`repo` scope, which is deliberately NOT stored as a CI secret. See
BUILDING.md § Homebrew cask.
- name: Report failure
if: failure()
uses: actions/github-script@v9
with:
script: |
const tag = context.payload.release?.tag_name || context.payload.inputs?.tag || 'unknown';
const tag = context.payload.workflow_run?.head_branch || context.payload.inputs?.tag || 'unknown';
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `[release-ops] bump-homebrew failed for ${tag}`,
title: `[release-ops] sync-homebrew-cask failed for ${tag}`,
body: [
`Homebrew cask bump failed for release \`${tag}\`.`,
`amethyst-nostr cask sync failed for release \`${tag}\`.`,
``,
`- Run: ${runUrl}`,
`- Channel: Homebrew Cask (\`amethyst-nostr\`)`,
``,
`Recovery options:`,
`1. Re-run the workflow once underlying issue is fixed`,
`2. Manually run \`brew bump-cask-pr amethyst-nostr --version ${tag.replace(/^v/, '')}\``,
`3. File PR directly against Homebrew/homebrew-cask`
`1. Re-run the workflow once the underlying issue is fixed`,
`2. Check the DMG is notarized: \`xcrun stapler validate\` on the release asset`,
`3. Manually update \`desktopApp/packaging/homebrew/amethyst-nostr.rb\` (version + sha256)`
].join('\n'),
labels: ['release-ops', 'bug']
});
+171 -29
View File
@@ -1,72 +1,214 @@
name: Bump Winget Manifest
name: Sync Winget Manifest Reference
# Fourth sibling of the three Homebrew sync workflows, same shape:
# bump-homebrew-formula.yml -> Formula `amy`
# bump-homebrew-geode-formula.yml -> Formula `geode`
# bump-homebrew.yml -> Cask `amethyst-nostr`
# this workflow -> Winget `VitorPamplona.Amethyst`
#
# What it does: after a stable release, download the published Windows MSI,
# compute its sha256, read its ProductCode, and open a PR syncing
# desktopApp/packaging/winget/*.yaml to that release.
#
# What it does NOT do: open a PR against microsoft/winget-pkgs. That step is
# deliberately MANUAL and runs on a maintainer's machine —
# `scripts/bump-winget.sh`. Reason: submitting requires push access to a fork of
# winget-pkgs. The previous design stored a classic `public_repo` PAT as
# WINGET_TOKEN and handed it to a third-party action; that scope grants write to
# every public repo the account can reach, and as an Actions secret it was
# usable by anyone with push access to this repo. The local script uses the
# maintainer's existing `gh` auth instead, so no PAT is created at all.
# See BUILDING.md § Winget.
#
# Consequence: this workflow needs NO external token and no third-party action.
#
# Trigger: after "Create Release Assets" succeeds for a tag push. NOT
# `release: types: [released]` — that event never fires, because the release is
# created by create-release.yml under GITHUB_TOKEN and GitHub suppresses
# workflow-triggering events for it. See the note in bump-homebrew-formula.yml.
on:
release:
types: [released]
workflow_run:
workflows: ["Create Release Assets"]
types: [completed]
workflow_dispatch:
inputs:
tag:
description: 'Release tag to submit (for manual recovery)'
description: 'Release tag to sync (for manual recovery)'
required: true
type: string
permissions:
contents: read
# The "Report failure" step below opens a [release-ops] issue via
# github.rest.issues.create; that needs issues:write. Without it the failure
# reporter itself 403s and no alert is ever filed.
contents: write
pull-requests: write
# The "Report failure" step opens a [release-ops] issue via
# github.rest.issues.create, which needs issues:write.
issues: write
concurrency:
group: bump-winget-${{ github.event.release.tag_name || inputs.tag }}
group: bump-winget-${{ github.event.workflow_run.head_branch || inputs.tag }}
cancel-in-progress: false
jobs:
bump:
if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false
runs-on: windows-latest
timeout-minutes: 30
sync-manifest:
# See bump-homebrew-formula.yml for why these three conditions: successful,
# tag-push (not a dry-run dispatch), v-prefixed. Format enforced downstream.
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
startsWith(github.event.workflow_run.head_branch, 'v'))
# Linux, not Windows: msitools reads the MSI Property table just as well, and
# this leg is billed 1x instead of 2x.
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Resolve release
id: rel
uses: ./.github/actions/resolve-release
with:
tag: ${{ github.event.workflow_run.head_branch || inputs.tag }}
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Re-assert stable release
uses: ./.github/actions/assert-stable-release
with:
tag: ${{ github.event.release.tag_name || inputs.tag }}
is_prerelease: ${{ github.event.release.prerelease || 'false' }}
is_draft: ${{ github.event.release.draft || 'false' }}
tag: ${{ steps.rel.outputs.tag }}
is_prerelease: ${{ steps.rel.outputs.is_prerelease }}
is_draft: ${{ steps.rel.outputs.is_draft }}
- name: Submit manifest to winget-pkgs
uses: vedantmgoyal9/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2
- name: Install msitools
run: sudo apt-get update -qq && sudo apt-get install -y -qq msitools
- name: Download MSI, compute sha256 + ProductCode
id: asset
run: |
set -euo pipefail
TAG="${{ steps.rel.outputs.tag }}"
VER="${{ steps.rel.outputs.ver }}"
URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/amethyst-desktop-${VER}-windows-x64.msi"
echo "Fetching $URL"
# workflow_run fires only after every upload leg has finished, so the
# asset should already be there. Retry anyway for release-CDN
# propagation (mirrors the repo's push/pull retry ethos).
ok=0
for i in 1 2 3 4 5; do
if curl -fsSL -o amethyst.msi "$URL"; then ok=1; break; fi
wait=$(( 2 ** i ))
echo "attempt $i failed; retrying in ${wait}s"
sleep "$wait"
done
[[ "$ok" == 1 ]] || { echo "::error::could not download $URL"; exit 1; }
test -s amethyst.msi
SHA=$(sha256sum amethyst.msi | awk '{print $1}' | tr '[:lower:]' '[:upper:]')
# ProductCode is the ARP key winget uses to detect an existing install.
# jpackage regenerates it per build, so read it rather than pin it.
PRODUCT_CODE=$(msiinfo export amethyst.msi Property \
| awk -F'\t' '$1 == "ProductCode" { print $2 }' | tr -d '\r')
if [[ ! "$PRODUCT_CODE" =~ ^\{[0-9A-Fa-f-]{36}\}$ ]]; then
echo "::error::could not read a valid ProductCode from the MSI (got: '${PRODUCT_CODE}')"
exit 1
fi
echo "url=$URL" >> "$GITHUB_OUTPUT"
echo "sha256=$SHA" >> "$GITHUB_OUTPUT"
echo "product_code=$PRODUCT_CODE" >> "$GITHUB_OUTPUT"
echo "sha256=$SHA"
echo "ProductCode=$PRODUCT_CODE"
- name: Update reference manifests
run: |
set -euo pipefail
DIR=desktopApp/packaging/winget
TAG="${{ steps.rel.outputs.tag }}"
VER="${{ steps.rel.outputs.ver }}"
SHA="${{ steps.asset.outputs.sha256 }}"
URL="${{ steps.asset.outputs.url }}"
PC="${{ steps.asset.outputs.product_code }}"
# Anchored substitutions so the header comments are never touched.
sed -i -E "s|^(PackageVersion: ).*|\1${VER}|" \
"$DIR/VitorPamplona.Amethyst.yaml" \
"$DIR/VitorPamplona.Amethyst.installer.yaml" \
"$DIR/VitorPamplona.Amethyst.locale.en-US.yaml"
sed -i -E "s|^( InstallerUrl: ).*|\1${URL}|" "$DIR/VitorPamplona.Amethyst.installer.yaml"
# Quoted: an all-digit 64-char digest would otherwise parse as a YAML
# integer and fail the schema's `string` type.
sed -i -E "s|^( InstallerSha256: ).*|\1'${SHA}'|" "$DIR/VitorPamplona.Amethyst.installer.yaml"
sed -i -E "s|^( ProductCode: ).*|\1'${PC}'|" "$DIR/VitorPamplona.Amethyst.installer.yaml"
sed -i -E "s|^(ReleaseNotesUrl: ).*|\1https://github.com/${{ github.repository }}/releases/tag/${TAG}|" \
"$DIR/VitorPamplona.Amethyst.locale.en-US.yaml"
echo "----- synced -----"
grep -hE "^(PackageVersion| InstallerUrl| InstallerSha256| ProductCode|ReleaseNotesUrl): " "$DIR"/*.yaml
- name: Sanity-check the manifests still parse
run: |
set -euo pipefail
python3 - <<'PY'
import glob, sys, yaml
for f in sorted(glob.glob('desktopApp/packaging/winget/*.yaml')):
d = yaml.safe_load(open(f))
assert d['PackageIdentifier'] == 'VitorPamplona.Amethyst', f
assert d['PackageVersion'], f
print('OK', f, d['ManifestType'])
PY
- name: Open or update the manifest-sync PR
# peter-evans/create-pull-request is MIT-licensed CI-only tooling (not
# linked into any shipped artifact). It no-ops when there is no diff.
uses: peter-evans/create-pull-request@v8
with:
identifier: VitorPamplona.Amethyst
version: ${{ github.event.release.tag_name || inputs.tag }}
# Asset naming contract: scripts/asset-name.sh
installers-regex: '^amethyst-desktop-.*-windows-x64\.msi$'
token: ${{ secrets.WINGET_TOKEN }}
token: ${{ secrets.GITHUB_TOKEN }}
base: main
branch: chore/bump-winget-manifest-${{ steps.rel.outputs.tag }}
add-paths: desktopApp/packaging/winget
commit-message: 'chore: sync winget manifests to ${{ steps.rel.outputs.tag }}'
title: 'chore: sync winget manifests to ${{ steps.rel.outputs.tag }}'
body: |
Auto-synced `desktopApp/packaging/winget/` to the
`${{ steps.rel.outputs.tag }}` release:
- `PackageVersion` -> `${{ steps.rel.outputs.ver }}`
- `InstallerSha256` -> `${{ steps.asset.outputs.sha256 }}`
- `ProductCode` -> `${{ steps.asset.outputs.product_code }}`
**Merge this, then push it upstream from a maintainer machine:**
```bash
scripts/bump-winget.sh ${{ steps.rel.outputs.tag }}
```
That step is manual on purpose — it needs push access to a fork of
`microsoft/winget-pkgs`, which is deliberately NOT stored as a CI
secret. The script uses your existing `gh` auth. See
BUILDING.md § Winget.
- name: Report failure
if: failure()
uses: actions/github-script@v9
with:
script: |
const tag = context.payload.release?.tag_name || context.payload.inputs?.tag || 'unknown';
const tag = context.payload.workflow_run?.head_branch || context.payload.inputs?.tag || 'unknown';
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `[release-ops] bump-winget failed for ${tag}`,
title: `[release-ops] sync-winget-manifest failed for ${tag}`,
body: [
`Winget manifest submission failed for release \`${tag}\`.`,
`Winget manifest sync failed for release \`${tag}\`.`,
``,
`- Run: ${runUrl}`,
`- Channel: Winget (\`VitorPamplona.Amethyst\`)`,
``,
`Recovery options:`,
`1. Re-run the workflow once underlying issue is fixed`,
`2. Manually submit via \`wingetcreate update VitorPamplona.Amethyst -v ${tag.replace(/^v/, '')}\``,
`3. File PR directly against microsoft/winget-pkgs`
`1. Re-run the workflow once the underlying issue is fixed`,
`2. Check the release actually published \`amethyst-desktop-${tag.replace(/^v/, '')}-windows-x64.msi\``,
`3. Manually update \`desktopApp/packaging/winget/*.yaml\` (version, sha256, ProductCode)`
].join('\n'),
labels: ['release-ops', 'bug']
});
+38 -2
View File
@@ -155,8 +155,44 @@ jobs:
AMETHYST_NOTARY_TEAM_ID: ${{ secrets.MAC_NOTARY_TEAM_ID }}
with:
max_attempts: 2
timeout_minutes: 15
command: ./gradlew --no-daemon :desktopApp:${{ matrix.tasks }}
# macOS needs far longer: notarizeReleaseDmg blocks on `notarytool
# submit --wait`, which is minutes-to-tens-of-minutes on Apple's side.
timeout_minutes: ${{ matrix.family == 'macos' && 45 || 15 }}
# Append notarization on the macOS leg. `packageReleaseDmg` only SIGNS
# the DMG — notarization is a separate Compose task, and because it was
# never invoked every release up to v1.13.1 shipped a signed but
# UNNOTARIZED DMG that Gatekeeper blocks on first launch. The task runs
# `notarytool submit --wait` and then `stapler staple`, in place, so the
# asset-collection step below still finds the same file.
#
# Gated on the cert AND all three notary secrets being present, so forks
# and credential-less runs keep producing a plain unsigned DMG exactly as
# before instead of failing.
command: ./gradlew --no-daemon :desktopApp:${{ matrix.tasks }}${{ (matrix.family == 'macos' && steps.mac_keychain.outputs.signing == 'true' && secrets.MAC_NOTARY_APPLE_ID != '' && secrets.MAC_NOTARY_PASSWORD != '' && secrets.MAC_NOTARY_TEAM_ID != '') && ' :desktopApp:notarizeReleaseDmg' || '' }}
# Regression guard. The missing-notarization bug was invisible for many
# releases precisely because nothing ever asserted the outcome; assert it
# now so a silently-dropped notarization step can never ship again.
- name: Verify the DMG is notarized and stapled (macOS leg)
if: matrix.family == 'macos'
env:
EXPECT_NOTARIZED: ${{ (steps.mac_keychain.outputs.signing == 'true' && secrets.MAC_NOTARY_APPLE_ID != '' && secrets.MAC_NOTARY_PASSWORD != '' && secrets.MAC_NOTARY_TEAM_ID != '') && 'true' || 'false' }}
run: |
set -euo pipefail
DMG=$(find desktopApp/build/compose/binaries -name "*.dmg" -print -quit)
[[ -n "$DMG" ]] || { echo "::error::no DMG produced"; exit 1; }
echo "Checking $DMG"
if [[ "$EXPECT_NOTARIZED" != "true" ]]; then
echo "::warning::Apple signing/notary credentials are not configured; this DMG is unsigned and unnotarized. Gatekeeper will block it, and it is not eligible for the Homebrew cask."
exit 0
fi
if ! xcrun stapler validate "$DMG"; then
echo "::error::$DMG has no stapled notarization ticket -- notarizeReleaseDmg did not run or failed"
exit 1
fi
echo "notarization ticket stapled OK"
# jpackage pins libicu to the build host's version (libicu74 on
# ubuntu-24.04). Rewrite the .deb so it installs across Debian/Ubuntu.
+109 -20
View File
@@ -325,15 +325,29 @@ Quartz library in one pipeline.
3. **Wait** for the `Create Release Assets` workflow to finish (~2530 min).
4. **Verify**:
- GH Release contains 8 desktop assets + 12 Android assets
4. **Verify** — the GH Release should hold **31 assets**:
- **8 desktop** — `dmg` (macOS arm64), `msi` + `zip` (Windows), `deb`, `rpm`,
`AppImage`, `flatpak`, `tar.gz` (Linux). There is **no Intel/x64 macOS
DMG** — `jpackage` cannot cross-compile and no Intel runner leg is
configured, so macOS ships arm64-only.
- **13 Android** — 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the
F-Droid `.apks` set built for Accrescent.
- **5 amy** + **5 geode** bundles.
- Asset sizes look sane (see §Enforce asset size budget — CI auto-fails at 1 GB/asset)
- Intel + ARM DMGs both present
- Android flow unchanged
Quick diff against the previous release, which catches a silently-dropped
matrix leg better than any count:
```bash
diff <(gh release view v1.13.0 --json assets --jq '.assets[].name' | sed 's/1\.13\.0/VER/g' | sort) \
<(gh release view v1.13.1 --json assets --jq '.assets[].name' | sed 's/1\.13\.1/VER/g' | sort)
```
5. **Stable vs prerelease** — a tag containing `-rc`, `-beta`, `-alpha`, `-dev`,
or `-snapshot` is auto-classified as prerelease. Stable tags trigger the
Homebrew + Winget bump workflows.
or `-snapshot` is auto-classified as prerelease. Only stable tags run the
Homebrew + Winget bump workflows (and those are no-ops until the one-time
bootstrap PRs land — see § Bootstrap).
### Dry-run (no tag push)
@@ -389,8 +403,8 @@ provided automatically; everything else you set yourself.)
| `MAC_NOTARY_APPLE_ID` | Apple ID email of the notarization account | Apple notarization (`notarytool`) |
| `MAC_NOTARY_PASSWORD` | **App-specific** password for that Apple ID (not the login password) | Same |
| `MAC_NOTARY_TEAM_ID` | 10-char Apple Developer **Team ID** | Same |
| `HOMEBREW_TOKEN` | PAT for `Homebrew/homebrew-cask` | Desktop cask bump (stable tags) |
| `WINGET_TOKEN` | PAT for `microsoft/winget-pkgs` | Desktop winget bump (stable tags) |
| ~~`HOMEBREW_TOKEN`~~ | *Not used.* The cask bump runs on a maintainer's machine — see § Homebrew cask | — |
| ~~`WINGET_TOKEN`~~ | *Not used.* The winget bump runs on a maintainer's machine — see § Winget | — |
| `CROWDIN_PERSONAL_TOKEN`, `CROWDIN_PROJECT_ID` | Crowdin API creds | Translation sync (separate workflow, not the release) |
Note the **three distinct signing identities** people often conflate:
@@ -473,11 +487,11 @@ distributes; the official Amethyst rollout for each is in
| Channel | How it ships | Push or pull |
|---|---|---|
| **GitHub Releases** | The release workflow builds + signs all assets and attaches them to the tag's Release | Automatic (CI) |
| **Maven Central** | Same workflow runs `publishAllPublicationsToMavenCentral` for `quartz` | Automatic (CI) |
| **Maven Central** | Same workflow runs `publishAllPublicationsToMavenCentral` for `quartz` — a *step* at the end of the `deploy-android` job, not a job of its own, so it does not appear in a job list | Automatic (CI) |
| **Google Play** | Download the signed `amethyst-googleplay-<version>.aab` from the GH Release and upload it in Play Console | **Manual push** |
| **F-Droid** | F-Droid's build server detects the new tag and **builds the `fdroid` flavor from source** per its recipe in the external [`fdroiddata`](https://gitlab.com/fdroid/fdroiddata) repo, then signs + publishes itself | **Pull (build-from-source)** |
| **Zapstore** | The [`zsp`](https://zapstore.dev/) CLI reads [`zapstore.yaml`](zapstore.yaml) and publishes a Nostr software-release event signed with the app's nsec | **Manual push (Nostr)** |
| **Homebrew + Winget** | `bump-homebrew.yml` / `bump-winget.yml` open version-bump PRs on stable tags | Automatic (CI) |
| **Homebrew + Winget** | `bump-homebrew.yml` / `bump-winget.yml` open version-bump PRs on stable tags — **currently no-ops**: neither package has been bootstrapped upstream yet (§ Bootstrap) | Automatic (CI), inactive |
Two channels need the build to stay split into product flavors (see
`amethyst/build.gradle.kts` → `productFlavors`):
@@ -498,20 +512,88 @@ reads an optional per-release changelog from
## Bootstrap runbook (one-time)
### Secrets to provision in GitHub repo settings
> **Status as of v1.13.1: neither Homebrew nor Winget has been bootstrapped.**
> `https://formulae.brew.sh/api/cask/amethyst-nostr.json` and
> `microsoft/winget-pkgs/manifests/v/VitorPamplona/Amethyst` both 404, so
> **Amethyst does not currently ship through either channel.** The bump
> workflows detect this and skip with a `::warning::` instead of failing, so a
> green release run does *not* mean Homebrew/Winget shipped. The two subsections
> below are the work that activates them; until then treat the desktop app as
> GitHub-Releases-only on macOS and Windows.
### Package-manager credentials (and why there are none)
The full secret inventory is in [§ Secrets the CI needs](#secrets-the-ci-needs).
The two that need the most setup care are the package-manager PATs, because of
their token type and scope:
Neither package-manager channel adds anything to it:
| Secret | Purpose | Scope |
|---|---|---|
| `HOMEBREW_TOKEN` | Bump Homebrew cask | Fine-grained PAT — `Homebrew/homebrew-cask` only — `Contents: write` + `Pull requests: write` — 90d expiry |
| `WINGET_TOKEN` | Submit Winget manifests | Classic PAT — `public_repo` — 90d expiry (dedicated bot account preferred; `vedantmgoyal9/winget-releaser` does not support fine-grained) |
**There are deliberately no package-manager PATs in CI.** Both the Homebrew
cask and the Winget manifest bumps run on a maintainer's machine. The reasoning
is worth keeping, because it is the reason this repo has no third secret to
rotate:
Rotate both on a 90-day cadence. Owner: assigned via `RELEASE_OPS.md`
or equivalent issue tracker. On rotation, paste new token and run
`gh workflow run bump-homebrew.yml` on the most recent stable tag to verify.
`brew bump-cask-pr` forks `Homebrew/homebrew-cask` **into the token owner's
account** (`POST /repos/Homebrew/homebrew-cask/forks`), pushes a branch to that
fork, then opens the PR upstream. That shape forces a **classic** PAT with the
`repo` scope:
- A fine-grained PAT cannot express it. Its "Repository access" selector only
lists repos owned by the resource owner, so `Homebrew/homebrew-cask` can never
be selected — and Homebrew's API layer authorises against classic OAuth scopes
(`x-oauth-scopes`), which fine-grained tokens do not emit.
- Homebrew declares the requirement in source as
`CREATE_ISSUE_FORK_OR_PR_SCOPES = ["repo"]` (`utils/github.rb`).
And `repo` cannot be narrowed: it grants write to *every* repository the owning
account can reach — including `vitorpamplona/amethyst` itself. Stored as an
Actions secret it would be usable by **anyone with push access to this repo**,
since a pushed branch containing a workflow runs with repo secrets. That is a
strict escalation for a channel that ships one DMG a month.
So the split is:
- **CI** (`bump-homebrew.yml`, `GITHUB_TOKEN` only) does the error-prone
bookkeeping: downloads the DMG, asserts it is notarized + stapled, computes
the sha256, and opens an in-repo PR syncing
`desktopApp/packaging/homebrew/amethyst-nostr.rb`.
- **A maintainer** merges that PR and runs `scripts/bump-homebrew-cask.sh`,
which re-verifies the sha256 and the notarization ticket against the live
asset before calling `brew bump-cask-pr`.
The token then lives only in that maintainer's shell:
```bash
export HOMEBREW_GITHUB_API_TOKEN=ghp_... # classic PAT, `repo` scope
scripts/bump-homebrew-cask.sh v1.13.2
```
Create one at
<https://github.com/settings/tokens/new?scopes=repo&description=Homebrew%20cask%20bump>.
Prefer a dedicated bot account whose only asset is a fork of `homebrew-cask`, so
a leak reaches nothing else.
### Winget
Same split, and it needs **no token at all**. `scripts/bump-winget.sh` drives
`gh`, which a maintainer is already authenticated with, and it does not need
`wingetcreate` (Windows-only) because winget manifests are plain YAML — so it
runs fine from macOS or Linux:
```bash
scripts/bump-winget.sh v1.13.2
```
CI (`bump-winget.yml`, `GITHUB_TOKEN` only) does the bookkeeping: downloads the
MSI, computes the sha256, reads the `ProductCode` out of the MSI Property table
with `msitools`, and opens an in-repo PR syncing
`desktopApp/packaging/winget/*.yaml`. The script re-verifies the sha256 against
the live asset, then forks `microsoft/winget-pkgs`, commits the three manifests
to `manifests/v/VitorPamplona/Amethyst/<version>/`, and opens the PR.
The previous design stored a classic `public_repo` PAT as `WINGET_TOKEN` and
passed it to the third-party `vedantmgoyal9/winget-releaser` action — a token
with write access to every public repo the account owns, handed to code we do
not control, in a place any push-access collaborator could read it from. None of
that is needed.
### Homebrew cask (one-time initial PR)
@@ -636,12 +718,19 @@ by any other channel.
| OS | App location | State directories |
|---|---|---|
| macOS | `/Applications/Amethyst.app` | `~/Library/Application Support/Amethyst`<br>`~/Library/Preferences/com.vitorpamplona.amethyst.desktop.plist`<br>`~/Library/Caches/Amethyst` |
| macOS | `/Applications/Amethyst.app` | `~/.amethyst` (accounts + keys)<br>`~/Library/Application Support/Amethyst` (Tor)<br>`~/Library/Caches/AmethystDesktop` (image cache)<br>`~/Library/Preferences/com.apple.java.util.prefs.plist` (**shared** — see below) |
| Windows | `%LOCALAPPDATA%\Amethyst` or `C:\Program Files\Amethyst` | `%APPDATA%\Amethyst`<br>`%LOCALAPPDATA%\Amethyst` |
| Linux (deb/rpm) | `/opt/amethyst` | `~/.config/amethyst`<br>`~/.local/share/amethyst`<br>`~/.cache/amethyst` |
| Linux (AppImage/tar.gz) | user-chosen | Same as above |
| Linux (Flatpak) | `/var/lib/flatpak` or `~/.local/share/flatpak` | `~/.var/app/com.vitorpamplona.amethyst.Desktop/` |
**macOS preferences are in a SHARED file.** `DesktopPreferences` uses the Java
Preferences API, which on macOS writes into
`~/Library/Preferences/com.apple.java.util.prefs.plist` — one plist for *every*
Java application on the machine, not a per-app file. Never delete it to "reset
Amethyst": that wipes unrelated apps' settings. This is why the Homebrew cask's
`zap` stanza deliberately omits it.
Uninstall:
- Homebrew: `brew uninstall --cask amethyst-nostr && brew zap amethyst-nostr`
+94 -23
View File
@@ -14,7 +14,7 @@ specific to shipping the official Amethyst artifacts.
## At a glance
A release is one tag push that fans out to five distribution channels:
A release is one tag push that fans out to four live distribution channels:
| Channel | Mechanism | Who pushes |
|---|---|---|
@@ -22,10 +22,11 @@ A release is one tag push that fans out to five distribution channels:
| **Google Play** | **Manual** — download the signed AAB from the GH Release, upload in Play Console | Maintainer |
| **F-Droid** | **Pull** — F-Droid's build server builds the `fdroid` flavor from source when it sees the new tag | F-Droid (we just maintain the recipe + metadata) |
| **Zapstore** | `zsp publish` reads `zapstore.yaml`, signs a Nostr release event with Amethyst's nsec | Maintainer |
| **Homebrew + Winget** | Automatic — `bump-homebrew.yml` / `bump-winget.yml` fire on stable tags | CI |
| **Homebrew + Winget** | ⚠️ **Not shipping.** The bump workflows run, but skip: neither package exists upstream yet | Nobody (see § 3) |
Maven Central (the `quartz` library) also publishes automatically from the same
workflow.
workflow — as a *step* at the end of the `deploy-android` job, not a job of its
own, so don't expect to find it in the run's job list.
---
@@ -61,8 +62,10 @@ workflow.
`scripts/translators.sh --seed` with `CROWDIN_PROJECT_ID` /
`CROWDIN_PERSONAL_TOKEN` set, which refreshes the file.)
3. **Publish the release-notes note on Nostr** with Amethyst's account and paste
its event id into `amethyst/build.gradle.kts`:
3. **Publish the release-notes note on Nostr** — *minor releases only.* In
practice this id has only ever been bumped on `x.y.0` (1.11.0, 1.12.0,
1.13.0); patch releases leave it pointing at their minor's note. Publish with
Amethyst's account and paste the event id into `amethyst/build.gradle.kts`:
```kotlin
buildConfigField("String", "RELEASE_NOTES_ID", "\"<new-event-id-hex>\"")
```
@@ -85,17 +88,33 @@ workflow.
Commit, tag, push — see [`BUILDING.md` § Release runbook](BUILDING.md#release-runbook)
for the exact commands. The tag must equal `app` from the catalog (the workflow
asserts this and fails fast otherwise). A clean `vMAJOR.MINOR.PATCH` tag is
classified **stable** and triggers the Homebrew/Winget bumps; anything with a
`-rc`/`-beta`/`-alpha`/`-dev` suffix is a prerelease and skips them.
classified **stable** and runs the Homebrew/Winget bump workflows; anything with
a `-rc`/`-beta`/`-alpha`/`-dev` suffix is a prerelease and skips them.
Heads-up on the `git push`: this repo has `git-credential-manager` configured as
a credential helper, and it blocks on an interactive prompt (a plain
`GIT_TERMINAL_PROMPT=0` does **not** stop it — the push just hangs). If that
happens, push using `gh`'s helper for the one command:
```bash
git -c credential.helper= -c credential.helper='!gh auth git-credential' push upstream main
```
When the `Create Release Assets` workflow finishes (~2530 min) the GH Release
holds, per the asset-name contract:
holds **31 assets**, per the asset-name contract:
- **Android:** 5 Google Play APKs + 5 F-Droid APKs + 2 AABs
(`amethyst-googleplay-*-v…apk` / `.aab`, `amethyst-fdroid-*-v…apk` / `.aab`)
- **Desktop:** 8 assets (DMG/MSI/DEB/RPM/AppImage/zip/tar.gz)
- **CLI:** the `amy` artifacts
- **Maven Central:** `com.vitorpamplona.quartz:quartz:<version>` published
- **Android (13):** 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the F-Droid
`.apks` set for Accrescent
(`amethyst-googleplay-*-v…apk` / `.aab`, `amethyst-fdroid-*-v…apk` / `.aab` / `.apks`)
- **Desktop (8):** DMG (macOS **arm64 only** — there is no Intel DMG),
MSI + zip, DEB, RPM, AppImage, flatpak, tar.gz
- **CLI (5):** the `amy` artifacts
- **Relay (5):** the `geode` artifacts, plus the geode Docker image
- **Maven Central:** `com.vitorpamplona.quartz:quartz:<version>` published.
`repo1.maven.org` lags the publish by tens of minutes — a 404 right after the
run is normal. Confirm the step's log says "Deployment is being published to
Maven Central", and compare against the *previous* version's POM before
concluding anything is broken.
---
@@ -165,11 +184,53 @@ RELAY_URLS="wss://relay.zapstore.dev,wss://relay.damus.io,wss://nos.lol,wss://vi
Keep `wss://relay.zapstore.dev` in the list — that is the relay the Zapstore app
itself reads from.
### Homebrew + Winget — automatic
`bump-homebrew.yml` and `bump-winget.yml` fire on stable tags and open PRs
against `Homebrew/homebrew-cask` (cask `amethyst-nostr`) and
`microsoft/winget-pkgs` (`VitorPamplona.Amethyst`). No action unless one fails —
then see BUILDING.md § Bootstrap and § Incident response.
### Homebrew + Winget — ⚠️ not shipping yet
`bump-homebrew.yml` and `bump-winget.yml` are wired to open PRs against
`Homebrew/homebrew-cask` (cask `amethyst-nostr`) and `microsoft/winget-pkgs`
(`VitorPamplona.Amethyst`) — but **neither package has ever been submitted
upstream**, so both workflows detect that and skip with a `::warning::`. As of
**v1.13.1** these two channels deliver nothing; macOS and Windows users get the
desktop app from GitHub Releases only.
Two separate faults kept this invisible until v1.13.1, both now fixed:
1. **The workflows never ran at all** — for *any* release. They triggered on
`release: types: [released]`, and GitHub does not raise workflow-triggering
events for a release created by `GITHUB_TOKEN`, which is exactly how
`create-release.yml` creates it. They now trigger on `workflow_run` after
`Create Release Assets` succeeds, which also fixes a latent race — the old
event fired while assets were still uploading.
2. **Nothing exists upstream to bump.** `brew bump-cask-pr` and
`winget-releaser` can only *update* an existing package. The first
submission is a manual, human-reviewed PR: BUILDING.md § Homebrew cask
(one-time initial PR) and § Winget (one-time initial submission).
Until someone does that bootstrap, a green release run means the bump workflows
*skipped cleanly* — not that Homebrew/Winget shipped. Check the run's warnings
if you want to confirm which case you're in.
**Both bumps are half-manual by design.** CI does the bookkeeping with
`GITHUB_TOKEN` only — verifying the artifact, computing hashes, and opening an
in-repo PR syncing the reference packaging files. Pushing upstream needs
credentials that would be dangerous as CI secrets (a `repo`-scoped PAT is
readable by anyone with push access here), so a maintainer runs the last step:
```bash
# after merging the sync PRs
export HOMEBREW_GITHUB_API_TOKEN=ghp_... # classic PAT, `repo` scope
scripts/bump-homebrew-cask.sh v1.13.2
scripts/bump-winget.sh v1.13.2 # no token — uses your `gh` auth
```
Both scripts re-verify the published artifact's sha256 before submitting, and
the Homebrew one additionally refuses if the DMG is not notarized + stapled.
See BUILDING.md § Package-manager credentials.
All four in-repo sync workflows (`amy` formula, `geode` formula,
`amethyst-nostr` cask, winget manifests) open PRs against *this* repo on every
release. Merge them to keep the reference packaging files current.
---
@@ -211,7 +272,7 @@ ownership:
| `SIGNING_KEY`, `KEY_ALIAS`, `KEY_STORE_PASSWORD`, `KEY_PASSWORD` | The **Android upload keystore** — losing/leaking it is the worst case; Play app signing identity | Keep the keystore backed up offline; never rotate casually (Play upload key reset is a support process) |
| `SONATYPE_USERNAME`, `SONATYPE_PASSWORD` | Maven Central namespace `com.vitorpamplona` | On compromise |
| `SIGNING_PRIVATE_KEY`, `SIGNING_PASSWORD` | The **GPG key** signing Maven artifacts | Per GPG key expiry |
| `HOMEBREW_TOKEN`, `WINGET_TOKEN` | Cask + winget bump PRs | **90-day cadence** (see BUILDING.md § Bootstrap) |
| *(none for Homebrew/Winget)* | — | Both bumps run on a maintainer's machine — `scripts/bump-homebrew-cask.sh` and `scripts/bump-winget.sh` — so neither channel's PAT ever becomes a CI secret. See BUILDING.md § Package-manager credentials |
| `CROWDIN_PERSONAL_TOKEN`, `CROWDIN_PROJECT_ID` | Translation sync | On compromise |
Owner assignments and rotation reminders live with the team (issue tracker).
@@ -222,13 +283,23 @@ Owner assignments and rotation reminders live with the team (issue tracker).
## 6. Post-release verification
- [ ] GH Release: expected asset count, Intel + ARM DMGs, sizes sane.
- [ ] Maven Central: `quartz:<version>` resolves (allow propagation time).
- [ ] GH Release: 31 assets, sizes sane, and the asset-name set matches the
previous release (see the `diff` one-liner in BUILDING.md § Release
runbook). macOS is arm64-only — do **not** look for an Intel DMG.
- [ ] Maven Central: `quartz:<version>` resolves (allow tens of minutes of
propagation; the publish step's log is the authoritative signal).
- [ ] Play Console: rollout started, no policy rejection.
- [ ] Zapstore: release event visible.
- [ ] F-Droid: new version detected (may lag days).
- [ ] Homebrew + Winget bump PRs opened (stable only).
- [ ] In-app "Release Notes" link opens the note matching `RELEASE_NOTES_ID`.
- [ ] Four sync PRs opened against this repo (`amy` formula, `geode` formula,
`amethyst-nostr` cask, winget manifests) — merge them.
- [ ] Cask pushed upstream: `scripts/bump-homebrew-cask.sh vX.Y.Z` (manual, needs
`HOMEBREW_GITHUB_API_TOKEN` in your shell).
- [ ] Winget pushed upstream: `scripts/bump-winget.sh vX.Y.Z` (manual, no token —
uses your `gh` auth).
Both scripts error clearly until the one-time bootstrap PRs land (§ 3).
- [ ] In-app "Release Notes" link opens the note matching `RELEASE_NOTES_ID`
(only bumped on minor releases — patches keep pointing at the x.y.0 note).
- [ ] Push still works on a `play` build (only if the push contract changed —
see § 4); UnifiedPush still works on an `fdroid` build.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,808 @@
# Location: foreground-gate the listener, trim the request, fix the meter
Date: 2026-07-29
Module: `amethyst`
Origin: Finding 1 of `2026-07-29-resource-report-1.13.0-analysis.md` (1.13.0-PLAY,
Pixel 9a / Android 17)
Revision: 2 — incorporates spec review of 2026-07-29
## Context
The 1.13.0 resource report showed **7.13 h of location listening against 9.1
seconds of app foreground**, and the accompanying analysis called it the leading
suspect for that day's 11 pp of background battery drain. Root cause given: 30
`SharingStarted.Eagerly` top-nav filter states on the account scope
(`Account.kt:843-933`), one of which is always `TopFilter.AroundMe` because
`AccountSettings.kt:256` ships that as the Products default. The `AroundMe`
branch collects `locationFlow()`, and an `Eagerly`-shared subscriber holds
`LocationState`'s `WhileSubscribed(5000)` open for the life of the process.
That chain is real. **The battery conclusion drawn from it is not**, and this
spec is written against the measurement rather than the inference.
### What the device reports
`amethyst/src/main/AndroidManifest.xml:80` declares **only**
`ACCESS_COARSE_LOCATION` — no `ACCESS_FINE_LOCATION`, no
`ACCESS_BACKGROUND_LOCATION` — and no service declares
`foregroundServiceType="location"` (the declared types are `mediaPlayback`,
`microphone`, `camera`, `phoneCall`, `shortService`, `dataSync`, `specialUse`).
`targetSdk = 37`.
`adb shell dumpsys location`, Pixel 9a, 21 d 7 h of uptime. **These are the
`com.vitorpamplona.amethyst` rows** of the per-provider *Historical Aggregate
Location Provider Data* block — not system-wide totals:
| provider | registration held | **active** | **foreground** | fixes |
|---|---|---|---|---|
| passive | 9 d 14 h 20 m | 8 h 26 m 14 s | 8 h 14 m 50 s | 107 |
| network | 9 d 14 h 20 m | 8 h 26 m 13 s | 8 h 14 m 49 s | 95 |
| fused | 9 d 14 h 20 m | 8 h 26 m 12 s | 8 h 14 m 48 s | 95 |
| gps | 9 d 14 h 20 m | 8 h 26 m 11 s | 8 h 14 m 47 s | 98 |
Roughly **11 minutes of background-active location in three weeks**, and about
100 delivered fixes per provider. With the app backgrounded at the time of the
dump: `gps provider: service: ProviderRequest[OFF]`, `gps_hardware:
mStarted=false`.
The cleanest assumption-free comparison: the ledger's **two-day** `location.ms`
total (3.57 h + 7.13 h = 10.70 h) **exceeds the OS's three-week active total**
(8 h 26 m) by 27 %. `location.ms` is not measuring what its label implies.
**One figure does not reconcile, and is left open.** Registration-held is
9 d 14 h over 21 d 7 h ≈ 10.8 h/day, whereas `location.ms` averages 5.35 h/day
across the two ledger days. If `location.ms` measured subscription existence
these should agree; they are ~2× apart. Candidates: segments open at process
death are lost (the pre-flush hook does not run on a kill, and the report shows
6 process starts across the two days); the ledger covers 2 days while dumpsys
spans 21 with different usage. Not investigated. It does not affect the
conclusion below, which rests on `location.ms` versus OS-*active* time, not
versus registration-held.
### How far the "no background location" claim generalises
This matters because the "no behaviour change" argument rests on it, so it is
scoped rather than asserted universally:
- **API 29+ (Android 10 and up):** `ACCESS_BACKGROUND_LOCATION` gates background
access, and for `targetSdk ≥ 29` an FGS additionally needs
`foregroundServiceType="location"`. Amethyst has neither, so background
registrations are suspended. This is the case the Pixel 9a measurement above
covers.
- **API 2628 (Android 89):** `ACCESS_BACKGROUND_LOCATION` does not exist.
Amethyst *can* receive background location there, throttled by the platform to
a few updates per hour. The gate is a genuine, if small, improvement on these
releases rather than a no-op.
So "structural" applies to API 29+; on 2628 the change has real effect.
### What is actually wrong
1. **The meter lies.** `location.ms` measures how long a *subscription* existed,
not how long anything listened. That is what made Finding 1 read as the
top-priority battery bug.
2. **The request is 4× redundant.** `LocationFlow.kt:55` iterates
`locationManager.allProviders` and calls `requestLocationUpdates` on each —
passive, network, fused **and** gps (the last tagged `HIGH_ACCURACY`) — every
one at `@+10s0ms, minUpdateDistance=100.0`, to produce a **5 km** geohash.
This burns during the 8 h 14 m the app genuinely is foreground.
3. **The subscription is held for 45 % of device uptime** doing nothing, because
`Eagerly` never lets go.
4. **Every user is exposed**, since Products defaults to `AroundMe` and needs no
opt-in.
5. **Location may be entirely broken on Android 811** — see Hypothesis H1.
## Hypothesis H1 — location is dead below API 31 (unverified)
Through Android 11, AOSP's `getMinimumPermissionForProvider` required
`ACCESS_FINE_LOCATION` for the `gps`, `passive` and `fused` providers; only
`network` accepted `ACCESS_COARSE_LOCATION`. Approximate-location, which lets a
coarse-only app request any provider and receive a fuzzed result, is an Android
12 (API 31) change.
Amethyst holds coarse only. So on API 2630 today's `allProviders` loop should
throw `SecurityException` on three of the four providers. Two consequences:
- The throw escapes the `callbackFlow` builder → `.catch` in `LocationState`
`LackPermission`. Location would be **non-functional** on Android 811.
- The builder aborting means `awaitClose` never runs, so `removeUpdates` is
never called and any registration made before the throw **leaks** for the life
of the process.
**The leak is iteration-order dependent, and may not occur at all.**
`getLastKnownLocation` is permission-checked per provider too, and
`LocationFlow.kt:56-68` calls it *before* `requestLocationUpdates` on each
iteration. If a fine-only provider comes first in `allProviders``passive`
does, in the common AOSP ordering — the throw lands before any registration
exists: dead, but not leaking. A leak requires `network` to precede a fine-only
provider.
`@SuppressLint("MissingPermission")` at `LocationFlow.kt:40` suppresses the lint
warning, not the runtime check, so this would not have been caught statically.
**Not reproduced.** Verify before implementing, on the existing
`Medium_Phone_API_26_8_` AVD: grant coarse only, open a screen that subscribes,
and capture **both** the `SecurityException` *and* the actual
`locationManager.allProviders` order (log it). The PR should claim only what that
run observed — "location is dead on Android 811" and "registrations leak" are
separate claims and the second may not hold.
The design below is written to be correct either way (§B excludes
permission-incompatible providers by API level, and catches `SecurityException`
per provider). If H1 holds, this change also **fixes location on Android 811**,
which should be called out in the PR.
### H1 verification result (2026-07-30, Pixel 9a, API 37)
Partial. The API-level claim could **not** be tested on this hardware: API 31+
grants coarse-only apps access to every provider, so no `SecurityException` can
appear regardless of whether H1 is true. Only an API ≤ 30 image can settle it.
What *was* settled is the ordering, which decides the leak sub-claim.
`dumpsys location` recent-events shows the same iteration order on every
registration cycle across two days, all four sharing one registration id
(`88A8E679`), confirming a single `LocationFlow` subscription:
```
07-30 07:09:58.282: passive provider +registration .../88A8E679
07-30 07:09:58.291: network provider +registration .../88A8E679
07-30 07:09:58.293: fused provider +registration .../88A8E679
07-30 07:09:58.299: gps provider +registration .../88A8E679
```
`allProviders` yields **passive first**, and `passive` is one of the fine-only
providers below API 31. So on Android 811 the throw would land on the first
iteration, before any registration exists:
- "location is dead on Android 811" — **still unverified**, needs API ≤ 30.
- "registrations leak" — **disproved for this ordering**. Dead, but not leaking.
The PR must not claim the leak. Caveat: the ordering is observed on API 37 and
`getAllProviders()` could order differently on API 26.
**Unrelated but decisive "before" datum, same session:** with
`mWakefulness=Dozing` (screen off, device dozing) and `MainActivity` sitting in
`mLastPausedActivity`, Amethyst held **four** live registrations at
`@+10s0ms / minUpdateDistance=100.0`. That is the state §A's gate exists to
eliminate, captured on the owner's daily-driver device rather than an emulator.
## Goals
- Release the location registration whenever no activity is started.
- Register on one appropriate, permission-compatible provider at an interval
matched to the precision actually needed.
- Make `location.ms` reflect real listening time, correctly, under concurrency.
- No user-visible behaviour change to the "Around Me" feed or geohash chats.
## Non-goals
- Changing the Products `AroundMe` default (`AccountSettings.kt:256`). With the
gate in place its cost is bounded to foreground use. Worth revisiting
separately as a product decision.
- Requesting `ACCESS_FINE_LOCATION` or `ACCESS_BACKGROUND_LOCATION`.
- Findings 26 of the source analysis. Finding 2 (the relay reconnect storm,
1.65 GB/day at a 75 % dial-failure rate) is the more likely explanation for
the background battery drain and should be taken next.
## Design
### A. The gate
`LocationState` gains an `isForeground: StateFlow<Boolean>` parameter, wired in
`AppModules.kt:251` from the existing `foregroundTracker` (`AppModules.kt:333`,
registered at `Amethyst.kt:122`). `locationManager` is `by lazy`, so
initialisation order is safe.
Today's `hasLocationPermission.transformLatest { … }` becomes a three-state gate
over *permission × foreground*, applied identically to `geohashStateFlow` and
`preciseGeohashStateFlow`:
| gate state | behaviour |
|---|---|
| no permission | emit `LackPermission` (unchanged) |
| permitted, foreground | **R1**: emit `Loading` *only if* no `Success` is cached; then `emitAll(locationSource(…))` |
| permitted, backgrounded | emit nothing; the registration is released and the `StateFlow` retains its last value |
**R1 is a requirement, not an improvement.** `AroundMeFeedFlow.convert` collapses
to `geotags = emptySet()` for anything that is not `Success`. Without R1 the gate
would make the "Around Me" feed flash empty on **every** return to foreground — a
new, frequent, user-visible regression introduced by this change. (It also fixes
the same flash on permission grant, which exists today.)
**R1 corollary: the `NoPermission` branch must not clear the cache.** Today's
code emits `LackPermission` without touching `latestLocation`
(`LocationState.kt:94-96`), and that stays. Clearing it is superficially
attractive — a revoked permission arguably should not leave a fix readable — but
consumers already see `LackPermission` from the `StateFlow`; `latestLocation` is
private and its only jobs are seeding `stateIn` and deciding whether `Loading` is
emitted. Clearing it would therefore buy no privacy and would cost an
empty-feed flash on every permission flap, which is precisely what R1 exists to
prevent. The cache is in-memory and dies with the process regardless.
The `.catch` branch **does** clear the cache, and keeps doing so. That asymmetry
looks arbitrary next to the paragraph above, so to be explicit: it is inherited,
not introduced. Both branches preserve today's behaviour exactly
(`LocationState.kt:87-91` clears on failure, `:94-96` does not clear on missing
permission). This corollary argues against *adding* a clear, not for removing
the existing one — changing it would be an unmotivated behaviour change. The
asymmetry is also defensible on its own terms: a source that failed mid-stream
says something about the fix's provenance, whereas a permission known to be
absent says nothing about a fix already taken.
**R2 — grace period on the background edge.** The gate must delay the
`foreground → background` transition by **5 s** before tearing down. Without it a
one-second app switch destroys and rebuilds the registration, including a full
`getLastKnownLocation` sweep, so a user flipping between apps pays more than the
steady state. 5 s matches the existing `WhileSubscribed(5000)` and is the same
intent. The `background → foreground` edge is **not** delayed.
Mechanism, stated because the obvious operator is the wrong one: `debounce(5000)`
delays both edges, and the duration-selector overload that would allow an
asymmetric delay is `@FlowPreview`. Use `transformLatest`, already in this file
and already opted into via `@OptIn(ExperimentalCoroutinesApi::class)`:
```kotlin
isForeground.transformLatest { fg ->
if (!fg) delay(BACKGROUND_GRACE_MS)
emit(fg)
}
```
`transformLatest` cancels the pending `delay` if foreground returns first, which
is exactly the stated semantics, with no preview opt-in.
**R3 — the retained-value contract.** The "emit nothing" branch is what keeps
this behaviour-neutral: `stateIn` holds the last `Success`, so the ~60
synchronous `.value` reads across the feed filters
(`HomeNewThreadFeedFilter.kt`, `VideoFeedFilter.kt`,
`DiscoverLongFormFeedFilter.kt`, …) keep seeing the last known geohash. A 5 km
cell does not meaningfully decay while backgrounded.
**R4 — memory visibility.** `latestLocation` and `latestPreciseLocation`
(`LocationState.kt:63-64`) are plain `var`s today, used only as `stateIn` initial
values. R1 promotes them to control flow, read from a different coroutine than
the `onEach` that writes them. They must become `@Volatile` (or
`MutableStateFlow`).
**Rejected alternative:** switching `FeedTopNavFilterState.flow` from `Eagerly`
to `WhileSubscribed`. Roughly 60 call sites read
`account.live*FollowLists.value` synchronously rather than collecting; under
`WhileSubscribed` those reads would silently serve a stale or initial value
whenever no collector happened to be active. That is a correctness regression,
not a battery fix.
**Rejected alternative:** gating only at the `AppModules` wiring point
(`geolocationFlow = { … }`). Smaller diff, but it leaves the raw
`geohashStateFlow` as a loaded gun for the next eager consumer, does nothing for
`preciseGeohashStateFlow`, and introduces a second `StateFlow` layer over the
same data.
### B. Request shape
`LocationFlow.get` registers on **one** provider, chosen by a ladder over
**provider existence and permission compatibility** — both static facts:
```
chooseProviders(sdkInt, hasFine, exists) -> List<String>:
API 31+ or hasFine → [FUSED, NETWORK, GPS, PASSIVE] filtered by exists
API < 31, coarse → [NETWORK] filtered by exists (see H1)
```
It returns the **ordered candidate list**, not a single choice, because the
per-provider `SecurityException` fall-through below needs somewhere to fall to.
An empty list means no compatible provider exists.
**The ladder deliberately does not consult `isProviderEnabled`.** Today's code
registers regardless of enabled state, and such a registration goes live by
itself when the user enables location — including from the quick-settings shade
without leaving the app, which is exactly what someone does after seeing "Around
Me" empty. A guard evaluated once at subscription start would lose that, and the
foreground-transition restart does not cover the in-app path. Selecting on
existence keeps the property with no `PROVIDERS_CHANGED_ACTION` receiver. If
field reports show dead feeds on devices where the chosen provider exists but is
disabled while another is enabled, adding that receiver is the follow-up.
`requestLocationUpdates` is wrapped in a per-provider `SecurityException` catch
that falls through to the next rung, so H1 cannot abort the builder and leak
registrations regardless of how the AOSP check actually behaves.
**When no provider can be registered** — the candidate list was empty, or every
rung threw — `LocationFlow` **throws** `SecurityException`. It cannot emit
`LackPermission`: the seam is `(Long, Float) -> Flow<Location>`, and
`LackPermission` is a `LocationState.LocationResult`, which `LocationFlow` has no
way to express. Throwing routes it through the `.catch` already present in
`LocationState` (`LocationState.kt:87-91`, `:126-130`), which sets
`latestLocation = LackPermission` and emits it — the existing, unchanged path.
Throwing covers **both** failure cases, and it subsumes R5's `registered`-flag
guard: the throw happens before the acquire, so `onListening(true)` cannot fire
without a live registration and no separate flag is needed. That is only half of
R5's pairing, though — see R5 for the release half, which the throw does **not**
cover and which needs `try`/`finally`.
**Stated decision: `LackPermission` stays conflated with "no usable provider".**
That value renders `R.string.lack_location_permissions` — "No Location
Permissions" — at `DisplayLocationObserver.kt:49` and `FeedFilterSpinner.kt:224`,
which is wrong for a coarse-only pre-31 device that has no `network` provider.
The conflation is accepted rather than introduced: if H1 holds, today's
`SecurityException` already lands in the same `.catch` and shows the same wrong
message. Adding an `Unavailable` state would ripple through four UI `when`s plus
`LocationState` (10 references across 5 files) and belongs with the H1 fix
messaging, not here. Recorded as a follow-up.
The `getLastKnownLocation` seed stays a sweep across all providers, taking the
freshest result. It requires no registration and is what makes the first geohash
appear immediately rather than after a fix.
`MIN_TIME` / `MIN_DISTANCE` split into two profiles, passed per call:
| flow | precision | interval / distance | provider set |
|---|---|---|---|
| `geohashStateFlow` | `KM_5_X_5` | 10 s / 100 m → **60 s / 500 m** | 4 → 1 |
| `preciseGeohashStateFlow` | `BUILDING` (8 chars) | 10 s / 100 m (kept) | 4 → 1 |
Both rows change: the ladder narrows the precise flow's provider set too, and
below API 31 that means `network` only, no GPS. Academic while the app holds
coarse only (see Follow-ups), but it is not "unchanged".
At 120 km/h a 5 km cell takes 2.5 minutes to cross, so 60 s / 500 m has no
observable effect on the feed.
**Rejected alternative — one shared source at the fine profile,** deriving the
coarse geohash by prefix truncation. It halves registrations and removes the need
for `RefCountedSession` entirely, but it upgrades the **common** case — the
"Around Me" feed alone, which is always on via the Products default — from
60 s/500 m to 10 s/100 m. That trades the change's main win for a rarer one.
**Rejected alternative — one shared source whose profile tracks the finest
active subscriber.** Recovers the above and is the best of the three on both
axes, but it is refcounting with the counter moved from the meter into the
request path, for a benefit bounded by how often the two flows overlap. They
overlap only while one of three composable-scoped, foreground-only screens is
open (`GeohashChatScreen`, `NewGeohashChatScreen`,
`GeohashLocationPickerDialog`). Not worth the machinery; revisit if that changes.
### C. The meter
`AppModules.kt:251` hands both flows the same non-refcounted
`SessionTimeIntegrator`, so `setActive(false)` from either closes the segment
while the other is still listening. Both can be live at once — the "Around Me"
feed plus an open geohash chat.
**R5 — the hook moves inside `LocationFlow`, and both edges are paired.** Today
`onListening(true)` is an `onStart` on the flow returned by `LocationFlow.get`,
so it fires on *collection* whether or not anything was registered — meaning a
device with no usable provider accrues `location.ms` with nothing listening,
reintroducing the exact defect this section exists to fix. The hook must instead
fire from inside the `callbackFlow`, after `requestLocationUpdates` returns
without throwing, and again on the way out.
The obvious "way out" is `awaitClose`, and that would introduce a worse bug than
it fixes — twice over. First, `awaitClose` runs on every normal
completion, including one where no rung ever registered, so it would fire an
**unpaired** `onListening(false)`. With R6 that does not merely under-count — it
decrements a holder it never acquired, stealing another flow's. Concretely:
`geohashStateFlow` registers (`holders = 1`), `preciseGeohashStateFlow` fails to
register and closes (`holders = 0`), and the session latches off while the coarse
flow is still listening. `coerceAtLeast(0)` does not help; the count never went
negative.
Second — and this is the one that survives fixing the first — `awaitClose` also
fails to run at all on some paths that *did* register. See below.
The pair therefore has to be guaranteed from **both** ends, and the two ends need
different mechanisms.
*No acquire without a registration* is §B's **throw**: if no rung registers, the
builder throws before reaching the acquire at all.
*No acquire without a release* needs `try`/`finally`, **not** `awaitClose`. This
is the subtlest point in the document, so the justification below is the one that
was **demonstrated**, not the one that sounds most obvious.
Anything between the acquire and `awaitClose` that unwinds skips cleanup parked
inside `awaitClose`, because `awaitClose` is never reached to register it. The
registration then leaks and the refcount sticks at ≥ 1 for the life of the
process, so `location.ms` accrues forever with nothing listening — this exact
defect, arrived at from the other direction, and unrecoverable once hit.
The **proven** path is the seed throwing a non-cancellation exception:
`getLastKnownLocation` is a binder call and can fail. The regression test
`releasesTheRegistrationWhenTheSeedThrows` provokes exactly this and was watched
failing against an `awaitClose`-only implementation
(`expected:<[true, false]> but was:<[true]>`).
A cancellation during the seed is *in principle* a second such path, since `send`
is a suspending call. Recorded honestly: **this one could not be reproduced.**
Two attempts during implementation both produced tests that passed against a
deliberately broken implementation, because `callbackFlow`'s channel is buffered,
so `send` returns without suspending and never observes the cancel. Do not treat
the cancellation story as the reason for the `try`/`finally`; a future reader who
tries to reproduce it, fails, and concludes the guard is unnecessary would
reintroduce the leak.
```kotlin
var registered: String? = null
for (provider in candidates) {
try {
locationManager.requestLocationUpdates(provider, minTimeMs, minDistanceM, callback, Looper.getMainLooper())
registered = provider
break
} catch (e: SecurityException) { /* next rung */ }
}
if (registered == null) throw SecurityException("no usable location provider")
onListening?.invoke(true) // cannot fire without a registration
try {
freshestLastKnownLocation(providers)?.let { send(it) } // suspends — cancellable
awaitClose { } // only to satisfy callbackFlow's contract
} finally {
locationManager.removeUpdates(callback)
onListening?.invoke(false) // cannot be skipped
}
```
`onListening?.invoke(true)` sits immediately before the `try`, with no suspension
between them, so the acquire cannot happen outside the block that guarantees its
release.
`trySend` for the seed would also close this particular hole, being
non-suspending. It is rejected because it leaves the invariant resting on nobody
adding a suspending call to that block later — vigilance rather than
impossibility, which is the standard the rest of R5 is held to.
**R6 — refcounting.** An `AtomicInteger` beside the `setActive` call is not
sufficient: two threads can leave the counter at 1 while the last
`setActive(false)` lands after the `setActive(true)`, latching the session off.
The count and the transition must move under one lock. New class in
`service/resourceusage/`:
```kotlin
class RefCountedSession(private val setSessionActive: (Boolean) -> Unit) {
private val lock = Any()
private var holders = 0
fun setActive(active: Boolean) =
synchronized(lock) {
holders = if (active) holders + 1 else (holders - 1).coerceAtLeast(0)
setSessionActive(holders > 0)
}
}
```
It takes the setter as a lambda rather than a `SessionTimeIntegrator` because
that is all it needs, and because constructing a real integrator in a unit test
would drag in a `ResourceUsageAccountant`, a `ResourceUsageStore` and a temp
file to observe one boolean.
`AppModules` wires `RefCountedSession(locationSession::setActive)` and passes
`onListening = { locationRefCount.setActive(it) }`. The outer
lock serialises entry into `SessionTimeIntegrator.setActive`, whose own lock is
then nested but never acquired in the reverse order, so there is no deadlock.
`coerceAtLeast(0)` guards an unmatched release.
### What `location.ms` means after this change
Stated plainly, because the finding that opened this spec is "the meter lies"
and the next reader should not over-trust the fixed number the way the last one
over-trusted the broken one:
> `location.ms` measures **how long a location registration was held while the
> app was in the foreground**. It is not radio-on time and not an energy
> figure. A `network`-provider registration at 60 s costs close to nothing; a
> `gps` registration at 10 s costs a great deal. The counter cannot tell them
> apart.
Reading it as a battery signal requires knowing which provider was chosen —
which the ledger does not record. Recording the chosen provider as a separate
counter is a possible follow-up.
## Testing
JVM unit tests — JUnit + MockK + `kotlinx-coroutines-test`, no Robolectric,
alongside the existing `service/resourceusage/ResourceUsageLedgerTest.kt`.
**Gate.** `LocationState` gains `locationSource: (Long, Float) -> Flow<Location>`,
defaulting to `LocationFlow(context.getSystemService(…) as LocationManager)::get`
(see *Registration pairing* for why `LocationFlow` now takes the manager rather
than the `Context`). That is the seam:
`Location.toGeoHash` is `GeoHash.encode(lat, lon, chars)` from quartz — pure
Kotlin — and `unitTests.isReturnDefaultValues = true` is already set, so a
`mockk<Location>` with stubbed `latitude`/`longitude` suffices. Against a
counting fake source:
- backgrounded + permitted → source never subscribed
- foreground + permitted → subscribed exactly once
- foreground → background → subscription released after the R2 grace period,
last `Success` still readable via `.value`
- background edge shorter than the grace period → subscription **not** torn down
- return to foreground with a cached `Success`**no** `Loading` emission (R1)
- return to foreground with no cached fix → `Loading` first
- permission revoked → `LackPermission` regardless of foreground state
**Provider ladder.** Extracted as a pure function
`chooseProviders(sdkInt: Int, hasFine: Boolean, exists: (String) -> Boolean):
List<String>` so §B is covered rather than sitting below the seam. Cases: rungs
returned in order; missing rungs filtered out; API < 31 coarse-only yields
`[network]`; API < 31 with fine yields the full ladder; no compatible provider
yields an empty list.
`hasFine` is **always `false` in production** — the non-goals rule out ever
requesting `ACCESS_FINE_LOCATION`. It is a parameter rather than a constant so
that the function is total over the permission axis and the API < 31 branch can
be tested from both sides, not because fine access is anticipated. If that
changes, the ladder is already correct.
R5 sits below the `locationSource` seam, so a fake source never fires it. It gets
its own tests against a mocked `LocationManager` — see *Registration pairing*
below.
**Meter.** `RefCountedSession`: overlapping holders keep the session open;
balanced pairs close it; an unmatched release does not drive the count negative.
Note what this class **cannot** do: it cannot distinguish an unpaired release
from a legitimate one, so `acquire → unpaired release` closes the session even
while another holder is listening. That is precisely the R5 bug, and
`coerceAtLeast(0)` is no defence against it. **The pairing guarantee belongs to
`LocationFlow`, not here** — which is why it needs its own test below.
**Registration pairing (R5).** To make this testable rather than device-only,
`LocationFlow` takes a `LocationManager` instead of a `Context`
(`LocationFlow(context)``LocationFlow(locationManager)`; the caller in
`LocationState` does the `getSystemService` lookup). A `mockk<LocationManager>`
then covers:
- every rung throws `SecurityException` → the flow throws and `onListening` fires
**neither** edge
- `chooseProviders` returns an empty list → same: throws, neither edge
- an earlier rung throws and a later one succeeds → registration falls through,
exactly one `true`
- a successful registration → exactly one `true`, and exactly one `false` plus
`removeUpdates` on cancellation
- **cancellation mid-seed**, while the `getLastKnownLocation` sweep is in flight
→ both edges still fire and `removeUpdates` is still called. This is the case
the `try`/`finally` exists for; without it the test fails by hanging the
refcount at 1 rather than by throwing, so assert on the edges, not on the
absence of an exception.
These cover the *semantics* only — the two-thread interleaving that motivates the
lock is made unobservable by the lock itself and is not reproduced by any test
here.
## Acceptance criteria
On device, re-running the measurement above:
- **Backgrounded:** after the 5 s grace period, `adb shell dumpsys location`
shows no `com.vitorpamplona.amethyst` entry under any provider's `listeners:`,
and a `-registration` in the recent-events log.
- **Foregrounded:** **one registration per actively-collected flow — at most
two**, and one in the steady state where only the "Around Me" feed is live
(§C exists precisely because the two flows may overlap). Not four. The coarse
registration reads `@+60s0ms` / `minUpdateDistance=500.0` rather than
`@+10s0ms` / `100.0`.
- The historical aggregates are cumulative since boot; compare deltas across a
foreground/background cycle, not absolute totals.
- **Invariant:** a subsequent in-app Resource Usage Report shows
```
location.ms ≤ app.fgms + 5 s × (background transitions)
```
Both counters are driven by the same `foregroundTracker.isForeground` flow, so
without R2 this would hold exactly. R2 is deliberately the error term: the
registration really *is* live during the grace period, so counting it is the
honest reading, and a stated fudge factor beats an invariant quietly known to
be false. The term is not negligible — the source report shows 6 process
starts across two days, and app switches are far more frequent than that — so
writing `location.ms ≤ app.fgms` would guarantee that the first person to
check it files a bug against this change.
Second caveat: Finding 4 of the source analysis suspects `app.fgms` of
under-reporting, so a violation beyond the grace term indicts that counter
rather than this one.
- If H1 holds: location works on the API 26 AVD after the change and did not
before.
### Verified on device (2026-07-30, Pixel 9a / Android 17, API 37)
Measured against the `benchmark` variant — `initWith(release)`, so R8-minified with
the shipping proguard rules, installed as `com.vitorpamplona.amethyst.benchmark`
beside the untouched Play install. The Play install was force-stopped for the
duration so its own (unfixed) registrations could not be mistaken for these.
| criterion | before | after |
|---|---|---|
| registrations, foreground | **4** (passive, network, fused, gps) | **1** (fused) |
| request profile | `@+10s0ms HIGH_ACCURACY`, `minUpdateDistance=100.0` | `@+1m0s0ms BALANCED`, `minUpdateDistance=500.0` |
| registrations, backgrounded | **4**, held while `mWakefulness=Dozing` | **0** |
Event trace for one full cycle, process alive throughout (pid 28682):
```
17:57:00.117 +registration fused …/40F5A6D7 @+1m0s0ms BALANCED, minUpdateDistance=500.0
17:57:33.894 -registration fused …/40F5A6D7 ← HOME pressed, released after the grace
17:58:05.798 +registration fused …/091EDA96 @+1m0s0ms BALANCED, minUpdateDistance=500.0
(HOME then reopen within 2 s — no -/+ pair; 091EDA96 survives)
```
- **§A gate** — zero registrations while backgrounded, with the process still
alive. That is the state the change exists to create; before, four
registrations survived screen-off and doze.
- **§B request shape** — one provider, top of the ladder (`fused`), at exactly
`COARSE_MIN_TIME` / `COARSE_MIN_DISTANCE`. The OS tags it `(COARSE)` and
coalesces the effective service request to `@+10m0s0ms LOW_POWER`.
- **R2 grace period** — a sub-grace app switch produced **no** teardown/rebuild
pair, so a brief switch no longer costs a re-registration and a fresh
`getLastKnownLocation` sweep.
**The OS aggregate after four foreground/background cycles is the headline
result**, because it is the same counter shape `location.ms` measures:
```
com.vitorpamplona.amethyst.benchmark:
min/max interval = 60s/60s
total/active/foreground duration = +2m33s542ms / +2m33s456ms / +2m33s531ms
locations = 4
```
Total ≈ active ≈ foreground, all three within 90 ms — against the Play install's
`9d14h20m / 8h26m / 8h14m`, where registration was held for 45 % of uptime while
only 1.7 % was active. The four foreground windows sum to 153.6 s, matching the
aggregate exactly, so nothing is held outside them. Registration-held time now
*equals* foreground time, which is precisely what makes `location.ms` honest: the
counter measures registration lifetime, and that quantity is no longer divorced
from reality.
**A fix arrives within milliseconds of every re-registration**, which bounds the
staleness the coarser profile was feared to introduce:
```
17:57:00.117 +registration → 17:57:00.127 delivered location[1] (10 ms)
17:58:05.798 +registration → 17:58:05.802 delivered location[1] ( 4 ms)
17:59:09.965 +registration → 17:59:09.967 delivered location[1] ( 2 ms)
18:00:09.206 +registration → 18:00:09.213 delivered location[1] ( 7 ms)
```
The `fused` provider hands over its cached fix on registration, so the window in
which a returning user could act on a stale geohash is milliseconds, not the 60 s
poll interval. Caveat: that cache is warm on this device because Maps and GMS
keep it fresh; on a device with no other location consumer it could be colder,
which is what `freshestLastKnownLocation` exists to cover.
**Side-by-side A/B, same device, same instant, both clients backgrounded and
running.** The unmodified release client (1.13.1, installed via Obtainium, pid
5552) and the benchmark build of this branch (pid 28682) were sampled together:
```
com.vitorpamplona.amethyst/B7B299BE {bg, na} (COARSE) Request[PASSIVE, minUpdateDistance=100.0] (inactive)
com.vitorpamplona.amethyst/B7B299BE {bg, na} (COARSE) Request[@+10m LOW_POWER, minUpdateDistance=100.0] (inactive)
com.vitorpamplona.amethyst/B7B299BE {bg, na} (COARSE) Request[@+10m LOW_POWER, minUpdateDistance=100.0] (inactive)
com.vitorpamplona.amethyst/B7B299BE {bg, na} (COARSE) Request[@+10m LOW_POWER, minUpdateDistance=100.0] (inactive)
← com.vitorpamplona.amethyst.benchmark: no rows at all
```
Four held registrations versus zero. Note the release client's rows are all
`{bg, na} … (inactive)`: the OS has throttled the effective interval to 10
minutes and suspended delivery, exactly as §"What the device reports" describes —
but the **registration is still held**, and registration-held time is precisely
what `location.ms` counts. That is the inflation, visible in one frame.
Naming note for anyone re-reading the numbers above: both artifacts are `play`
**flavor** builds and differ only by buildType, so "the Play install" is an
ambiguous label. The unmodified client here is the *release* build, and on this
device it came from Obtainium rather than Google Play.
### Ledger invariant confirmed (2026-07-31, benchmark client, in-app report)
The acceptance criterion `location.ms ≤ app.fgms + 5 s × transitions` now checks
out against accumulated data:
| | `location.ms` | `app.fgms` | ratio |
|---|---|---|---|
| release client 1.13.0, day 20663 (before) | 25,660,172 | 9,147 | **2,805×** |
| benchmark, day 20664 (permission granted mid-day) | 3m7.3s | 11m31.0s | 0.27× |
| benchmark, **day 20665** (granted all day) | **2m10.8s** | **1m56.9s** | **1.12×** |
Day 20665 is the clean case: `location.ms` exceeds `app.fgms` by 13.9 s, which
requires ≥ 3 background transitions to fall inside the grace allowance — met by
the report navigation plus an `am start`. Day 20664 independently reconciles with
the `dumpsys` measurement: 2m33.5s of OS registration-held + 4 × 5 s grace =
~2m53.5s predicted, 3m7.3s actual, the residual being foreground use after the
measurement ended. **The ledger and the OS now agree**, where before they were
irreconcilable (10.7 h ledger vs 8h26m OS-active over three weeks).
**Limitation:** this is not a within-package before/after. `location.ms` is
absent from days 2064820663 because the benchmark client had location permission
*denied* until 2026-07-30; the "before" is no data, not inflated data.
### The same data closes the battery question
Over days 2065920665 on this device: **5m18s** of location listening against
**596 pp** of background battery drain (~85 pp/day). Location cannot be a
meaningful contributor at that ratio — Finding 1 is settled, and not in the
direction the original analysis assumed.
Three consumers visible in the same report, none of them location:
- `service.alwayson.ms` ≈ **23.9 h/day** (148.9 h over 7 days) — an always-on
foreground service running essentially continuously. Largest structural
difference from a stock client; worth confirming it is deliberately enabled.
- **Finding 2, unchanged.** 3,732 relay-hours over 7 days. Day 20664 alone:
9,831 successful dials against 25,133 failures = **71.9 %**, matching the
original report's 75 %.
- **Finding 4, now on cellular.** Day 20664 `net.other.mobile.bg.activems =
52,392,613` — **14.6 h** of background mobile active time with **0 requests and
0 bytes**. The three-moment `isForeground()` sampling, exactly as diagnosed, so
the fg/bg split in this report still cannot be trusted.
Caveat: the benchmark client's round-the-clock always-on service makes its
battery figures non-comparable to a stock install. The relay and `net.other`
figures do match the release client's original report closely.
### Smoke test on a clean install (2026-07-31, benchmark client)
Uninstalled and reinstalled from branch HEAD so the account, ledger and
permission grant all started empty — which is what makes the first-grant and
empty-cache paths reachable. UID changed 10805 → 10806, cleanly separating the
new data.
- **R1 — no `Loading` flash on return to foreground: PASS.** Observed directly:
granted the permission, set a feed to Around Me, backgrounded for 10 s,
reopened — the geohash was present immediately with no blank feed. This was the
last open acceptance criterion on the branch and the only one no test could
cover. `dumpsys` shows why it works: the fix is delivered 16 ms after each
re-registration.
- **Precise profile live and distinct: PASS.** Geohash screens produce
`@+10s0ms BALANCED, minUpdateDistance=100.0`, and the aggregate's `min/max
interval` moved from `60s/60s` to `10s/60s`. Both profiles are real in
production, not just in the unit tests.
- **Refcount under concurrent flows: PASS.** The coarse registration `766C58A4`
came up at 15:49:10 and survived **four complete precise-flow cycles**
untouched (15:49:4146, 15:50:4752, 15:52:0715:53:05, 15:53:1722), with
both live simultaneously during the first. Opening and closing geohash chats
never closes the "Around Me" registration — `RefCountedSession` doing on a real
device what `LocationLedgerCompositionTest` asserts.
- **No hang in the location picker.** Every precise registration received a fix
within ~1 ms; none stuck open. That path does
`preciseGeohashStateFlow.first { it is Success }`, which would hang rather than
crash if the gate failed to yield.
- **Aggregate:** total 6m7.553s / active 6m7.401s (152 ms apart) / foreground
5m49.441s. Foreground trails total by 18.1 s across ~7 background transitions,
≈ 2.6 s each — the grace period, visible at a scale where it is easy to check.
**Measurement note:** counting listeners with `grep -c` on the package name is
unreliable — the `service: ProviderRequest[…]` summary line also names the
package when it is the only requester, inflating the count by one. List the rows
and exclude that line instead. A count of zero is unambiguous either way.
Still not verified: nothing on the gate itself. The `location.ms ≤ app.fgms +
5 s × transitions` invariant was separately confirmed from accumulated ledger
data (see above); the clean install reset that counter, so it will need another
day of use to re-check at scale.
## Follow-ups (not in this change)
- **`preciseGeohashStateFlow` is not actually building-level.** With only
`ACCESS_COARSE_LOCATION`, Android fuzzes every fix to roughly a 3 km grid, so
the 8-char geohash and the location chat channels built on it are far coarser
than they claim (`LocationState.kt:104-141`, `GeohashChatScreen.kt:165`,
`NewGeohashChatScreen.kt:309`). The profile is kept intact here so the intent
survives if the app ever requests `ACCESS_FINE_LOCATION`; whether to request
it, or to stop advertising building-level precision, is a separate decision.
- **`GeohashChatScreen.kt:161-163` is a one-way permission latch** — it calls
`setLocationPermission(true)` inside an `if (isGranted)` rather than passing
the boolean, as every other caller does (`LoggedInPage.kt:144`,
`LocationAsHash.kt:64`, `NewGeohashChatScreen.kt:285`,
`GeohashLocationPickerDialog.kt:270`). Once set, a revoked permission is never
reflected back into the shared `LocationState`. Small, in the blast radius,
and cheap.
- **An `Unavailable` `LocationResult`**, distinct from `LackPermission`, so a
device with no usable provider stops being told "No Location Permissions" when
it has them. Ten references across five files
(`DisplayLocationObserver.kt`, `FeedFilterSpinner.kt`, `HomeScreen.kt`,
`NewGeohashChatScreen.kt`, `LocationState.kt`). Belongs with the H1 fix
messaging — see the stated decision in §B.
- Recording the chosen provider as a ledger counter, so `location.ms` can be
read as a cost signal.
- Whether Products should still default to `TopFilter.AroundMe`.
- Finding 2 — the relay reconnect storm.
+87
View File
@@ -295,6 +295,93 @@
</intent-filter>
</activity-alias>
<!-- "New Picture" share target: a gallery (or camera) shares an image and it goes straight
into the picture feed as a NIP-68 kind-20 post instead of a text note with a link. The
android:name simple class ("ShareAsPictureAlias") is matched at runtime by
ShareIntentRouting.SHARE_AS_PICTURE_ALIAS_SIMPLE_NAME; keep the two in sync. -->
<activity-alias
android:name=".ui.ShareAsPictureAlias"
android:exported="true"
android:label="@string/share_target_as_picture"
android:targetActivity=".ui.MainActivity">
<intent-filter android:label="@string/share_target_as_picture">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<intent-filter android:label="@string/share_target_as_picture">
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity-alias>
<!-- "New Short" share target: a video shared here always becomes a NIP-71 kind-22 short so
it lands in the Shorts feed, whatever its orientation. Video-only — a short is a video.
The android:name simple class ("ShareAsShortVideoAlias") is matched at runtime by
ShareIntentRouting.SHARE_AS_SHORT_VIDEO_ALIAS_SIMPLE_NAME; keep the two in sync. -->
<activity-alias
android:name=".ui.ShareAsShortVideoAlias"
android:exported="true"
android:label="@string/share_target_as_short_video"
android:targetActivity=".ui.MainActivity">
<intent-filter android:label="@string/share_target_as_short_video">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter android:label="@string/share_target_as_short_video">
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
</activity-alias>
<!-- "New Video" share target: opens the Video feed's media composer, which publishes a
NIP-71 video event (kind 21 or 22 by orientation) instead of a text note. The
android:name simple class ("ShareAsVideoAlias") is matched at runtime by
ShareIntentRouting.SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME; keep the two in sync. -->
<activity-alias
android:name=".ui.ShareAsVideoAlias"
android:exported="true"
android:label="@string/share_target_as_video"
android:targetActivity=".ui.MainActivity">
<intent-filter android:label="@string/share_target_as_video">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter android:label="@string/share_target_as_video">
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
</activity-alias>
<!-- "New Highlight" share target: a browser (or reader) shares a selected passage of
text and it opens the NIP-84 highlight composer. Text-only — a highlight is a text
passage, so no image/video filters here. The android:name simple class
("ShareAsHighlightAlias") is matched at runtime by
ShareIntentRouting.SHARE_AS_HIGHLIGHT_ALIAS_SIMPLE_NAME; keep the two in sync. -->
<activity-alias
android:name=".ui.ShareAsHighlightAlias"
android:exported="true"
android:label="@string/share_target_as_highlight"
android:targetActivity=".ui.MainActivity">
<intent-filter android:label="@string/share_target_as_highlight">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity-alias>
<!-- Health Connect privacy-policy rationale on Android 14+. Without this activity-alias
the permission request fails silently (no dialog appears). The system launches it,
guarded by START_VIEW_PERMISSION_USAGE, to show our privacy policy; it routes into
@@ -56,11 +56,36 @@ import java.io.File
*/
class Amethyst : Application() {
init {
Log.minLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.ERROR
Log.minLevel = DEFAULT_LOG_LEVEL
Log.d("AmethystApp") { "Creating App $this" }
}
companion object {
/**
* Restores the full firehose in debug builds: per-socket relay lifecycle
* (`Connecting…`/`Disconnected`/`OnOpen`), per-second event throughput, per-stream Arti
* SOCKS errors and per-relay census detail.
*
* Off by default. A cold start on the outbox model dials ~400 relays, and those sources
* alone emit ~3.7k of the ~6.2k lines an 80s boot produces — which buries the boot
* narrative and the relay-protocol warnings (`auth-required`, `rate-limited`,
* `unsupported: too many filters`) that are the actionable ones. Flip this, or set
* [Log.minLevel] at runtime, when you need the per-socket detail back.
*/
const val VERBOSE_LOGS = false
/**
* Debug defaults to INFO so a boot reads as a narrative plus warnings; release defaults to
* WARN rather than ERROR so relay-protocol refusals stay visible in the field — they are
* the highest-value-per-line diagnostics we emit and were previously dropped entirely.
*/
val DEFAULT_LOG_LEVEL: LogLevel =
when {
!BuildConfig.DEBUG -> LogLevel.WARN
VERBOSE_LOGS -> LogLevel.DEBUG
else -> LogLevel.INFO
}
lateinit var instance: AppModules
private set
}
@@ -83,10 +108,15 @@ class Amethyst : Application() {
// is self-contained (WebView + IPC), so we skip all app init there and
// leave `instance` unset; any accidental use fails fast.
if (isNappletSandbox) {
Log.d("AmethystApp") { "Skipping AppModules init in sandbox process" }
// Milestone, not chatter: this is the one line that explains why `instance` is unset
// and `LocalCache` is empty in this process. Without it a napplet-process log looks
// like a broken app rather than a deliberately secret-free sandbox.
Log.i("AmethystApp") { "Napplet sandbox process starting — no account, no AppModules" }
return
}
Log.i("AmethystApp") { "Amethyst ${BuildConfig.VERSION_NAME} starting in main process (log level ${Log.minLevel})" }
instance = AppModules(this)
// Hydrate the device-local favorite-apps list (main process only; the sandbox never reads it).
@@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.model.preferences.BuzzChannelStarPreferences
import com.vitorpamplona.amethyst.model.preferences.BuzzWorkspacePreferences
import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences
import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences
import com.vitorpamplona.amethyst.model.preferences.RelayGroupDeletionPreferences
import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences
import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
@@ -104,6 +105,7 @@ import com.vitorpamplona.amethyst.service.resourceusage.HttpUsageMeter
import com.vitorpamplona.amethyst.service.resourceusage.MeteringNostrSigner
import com.vitorpamplona.amethyst.service.resourceusage.ProcessCpuSampler
import com.vitorpamplona.amethyst.service.resourceusage.RadioBurstEstimator
import com.vitorpamplona.amethyst.service.resourceusage.RefCountedSession
import com.vitorpamplona.amethyst.service.resourceusage.RelayConnectionTimeIntegrator
import com.vitorpamplona.amethyst.service.resourceusage.RelayUsageListener
import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageAccountant
@@ -245,10 +247,17 @@ class AppModules(
OtsSharedPreferences(appContext, applicationIOScope)
}
// App services that should be run as soon as there are subscribers to their flows
// App services that should be run as soon as there are subscribers to their
// flows. Location additionally releases its OS registration whenever no
// activity is started — see the foreground gate inside LocationState.
val locationManager by lazy {
Log.d("AppModules", "LocationManager Init")
LocationState(appContext, applicationIOScope, onListening = { locationSession.setActive(it) })
LocationState(
appContext,
applicationIOScope,
isForeground = foregroundTracker.isForeground,
onListening = { locationRefCount.setActive(it) },
)
}
val connManager = ConnectivityManager(appContext, applicationIOScope)
@@ -287,6 +296,11 @@ class AppModules(
// Restore + persist the user's starred Buzz workspace channels across restarts (device-global).
val buzzChannelStarPrefs = BuzzChannelStarPreferences(appContext, applicationIOScope)
// Restore + persist the set of relay-group channels deleted (kind-9008) on this device, so a
// deleted channel stays hidden across a restart even if the host relay re-announces a stale
// kind-44100 for it (device-global; a delete is authoritative and terminal for everyone).
val relayGroupDeletionPrefs = RelayGroupDeletionPreferences(appContext, applicationIOScope)
// Service that will run at all times to receive events from Pokey
val pokeyReceiver = PokeyReceiver()
@@ -368,6 +382,11 @@ class AppModules(
private val torSession = SessionTimeIntegrator(resourceUsage, UsageKeys.TOR_MS, UsageKeys.TOR_STARTS).also { it.registerFlushHook() }
private val locationSession = SessionTimeIntegrator(resourceUsage, UsageKeys.LOCATION_MS).also { it.registerFlushHook() }
// LocationState exposes two independent flows that can both be listening at
// once (the "Around Me" feed plus an open geohash chat). Refcounting keeps
// either one stopping from closing the other's segment.
private val locationRefCount = RefCountedSession(locationSession::setActive)
// Time-per-screen (route base names only — arguments never reach the
// ledger). Fed by the navigation listener in AppNavigation; foreground
// gating means backgrounding on a screen closes its segment.
@@ -1224,6 +1243,14 @@ class AppModules(
blossomResolver.uriToUrlCache.evictAll()
blossomResolver.blossomHitCache.cache.evictAll()
localBlossomCacheProbe.invalidate()
// Re-probe immediately so enabling the feature activates it
// this session. Otherwise `available` only advances when a
// `blossom:` URI is resolved, and the common feed-image path
// never routes through the resolver until `available` is
// already true — so a freshly-enabled toggle (or a cache that
// came up after launch) would stay dormant and the settings
// "detected" chip would read stale.
localBlossomCacheProbe.isAvailable()
}
}
}
@@ -73,6 +73,7 @@ import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@@ -132,6 +133,7 @@ private object PrefKeys {
const val DEFAULT_NSITES_FOLLOW_LIST = "defaultNsitesFollowList"
const val DEFAULT_WORKOUTS_FOLLOW_LIST = "defaultWorkoutsFollowList"
const val DEFAULT_GIT_REPOSITORIES_FOLLOW_LIST = "defaultGitRepositoriesFollowList"
const val DEFAULT_HIGHLIGHTS_FOLLOW_LIST = "defaultHighlightsFollowList"
const val DEFAULT_CALENDARS_FOLLOW_LIST = "defaultCalendarsFollowList"
const val DEFAULT_PRODUCTS_FOLLOW_LIST = "defaultProductsFollowList"
const val DEFAULT_SHORTS_FOLLOW_LIST = "defaultShortsFollowList"
@@ -324,6 +326,9 @@ object LocalPreferences {
}
if (!newSystemOfAccounts.isNullOrEmpty()) {
// How many accounts are in play is the first thing you need when reading any
// boot log: nearly every per-account subsystem below multiplies by this number.
Log.i("LocalPreferences") { "Found ${newSystemOfAccounts.size} saved account(s)" }
newSystemOfAccounts
} else {
val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(COMMA) ?: listOf()
@@ -522,6 +527,7 @@ object LocalPreferences {
putString(PrefKeys.DEFAULT_NSITES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNsitesFollowList.value))
putString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultWorkoutsFollowList.value))
putString(PrefKeys.DEFAULT_GIT_REPOSITORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultGitRepositoriesFollowList.value))
putString(PrefKeys.DEFAULT_HIGHLIGHTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHighlightsFollowList.value))
putString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultCalendarsFollowList.value))
putString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultProductsFollowList.value))
putString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultShortsFollowList.value))
@@ -710,6 +716,7 @@ object LocalPreferences {
private suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): AccountSettings? {
Log.d("LocalPreferences") { "Load account from file $npub" }
val startedAtMs = TimeUtils.nowMillis()
val result =
withContext(Dispatchers.IO) {
return@withContext with(encryptedPreferences(npub)) {
@@ -935,6 +942,7 @@ object LocalPreferences {
defaultNsitesFollowList = MutableStateFlow(followListPrefs.nsites),
defaultWorkoutsFollowList = MutableStateFlow(followListPrefs.workouts),
defaultGitRepositoriesFollowList = MutableStateFlow(followListPrefs.gitRepositories),
defaultHighlightsFollowList = MutableStateFlow(followListPrefs.highlights),
defaultCalendarsFollowList = MutableStateFlow(followListPrefs.calendars),
defaultProductsFollowList = MutableStateFlow(followListPrefs.products),
defaultShortsFollowList = MutableStateFlow(followListPrefs.shorts),
@@ -1014,7 +1022,11 @@ object LocalPreferences {
)
}
}
Log.d("LocalPreferences") { "Loaded account from file $npub" }
// Milestone with its cost attached. Decrypting and parsing one account's settings is one of
// the most expensive things a cold start does (it resolves a fan of backup events), it runs
// once per account, and "which account was slow" is the first question when a boot drags.
// The six intermediate steps above stay at DEBUG.
Log.i("LocalPreferences") { "Loaded account $npub in ${TimeUtils.nowMillis() - startedAtMs}ms" }
return result
}
@@ -1044,6 +1056,7 @@ object LocalPreferences {
val nsites: TopFilter,
val workouts: TopFilter,
val gitRepositories: TopFilter,
val highlights: TopFilter,
val calendars: TopFilter,
val products: TopFilter,
val shorts: TopFilter,
@@ -1101,6 +1114,7 @@ object LocalPreferences {
nsites = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NSITES_FOLLOW_LIST, null), TopFilter.Global),
workouts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, null), TopFilter.Global),
gitRepositories = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_GIT_REPOSITORIES_FOLLOW_LIST, null), TopFilter.Global),
highlights = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_HIGHLIGHTS_FOLLOW_LIST, null), TopFilter.Global),
calendars = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, null), TopFilter.Global),
products = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, null), TopFilter.AroundMe),
shorts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, null), TopFilter.Global),
@@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChann
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListState
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListState
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership
@@ -873,6 +874,9 @@ class Account(
val liveGitRepositoriesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultGitRepositoriesFollowList)
val liveGitRepositoriesFollowListsPerRelay = OutboxLoaderState(liveGitRepositoriesFollowLists, cache, scope).flow
val liveHighlightsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultHighlightsFollowList)
val liveHighlightsFollowListsPerRelay = OutboxLoaderState(liveHighlightsFollowLists, cache, scope).flow
val liveCalendarsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultCalendarsFollowList)
val liveCalendarsFollowListsPerRelay = OutboxLoaderState(liveCalendarsFollowLists, cache, scope).flow
@@ -3462,6 +3466,10 @@ class Account(
val template = DeleteGroupEvent.build(channel.groupId.id)
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
unfollow(channel)
// Remember the deletion so the channel leaves the community's browse list immediately and
// stays gone across a restart — the relay drops the group but our cached 39000 metadata (and a
// stale re-announced 44100 on a Buzz relay) would otherwise keep it visible.
RelayGroupDeletions.markDeleted(channel.groupId)
}
/**
@@ -3668,6 +3676,10 @@ class Account(
parent: String? = channel.parentGroupId(),
children: List<String> = channel.childGroupIds(),
) {
// On a Buzz relay, visibility rides a `visibility` ("open"/"private") tag — the relay does NOT
// read NIP-29's `private` status flag — so a Buzz channel's visibility only actually changes on
// edit when we send that tag. A plain NIP-29 relay ignores it and honours the status flag.
val isBuzz = BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)
val template =
EditMetadataEvent.build(
channel.groupId.id,
@@ -3679,10 +3691,24 @@ class Account(
geohashes = geohashes,
parent = parent,
children = children,
visibility = if (isBuzz) (if (isPrivate) BUZZ_VISIBILITY_PRIVATE else BUZZ_VISIBILITY_OPEN) else null,
)
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/**
* Archive or unarchive a Buzz channel (a minimal kind-9002 carrying only the `archived` tag). The
* relay hides an archived channel from the sidebar and stamps the 39000, but keeps it and its
* history the reversible counterpart to [deleteRelayGroup]. Admin/owner only; the relay enforces.
*/
suspend fun archiveRelayGroup(
channel: RelayGroupChannel,
archived: Boolean,
) {
val template = EditMetadataEvent.build(channel.groupId.id, archived = archived)
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
suspend fun follow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.follow(community))
suspend fun unfollow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.unfollow(community))
@@ -4119,6 +4145,7 @@ class Account(
alt: String?,
contentWarningReason: String?,
originalHash: String? = null,
videoKind: VideoPostKind = VideoPostKind.AUTO,
) {
if (!isWriteable()) return
@@ -4162,7 +4189,10 @@ class Account(
thumbhash = headerInfo.thumbHash?.thumbhash,
)
if (headerInfo.dim.height > headerInfo.dim.width) {
// The composer forces the kind when it was opened from a feed that only reads one of
// them (Shorts, Longs) or from that feed's share target, so the post lands where the
// user asked for it. Everywhere else the orientation decides.
if (videoKind.isShort(headerInfo.dim)) {
VideoShortEvent.build(videoMeta, alt ?: "") {
contentWarningReason?.let { contentWarning(contentWarningReason) }
}
@@ -251,6 +251,7 @@ class AccountSettings(
val defaultNsitesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultWorkoutsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultGitRepositoriesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultHighlightsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultCalendarsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultProductsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AroundMe),
val defaultShortsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
@@ -895,6 +896,17 @@ class AccountSettings(
}
}
fun changeDefaultHighlightsFollowList(name: FeedDefinition) {
changeDefaultHighlightsFollowList(name.code)
}
fun changeDefaultHighlightsFollowList(name: TopFilter) {
if (defaultHighlightsFollowList.value != name) {
defaultHighlightsFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultCalendarsFollowList(name: FeedDefinition) {
changeDefaultCalendarsFollowList(name.code)
}
@@ -89,7 +89,13 @@ class AntiSpamFilter {
val link2 = njumpLink(NAddress.create(event.kind, event.pubKey, event.dTag(), relay))
val link1 = existingAddress?.let { njumpLink(NAddress.create(it.kind, it.pubKeyHex, it.dTag, relay)) } ?: link2
Log.w("Duplicated/SPAM") { "${relay?.url} $link1 $link2" }
// Debug, not warn: a duplicate detection is this filter working, not a fault, and
// it is already reported where it can be acted on — relayStats.newSpam below and
// the flowSpam emission that drives the UI. On a normal boot this fires ~34 times
// with a pair of njump links each, which is the widest line in the log and says
// nothing the spam counters don't. Keep the links at DEBUG for when you need to
// open the two events and compare them.
Log.d("Duplicated/SPAM") { "${relay?.url} $link1 $link2" }
// Log down offenders
val spammer = logOffender(hash, event)
@@ -118,7 +124,13 @@ class AntiSpamFilter {
// LRU cache while the spammer record still matches this hash.
val link1 = existingEvent?.let { njumpLink(NEvent.create(it, null, null, relay)) } ?: link2
Log.w("Duplicated/SPAM") { "${relay?.url} $link1 $link2" }
// Debug, not warn: a duplicate detection is this filter working, not a fault, and
// it is already reported where it can be acted on — relayStats.newSpam below and
// the flowSpam emission that drives the UI. On a normal boot this fires ~34 times
// with a pair of njump links each, which is the widest line in the log and says
// nothing the spam counters don't. Keep the links at DEBUG for when you need to
// open the two events and compare them.
Log.d("Duplicated/SPAM") { "${relay?.url} $link1 $link2" }
// Log down offenders
val spammer = logOffender(hash, event)
@@ -40,12 +40,18 @@ import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
@@ -72,11 +78,15 @@ enum class HomeFeedType(
TEXT_NOTES("text_notes", listOf(TextNoteEvent.KIND)),
REPOSTS("reposts", listOf(RepostEvent.KIND, GenericRepostEvent.KIND)),
COMMENTS("comments", listOf(CommentEvent.KIND)),
PICTURES("pictures", listOf(PictureEvent.KIND)),
VIDEOS("videos", listOf(VideoNormalEvent.KIND, VideoHorizontalEvent.KIND)),
SHORTS("shorts", listOf(VideoShortEvent.KIND, VideoVerticalEvent.KIND)),
ARTICLES("articles", listOf(LongTextNoteEvent.KIND)),
WIKI("wiki", listOf(WikiNoteEvent.KIND)),
HIGHLIGHTS("highlights", listOf(HighlightEvent.KIND)),
POLLS("polls", listOf(PollEvent.KIND, ZapPollEvent.KIND, PollResponseEvent.KIND)),
CLASSIFIEDS("classifieds", listOf(ClassifiedsEvent.KIND)),
TORRENTS("torrents", listOf(TorrentEvent.KIND)),
VOICE("voice", listOf(VoiceEvent.KIND, VoiceReplyEvent.KIND)),
LIVE_ACTIVITIES("live_activities", listOf(LiveActivitiesEvent.KIND, LiveActivitiesChatMessageEvent.KIND)),
EPHEMERAL_CHAT("ephemeral_chat", listOf(EphemeralChatEvent.KIND)),
@@ -41,6 +41,7 @@ import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtIdHexComparator
import com.vitorpamplona.amethyst.commons.model.observables.EventListMatchingFilter
@@ -114,6 +115,7 @@ import com.vitorpamplona.quartz.buzz.stream.StreamMessageScheduledEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.buzz.stream.StreamReminderEvent
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
import com.vitorpamplona.quartz.buzz.stream.SystemMessagePayload
import com.vitorpamplona.quartz.buzz.stream.sidecars.ChannelSummaryEvent
import com.vitorpamplona.quartz.buzz.stream.sidecars.PresenceSnapshotEvent
import com.vitorpamplona.quartz.buzz.teams.TeamEvent
@@ -2223,6 +2225,30 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
attachToRelayGroupIfScoped(event, relay)
}
/**
* A Buzz kind-40099 system message. It renders as a narration row in the channel feed (via
* [consumeBuzzTimelineEvent]), but a `channel_deleted` one is also the **authoritative signal that
* a channel is gone**: the relay soft-deletes the channel and its 39000/39001/39002 discovery
* events but emits no member-removed notification and never retracts the kind-44100 that seeds the
* browse list — so without this the deleted channel keeps re-appearing (a stale 44100 re-announced
* every restart, its metadata now blank so it shows optimistically). Recording the delete in
* [RelayGroupDeletions] filters it out of every list and persists it across restarts.
*
* Gated on [isRelaySignedGroupEvent] (the 40099 is signed by the relay keypair) so a spoofed
* system message from a stray author can't hide a channel. Cross-device by construction: the relay
* replays this on subscribe, so a channel deleted on Buzz web/desktop is honored here too.
*/
private fun consume(
event: SystemMessageEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
consumeBuzzTimelineEvent(event, relay, wasVerified).also {
if (relay != null && isRelaySignedGroupEvent(event, relay) && event.payload()?.type == SystemMessagePayload.CHANNEL_DELETED) {
event.channel()?.let { channelId -> RelayGroupDeletions.markDeleted(GroupId(channelId, relay)) }
}
}
/** Store-only consume for Buzz kinds that carry no channel timeline row. */
private fun consumeBuzzRegularEvent(
event: Event,
@@ -4798,7 +4824,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
is StreamMessageV2Event -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is StreamMessageEditEvent -> consume(event, relay, wasVerified)
is StreamMessageDiffEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is SystemMessageEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is SystemMessageEvent -> consume(event, relay, wasVerified)
is CanvasEvent -> consume(event, relay, wasVerified)
// Forum root (45001) is a thread, not a chat row → Threads collection. Comments (45003)
// and votes (45002) are store-only: the forum-thread detail loads them on demand by root.
@@ -0,0 +1,53 @@
/*
* 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.nip94FileMetadata.tags.DimensionTag
/**
* Which NIP-71 kind a video upload is published as. Only videos are affected — whether an upload
* is a picture (NIP-68 kind 20) or a video is decided by the file's mime type and can't be
* overridden.
*
* The composer picks this from the feed it was opened on, so a post always lands in the feed the
* user was standing in (or shared to): the Shorts feed reads kind 22, the Longs feed reads kind 21
* and the Video feed reads both.
*/
enum class VideoPostKind {
/** Derive from the video's dimensions: portrait -> kind 22, landscape -> kind 21. */
AUTO,
/** Always NIP-71 kind 22 (short-form video), regardless of orientation. */
SHORT,
/** Always NIP-71 kind 21 (normal video), regardless of orientation. */
NORMAL,
;
/** True when a video of [dim] should be published as a NIP-71 kind 22 short. */
fun isShort(dim: DimensionTag): Boolean =
when (this) {
SHORT -> true
NORMAL -> false
AUTO -> dim.height > dim.width
}
}
@@ -0,0 +1,77 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.preferences
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
/**
* Device-global persistence for the set of deleted NIP-29 relay-group channels ([RelayGroupDeletions]),
* so a channel the user deleted (kind-9008) stays gone across a restart — even if the host relay keeps
* re-announcing a stale kind-44100 for it. Mirrors [BuzzChannelStarPreferences]: app-wide (not
* per-account), loads the saved keys into the singleton on construction, then writes every later change
* back. Construct once, eagerly.
*/
@Stable
class RelayGroupDeletionPreferences(
private val context: Context,
private val scope: CoroutineScope,
) {
init {
scope.launch {
restoreFromDisk()
// drop(1) skips the value present at collection start, which restoreFromDisk already wrote.
RelayGroupDeletions.flow.drop(1).collect { persist(it) }
}
}
private suspend fun restoreFromDisk() {
try {
val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return
if (raw.isNotEmpty()) RelayGroupDeletions.restore(raw)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("RelayGroupDeletionPrefs") { "Error reading deleted channels: ${e.message}" }
}
}
private suspend fun persist(keys: Set<String>) {
try {
context.sharedPreferencesDataStore.edit { prefs -> prefs[KEY] = keys }
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("RelayGroupDeletionPrefs") { "Error writing deleted channels: ${e.message}" }
}
}
companion object {
private val KEY = stringSetPreferencesKey("nip29.deletedChannels")
}
}
@@ -21,56 +21,137 @@
package com.vitorpamplona.amethyst.service.location
import android.annotation.SuppressLint
import android.content.Context
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.Looper
import com.vitorpamplona.amethyst.service.location.LocationState.Companion.MIN_DISTANCE
import com.vitorpamplona.amethyst.service.location.LocationState.Companion.MIN_TIME
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.launch
/**
* Wraps [LocationManager] update registration as a cold [Flow].
*
* Registers on **one** provider, chosen by [LocationProviderLadder], rather than
* on every provider the device reports. The previous shotgun cost four
* simultaneous registrations — passive, network, fused and gps, the last at
* HIGH_ACCURACY — to produce a 5 km geohash.
*
* Takes a [LocationManager] rather than a `Context` so the registration
* behaviour is unit-testable; the caller does the `getSystemService` lookup.
*
* [onListening] is fired from inside the flow, after a registration succeeds, and
* released again from the `try`/`finally` that wraps everything after it, never
* as an `onStart`/`onCompletion` pair on the returned flow. The distinction
* matters: an `onStart` fires on collection even when nothing registered, so a
* device with no usable provider would accrue location time with no location
* running, and — because the ledger refcounts the two [LocationState] flows
* together — the unpaired close would steal the other flow's holder.
*
* The pair is kept honest from both ends. The acquire cannot fire without a
* registration, because a failure to register throws before reaching it. The
* release cannot be skipped, because everything after the acquire runs inside a
* `try`/`finally` rather than inside `awaitClose` — [freshestLastKnownLocation]
* (the seed sweep below) can throw a non-cancellation exception and unwind
* before `awaitClose` is ever reached, and `try`/`finally` is what still runs
* the release on that path; see `releasesTheRegistrationWhenTheSeedThrows` in
* `LocationFlowTest`. (In principle a collector cancelling mid-seed would also
* unwind past `awaitClose` the same way, but that path could not be
* reproduced — `callbackFlow`'s buffer is empty at this point, so the single
* seed send returns without suspending and never observes the cancellation.)
*/
class LocationFlow(
private val context: Context,
private val locationManager: LocationManager,
private val sdkInt: Int = Build.VERSION.SDK_INT,
) {
@SuppressLint("MissingPermission")
fun get(
minTimeMs: Long = MIN_TIME,
minDistanceM: Float = MIN_DISTANCE,
minTimeMs: Long,
minDistanceM: Float,
onListening: ((Boolean) -> Unit)? = null,
): Flow<Location> =
callbackFlow {
Log.i("LocationFlow", "Start")
val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
val locationCallback =
LocationListener { location ->
Log.d("LocationFlow") { "onLocationChanged $location" }
launch { send(location) }
}
locationManager.allProviders.forEach {
val location = locationManager.getLastKnownLocation(it)
Log.d("LocationFlow") { "Last Known location is $location" }
if (location != null) {
send(location)
}
Log.d("LocationFlow", "Requesting Updates")
locationManager.requestLocationUpdates(
it,
minTimeMs,
minDistanceM,
locationCallback,
Looper.getMainLooper(),
)
}
// One binder call, reused for both the ladder filter and the seed.
val providers = locationManager.allProviders
awaitClose {
Log.i("LocationFlow", "Stop")
val candidates = LocationProviderLadder.chooseProviders(sdkInt) { it in providers }
val registered =
candidates.firstOrNull { requestUpdates(it, minTimeMs, minDistanceM, locationCallback) }
?: throw SecurityException("No usable location provider. Candidates: $candidates")
Log.i("LocationFlow") { "Listening on $registered every ${minTimeMs}ms / ${minDistanceM}m" }
onListening?.invoke(true)
// Cleanup lives in this finally, not in awaitClose — see the class
// KDoc, and `releasesTheRegistrationWhenTheSeedThrows` in
// LocationFlowTest, which fails if it moves.
try {
// Seeded after registration so the no-provider path throws
// without having emitted anything; seeding first would show the
// consumer Success -> LackPermission on a device with no
// compatible provider.
freshestLastKnownLocation(candidates)?.let {
Log.d("LocationFlow") { "Last known location is $it" }
send(it)
}
awaitClose { }
} finally {
Log.i("LocationFlow") { "Stopped listening on $registered" }
locationManager.removeUpdates(locationCallback)
onListening?.invoke(false)
}
}
/** True when the registration was accepted; false when the provider refused it. */
@SuppressLint("MissingPermission")
private fun requestUpdates(
provider: String,
minTimeMs: Long,
minDistanceM: Float,
locationCallback: LocationListener,
): Boolean =
try {
locationManager.requestLocationUpdates(
provider,
minTimeMs,
minDistanceM,
locationCallback,
Looper.getMainLooper(),
)
true
} catch (e: SecurityException) {
Log.w("LocationFlow", "Provider $provider refused the update request", e)
false
}
/**
* The freshest cached fix across the providers the ladder deemed usable.
* Sweeping every provider the device reports instead would, on the
* coarse-only legacy path, pay a guaranteed-to-throw binder call per
* fine-only provider on every flow start. Still guarded per provider, like
* the update request is — on a device where one refuses us, the others
* should still seed.
*/
@SuppressLint("MissingPermission")
private fun freshestLastKnownLocation(providers: List<String>): Location? =
providers
.mapNotNull { provider ->
try {
locationManager.getLastKnownLocation(provider)
} catch (e: SecurityException) {
Log.w("LocationFlow", "No permission to read the last known location of $provider", e)
null
}
}.maxByOrNull { it.time }
}
@@ -0,0 +1,72 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.location
import android.location.LocationManager
import android.os.Build
/**
* Picks which location providers to try, in order.
*
* Deliberately selects on **provider existence**, never on
* [LocationManager.isProviderEnabled]. A registration on a disabled provider
* goes live by itself when the user enables location — including from the
* quick-settings shade without leaving the app, which is exactly what someone
* does after seeing an empty "Around Me" feed. An enabled-state guard evaluated
* once at subscription start would lose that.
*
* Below API 31, `gps`, `passive` and `fused` required `ACCESS_FINE_LOCATION`;
* only `network` accepted `ACCESS_COARSE_LOCATION`. Approximate location, which
* lets a coarse-only app request any provider and receive a fuzzed result, is an
* Android 12 change. Amethyst declares coarse only, so the legacy branch is
* unconditional below API 31.
*
* Adding `ACCESS_FINE_LOCATION` later would **not** widen this on its own — the
* branch below has no permission input, so pre-31 devices would keep getting
* `network` alone and silently lose the precision the new permission was granted
* for. Whoever adds it must widen the condition here too.
*
* Returns the ordered candidate list rather than a single choice so the caller
* can fall through to the next rung if a registration is refused. An empty list
* means no compatible provider exists.
*/
object LocationProviderLadder {
// Compile-time String constants, inlined by the compiler, so naming
// FUSED_PROVIDER (added in API 31) is safe on older runtimes.
private val FULL_LADDER =
listOf(
LocationManager.FUSED_PROVIDER,
LocationManager.NETWORK_PROVIDER,
LocationManager.GPS_PROVIDER,
LocationManager.PASSIVE_PROVIDER,
)
private val COARSE_ONLY_LEGACY_LADDER = listOf(LocationManager.NETWORK_PROVIDER)
fun chooseProviders(
sdkInt: Int,
exists: (String) -> Boolean,
): List<String> {
val ladder = if (sdkInt >= Build.VERSION_CODES.S) FULL_LADDER else COARSE_ONLY_LEGACY_LADDER
return ladder.filter(exists)
}
}
@@ -21,32 +21,83 @@
package com.vitorpamplona.amethyst.service.location
import android.content.Context
import android.location.Location
import android.location.LocationManager
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChannelLevel
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeohashPrecision
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
// `toGeoHash` is an extension on Location declared in LocationGeoHash.kt, same
// package, so it needs no import.
/**
* Turns the device's location into geohashes, listening **only while the app is
* in the foreground**.
*
* The gate is not an optimisation of last resort: `Account` builds 30
* `SharingStarted.Eagerly` top-nav filter states on the account scope, and
* `AccountSettings.defaultProductsFollowList` ships as `TopFilter.AroundMe`, so
* without it every user with location permission holds a registration for the
* life of the process. See `amethyst/plans/2026-07-29-location-foreground-gate.md`.
*
* Switching the *consumers* to `WhileSubscribed` is not an option: roughly 60
* call sites read `account.live*FollowLists.value` synchronously rather than
* collecting, and would silently serve a stale or initial value.
*/
class LocationState(
context: Context,
scope: CoroutineScope,
/** Resource-ledger hook: true while GPS/location updates are actively requested. */
private val scope: CoroutineScope,
private val isForeground: StateFlow<Boolean>,
/**
* Resource-ledger hook: true while location updates are actively
* registered. Reaches the OS only through the default [locationSource],
* which hands it to [LocationFlow] — a caller that overrides
* [locationSource] (the tests do) is responsible for firing it, or not.
*/
private val onListening: ((Boolean) -> Unit)? = null,
private val locationSource: (Long, Float) -> Flow<Location> = { minTimeMs, minDistanceM ->
LocationFlow(context.getSystemService(Context.LOCATION_SERVICE) as LocationManager)
.get(minTimeMs, minDistanceM, onListening)
},
) {
companion object {
const val MIN_TIME: Long = 10000L
const val MIN_DISTANCE: Float = 100.0f
/** A 5 km cell takes 2.5 minutes to cross at 120 km/h; 60s/500m is ample. */
const val COARSE_MIN_TIME: Long = 60_000L
const val COARSE_MIN_DISTANCE: Float = 500.0f
/** Building-level geohashes need the tighter profile. */
const val PRECISE_MIN_TIME: Long = 10_000L
const val PRECISE_MIN_DISTANCE: Float = 100.0f
/**
* How long to keep listening after the last activity stops, so a
* one-second app switch doesn't destroy and rebuild the registration.
* Same intent as [SUBSCRIPTION_STOP_TIMEOUT_MS], on the other axis.
*/
const val BACKGROUND_GRACE_MS: Long = 5_000L
/**
* How long `stateIn` keeps the upstream alive after the last collector
* leaves, so a screen rotation or a tab switch doesn't rebuild the
* registration either.
*/
const val SUBSCRIPTION_STOP_TIMEOUT_MS: Long = 5_000L
}
sealed class LocationResult {
@@ -59,9 +110,16 @@ class LocationState(
object Loading : LocationResult()
}
private enum class Gate { NoPermission, Paused, Listen }
private var hasLocationPermission = MutableStateFlow(false)
private var latestLocation: LocationResult = LocationResult.Loading
private var latestPreciseLocation: LocationResult = LocationResult.Loading
// Read by R1 below to decide whether to emit Loading, from a different
// coroutine than the onEach that writes it — hence a StateFlow rather than
// a plain field.
private val latestLocation = MutableStateFlow<LocationResult>(LocationResult.Loading)
private val latestPreciseLocation = MutableStateFlow<LocationResult>(LocationResult.Loading)
fun setLocationPermission(newValue: Boolean) {
if (newValue != hasLocationPermission.value) {
@@ -69,36 +127,92 @@ class LocationState(
}
}
/**
* Foreground with an asymmetric delay: leaving the foreground waits out
* [BACKGROUND_GRACE_MS], returning to it is immediate.
*
* `debounce(5000)` would delay both edges, and the duration-selector
* overload that allows an asymmetric delay is `@FlowPreview`.
* `transformLatest` cancels the pending `delay` when foreground returns
* first, which is exactly the semantics wanted, with no preview opt-in.
*
* Known and harmless: [ForegroundTracker] starts at `false`, so on a
* process that starts backgrounded the first emission — and therefore the
* first gate verdict, including `LackPermission` — is delayed by
* [BACKGROUND_GRACE_MS]. Nothing renders while backgrounded, and a process
* that starts into the foreground emits immediately, because the activity's
* `onStart` cancels the pending delay.
*/
@OptIn(ExperimentalCoroutinesApi::class)
val geohashStateFlow by lazy {
hasLocationPermission
.transformLatest {
if (it) {
emit(LocationResult.Loading)
val result =
LocationFlow(context)
.get(MIN_TIME, MIN_DISTANCE)
.onStart { onListening?.invoke(true) }
.onCompletion { onListening?.invoke(false) }
.map {
LocationResult.Success(it.toGeoHash(GeohashPrecision.KM_5_X_5.digits)) as LocationResult
}.onEach {
latestLocation = it
}.catch { e ->
Log.w("GeohashStateFlow", "Exception in the flow", e)
latestLocation = LocationResult.LackPermission
emit(LocationResult.LackPermission)
}
private val settledForeground: Flow<Boolean> =
isForeground.transformLatest { foreground ->
if (!foreground) delay(BACKGROUND_GRACE_MS)
emit(foreground)
}
emitAll(result)
} else {
emit(LocationResult.LackPermission)
private val gate: Flow<Gate> =
combine(hasLocationPermission, settledForeground) { permitted, foreground ->
when {
!permitted -> Gate.NoPermission
foreground -> Gate.Listen
else -> Gate.Paused
}
}.distinctUntilChanged()
@OptIn(ExperimentalCoroutinesApi::class)
private fun buildGeohashStateFlow(
tag: String,
charsCount: Int,
minTimeMs: Long,
minDistanceM: Float,
cache: MutableStateFlow<LocationResult>,
): StateFlow<LocationResult> =
gate
.transformLatest { state ->
when (state) {
// Deliberately does NOT write to the cache. Today's code emits
// LackPermission without touching the cache, and wiping it
// here would cost a Loading emission — and so an empty-feed
// flash — on every permission flap, which is the regression R1
// exists to prevent. Consumers already see LackPermission from
// the StateFlow; the cache is internal and only decides whether
// Loading is emitted.
Gate.NoPermission -> emit(LocationResult.LackPermission)
// Emit nothing: stateIn keeps the last value, so every
// synchronous .value reader still sees the last known geohash
// while the OS registration is released.
Gate.Paused -> Unit
Gate.Listen -> {
// Only when there is nothing cached. Emitting Loading on
// every foreground return would flash the "Around Me" feed
// empty, because AroundMeFeedFlow.convert maps anything
// that is not Success to an empty geotag set.
if (cache.value !is LocationResult.Success) emit(LocationResult.Loading)
emitAll(
locationSource(minTimeMs, minDistanceM)
.map { LocationResult.Success(it.toGeoHash(charsCount)) as LocationResult }
.onEach { cache.value = it }
.catch { e ->
Log.w(tag, "Exception in the flow", e)
cache.value = LocationResult.LackPermission
emit(LocationResult.LackPermission)
},
)
}
}
}.stateIn(
scope,
SharingStarted.WhileSubscribed(5000),
latestLocation,
)
}.stateIn(scope, SharingStarted.WhileSubscribed(SUBSCRIPTION_STOP_TIMEOUT_MS), cache.value)
val geohashStateFlow: StateFlow<LocationResult> by lazy {
buildGeohashStateFlow(
tag = "GeohashStateFlow",
charsCount = GeohashPrecision.KM_5_X_5.digits,
minTimeMs = COARSE_MIN_TIME,
minDistanceM = COARSE_MIN_DISTANCE,
cache = latestLocation,
)
}
/**
@@ -107,36 +221,19 @@ class LocationState(
* to every coarser level (a geohash is a prefix code), so one fix yields the
* whole region→building ladder. Kept separate so the coarser
* [geohashStateFlow] the "around me" feed relies on is unchanged.
*
* Note that Amethyst declares only `ACCESS_COARSE_LOCATION`, so Android
* fuzzes every fix to roughly a 3 km grid and this is not in fact
* building-level today. The profile is kept so the intent survives if the
* app ever requests `ACCESS_FINE_LOCATION`.
*/
@OptIn(ExperimentalCoroutinesApi::class)
val preciseGeohashStateFlow by lazy {
hasLocationPermission
.transformLatest {
if (it) {
emit(LocationResult.Loading)
val result =
LocationFlow(context)
.get(MIN_TIME, MIN_DISTANCE)
.onStart { onListening?.invoke(true) }
.onCompletion { onListening?.invoke(false) }
.map {
LocationResult.Success(it.toGeoHash(GeohashChannelLevel.BUILDING.chars)) as LocationResult
}.onEach {
latestPreciseLocation = it
}.catch { e ->
Log.w("GeohashStateFlow", "Exception in the precise flow", e)
latestPreciseLocation = LocationResult.LackPermission
emit(LocationResult.LackPermission)
}
emitAll(result)
} else {
emit(LocationResult.LackPermission)
}
}.stateIn(
scope,
SharingStarted.WhileSubscribed(5000),
latestPreciseLocation,
)
val preciseGeohashStateFlow: StateFlow<LocationResult> by lazy {
buildGeohashStateFlow(
tag = "PreciseGeohashStateFlow",
charsCount = GeohashChannelLevel.BUILDING.chars,
minTimeMs = PRECISE_MIN_TIME,
minDistanceM = PRECISE_MIN_DISTANCE,
cache = latestPreciseLocation,
)
}
}
@@ -389,7 +389,7 @@ object NotificationUtils {
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
return NotificationCompat.Action
.Builder(R.drawable.ic_notif_reply, stringRes(applicationContext, R.string.app_notification_reply_label), replyPendingIntent)
.Builder(R.drawable.ic_action_reply, stringRes(applicationContext, R.string.app_notification_reply_label), replyPendingIntent)
.addRemoteInput(replyRemoteInput(applicationContext))
.setAllowGeneratedReplies(true)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
@@ -460,7 +460,7 @@ object NotificationUtils {
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
return NotificationCompat.Action
.Builder(R.drawable.ic_notif_message, stringRes(applicationContext, R.string.app_notification_mark_read_label), markReadPendingIntent)
.Builder(R.drawable.ic_action_mark_read, stringRes(applicationContext, R.string.app_notification_mark_read_label), markReadPendingIntent)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
.build()
}
@@ -53,9 +53,9 @@ import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderTop
import com.vitorpamplona.amethyst.service.playback.composable.controls.TopGradientOverlay
import com.vitorpamplona.amethyst.service.playback.composable.controls.fullscreenSwipeControls
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.AudioPlayingAnimation
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.rememberIsAudioTrack
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
import com.vitorpamplona.amethyst.ui.components.getDialogWindow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -105,7 +105,7 @@ fun RenderVideoPlayer(
// unnecessary recomposition of the whole player tree just to update a value that is only
// ever read inside the onDoubleTap callback below.
val containerWidth = remember { intArrayOf(0) }
val isLive = remember(mediaItem.src.videoUri) { isLiveStreaming(mediaItem.src.videoUri) }
val isLive = remember(mediaItem.src.videoUri, mediaItem.src.mimeType) { isHlsMedia(mediaItem.src.videoUri, mediaItem.src.mimeType) }
val swipeState = remember { FullscreenSwipeControlsState() }
val context = LocalContext.current
@@ -63,7 +63,7 @@ import com.vitorpamplona.amethyst.service.cast.CastSessionState
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia
import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity
import com.vitorpamplona.amethyst.ui.cast.CastDevicePickerDialog
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
@@ -113,7 +113,7 @@ fun RenderTopButtons(
accountViewModel: AccountViewModel,
) {
val context = LocalContext.current
val isLive = remember(mediaData.videoUri) { isLiveStreaming(mediaData.videoUri) }
val isLive = remember(mediaData.videoUri, mediaData.mimeType) { isHlsMedia(mediaData.videoUri, mediaData.mimeType) }
val pipSupported =
remember {
context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
@@ -0,0 +1,51 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
import androidx.media3.common.MimeTypes
/**
* Whether a URL plus its imeta mime identifies HLS.
*
* This is the caller-side form for code that holds a URL and a mime but no `MediaItem` yet — UI that
* has a [MediaItemData] or a `MediaUrlContent`. Once a `MediaItem` exists,
* `isHlsMediaItem` is the equivalent, and both must answer the same way: the UI decides what to
* render, the factory decides how to load it, and a disagreement shows up as a player that streams
* something the surrounding chrome says is a still image.
*
* Defined in terms of [MediaItemCache.toExoPlayerMimeType] rather than re-deriving the rules, so it
* cannot drift from the mime that actually reaches ExoPlayer. That normalizer prefers an explicit
* mime (mapping the four HLS aliases onto [MimeTypes.APPLICATION_M3U8]) and otherwise falls back to a
* **path-anchored** `.m3u8` test.
*
* Both halves matter. A BUD-10 blossom playlist is `https://host/<sha256>` with no extension at all,
* so only the mime identifies it; and anchoring to the path stops `video.mp4?ref=a.m3u8` counting as
* HLS on the strength of its query string.
*
* Note this answers *is it HLS*, which callers use as a proxy for *is it live*. The proxy is
* imprecise in the same way for every HLS URL — an on-demand HLS playlist also answers true — and
* that imprecision is older than this function. Liveness is only truly knowable from
* `#EXT-X-ENDLIST` once the playlist is loaded, which is what `HlsLivenessCache` records.
*/
fun isHlsMedia(
url: String,
mimeType: String?,
): Boolean = MediaItemCache.toExoPlayerMimeType(mimeType, url) == MimeTypes.APPLICATION_M3U8
@@ -20,10 +20,13 @@
*/
package com.vitorpamplona.amethyst.service.playback.playerPool
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import androidx.media3.common.util.Util
import androidx.media3.datasource.DataSource
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
@@ -31,7 +34,6 @@ import com.vitorpamplona.amethyst.service.playback.PLAYBACK_DIAG_TAG
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemCache
import com.vitorpamplona.amethyst.service.playback.diskCache.HlsLivenessCache
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
import com.vitorpamplona.quartz.utils.Log
/**
@@ -58,6 +60,26 @@ internal fun shouldBypassCache(
else -> true
}
/**
* Whether a [MediaItem] is HLS, via media3's own content-type inference.
*
* This is the one "is this HLS?" predicate for playback routing. [CustomMediaSourceFactory] uses it
* to pick both the cache route and the media-source factory, and [HlsLivenessRecorder] uses it to
* decide which items are worth learning a liveness verdict for. Those two **must** agree: if the
* recorder tested more narrowly than the factory, an item the factory treats as HLS would never be
* classified, [HlsLivenessCache] would answer `isKnownOnDemand = false` for it forever, and
* [shouldBypassCache] would keep it out of the disk cache permanently.
*
* It reads back the mimeType [MediaItemCache] already normalised, which is what makes it correct for
* BUD-10 blossom URIs — `https://host/<sha256>`, no extension, mime as the only signal. A `.m3u8`
* substring test on the URL misses those, and separately gives a false positive on a query string
* over progressive media (`video.mp4?ref=a.m3u8`).
*/
internal fun isHlsMediaItem(mediaItem: MediaItem?): Boolean {
val config = mediaItem?.localConfiguration ?: return false
return Util.inferContentTypeForUriAndMimeType(config.uri, config.mimeType) == C.CONTENT_TYPE_HLS
}
/**
* Decides whether a [MediaItem] plays through the caching data source or bypasses it.
*
@@ -81,20 +103,42 @@ class CustomMediaSourceFactory(
videoCache: VideoCache,
dataSourceFactory: DataSource.Factory,
) : MediaSource.Factory {
private var cachingFactory: MediaSource.Factory =
DefaultMediaSourceFactory(videoCache.get(dataSourceFactory))
private var nonCachingFactory: MediaSource.Factory =
private val cachingDataSource: DataSource.Factory = videoCache.get(dataSourceFactory)
private val cachingFactory: MediaSource.Factory =
DefaultMediaSourceFactory(cachingDataSource)
private val nonCachingFactory: MediaSource.Factory =
DefaultMediaSourceFactory(dataSourceFactory)
// Stateless, so one instance serves both HLS factories.
private val playlistParserFactory = LowLatencyStrippingHlsPlaylistParserFactory()
// HLS is built explicitly rather than through DefaultMediaSourceFactory, which exposes no hook
// for a playlist parser factory. See LowLatencyStrippingHlsPlaylistParserFactory for why we need
// one. Everything else still routes through the Default factories above.
//
// The cost of bypassing it: HLS items skip what DefaultMediaSourceFactory wraps around the
// source — side-loaded subtitleConfigurations (MergingMediaSource), clipping, ad insertion, and
// live target-offset defaults. None are reachable today (MediaItemCache sets none of them, and
// the live setters aren't on the MediaSource.Factory interface), but anything added later must
// be mirrored here.
private val cachingHlsFactory: MediaSource.Factory = hlsFactory(cachingDataSource)
private val nonCachingHlsFactory: MediaSource.Factory = hlsFactory(dataSourceFactory)
private fun hlsFactory(dataSource: DataSource.Factory): MediaSource.Factory =
HlsMediaSource
.Factory(dataSource)
.setPlaylistParserFactory(playlistParserFactory)
private val allFactories = listOf(cachingFactory, nonCachingFactory, cachingHlsFactory, nonCachingHlsFactory)
override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory {
cachingFactory.setDrmSessionManagerProvider(drmSessionManagerProvider)
nonCachingFactory.setDrmSessionManagerProvider(drmSessionManagerProvider)
allFactories.forEach { it.setDrmSessionManagerProvider(drmSessionManagerProvider) }
return this
}
override fun setLoadErrorHandlingPolicy(loadErrorHandlingPolicy: LoadErrorHandlingPolicy): MediaSource.Factory {
cachingFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
nonCachingFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
allFactories.forEach { it.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy) }
return this
}
@@ -103,18 +147,24 @@ class CustomMediaSourceFactory(
override fun createMediaSource(mediaItem: MediaItem): MediaSource {
val id = mediaItem.mediaId
val flaggedLive = isFlaggedLive(mediaItem)
val hls = isLiveStreaming(id)
// One predicate governs both the cache routing below and which factory builds the source, so
// the two can never disagree. See isHlsMediaItem.
val hls = isHlsMediaItem(mediaItem)
val knownOnDemand = HlsLivenessCache.isKnownOnDemand(id)
val bypassCache = shouldBypassCache(flaggedLive, hls, knownOnDemand)
val source =
if (bypassCache) {
nonCachingFactory.createMediaSource(mediaItem)
val factory =
if (hls) {
if (bypassCache) nonCachingHlsFactory else cachingHlsFactory
} else {
cachingFactory.createMediaSource(mediaItem)
if (bypassCache) nonCachingFactory else cachingFactory
}
// Logs the three routing inputs directly rather than a re-derived label, so it can't drift
// from shouldBypassCache.
val source = factory.createMediaSource(mediaItem)
// Logs the routing inputs directly rather than a re-derived label, so it can't drift from
// shouldBypassCache.
Log.d(PLAYBACK_DIAG_TAG) {
"SOURCE ${if (bypassCache) "BYPASS" else "CACHE"} flaggedLive=$flaggedLive hls=$hls knownOnDemand=$knownOnDemand " +
"mime=${mediaItem.localConfiguration?.mimeType} -> ${source::class.java.simpleName} id=$id"
@@ -25,7 +25,6 @@ import androidx.media3.common.Player
import androidx.media3.common.Timeline
import com.vitorpamplona.amethyst.service.playback.PLAYBACK_DIAG_TAG
import com.vitorpamplona.amethyst.service.playback.diskCache.HlsLivenessCache
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
import com.vitorpamplona.quartz.utils.Log
/**
@@ -66,7 +65,7 @@ internal fun livenessVerdictToRecord(
* reliable live/on-demand discriminator (`#EXT-X-ENDLIST`) is inside the playlist, so it is knowable
* only once ExoPlayer has loaded it — hence learning it here rather than from the URL.
*
* Only `.m3u8` items are considered; progressive media is unambiguous and never routed by liveness.
* Only HLS items are considered; progressive media is unambiguous and never routed by liveness.
*/
class HlsLivenessRecorder(
private val player: Player,
@@ -85,8 +84,11 @@ class HlsLivenessRecorder(
private fun maybeRecord(allowOnDemand: Boolean) {
if (player.currentTimeline.isEmpty) return
val url = player.currentMediaItem?.mediaId ?: return
if (!isLiveStreaming(url)) return
val mediaItem = player.currentMediaItem ?: return
// Must be the same predicate CustomMediaSourceFactory routes on — see isHlsMediaItem for
// what goes wrong when the two disagree.
if (!isHlsMediaItem(mediaItem)) return
val url = mediaItem.mediaId
val known = HlsLivenessCache.verdict(url)
val toRecord =
@@ -0,0 +1,165 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback.playerPool
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.hls.playlist.DefaultHlsPlaylistParserFactory
import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist
import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist
import androidx.media3.exoplayer.hls.playlist.HlsPlaylist
import androidx.media3.exoplayer.hls.playlist.HlsPlaylistParserFactory
import androidx.media3.exoplayer.upstream.ParsingLoadable
import java.io.ByteArrayInputStream
import java.io.InputStream
/**
* Low-Latency HLS tags that we remove before media3's playlist parser sees them.
*
* `EXT-X-PART` and `EXT-X-PRELOAD-HINT` are the ones that matter: they are the only things in these
* playlists that produce a **byte-range-bounded** chunk, which is what triggers the crash documented
* on [LowLatencyStrippingHlsPlaylistParserFactory]. `EXT-X-PART-INF` and `EXT-X-SERVER-CONTROL` go
* with them — leaving those behind advertises a low-latency contract (PART-TARGET, PART-HOLD-BACK,
* CAN-BLOCK-RELOAD) that the stripped playlist can no longer honour.
*
* Deliberately **not** stripped:
* - `EXT-X-SKIP` — marks a delta playlist whose segments were legitimately omitted. Removing the tag
* while the segments stay missing would corrupt the playlist. Dropping `EXT-X-SERVER-CONTROL`
* already stops media3 requesting deltas (`_HLS_skip=YES`), so this should never appear anyway.
* - `EXT-X-RENDITION-REPORT` — inert once the parts are gone.
*/
private val LOW_LATENCY_TAGS =
listOf(
"#EXT-X-PART:",
"#EXT-X-PART-INF:",
"#EXT-X-PRELOAD-HINT:",
"#EXT-X-SERVER-CONTROL:",
)
/**
* Removes the Low-Latency HLS tags from a playlist, leaving every other byte untouched.
*
* Line separators are preserved exactly: the split/join round-trips `\n`, keeps the `\r` of a CRLF
* playlist as trailing content, and keeps a trailing newline (which `split` surfaces as a final
* empty element). Blank lines are never dropped.
*/
internal fun stripLowLatencyTags(playlist: String): String {
// Cheap pre-check: the overwhelming majority of playlists carry no LL tags at all, and this
// runs on every playlist reload of every live stream.
if (LOW_LATENCY_TAGS.none { playlist.contains(it) }) return playlist
return playlist
.split("\n")
.filterNot { line ->
val trimmed = line.trimStart()
LOW_LATENCY_TAGS.any { trimmed.startsWith(it) }
}.joinToString("\n")
}
/**
* The byte-level form: decode as UTF-8, strip, re-encode.
*
* Split out from [LowLatencyStrippingParser] so the charset round-trip is reachable from a plain JVM
* unit test — `parse` takes an `android.net.Uri`, which stubs to null under
* `unitTests.isReturnDefaultValues`, so nothing that goes through it is testable without Robolectric.
*
* A UTF-8 BOM survives: decoding leaves U+FEFF in the string, `trimStart` does not treat it as
* whitespace, and re-encoding reproduces the same three bytes. When there is nothing to strip the
* *original array* is returned, so the common path neither re-encodes nor copies.
*/
internal fun stripLowLatencyTags(playlist: ByteArray): ByteArray {
val original = playlist.toString(Charsets.UTF_8)
val stripped = stripLowLatencyTags(original)
return if (stripped === original) playlist else stripped.toByteArray(Charsets.UTF_8)
}
/**
* Wraps a media3 playlist parser and strips the Low-Latency tags before delegating.
*
* Playlists are a few KB, so reading the stream fully into memory is cheaper than the alternative of
* a streaming line filter and keeps the transform a pure, testable [stripLowLatencyTags] call.
*/
@UnstableApi
internal class LowLatencyStrippingParser(
private val delegate: ParsingLoadable.Parser<HlsPlaylist>,
) : ParsingLoadable.Parser<HlsPlaylist> {
override fun parse(
uri: Uri,
inputStream: InputStream,
): HlsPlaylist = delegate.parse(uri, ByteArrayInputStream(stripLowLatencyTags(inputStream.readBytes())))
}
/**
* Serves media3 a de-low-latency-ed view of every HLS playlist.
*
* ## Why
*
* media3 crashes fatally on a Low-Latency HLS playlist whose parts are byte ranges — which is what
* zap-stream-core emits (`#EXT-X-PART:URI="…",DURATION=…,BYTERANGE="359712@0"`). Reproduced on
* media3 1.10.1, Pixel 9a / Android 17, ~0.6s after `ExoPlayerImpl.Init`:
*
* ```
* IllegalArgumentException
* at androidx.media3.datasource.DataSpec.<init> // checkArgument(length > 0 || length == LENGTH_UNSET)
* at androidx.media3.datasource.DataSpec.subrange
* at androidx.media3.exoplayer.hls.HlsMediaChunk.feedDataToExtractor
* at androidx.media3.exoplayer.hls.HlsMediaChunk.loadMedia
* ```
*
* `HlsMediaChunk.feedDataToExtractor` re-enters as `dataSpec.subrange(nextLoadPosition)`. Once the
* whole bounded range has been fed to the extractor, `nextLoadPosition == length`, so `subrange`
* asks for a zero-length `DataSpec` and the constructor's `length > 0` precondition throws. The
* early-return guard in `subrange` only covers `offset == 0`, so a fully-consumed chunk falls
* straight through. Unbounded chunks are safe — `length == C.LENGTH_UNSET` short-circuits — so this
* is reachable only via a byte-range part.
*
* The failure is unrecoverable rather than merely retried: `Loader` wraps it as
* `UnexpectedLoaderException`, which `DefaultLoadErrorHandlingPolicy` lists as non-retriable, so it
* becomes a fatal `ExoPlaybackException: Source error`. Forcing a retry would not help either — the
* `HlsMediaChunk` instance keeps its `nextLoadPosition`, so it would throw identically forever.
*
* Still present verbatim in media3 1.11.0-rc01, so there is no version to upgrade to.
*
* **Tracking: https://github.com/androidx/media/issues/3350** — delete this whole file and its test
* once that is fixed and we are on a media3 release carrying the fix, then drop the explicit
* `HlsMediaSource.Factory` in [CustomMediaSourceFactory] and let `DefaultMediaSourceFactory` build
* HLS again. That also restores low latency, and removes the caveat about the wrapping
* `DefaultMediaSourceFactory` features documented there.
*
* ## Trade-off
*
* We lose low latency on LL-HLS streams: playback falls back to whole segments, roughly one
* `TARGETDURATION` further behind the live edge. LL playlists still list their complete segments
* below the part tags, so they play normally otherwise. Given the alternative is a hard failure
* within a second, and that media3 offers no per-stream way to decline just the parts, disabling it
* globally is the conservative trade.
*/
@UnstableApi
internal class LowLatencyStrippingHlsPlaylistParserFactory(
private val delegate: HlsPlaylistParserFactory = DefaultHlsPlaylistParserFactory(),
) : HlsPlaylistParserFactory {
override fun createPlaylistParser(): ParsingLoadable.Parser<HlsPlaylist> = LowLatencyStrippingParser(delegate.createPlaylistParser())
override fun createPlaylistParser(
multivariantPlaylist: HlsMultivariantPlaylist,
previousMediaPlaylist: HlsMediaPlaylist?,
): ParsingLoadable.Parser<HlsPlaylist> = LowLatencyStrippingParser(delegate.createPlaylistParser(multivariantPlaylist, previousMediaPlaylist))
}
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service.playback.playerPool.positions
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
import com.vitorpamplona.amethyst.service.playback.playerPool.isHlsMediaItem
import kotlin.math.abs
class CurrentPlayPositionCacher(
@@ -41,7 +41,10 @@ class CurrentPlayPositionCacher(
isLiveStreaming = false
} else {
currentUrl = mediaItem.mediaId
isLiveStreaming = isLiveStreaming(mediaItem.mediaId)
// Same predicate the source factory routes on, so a blossom-hosted playlist — which has
// no `.m3u8` in its URL — is recognised here too and doesn't get a resume position
// persisted against a stream that has no stable position to return to.
isLiveStreaming = isHlsMediaItem(mediaItem)
}
}
@@ -56,7 +56,10 @@ import kotlin.concurrent.thread
*/
class BootRelayDiagnostics(
val client: INostrClient,
val dumpAtSeconds: List<Long> = listOf(20, 45, 90),
// 5s first: the pool is assembled and dialling well before 20s, and the early datapoint is what
// distinguishes "slow to connect" from "connected fine, slow to serve". Affordable because the
// rollup is now 3 INFO lines rather than 5 — the ===== banners moved to DEBUG.
val dumpAtSeconds: List<Long> = listOf(5, 20, 45, 90),
) {
companion object {
const val TAG = "BootRelayDiag"
@@ -197,8 +200,13 @@ class BootRelayDiagnostics(
fun detach() = client.removeConnectionListener(listener)
/**
* One line per relay plus a rollup. Kept to a single Log.w per line so the whole census
* One line per relay plus a rollup. Kept to a single Log call per line so the whole census
* survives logcat's per-tag rate limiting on a busy boot.
*
* The rollup goes out at INFO — it is the one boot line worth reading by default, and it
* carries the aggregate that per-socket failure logging used to spell out a few hundred
* times. The per-relay WASTE/SERVE tables are DEBUG: useful when you are chasing a specific
* relay, too long (up to 45 lines a census) to sit in the default log.
*/
fun dump(atSeconds: Long) {
val snapshot = records.toMap()
@@ -214,27 +222,27 @@ class BootRelayDiagnostics(
r.closed.forEach { (k, v) -> closedTotals[k] = (closedTotals[k] ?: 0) + v.get() }
}
Log.w(TAG, "===== boot census @${atSeconds}s =====")
Log.w(
Log.d(TAG, "===== boot census @${atSeconds}s =====")
Log.i(
TAG,
"pool=${snapshot.size} opened=${opened.size} served_events=${served.size} never_opened=${neverOpened.size} " +
"census @${atSeconds}s pool=${snapshot.size} opened=${opened.size} served_events=${served.size} never_opened=${neverOpened.size} " +
"dials=${snapshot.values.sumOf { it.tentatives.get() }} " +
"events=${snapshot.values.sumOf { it.events.get() }} " +
"reqs=${snapshot.values.sumOf { it.reqsSent.get() }} " +
"auths=${snapshot.values.sumOf { it.authsSent.get() }}",
)
Log.w(TAG, "failures_by_cause=" + causeTotals.entries.sortedByDescending { it.value }.joinToString { "${it.key}:${it.value}" })
Log.w(TAG, "closed_by_prefix=" + closedTotals.entries.sortedByDescending { it.value }.joinToString { "${it.key}:${it.value}" })
Log.i(TAG, "census @${atSeconds}s failures_by_cause=" + causeTotals.entries.sortedByDescending { it.value }.joinToString { "${it.key}:${it.value}" })
Log.i(TAG, "census @${atSeconds}s closed_by_prefix=" + closedTotals.entries.sortedByDescending { it.value }.joinToString { "${it.key}:${it.value}" })
// Relays that cost us dials and gave nothing back, worst first: the wasted-effort list.
Log.w(TAG, "--- top wasted dials (no events received) ---")
Log.d(TAG, "--- top wasted dials (no events received) ---")
snapshot
.filter { it.value.events.get() == 0 }
.entries
.sortedByDescending { it.value.tentatives.get() }
.take(25)
.forEach { (url, r) ->
Log.w(
Log.d(
TAG,
"WASTE ${url.url} dials=${r.tentatives.get()} opens=${r.opens.get()} " +
"fail=[${r.failures.entries.joinToString { "${it.key}:${it.value.get()}" }}] " +
@@ -245,17 +253,17 @@ class BootRelayDiagnostics(
// The relays actually carrying the boot, so a suppression change can be checked for
// coverage loss rather than just CLOSED reduction.
Log.w(TAG, "--- top event providers ---")
Log.d(TAG, "--- top event providers ---")
served.entries
.sortedByDescending { it.value.events.get() }
.take(20)
.forEach { (url, r) ->
Log.w(
Log.d(
TAG,
"SERVE ${url.url} events=${r.events.get()} reqs=${r.reqsSent.get()} eose=${r.eoses.get()} " +
"openMs=${r.firstOpenAtMs.get()} eoseMs=${r.firstEoseAtMs.get()} dials=${r.tentatives.get()}",
)
}
Log.w(TAG, "===== end census @${atSeconds}s =====")
Log.d(TAG, "===== end census @${atSeconds}s =====")
}
}
@@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashF
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.datasource.RepositoryFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.GitRepositoriesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.HighlightsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.datasource.LiveStreamsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.LongsFilterAssembler
@@ -170,6 +171,7 @@ class RelaySubscriptionsCoordinator(
val pictures = PicturesFilterAssembler(client)
val workouts = WorkoutsFilterAssembler(client)
val gitRepositories = GitRepositoriesFilterAssembler(client)
val highlights = HighlightsFilterAssembler(client)
val calendars = CalendarsFilterAssembler(client)
val products = ProductsFilterAssembler(client)
val shorts = ShortsFilterAssembler(client)
@@ -235,6 +237,7 @@ class RelaySubscriptionsCoordinator(
pictures,
workouts,
gitRepositories,
highlights,
calendars,
products,
shorts,
@@ -0,0 +1,64 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.resourceusage
/**
* Refcounts a boolean session so overlapping holders don't close each other's
* segment. [LocationState][com.vitorpamplona.amethyst.service.location.LocationState]
* exposes two independent location flows that can both be listening at once —
* the "Around Me" feed plus an open geohash chat — and a bare
* [SessionTimeIntegrator] would close the segment when either one stops.
*
* The count and the transition it drives are taken under one lock. An
* [java.util.concurrent.atomic.AtomicInteger] beside an unsynchronised call is
* not enough: two threads can leave the counter at 1 while the last
* `setActive(false)` lands after the `setActive(true)`, latching the session
* off with a holder still active.
*
* Takes the setter as a lambda rather than a [SessionTimeIntegrator] because
* that is all it needs, and so tests need no accountant or store file.
*
* Reports **transitions only**, not every call — the contract the name implies,
* and what keeps the class honest for a future caller that reacts to the
* callback rather than integrating it. (For [SessionTimeIntegrator] specifically
* a 1 -> 2 re-entry would have been harmless: it splits one segment into two
* contiguous pieces that `account()` re-adds, and its starts increment is
* already guarded on `prev == null`.)
*
* Releases must be paired with acquires. This class cannot tell an unpaired
* release from a real one, so callers guarantee the pairing; see `LocationFlow`,
* which throws rather than reaching `awaitClose` when nothing registered.
*/
class RefCountedSession(
private val setSessionActive: (Boolean) -> Unit,
) {
private val lock = Any()
private var holders = 0
fun setActive(active: Boolean) {
synchronized(lock) {
val wasActive = holders > 0
holders = if (active) holders + 1 else (holders - 1).coerceAtLeast(0)
val isActive = holders > 0
if (isActive != wasActive) setSessionActive(isActive)
}
}
}
@@ -29,11 +29,14 @@ import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
import com.vitorpamplona.quartz.utils.firstNotNullOrNullAsync
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.OkHttpClient
@@ -57,9 +60,16 @@ class BlossomServerResolver(
suspend fun findServers(uriStr: String): BlossomUriServer? {
uriToUrlCache[uriStr]?.let { return it }
// Confined to Dispatchers.IO: this is reached from Compose
// `produceState`/`LaunchedEffect` (RichTextViewer, MarmotGroupIconDisplay),
// which run on the main dispatcher. The pre-suspension work here —
// BlossomUri parsing, LruCache lookups, the local-cache probe's client
// build, and the server-list flow setup — must stay off the UI thread.
val result =
withTimeoutOrNull(10000) {
findServersInner(uriStr)
withContext(Dispatchers.IO) {
withTimeoutOrNull(10000) {
findServersInner(uriStr)
}
}
if (result != null) {
@@ -22,10 +22,13 @@ package com.vitorpamplona.amethyst.service.uploads.blossom.bud10
import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuilder
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okhttp3.Request
import okhttp3.coroutines.executeAsync
import java.util.concurrent.TimeUnit
@@ -77,33 +80,39 @@ class LocalBlossomCacheProbe(
cachedAtMs = 0L
}
// Confined to Dispatchers.IO because callers reach this through suspend
// resolvers invoked from Compose `LaunchedEffect`/`produceState`, which run
// on the main dispatcher: building the OkHttp client and issuing the HEAD
// must not touch the UI thread.
private suspend fun probe(): Boolean =
try {
val baseClient = httpClientBuilder.okHttpClientForPreview(LOCAL_CACHE_BASE)
val client =
baseClient
.newBuilder()
.connectTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.readTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.callTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.build()
withContext(Dispatchers.IO) {
try {
val baseClient = httpClientBuilder.okHttpClientForPreview(LOCAL_CACHE_BASE)
val client =
baseClient
.newBuilder()
.connectTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.readTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.callTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.build()
val request =
Request
.Builder()
.url("$LOCAL_CACHE_BASE/")
.head()
.build()
val request =
Request
.Builder()
.url("$LOCAL_CACHE_BASE/")
.head()
.build()
client.newCall(request).executeAsync().use { response ->
// Spec says HEAD / returns 2xx when available. Some implementations
// may answer 405 (method not allowed) while still being a working
// Blossom cache, so treat that as available too.
response.isSuccessful || response.code == 405
client.newCall(request).executeAsync().use { response ->
// Spec says HEAD / returns 2xx when available. Some implementations
// may answer 405 (method not allowed) while still being a working
// Blossom cache, so treat that as available too.
response.isSuccessful || response.code == 405
}
} catch (e: Exception) {
if (e is CancellationException) throw e
false
}
} catch (e: Exception) {
if (e is CancellationException) throw e
false
}
private fun currentTimeMs(): Long = System.currentTimeMillis()
@@ -31,6 +31,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.VideoPostKind
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
@@ -73,15 +74,21 @@ open class NewMediaModel : ViewModel() {
// Strip location and sensitive metadata from files before upload
var stripMetadata by mutableStateOf(true)
// Which NIP-71 kind videos in this batch are published as. Set by the composer's host feed.
var videoKind: VideoPostKind = VideoPostKind.AUTO
open fun load(
account: Account,
uris: ImmutableList<SelectedMedia>,
videoKind: VideoPostKind = VideoPostKind.AUTO,
caption: String = "",
) {
this.caption = ""
this.caption = caption
this.account = account
this.multiOrchestrator = MultiOrchestrator(uris)
this.selectedServer = defaultServer()
this.stripMetadata = account.settings.stripLocationOnUpload
this.videoKind = videoKind
}
fun isImage(
@@ -180,6 +187,7 @@ open class NewMediaModel : ViewModel() {
alt = caption,
contentWarningReason = if (sensitiveContent) "" else null,
originalHash = it.uploadedHash,
videoKind = videoKind,
)
}
}
@@ -54,6 +54,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.VideoPostKind
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery
@@ -79,14 +80,16 @@ fun NewMediaView(
postViewModel: NewMediaModel,
accountViewModel: AccountViewModel,
nav: INav,
videoKind: VideoPostKind = VideoPostKind.AUTO,
initialCaption: String = "",
) {
val account = accountViewModel.account
val context = LocalContext.current
val scrollState = rememberScrollState()
LaunchedEffect(uris) {
postViewModel.load(account, uris)
LaunchedEffect(uris, videoKind, initialCaption) {
postViewModel.load(account, uris, videoKind, initialCaption)
}
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
@@ -22,6 +22,9 @@ package com.vitorpamplona.amethyst.ui.actions.uploads
import android.content.Context
import androidx.core.net.toUri
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -40,3 +43,20 @@ suspend fun resolveSharedMedia(
SelectedMedia(uri, context.contentResolver.getType(uri))
}
}
/**
* Batch form for SEND_MULTIPLE shares: resolves every URI in one trip to the IO dispatcher
* instead of one context switch per file. Blank entries are dropped, so an empty list in means
* an empty list out and the composer stays closed.
*/
suspend fun resolveSharedMedia(
context: Context,
uriStrings: List<String>,
): ImmutableList<SelectedMedia> {
val uris = uriStrings.mapNotNull { it.ifBlank { null }?.toUri() }
if (uris.isEmpty()) return persistentListOf()
return withContext(Dispatchers.IO) {
uris.map { SelectedMedia(it, context.contentResolver.getType(it)) }.toImmutableList()
}
}
@@ -52,13 +52,10 @@ fun LoadUrlPreview(
}
@Composable
fun LoadUrlPreviewDirect(
fun rememberUrlPreviewState(
url: String,
urlText: String,
callbackUri: String? = null,
accountViewModel: AccountViewModel,
nav: INav? = null,
) {
): UrlPreviewState {
@Suppress("ProduceStateDoesNotAssignValue")
val urlPreviewState by
produceState(
@@ -69,6 +66,18 @@ fun LoadUrlPreviewDirect(
accountViewModel.urlPreview(url) { value = it }
}
}
return urlPreviewState
}
@Composable
fun LoadUrlPreviewDirect(
url: String,
urlText: String,
callbackUri: String? = null,
accountViewModel: AccountViewModel,
nav: INav? = null,
) {
val urlPreviewState = rememberUrlPreviewState(url, accountViewModel)
CrossfadeIfEnabled(
targetState = urlPreviewState,
@@ -23,13 +23,17 @@ package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboard
@@ -37,12 +41,14 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextOverflow
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.preview.UrlInfoItem
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
import com.vitorpamplona.amethyst.ui.theme.Size14Modifier
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
import com.vitorpamplona.amethyst.ui.theme.previewCardImageModifier
import kotlinx.coroutines.launch
@@ -53,6 +59,7 @@ fun UrlPreviewCard(
url: String,
previewInfo: UrlInfoItem,
onUrlComments: (() -> Unit)? = null,
onCardClick: (() -> Unit)? = null,
) {
val uri = LocalUriHandler.current
val popupExpanded =
@@ -95,7 +102,11 @@ fun UrlPreviewCard(
MaterialTheme.colorScheme.innerPostModifier
.combinedClickable(
onClick = {
runCatching { uri.openUri(url) }
if (onCardClick != null) {
onCardClick()
} else {
runCatching { uri.openUri(url) }
}
},
onLongClick = {
popupExpanded.value = true
@@ -109,14 +120,34 @@ fun UrlPreviewCard(
modifier = previewCardImageModifier,
)
Text(
text = previewInfo.verifiedUrl?.host ?: previewInfo.url,
style = MaterialTheme.typography.bodySmall,
Row(
modifier = MaxWidthWithHorzPadding,
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = previewInfo.verifiedUrl?.host ?: previewInfo.url,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f, fill = false),
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
// Only meaningful when the card's own tap does something else (e.g. opening the
// comment thread); otherwise it would duplicate the card's open-in-browser tap.
if (onCardClick != null) {
IconButton(
onClick = { runCatching { uri.openUri(url) } },
) {
Icon(
symbol = MaterialSymbols.AutoMirrored.OpenInNew,
contentDescription = stringRes(R.string.url_preview_open_in_browser),
modifier = Size14Modifier,
tint = Color.Gray,
)
}
}
}
Text(
text = previewInfo.title,
@@ -98,7 +98,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.toCoilModel
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.playback.composable.VideoViewInner
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@@ -431,7 +431,7 @@ private fun DialogContent(
}
val isPdfOrStaticImage = myContent is MediaUrlImage || myContent is MediaLocalImage || myContent is MediaUrlPdf
val isNotLiveStream = myContent !is MediaUrlContent || !isLiveStreaming(myContent.url)
val isNotLiveStream = myContent !is MediaUrlContent || !isHlsMedia(myContent.url, myContent.mimeType)
if (isPdfOrStaticImage && isNotLiveStream) {
val localContext = LocalContext.current
@@ -47,6 +47,7 @@ fun FeedLoaded(
routeForLastRead: String?,
accountViewModel: AccountViewModel,
nav: INav,
header: (@Composable () -> Unit)? = null,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
@@ -54,6 +55,12 @@ fun FeedLoaded(
contentPadding = rememberFeedContentPadding(FeedPadding),
state = listState,
) {
if (header != null) {
item {
header()
}
}
itemsIndexed(
items.list,
key = { _, item -> item.idHex },
@@ -51,6 +51,8 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip14Subject.subject
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip71Video.image
import com.vitorpamplona.quartz.nip73ExternalIds.scope
import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import com.vitorpamplona.quartz.nip92IMeta.imetas
import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag
@@ -313,10 +315,10 @@ private class WarmTargets {
/**
* Collects everything worth warming for this note. Reads NIP-92 imeta tags (every
* kind), then the free-text body: text notes and comments go through the shared
* render cache — which both warms the renderer and yields a fully classified body
* — while every other kind gets a cheap URL scan. Returns null only when the note
* has no event.
* kind), the NIP-73 external-content scope (kind 1111 only), then the free-text
* body: text notes and comments go through the shared render cache — which both
* warms the renderer and yields a fully classified body — while every other kind
* gets a cheap URL scan. Returns null only when the note has no event.
*/
private fun Note.collectWarmTargets(): WarmTargets? {
val ev = event ?: return null
@@ -335,6 +337,13 @@ private fun Note.collectWarmTargets(): WarmTargets? {
// imetas() above misses these entirely.
targets.addFileHeaderMedia(ev)
// NIP-73 external-content scope. Only kind 1111 carries one, so the type check
// keeps every other kind out of the tag walk. Reuses scope() — the same accessor
// the renderer uses — so we only warm a URL that will actually be shown.
if (ev is CommentEvent) {
(ev.scope() as? UrlId)?.let { targets.link(it.url) }
}
// Free-text body. The reproducible-key kinds parse through the shared cache
// (which also warms the render); everything else is scanned for URLs only.
val rendered = warmRenderCache(ev)
@@ -69,6 +69,7 @@ object ScrollStateKeys {
const val PICTURES_SCREEN = "PicturesFeed"
const val WORKOUTS_SCREEN = "WorkoutsFeed"
const val GIT_REPOSITORIES_SCREEN = "GitRepositoriesFeed"
const val HIGHLIGHTS_SCREEN = "HighlightsFeed"
const val RELAY_GROUPS_DISCOVERY_SCREEN = "RelayGroupsDiscoveryFeed"
const val CALENDARS_SCREEN = "CalendarsFeed"
const val CALENDAR_COLLECTIONS_SCREEN = "CalendarCollectionsFeed"
@@ -72,6 +72,7 @@ import com.vitorpamplona.amethyst.ui.navigation.bottombars.TabReselectCoordinato
import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.navs.rememberNav
import com.vitorpamplona.amethyst.ui.navigation.routes.MediaFeedRoute
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments
import com.vitorpamplona.amethyst.ui.navigation.routes.isBaseRoute
@@ -193,6 +194,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.GitRepositoriesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.HighlightsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.NewHighlightScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.HomeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.VoiceReplyScreen
@@ -310,6 +313,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog
import com.vitorpamplona.amethyst.ui.uriToRoute
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip84Highlights.parse.SharedHighlightParser
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.net.URI
@@ -425,7 +429,7 @@ fun BuildNavigation(
) {
composableCapped<Route.Home> { HomeScreen(accountViewModel, nav) }
composable<Route.Message> { MessagesScreen(accountViewModel, nav) }
composableCapped<Route.Video> { VideoScreen(accountViewModel, nav) }
composableArgs<Route.Video> { VideoScreen(accountViewModel, nav, it.attachments, it.message) }
composableArgs<Route.Discover> { DiscoverScreen(it.initialTab, accountViewModel, nav) }
composableArgs<Route.Notification> { NotificationScreen(it.scrollToEventId, accountViewModel, nav) }
composableFromEnd<Route.Polls> { PollsScreen(accountViewModel, nav) }
@@ -436,9 +440,11 @@ fun BuildNavigation(
composableFromEnd<Route.ProfileBadges> { ProfileBadgesScreen(accountViewModel, nav) }
composableFromEnd<Route.ProfileAppRecommendations> { ProfileAppRecommendationsScreen(accountViewModel, nav) }
composableFromBottomArgs<Route.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) }
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
composableFromEndArgs<Route.Pictures> { PicturesScreen(accountViewModel, nav, it.attachments, it.message) }
composableFromEnd<Route.Workouts> { WorkoutsScreen(accountViewModel, nav) }
composableFromEnd<Route.GitRepositories> { GitRepositoriesScreen(accountViewModel, nav) }
composableFromEnd<Route.Highlights> { HighlightsScreen(accountViewModel, nav) }
composableFromEnd<Route.SoftwareApps> { SoftwareAppsScreen(accountViewModel, nav) }
composableFromEnd<Route.Napplets> { NappletsScreen(accountViewModel, nav) }
composableFromEnd<Route.Nsites> { NsitesScreen(accountViewModel, nav) }
@@ -464,7 +470,7 @@ fun BuildNavigation(
}
composableFromBottomArgs<Route.NewCalendarCollection> { NewCalendarCollectionScreen(nav, accountViewModel, it.dTag) }
composableFromEnd<Route.Products> { ProductsScreen(accountViewModel, nav) }
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.Shorts> { ShortsScreen(accountViewModel, nav, it.attachments, it.message) }
composableFromEnd<Route.PublicChats> { PublicChatsScreen(accountViewModel, nav) }
composableFromEnd<Route.RelayGroups> { RelayGroupDiscoveryScreen(accountViewModel, nav) }
composableFromEndArgs<Route.BuzzDmList> { BuzzDmListScreen(it.relayUrl, accountViewModel, nav) }
@@ -785,6 +791,7 @@ fun BuildNavigation(
composableFromEndArgs<Route.RelayGroupCreate> {
RelayGroupCreateScreen(
relayUrl = it.relayUrl,
isForum = it.isForum,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -935,6 +942,22 @@ fun BuildNavigation(
)
}
composableFromBottomArgs<Route.NewHighlight> {
NewHighlightScreen(
quote = it.quote,
url = it.url,
prefix = it.prefix,
suffix = it.suffix,
comment = it.comment,
context = it.context,
sourceAddress = it.sourceAddress,
sourceEventId = it.sourceEventId,
author = it.author,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromBottomArgs<Route.VoiceReply> {
VoiceReplyScreen(
replyToNoteId = it.replyToNoteId,
@@ -949,6 +972,42 @@ fun BuildNavigation(
}
}
/** True for both share flavors: a single file/text (SEND) and a multi-file selection (SEND_MULTIPLE). */
private fun Intent.isShareAction(): Boolean = action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE
/** The text Android sent with the share — a caption, a URL, or the whole payload of a text-only share. */
private fun Intent.sharedText(): String? = getStringExtra(Intent.EXTRA_TEXT)?.ifBlank { null }
/**
* Every content URI the share carries, in the order the sending app listed them. SEND_MULTIPLE
* puts them in a parcelable ArrayList; plain SEND has at most one. Only the media targets declare
* SEND_MULTIPLE, so every other caller can just take the first.
*/
private fun Intent.sharedStreamUris(): List<String> =
if (action == Intent.ACTION_SEND_MULTIPLE) {
IntentCompat
.getParcelableArrayListExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
?.map { it.toString() }
.orEmpty()
} else {
listOfNotNull(IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)?.toString())
}
/**
* Opens a media feed on a share. Sharing again while an earlier share is still on screen replaces
* it instead of stacking a second copy of the feed — but only when the entry on top is itself a
* share (it carries attachments). A feed the user opened from the bottom bar carries none, so it
* stays put underneath and keeps its tab-root marker.
*/
private inline fun <reified T> Nav.navToSharedFeed(route: T) where T : Route, T : MediaFeedRoute {
val current = getRouteWithArguments(T::class, controller)
if (current is MediaFeedRoute && current.attachments.isNotEmpty()) {
popUpTo(route, T::class)
} else {
newStack(route)
}
}
@Composable
private fun NavigateIfIntentRequested(
nav: Nav,
@@ -965,36 +1024,43 @@ private fun NavigateIfIntentRequested(
val activity = LocalContext.current.getActivity()
if (activity.intent.action == Intent.ACTION_SEND) {
val isShareAsDm = ShareIntentRouting.isShareAsDm(activity.intent.component?.className)
if (activity.intent.isShareAction()) {
val target = ShareIntentRouting.targetOf(activity.intent.component?.className)
// avoids restarting the destination screen when the intent is for the screen.
// Microsoft's swift key sends Gifs as new actions
if (isShareAsDm) {
if (isBaseRoute<Route.ShareToDM>(nav.controller)) return
} else {
if (isBaseRoute<Route.NewShortNote>(nav.controller)) return
// Microsoft's swift key sends Gifs as new actions.
// The media targets land on a feed the user may well be standing on already, so they can't
// guard on the destination — they rely on the intent being consumed below instead.
when (target) {
ShareTarget.HIGHLIGHT -> if (isBaseRoute<Route.NewHighlight>(nav.controller)) return
ShareTarget.DIRECT_MESSAGE -> if (isBaseRoute<Route.ShareToDM>(nav.controller)) return
ShareTarget.NEW_POST -> if (isBaseRoute<Route.NewShortNote>(nav.controller)) return
ShareTarget.PICTURE, ShareTarget.SHORT_VIDEO, ShareTarget.VIDEO -> Unit
}
// saves the intent to avoid processing again
var message by remember {
mutableStateOf(
activity.intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
it.ifBlank { null }
},
)
}
val message = remember { activity.intent.sharedText() }
val attachments = remember { activity.intent.sharedStreamUris() }
var media by remember {
mutableStateOf(
IntentCompat.getParcelableExtra(activity.intent, Intent.EXTRA_STREAM, Uri::class.java),
)
}
if (isShareAsDm) {
nav.newStack(Route.ShareToDM(message = message, attachment = media?.toString()))
} else {
nav.newStack(Route.NewShortNote(message = message, attachment = media.toString()))
when (target) {
ShareTarget.HIGHLIGHT -> {
val parsed = message?.let { SharedHighlightParser.parse(it) }
nav.newStack(
Route.NewHighlight(
quote = parsed?.quote,
url = parsed?.url,
prefix = parsed?.prefix,
suffix = parsed?.suffix,
),
)
}
// The single-file composers take the first URI: only the media targets declare
// SEND_MULTIPLE, so anything else can only be carrying one.
ShareTarget.DIRECT_MESSAGE -> nav.newStack(Route.ShareToDM(message = message, attachment = attachments.firstOrNull()))
ShareTarget.PICTURE -> nav.navToSharedFeed(Route.Pictures(attachments = attachments, message = message))
ShareTarget.SHORT_VIDEO -> nav.navToSharedFeed(Route.Shorts(attachments = attachments, message = message))
ShareTarget.VIDEO -> nav.navToSharedFeed(Route.Video(attachments = attachments, message = message))
ShareTarget.NEW_POST -> nav.newStack(Route.NewShortNote(message = message, attachment = attachments.firstOrNull()))
}
// Consume the launch intent so a later recomposition can't re-fire
@@ -1061,25 +1127,44 @@ private fun NavigateIfIntentRequested(
DisposableEffect(nav, activity) {
val consumer =
Consumer<Intent> { intent ->
if (intent.action == Intent.ACTION_SEND) {
val isShareAsDm = ShareIntentRouting.isShareAsDm(intent.component?.className)
// avoids restarting the destination screen when the intent is for the screen.
// Microsoft's swift key sends Gifs as new actions
if (isShareAsDm) {
if (!isBaseRoute<Route.ShareToDM>(nav.controller)) {
val message = intent.getStringExtra(Intent.EXTRA_TEXT)?.ifBlank { null }
val attachment =
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.toString()
nav.newStack(Route.ShareToDM(message = message, attachment = attachment))
}
} else if (!isBaseRoute<Route.NewShortNote>(nav.controller)) {
intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
nav.newStack(Route.NewShortNote(message = it))
}
if (intent.isShareAction()) {
val target = ShareIntentRouting.targetOf(intent.component?.className)
val message = intent.sharedText()
val attachments = intent.sharedStreamUris()
val attachment = attachments.firstOrNull()
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.let {
nav.newStack(Route.NewShortNote(attachment = it.toString()))
}
// avoids restarting the destination screen when the intent is for the screen.
// Microsoft's swift key sends Gifs as new actions.
// The media targets land on a feed the user may well be standing on already, so
// they always navigate: the route carries the attachment, so the composer opens
// on the newly shared file even when the feed itself is already on screen.
when (target) {
ShareTarget.HIGHLIGHT ->
if (!isBaseRoute<Route.NewHighlight>(nav.controller)) {
val parsed = message?.let { SharedHighlightParser.parse(it) }
nav.newStack(
Route.NewHighlight(
quote = parsed?.quote,
url = parsed?.url,
prefix = parsed?.prefix,
suffix = parsed?.suffix,
),
)
}
ShareTarget.DIRECT_MESSAGE ->
if (!isBaseRoute<Route.ShareToDM>(nav.controller)) {
nav.newStack(Route.ShareToDM(message = message, attachment = attachment))
}
ShareTarget.PICTURE -> nav.navToSharedFeed(Route.Pictures(attachments = attachments, message = message))
ShareTarget.SHORT_VIDEO -> nav.navToSharedFeed(Route.Shorts(attachments = attachments, message = message))
ShareTarget.VIDEO -> nav.navToSharedFeed(Route.Video(attachments = attachments, message = message))
ShareTarget.NEW_POST ->
if (!isBaseRoute<Route.NewShortNote>(nav.controller) && (message != null || attachment != null)) {
nav.newStack(Route.NewShortNote(message = message, attachment = attachment))
}
}
} else {
val uri = intent.data?.toString()
@@ -20,10 +20,31 @@
*/
package com.vitorpamplona.amethyst.ui.navigation
/** Which entry of Android's share sheet the user picked. */
enum class ShareTarget {
/** The default "New Post" target: a kind-1 note with the shared text/media. */
NEW_POST,
/** "Send as DM": a NIP-17 private message. */
DIRECT_MESSAGE,
/** "New Highlight": a NIP-84 highlight of the shared passage. */
HIGHLIGHT,
/** "New Picture": a NIP-68 picture post, straight into the picture feed. */
PICTURE,
/** "New Short": a NIP-71 kind 22 video, straight into the Shorts feed. */
SHORT_VIDEO,
/** "New Video": a NIP-71 video, straight into the Video feed. */
VIDEO,
}
/**
* Distinguishes the "Send as DM" share target from the default "New Post" share
* target. Both intent-filters resolve to MainActivity; they are told apart by the
* component class name of the launching intent (the activity-alias name).
* Tells the share targets apart. Every SEND intent-filter resolves to MainActivity; which target
* the user picked is only visible in the component class name of the launching intent (the
* `<activity-alias>` name), so each alias below maps to the composer it opens.
*/
object ShareIntentRouting {
/**
@@ -34,5 +55,42 @@ object ShareIntentRouting {
*/
const val SHARE_AS_DM_ALIAS_SIMPLE_NAME = "ShareAsDMAlias"
fun isShareAsDm(componentClassName: String?): Boolean = componentClassName?.endsWith(".$SHARE_AS_DM_ALIAS_SIMPLE_NAME") == true
/**
* Simple class name of the `<activity-alias>` declared in AndroidManifest.xml
* (android:name=".ui.ShareAsHighlightAlias"). MUST stay in sync with the manifest —
* see the caveat on [SHARE_AS_DM_ALIAS_SIMPLE_NAME].
*/
const val SHARE_AS_HIGHLIGHT_ALIAS_SIMPLE_NAME = "ShareAsHighlightAlias"
/** See the caveat on [SHARE_AS_DM_ALIAS_SIMPLE_NAME]. */
const val SHARE_AS_PICTURE_ALIAS_SIMPLE_NAME = "ShareAsPictureAlias"
/** See the caveat on [SHARE_AS_DM_ALIAS_SIMPLE_NAME]. */
const val SHARE_AS_SHORT_VIDEO_ALIAS_SIMPLE_NAME = "ShareAsShortVideoAlias"
/** See the caveat on [SHARE_AS_DM_ALIAS_SIMPLE_NAME]. */
const val SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME = "ShareAsVideoAlias"
private val TARGET_BY_ALIAS =
mapOf(
SHARE_AS_DM_ALIAS_SIMPLE_NAME to ShareTarget.DIRECT_MESSAGE,
SHARE_AS_HIGHLIGHT_ALIAS_SIMPLE_NAME to ShareTarget.HIGHLIGHT,
SHARE_AS_PICTURE_ALIAS_SIMPLE_NAME to ShareTarget.PICTURE,
SHARE_AS_SHORT_VIDEO_ALIAS_SIMPLE_NAME to ShareTarget.SHORT_VIDEO,
SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME to ShareTarget.VIDEO,
)
/**
* Resolves the launching component to its target. Flavors can change the resolved package
* prefix, so the match is on the simple name; anything that isn't one of our aliases (chiefly
* MainActivity itself) is the default New Post target.
*/
fun targetOf(componentClassName: String?): ShareTarget {
if (componentClassName == null) return ShareTarget.NEW_POST
return TARGET_BY_ALIAS.entries
.firstOrNull { componentClassName.endsWith(".${it.key}") }
?.value
?: ShareTarget.NEW_POST
}
}
@@ -55,6 +55,7 @@ enum class NavBarItem {
PICTURES,
WORKOUTS,
GIT_REPOSITORIES,
HIGHLIGHTS,
SOFTWARE_APPS,
NAPPLETS,
NSITES,
@@ -111,7 +112,7 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
id = NavBarItem.VIDEO,
labelRes = R.string.route_video,
icon = MaterialSymbols.Subscriptions,
resolveRoute = { Route.Video },
resolveRoute = { Route.Video() },
),
NavBarItem.DISCOVER to
NavBarItemDef(
@@ -230,7 +231,7 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
id = NavBarItem.PICTURES,
labelRes = R.string.pictures,
icon = MaterialSymbols.Photo,
resolveRoute = { Route.Pictures },
resolveRoute = { Route.Pictures() },
),
NavBarItem.WORKOUTS to
NavBarItemDef(
@@ -246,6 +247,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
icon = MaterialSymbols.Code,
resolveRoute = { Route.GitRepositories },
),
NavBarItem.HIGHLIGHTS to
NavBarItemDef(
id = NavBarItem.HIGHLIGHTS,
labelRes = R.string.highlights,
icon = MaterialSymbols.FormatQuote,
resolveRoute = { Route.Highlights },
),
NavBarItem.SOFTWARE_APPS to
NavBarItemDef(
id = NavBarItem.SOFTWARE_APPS,
@@ -300,7 +308,7 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
id = NavBarItem.SHORTS,
labelRes = R.string.shorts,
icon = MaterialSymbols.PlayCircle,
resolveRoute = { Route.Shorts },
resolveRoute = { Route.Shorts() },
),
NavBarItem.MUSIC_TRACKS to
NavBarItemDef(
@@ -534,6 +542,7 @@ val BottomBarCategories: List<NavBarCategory> =
NavBarItem.PRODUCTS,
NavBarItem.WORKOUTS,
NavBarItem.GIT_REPOSITORIES,
NavBarItem.HIGHLIGHTS,
NavBarItem.COMMUNITIES,
NavBarItem.FOLLOW_PACKS,
NavBarItem.CALENDARS,
@@ -574,6 +583,7 @@ val DrawerFeedsItems: List<NavBarItem> =
NavBarItem.PRODUCTS,
NavBarItem.WORKOUTS,
NavBarItem.GIT_REPOSITORIES,
NavBarItem.HIGHLIGHTS,
NavBarItem.LIVE_STREAMS,
NavBarItem.NESTS,
NavBarItem.COMMUNITIES,
@@ -30,12 +30,32 @@ import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import kotlinx.serialization.Serializable
import kotlin.reflect.KClass
/**
* A feed whose composer can be pre-loaded from an Android share. [attachments] holds the shared
* content URIs and [message] the text sent alongside them; both are empty/null for every other way
* of reaching the feed, which is how the share flow tells its own entries apart from an ordinary
* bottom-bar visit.
*/
sealed interface MediaFeedRoute {
val attachments: List<String>
val message: String?
}
sealed class Route {
@Serializable object Home : Route()
@Serializable object Message : Route()
@Serializable object Video : Route()
/**
* The mixed media feed. [attachments] and [message] are set by the "New Video" share target: the content
* URIs of the shared files and the text Android sent alongside them, which pre-load the media
* composer and its caption. Empty for every other way of reaching the feed.
*/
@Serializable data class Video(
override val attachments: List<String> = emptyList(),
override val message: String? = null,
) : Route(),
MediaFeedRoute
@Serializable data class Discover(
val initialTab: DiscoverTab? = null,
@@ -81,12 +101,23 @@ sealed class Route {
)
}
@Serializable object Pictures : Route()
/**
* The picture feed. [attachments] and [message] are set by the "New Picture" share target: the content
* URIs of the shared files and the text Android sent alongside them, which pre-load the media
* composer and its caption. Empty for every other way of reaching the feed.
*/
@Serializable data class Pictures(
override val attachments: List<String> = emptyList(),
override val message: String? = null,
) : Route(),
MediaFeedRoute
@Serializable object Workouts : Route()
@Serializable object GitRepositories : Route()
@Serializable object Highlights : Route()
@Serializable object SoftwareApps : Route()
@Serializable object Napplets : Route()
@@ -173,7 +204,16 @@ sealed class Route {
@Serializable object Products : Route()
@Serializable object Shorts : Route()
/**
* The short-video feed. [attachments] and [message] are set by the "New Short" share target: the content
* URIs of the shared files and the text Android sent alongside them, which pre-load the media
* composer and its caption. Empty for every other way of reaching the feed.
*/
@Serializable data class Shorts(
override val attachments: List<String> = emptyList(),
override val message: String? = null,
) : Route(),
MediaFeedRoute
@Serializable object PublicChats : Route()
@@ -739,6 +779,9 @@ sealed class Route {
@Serializable data class RelayGroupCreate(
val relayUrl: String,
// Buzz only: start the create flow on a `forum` channel (threaded posts) instead of a
// `stream` (chat) one — set by the community screen's per-section "+" buttons.
val isForum: Boolean = false,
) : Route()
@Serializable data class RelayGroupEdit(
@@ -987,6 +1030,26 @@ sealed class Route {
val draft: String? = null,
) : Route()
/**
* The NIP-84 highlight composer. Opened by the "Add highlight" action (all fields null) or
* when a browser shares a text selection to Amethyst — in which case the shared string has
* already been run through SharedHighlightParser and the pieces arrive pre-split here.
*/
@Serializable
data class NewHighlight(
val quote: String? = null,
val url: String? = null,
val prefix: String? = null,
val suffix: String? = null,
val comment: String? = null,
val context: String? = null,
// A nostr source (set when highlighting a nostr article/note rather than a web page):
// an addressable coordinate (`a`), a specific event id (`e`) and the author (`p`).
val sourceAddress: String? = null,
val sourceEventId: String? = null,
val author: String? = null,
) : Route()
@Serializable data object NewHlsVideo : Route()
@Serializable
@@ -327,10 +327,7 @@ import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
import com.vitorpamplona.quartz.nip71Video.VideoEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
@@ -1514,19 +1511,9 @@ private fun RenderNoteRow(
FileHeaderDisplay(baseNote, true, ContentScale.FillWidth, accountViewModel)
}
is VideoHorizontalEvent -> {
VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav)
}
is VideoVerticalEvent -> {
VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav)
}
is VideoNormalEvent -> {
VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav)
}
is VideoShortEvent -> {
// Covers every NIP-71 video kind (21, 22, 34235, 34236) via the shared interface,
// so new video subtypes render automatically instead of falling through to text.
is VideoEvent -> {
VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav)
}
@@ -171,6 +171,25 @@ fun noteActionSections(
}
},
)
// Highlight this note/article as its source: opens the NIP-84 composer with the
// nostr source pre-tagged (`a` for an addressable article, else `e`) plus the author,
// and the passage left for the user to type or paste. Prose kinds only, and never a
// private rumor (a public highlight would e-tag the unsigned rumor onto relays).
if (!isPrivateRumor && (note.event is TextNoteEvent || note.event is LongTextNoteEvent)) {
add(
NoteAction(MaterialSymbols.FormatQuote, stringRes(R.string.highlight_action)) {
val author = note.author?.pubkeyHex
val route =
if (note is AddressableNote) {
Route.NewHighlight(sourceAddress = note.address.toValue(), author = author)
} else {
Route.NewHighlight(sourceEventId = note.idHex, author = author)
}
nav.nav(route)
handlers.onDismiss()
},
)
}
if (!isPrivateRumor) {
add(NoteAction(MaterialSymbols.Share, stringRes(R.string.quick_action_share), onClick = handlers.onShare))
}
@@ -28,6 +28,7 @@ import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.LinkAnnotation
@@ -41,6 +42,9 @@ 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.ui.components.UrlPreviewState
import com.vitorpamplona.amethyst.ui.components.UrlPreviewCard
import com.vitorpamplona.amethyst.ui.components.rememberUrlPreviewState
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -52,6 +56,14 @@ import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId
import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId
import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId
/**
* The normalized scope (e.g. [ExternalId.toScope]) of the external content a screen is
* currently dedicated to, if any. A screen built entirely around one external scope (e.g.
* the URL thread screen) provides this so nested comments sharing that same scope don't
* redundantly repeat the preview the screen itself already shows.
*/
val LocalCurrentExternalScope = staticCompositionLocalOf<String?> { null }
@Composable
fun DisplayExternalId(
externalId: ExternalId,
@@ -68,7 +80,7 @@ fun DisplayExternalId(
}
is UrlId -> {
DisplayUrlExternalId(externalId, nav)
DisplayUrlExternalId(externalId, accountViewModel, nav)
}
else -> {
@@ -80,14 +92,36 @@ fun DisplayExternalId(
@Composable
fun DisplayUrlExternalId(
externalId: UrlId,
accountViewModel: AccountViewModel,
nav: INav,
) {
DisplayExternalIdChip(
symbol = MaterialSymbols.Link,
contentDescription = stringRes(id = R.string.external_url_scope),
label = externalId.url,
linkInteractionListener = { nav.nav(Route.Url(externalId.url)) },
)
val url = externalId.url
val chip =
@Composable {
DisplayExternalIdChip(
symbol = MaterialSymbols.Link,
contentDescription = stringRes(id = R.string.external_url_scope),
label = url,
linkInteractionListener = { nav.nav(Route.Url(url)) },
)
}
// Respect the data-saver/privacy gate: fetching the preview reaches out to the
// third-party page, so when previews are off this stays a plain link chip.
if (!accountViewModel.settings.showUrlPreview()) {
chip()
return
}
when (val state = rememberUrlPreviewState(url, accountViewModel)) {
is UrlPreviewState.Loaded -> {
UrlPreviewCard(url, state.previewInfo, onCardClick = { nav.nav(Route.Url(url)) })
}
else -> {
chip()
}
}
}
@Composable
@@ -49,13 +49,16 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayNameChip
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.buzz.workspace.buzzParticipants
import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
/**
@@ -100,7 +103,14 @@ fun RenderRelayGroupMessage(
}
}
/** A compact, tappable header naming the Buzz group (or DM participant) a message belongs to. */
/**
* A compact, tappable header naming the Buzz group (or DM participant) a message belongs to, plus a
* [RelayNameChip] naming its host relay — the same pairing the Messages row uses, and the NIP-29
* analog of the [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCommunityPill]
* a Concord message wears on these same cards. A group id is only unique within its host relay, so
* without the relay a notification from `#general` doesn't say *which* `#general` it came from. The
* chip taps through to the relay's channel list, while the name/avatar open the room itself.
*/
@Composable
fun RelayGroupChannelHeader(
channel: RelayGroupChannel,
@@ -129,16 +139,21 @@ fun RelayGroupChannelHeader(
)
if (dmOther != null) {
RelayGroupDmName(dmOther, channel, accountViewModel, Modifier.weight(1f))
RelayGroupDmName(dmOther, channel, accountViewModel, Modifier.weight(1f, fill = false))
} else {
Text(
text = channel.toBestDisplayName(),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
modifier = Modifier.weight(1f, fill = false),
)
}
RelayNameChip(
label = channel.groupId.relayUrl.displayUrl(),
onClick = { nav.nav(Route.RelayGroupServer(channel.groupId.relayUrl.url)) },
)
}
}
@@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent
import com.vitorpamplona.amethyst.ui.note.ReplyNoteComposition
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.note.nip22Comments.DisplayExternalId
import com.vitorpamplona.amethyst.ui.note.nip22Comments.LocalCurrentExternalScope
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.PreloadThreadForReply
import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer
@@ -142,9 +143,11 @@ fun RenderTextEvent(
}
} 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.
// note. Show the scope itself as the reply context -- unless the screen we're
// in is already dedicated to this exact scope (e.g. the URL thread screen),
// in which case every row would otherwise redundantly repeat the same preview.
val scope = remember(note) { noteEvent.scope() }
if (scope != null) {
if (scope != null && scope.toScope() != LocalCurrentExternalScope.current) {
DisplayExternalId(scope, accountViewModel, nav)
Spacer(modifier = StdVertSpacer)
}
@@ -63,8 +63,8 @@ import com.vitorpamplona.amethyst.service.playback.composable.controls.PictureIn
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
import com.vitorpamplona.amethyst.ui.components.getActivity
@@ -349,7 +349,7 @@ fun RenderTopButtonsForVoice(
accountViewModel: AccountViewModel,
) {
Row(modifier) {
if (!isLiveStreaming(mediaData.videoUri)) {
if (!isHlsMedia(mediaData.videoUri, mediaData.mimeType)) {
AnimatedShareButton(controllerVisible) { popupExpanded, toggle ->
ShareMediaAction(
popupExpanded = popupExpanded,
@@ -337,6 +337,24 @@ class TopNavFilterState(
)
}
private val _highlightsRoutes =
combineTransform(
livePeopleListsFlow,
liveInterestFlows,
) { peopleLists, interests ->
checkNotInMainThread()
emit(
listOf(
// Highlights can be narrowed by author, hashtag and geohash, so this mirrors
// the kind3 catalog plus "Mine" — the user's own highlights.
listOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, mineFollow),
peopleLists,
interests,
listOf(muteListFollow),
).flatten().toImmutableList(),
)
}
private val _relayGroupsDiscoveryRoutes =
combineTransform(
livePeopleListsFlow,
@@ -470,6 +488,11 @@ class TopNavFilterState(
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, mineFollow, muteListFollow))
val highlightsRoutes =
_highlightsRoutes
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, mineFollow, muteListFollow))
val relayGroupsDiscoveryRoutes =
_relayGroupsDiscoveryRoutes
.flowOn(Dispatchers.IO)
@@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.dal.DraftEventsFeedF
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.dal.BrowseEmojiSetsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.list.dal.FollowPacksFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.dal.GitRepositoriesFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.dal.HighlightsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeEverythingFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeLiveFilter
@@ -117,6 +118,7 @@ class AccountFeedContentStates(
val picturesFeed = FeedContentState(PictureFeedFilter(account), scope, LocalCache)
val workoutsFeed = FeedContentState(WorkoutFeedFilter(account), scope, LocalCache)
val gitRepositoriesFeed = FeedContentState(GitRepositoriesFeedFilter(account), scope, LocalCache)
val highlightsFeed = FeedContentState(HighlightsFeedFilter(account), scope, LocalCache)
val relayGroupsDiscoveryFeed = FeedContentState(RelayGroupDiscoveryFeedFilter(account), scope, LocalCache)
val calendarAppointmentsFeed = FeedContentState(CalendarAppointmentsFeedFilter(account), scope, LocalCache)
val calendarCollectionsFeed = FeedContentState(CalendarCollectionsFeedFilter(account), scope, LocalCache)
@@ -327,6 +329,7 @@ class AccountFeedContentStates(
picturesFeed.updateFeedWith(newNotes)
workoutsFeed.updateFeedWith(newNotes)
gitRepositoriesFeed.updateFeedWith(newNotes)
highlightsFeed.updateFeedWith(newNotes)
relayGroupsDiscoveryFeed.updateFeedWith(newNotes)
productsFeed.updateFeedWith(newNotes)
shortsFeed.updateFeedWith(newNotes)
@@ -390,6 +393,7 @@ class AccountFeedContentStates(
picturesFeed.deleteFromFeed(newNotes)
workoutsFeed.deleteFromFeed(newNotes)
gitRepositoriesFeed.deleteFromFeed(newNotes)
highlightsFeed.deleteFromFeed(newNotes)
relayGroupsDiscoveryFeed.deleteFromFeed(newNotes)
productsFeed.deleteFromFeed(newNotes)
shortsFeed.deleteFromFeed(newNotes)
@@ -449,6 +453,7 @@ class AccountFeedContentStates(
picturesFeed.trimToSize(maxItems)
workoutsFeed.trimToSize(maxItems)
gitRepositoriesFeed.trimToSize(maxItems)
highlightsFeed.trimToSize(maxItems)
relayGroupsDiscoveryFeed.trimToSize(maxItems)
calendarAppointmentsFeed.trimToSize(maxItems)
calendarCollectionsFeed.trimToSize(maxItems)
@@ -1675,6 +1675,15 @@ class AccountViewModel(
/** Delete the channel/group for everyone (kind-9008). Owner/admin only; the relay enforces it. */
fun deleteRelayGroup(channel: RelayGroupChannel) = launchSigner { account.deleteRelayGroup(channel) }
/**
* Archive/unarchive a Buzz channel (kind-9002 `archived` tag) — hides it from the sidebar without
* destroying it, and is reversible. Owner/admin only; the relay enforces it.
*/
fun archiveRelayGroup(
channel: RelayGroupChannel,
archived: Boolean,
) = launchSigner { account.archiveRelayGroup(channel, archived) }
/**
* Take a relay group off Messages WITHOUT leaving it: drop it from my kind-10009 list so it stops
* showing, but send no kind-9022 — I stay in the relay roster and can still read/post, and re-joining
@@ -1708,6 +1717,22 @@ class AccountViewModel(
*/
fun acceptChannelInvite(channel: RelayGroupChannel) = addRelayGroupToMessages(channel)
/**
* Hide a Buzz DM from Messages (kind-41012). DM-specific — a DM has no kind-10009 entry; the relay
* republishes my per-viewer 30622 hidden snapshot, dropping it from the inbox until I re-open it.
*/
fun hideBuzzDm(channel: RelayGroupChannel) = launchSigner { account.hideBuzzDm(channel) }
/**
* Bring a hidden Buzz DM back to Messages: Buzz has no "unhide", so re-open the conversation with
* the same [participants] (a kind-41010 resolving to the same canonical channel), which drops it
* from the 30622 hidden snapshot.
*/
fun unhideBuzzDm(
relay: NormalizedRelayUrl,
participants: List<HexKey>,
) = launchSigner { account.openBuzzDm(relay, participants) }
/**
* Keep the channel off Messages without touching membership. Local and reversible — I stay in the
* roster and can still open and post; [leaveChannelInvite] is the one that actually removes me.
@@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.Discove
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasource.BrowseEmojiSetsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.list.datasource.FollowPacksFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.GitRepositoriesFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.HighlightsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.datasource.LiveStreamsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.LongsFilterAssemblerSubscription
@@ -100,6 +101,8 @@ private fun PreloadFor(
NavBarItem.GIT_REPOSITORIES -> GitRepositoriesFilterAssemblerSubscription(accountViewModel)
NavBarItem.HIGHLIGHTS -> HighlightsFilterAssemblerSubscription(accountViewModel)
NavBarItem.SOFTWARE_APPS -> SoftwareAppsFilterAssemblerSubscription(accountViewModel)
// Napplets & nSites read directly from the local cache; their screens open the discovery
@@ -608,7 +608,12 @@ class GroupEventHandler(
return
}
if (!manager.isMember(groupId)) {
Log.w("MarmotDbg") {
// Debug, not warn: relays serve kind:445 for every group they carry, so traffic for
// groups we are not in is the expected steady state, not an anomaly — unlike the null
// manager and missing 'h' tag above, which stay warnings because they mean we cannot
// process a group we *should* be handling. Fires ~22 times a boot (the same handful of
// events, re-offered on each pass), which drowns the two real warnings next to it.
Log.d("MarmotDbg") {
"GroupEventHandler.add: not a member of group=${groupId.take(8)}… — dropping kind:445 ${event.id.take(8)}"
}
return
@@ -32,16 +32,11 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -63,48 +58,39 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.buzzTimelinePreviewSummary
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordAuthorFacepile
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordUnreadBadge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.newestTimelineNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.recentAuthorHexes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupChannelUnreadCountFlow
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
/** How many recent-poster avatars a channel row's facepile shows at most. */
private const val FACEPILE_MAX = 4
/** A first screen's worth of recent messages to prefetch per visible card, so previews fill in. */
private const val CARD_WARMUP_LIMIT = 10
/**
* One channel row in a Buzz workspace's community view: a channel the user is a member of (via
* kind-44100), rendered like the Concord server view — a colored monogram, the channel name with a
* recent-posters facepile, a preview of the last message (author + snippet, or the Buzz activity
* summary for system/diff/job rows), the relative time of that message, and an unread-count badge.
* Tapping the card opens the channel ([onOpen]); the trailing overflow (3-dot) menu holds the
* per-channel actions Pin/Unpin and the Add/Remove-from-Messages toggle ([isAdded] says which half
* to show, and it must come from the live kind-10009 list) — so the row stays clean.
* kind-44100), rendered like the Concord server view — a colored monogram, the channel name with the
* last message's relative time, and below it a preview of the last message (author + snippet, or the
* Buzz activity summary for system/diff/job rows) with an unread-count badge.
* Tapping the card opens the channel ([onOpen]); the row itself is a clean tap-to-open target — its
* per-channel actions (Pin/Unpin, Add/Remove-from-Messages) live in the opened channel's/forum's
* top-bar overflow, not on the row. A pinned channel still shows a pin marker here ([isStarred]).
*
* Reused by the relay group-list screen where Buzz membership discovery is folded in.
*
* [showActivityPreview] gates the chat-activity machinery — the recent-message warmup, the
* last-message preview, the recent-posters facepile and the unread badge. Enable it for **chat**
* channels (whose content lives in [RelayGroupChannel.notes]); leave it off for **forum** channels,
* whose posts are threads (a separate store), so the row doesn't open a kind-9 chat subscription that
* would return nothing and drives a member-count summary instead.
* last-message preview and the unread badge. Enable it for **chat** channels (whose content lives in
* [RelayGroupChannel.notes]); leave it off for **forum** channels, whose posts are threads (a
* separate store), so the row doesn't open a kind-9 chat subscription that would return nothing and
* drives a member-count summary instead.
*/
@Composable
fun BuzzImportRow(
groupId: GroupId,
isAdded: Boolean,
onAdd: () -> Unit,
onRemove: () -> Unit,
accountViewModel: AccountViewModel,
onOpen: (() -> Unit)? = null,
isStarred: Boolean = false,
onToggleStar: (() -> Unit)? = null,
showActivityPreview: Boolean = true,
) {
val account = accountViewModel.account
@@ -131,11 +117,10 @@ fun BuzzImportRow(
val memberCount = channel.memberCount()
val isPrivate = channel.isPrivate()
// The channel's own notes flow drives the preview/facepile so they update the moment a message
// folds in, independent of the metadata-scoped [observeChannel] above. Only collected for chat
// channels; a forum row shows a member-count summary with no facepile/unread.
// The channel's own notes flow drives the preview so it updates the moment a message folds in,
// independent of the metadata-scoped [observeChannel] above. Only collected for chat channels; a
// forum row shows a member-count summary with no unread.
val lastNote: Note?
val faceAuthors: List<String>
val unread: Int
if (showActivityPreview) {
val notesState by channel
@@ -143,14 +128,12 @@ fun BuzzImportRow(
.notes.stateFlow
.collectAsStateWithLifecycle()
lastNote = remember(notesState) { channel.newestTimelineNote(account) }
faceAuthors = remember(notesState) { channel.recentAuthorHexes(account, FACEPILE_MAX) }
unread =
remember(groupId) { relayGroupChannelUnreadCountFlow(account, groupId) }
.collectAsStateWithLifecycle(0)
.value
} else {
lastNote = null
faceAuthors = emptyList()
unread = 0
}
val hasUnread = unread > 0
@@ -163,14 +146,9 @@ fun BuzzImportRow(
isPrivate = isPrivate,
memberCount = memberCount,
lastNote = lastNote,
faceAuthors = faceAuthors,
unread = unread,
hasUnread = hasUnread,
isAdded = isAdded,
isStarred = isStarred,
onToggleStar = onToggleStar,
onAdd = onAdd,
onRemove = onRemove,
accountViewModel = accountViewModel,
)
}
@@ -192,25 +170,20 @@ private fun BuzzImportRowContent(
isPrivate: Boolean,
memberCount: Int,
lastNote: Note?,
faceAuthors: List<String>,
unread: Int,
hasUnread: Boolean,
isAdded: Boolean,
isStarred: Boolean,
onToggleStar: (() -> Unit)?,
onAdd: () -> Unit,
onRemove: () -> Unit,
accountViewModel: AccountViewModel,
) {
Row(
modifier = Modifier.padding(start = 12.dp, top = 10.dp, bottom = 10.dp, end = 4.dp),
modifier = Modifier.padding(start = 12.dp, top = 10.dp, bottom = 10.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
BuzzImportAvatar(name = name, seed = seed)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
// Line 1: a lock (private), the channel name, a pin marker (starred), and the recent-
// posters facepile pushed to the right.
// Line 1: a lock (private), the channel name, a pin marker (starred), and the last-message
// time pushed to the right.
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
if (isPrivate) {
Icon(
@@ -236,13 +209,6 @@ private fun BuzzImportRowContent(
modifier = Modifier.size(14.dp),
)
}
ConcordAuthorFacepile(faceAuthors, accountViewModel)
}
// Line 2: the last-message preview, then the time + unread badge.
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(Modifier.weight(1f)) {
BuzzChannelPreviewLine(lastNote, memberCount, accountViewModel)
}
lastNote?.createdAt()?.let { ts ->
Text(
timeAgo(ts, LocalContext.current, prefix = ""),
@@ -251,16 +217,15 @@ private fun BuzzImportRowContent(
maxLines = 1,
)
}
}
// Line 2: the last-message preview, then the unread-message badge.
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(Modifier.weight(1f)) {
BuzzChannelPreviewLine(lastNote, memberCount, accountViewModel)
}
ConcordUnreadBadge(unread)
}
}
BuzzChannelRowMenu(
isAdded = isAdded,
onAdd = onAdd,
onRemove = onRemove,
isStarred = isStarred,
onToggleStar = onToggleStar,
)
}
}
@@ -308,68 +273,6 @@ private fun BuzzChannelPreviewLine(
)
}
/**
* The per-channel overflow (3-dot) menu: Pin/Unpin and Add-to-my-list. Moved off the row itself so a
* channel card reads as a clean Concord-style row, with its actions one tap behind the kebab.
*/
@Composable
private fun BuzzChannelRowMenu(
isAdded: Boolean,
onAdd: () -> Unit,
onRemove: () -> Unit,
isStarred: Boolean,
onToggleStar: (() -> Unit)?,
) {
var expanded by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { expanded = true }) {
Icon(
symbol = MaterialSymbols.MoreVert,
contentDescription = stringRes(R.string.more_options),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
if (onToggleStar != null) {
DropdownMenuItem(
leadingIcon = {
Icon(
symbol = MaterialSymbols.PushPin,
contentDescription = null,
tint = if (isStarred) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
},
text = { Text(stringRes(if (isStarred) R.string.buzz_unpin else R.string.buzz_pin)) },
onClick = {
expanded = false
onToggleStar()
},
)
}
// A toggle, not a one-way "Added" badge: a channel already on the kind-10009 list offers
// the way back off it. Neither half touches the relay roster, so the channel stays in this
// list (and readable) either way — only whether it shows on Messages changes.
DropdownMenuItem(
leadingIcon = {
Icon(
symbol = if (isAdded) MaterialSymbols.VisibilityOff else MaterialSymbols.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
},
text = { Text(stringRes(if (isAdded) R.string.remove_from_messages else R.string.add_to_messages)) },
onClick = {
expanded = false
if (isAdded) onRemove() else onAdd()
},
)
}
}
}
/** A round monogram whose color is derived deterministically from the channel id. */
@Composable
private fun BuzzImportAvatar(
@@ -23,11 +23,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_METADATA_KINDS
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -154,22 +156,49 @@ class BuzzRelayImportViewModel : ViewModel() {
false
}
// 2. Fetch each channel's NIP-29 metadata (39000-39003) so its name + Buzz `t` type load.
// 2. Fetch each channel's NIP-29 metadata (39000-39003, `#d`-scoped) so its name + Buzz
// `t` type load, AND the relay's kind-40099 system messages (`#h`-scoped) so a
// `channel_deleted` one is seen. The relay soft-deletes a deleted channel's 39000
// and never retracts the kind-44100 that seeded `channelIds`, so without pulling the
// 40099 a deleted channel — its metadata now blank — would show optimistically here
// forever. LocalCache records the delete into RelayGroupDeletions on consume.
if (channelIds.isNotEmpty()) {
account.client.fetchAllWithHooks(
filters = mapOf(relay to listOf(Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to channelIds.toList())))),
filters =
mapOf(
relay to
listOf(
Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to channelIds.toList())),
Filter(kinds = listOf(SystemMessageEvent.KIND), tags = mapOf("h" to channelIds.toList())),
),
),
timeoutMs = 8_000,
pendingOnAuthRequired = true,
) { _, _ -> false }
}
// 3. Keep only non-DM workspace channels; a channel whose metadata hasn't arrived
// (type unknown) is optimistically shown as a workspace channel.
// 3. Keep only the non-DM workspace channels the relay still serves metadata for.
//
// A deleted (or never-really-there) channel keeps its kind-44100 — the relay never
// retracts it — but the relay soft-deletes its 39000, so it arrives here with NO
// metadata. That absence is the reliable "it's gone" signal (the relay does not serve
// a `channel_deleted` 40099 for an already-deleted channel, so the fetch above can't
// catch this case). Before, such a channel showed optimistically — as a bare UUID row
// with "No messages yet", which is exactly the leak reported.
//
// So drop channels with no metadata — but ONLY when the fetch clearly worked (at least
// one channel came back with a 39000). If none did, the read failed (auth/connectivity)
// and we keep the whole set rather than blank the community; a genuinely new channel
// whose 39000 is merely slow re-appears on the next bind once its metadata is cached.
val channels = channelIds.associateWith { LocalCache.getOrCreateRelayGroupChannel(GroupId(it, relay)) }
val fetchHadMetadata = channels.values.any { it.event != null }
_channels.value =
channelIds
.mapNotNull { id ->
val groupId = GroupId(id, relay)
val channel = LocalCache.getOrCreateRelayGroupChannel(groupId)
if (RelayGroupDeletions.isDeleted(groupId)) return@mapNotNull null
val channel = channels.getValue(id)
if (fetchHadMetadata && channel.event == null) return@mapNotNull null
if (channel.event?.isBuzzDm() == true) null else groupId
}.sortedBy { it.id }
_status.value = Status.Ready
@@ -29,7 +29,10 @@ import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.awaitTouchSlopOrCancellation
import androidx.compose.foundation.gestures.horizontalDrag
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
@@ -47,20 +50,24 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
@@ -174,6 +181,12 @@ fun ChatBubbleLayout(
var dragOffset by remember { mutableFloatStateOf(0f) }
var settleJob by remember { mutableStateOf<Job?>(null) }
val swipeScope = rememberCoroutineScope()
// The caller passes a fresh onSwipeReply lambda every recomposition (it captures
// the note), so read it through updated-state and key pointerInput on the stable
// isLoggedInUser instead. Keying on the lambda would tear down and restart the
// detector on every recomposition — stranding an in-flight drag and churning the
// gesture coroutine on every idle row update.
val latestOnSwipeReply by rememberUpdatedState(onSwipeReply)
val density = LocalDensity.current
val swipeThresholdPx = remember(density) { with(density) { SwipeReplyThreshold.toPx() } }
val swipeMaxPx = remember(density) { with(density) { SwipeReplyMaxDrag.toPx() } }
@@ -187,13 +200,18 @@ fun ChatBubbleLayout(
},
) {
if (onSwipeReply != null) {
val progress = (abs(dragOffset) / swipeThresholdPx).coerceIn(0f, 1f)
if (progress > 0f) {
// Gate composition on a boolean that flips only at the 0<->non-0 edges, so
// the icon is added/removed twice per gesture instead of recomposing every
// frame; the fade/scale reads dragOffset inside graphicsLayer (layer phase),
// so the whole swipe animates without recomposing ChatBubbleLayout.
val swiping by remember { derivedStateOf { dragOffset != 0f } }
if (swiping) {
Box(
modifier =
Modifier
.align(if (isLoggedInUser) Alignment.CenterEnd else Alignment.CenterStart)
.graphicsLayer {
val progress = (abs(dragOffset) / swipeThresholdPx).coerceIn(0f, 1f)
alpha = progress
scaleX = 0.6f + 0.4f * progress
scaleY = 0.6f + 0.4f * progress
@@ -208,7 +226,7 @@ fun ChatBubbleLayout(
if (onSwipeReply != null) {
Modifier
.graphicsLayer { translationX = dragOffset }
.pointerInput(onSwipeReply) {
.pointerInput(isLoggedInUser) {
// Drag toward the screen center only; a haptic tick marks the
// commit point, releasing past it fires the reply.
var crossedThreshold = false
@@ -220,20 +238,7 @@ fun ChatBubbleLayout(
}
}
detectHorizontalDragGestures(
onDragStart = {
settleJob?.cancel()
crossedThreshold = false
},
onDragEnd = {
if (abs(dragOffset) >= swipeThresholdPx) {
onSwipeReply()
}
settleBack()
},
onDragCancel = { settleBack() },
) { change, dragAmount ->
change.consume()
val applyDrag = { dragAmount: Float ->
val newOffset =
if (isLoggedInUser) {
(dragOffset + dragAmount).coerceIn(-swipeMaxPx, 0f)
@@ -246,6 +251,50 @@ fun ChatBubbleLayout(
}
dragOffset = newOffset
}
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
// Claim the pointer only once the motion is clearly more
// horizontal than vertical; a mostly-vertical drag leaves the
// pointer unconsumed so the enclosing scroll keeps it and the
// bubble never moves while you scroll.
var overSlop = Offset.Zero
val drag =
awaitTouchSlopOrCancellation(down.id) { change, over ->
if (abs(over.x) > abs(over.y)) {
change.consume()
overSlop = over
}
}
if (drag != null) {
// Take over from any in-flight settle only now that we've
// committed to a horizontal drag. Cancelling earlier (on
// the down) would strand dragOffset when the gesture turns
// out to be a vertical scroll, since settleBack() below
// only runs on this path.
settleJob?.cancel()
crossedThreshold = false
applyDrag(overSlop.x)
try {
val completed =
horizontalDrag(drag.id) { change ->
applyDrag(change.positionChange().x)
change.consume()
}
if (completed && abs(dragOffset) >= swipeThresholdPx) {
latestOnSwipeReply?.invoke()
}
} finally {
// Settle even if the drag coroutine is cancelled (e.g.
// the bubble leaves composition mid-swipe); settleBack
// launches on swipeScope, not this pointer coroutine,
// so it still runs while that scope is alive.
settleBack()
}
}
}
}
} else {
Modifier
@@ -46,7 +46,6 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -74,6 +73,7 @@ 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
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelPreviewLoader
@@ -223,7 +223,7 @@ fun ConcordChannelListScreen(
Scaffold(
topBar = {
TopAppBar(
ShorterTopAppBar(
title = { Text(communityName, maxLines = 1) },
navigationIcon = {
// Back arrow only when pushed from elsewhere; as a bottom-nav tab the bar takes its place.
@@ -403,8 +403,6 @@ private fun ConcordChannelListRow(
remember(communityId, channelKey) { concordChannelUnreadCountFlow(account, communityId, channelKey) }
.collectAsStateWithLifecycle(0)
val hasUnread = unread > 0
// The recent posters' faces — recomputed as the channel's notes change (keyed on channelState).
val faceAuthors = remember(channelState) { channel.recentAuthorHexes(FACEPILE_MAX) }
Row(
Modifier
@@ -421,7 +419,7 @@ private fun ConcordChannelListRow(
tint = if (hasUnread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
// Line 1: channel name + the recent-posters facepile pushed to the right.
// Line 1: channel name + the last-message time pushed to the right.
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
channelName,
@@ -431,13 +429,6 @@ private fun ConcordChannelListRow(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
ConcordAuthorFacepile(faceAuthors, accountViewModel)
}
// Line 2: the last-message preview (or a live "typing…"), then the time + unread badge.
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(Modifier.weight(1f)) {
ConcordChannelPreviewLine(lastNote, isVoice, typingAuthors, accountViewModel)
}
lastNote?.createdAt()?.let { ts ->
Text(
timeAgo(ts, LocalContext.current, prefix = ""),
@@ -446,6 +437,12 @@ private fun ConcordChannelListRow(
maxLines = 1,
)
}
}
// Line 2: the last-message preview (or a live "typing…"), then the unread-message badge.
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(Modifier.weight(1f)) {
ConcordChannelPreviewLine(lastNote, isVoice, typingAuthors, accountViewModel)
}
ConcordUnreadBadge(unread)
}
}
@@ -458,9 +455,6 @@ private fun ConcordChannelListRow(
}
}
/** How many recent-poster avatars a channel row's facepile shows at most. */
private const val FACEPILE_MAX = 4
/**
* Bottom room the list leaves for the floating action button: a 56dp FAB + the Scaffold's 16dp margin
* + slack, so the last row's overflow menu stays tappable instead of sitting under the FAB. Matches
@@ -1,60 +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.chats.publicChannels.concord
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* A horizontal strip of the recent posters in a channel — the "who's here" cue that makes a busy
* channel feel alive. Each poster is drawn with the app's standard profile avatar
* ([ClickableUserPicture]), so it carries the same following badge (top-right) and trust-score tag
* (bottom-centre) shown everywhere else a user appears, instead of a bare cropped image. Laid out
* with a small gap rather than an overlapping stack so those badges stay readable; the newest poster
* is leftmost. Renders nothing for an empty [authorHexes], so callers can drop it in unconditionally.
*/
@Composable
fun ConcordAuthorFacepile(
authorHexes: List<HexKey>,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
avatarSize: Dp = 24.dp,
maxShown: Int = 4,
) {
if (authorHexes.isEmpty()) return
val shown = authorHexes.take(maxShown)
Row(modifier, horizontalArrangement = Arrangement.spacedBy(2.dp)) {
shown.forEach { hex ->
ClickableUserPicture(
baseUserHex = hex,
size = avatarSize,
accountViewModel = accountViewModel,
)
}
}
}
@@ -39,7 +39,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -68,6 +67,7 @@ import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
@@ -115,7 +115,7 @@ fun ConcordHomeScreen(
Scaffold(
topBar = {
TopAppBar(
ShorterTopAppBar(
title = { Text(stringRes(R.string.concord_home_title)) },
navigationIcon = {
// Back arrow only when this is a pushed screen (from the drawer / a deep link);
@@ -26,7 +26,6 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
@@ -135,22 +134,3 @@ private fun ConcordChannel.newMessagesSince(
notes.count { _, note ->
(note.createdAt() ?: 0L) > sinceSecs && isConcordTimelineMessage(note, account)
}
/**
* The pubkeys of the [limit] most-recent distinct posters in this channel, newest first — the
* facepile shown on a channel row. One O(notes) pass keeps each author's latest post time, so a
* chatty author counts once (at their newest message) rather than crowding out quieter voices.
*/
fun ConcordChannel.recentAuthorHexes(limit: Int): List<HexKey> {
val latestByAuthor = HashMap<HexKey, Long>()
for (note in notes.values()) {
val author = note.author?.pubkeyHex ?: continue
val at = note.createdAt() ?: continue
val prev = latestByAuthor[author]
if (prev == null || at > prev) latestByAuthor[author] = at
}
return latestByAuthor.entries
.sortedByDescending { it.value }
.take(limit)
.map { it.key }
}
@@ -0,0 +1,104 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
import androidx.compose.foundation.layout.size
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
/**
* Pin/Unpin a Buzz channel (a device-local favorite — [BuzzChannelStars]). Moved off the per-channel
* list row into the opened channel's/forum's top-bar overflow, so the list row stays a clean
* tap-to-open target. Reads the live starred set so the label + icon reflect the current state.
*/
@Composable
fun BuzzPinDropdownItem(
groupId: GroupId,
closeMenu: () -> Unit,
) {
val starred by BuzzChannelStars.flow.collectAsStateWithLifecycle()
val isStarred = groupId.id in starred
DropdownMenuItem(
leadingIcon = {
Icon(
symbol = MaterialSymbols.PushPin,
contentDescription = null,
tint = if (isStarred) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
},
text = { Text(stringRes(if (isStarred) R.string.buzz_unpin else R.string.buzz_pin)) },
onClick = {
closeMenu()
BuzzChannelStars.toggle(groupId.id)
},
)
}
/**
* Add/Remove this relay-group [channel] from my kind-10009 list (whether it shows in Messages). A
* reversible toggle that never touches my relay membership — same split as "Leave" — so it stays
* readable either way. Reads the live kind-10009 list so it flips as the change lands. Moved off the
* per-channel list row into the opened screen's top-bar overflow.
*/
@Composable
fun RelayGroupMessagesDropdownItem(
channel: RelayGroupChannel,
accountViewModel: AccountViewModel,
closeMenu: () -> Unit,
) {
val joinedGroupIds by accountViewModel.account.relayGroupList.liveRelayGroupIds
.collectAsStateWithLifecycle()
val onMyList = channel.groupId in joinedGroupIds
DropdownMenuItem(
leadingIcon = {
Icon(
symbol = if (onMyList) MaterialSymbols.VisibilityOff else MaterialSymbols.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
},
text = { Text(stringRes(if (onMyList) R.string.remove_from_messages else R.string.add_to_messages)) },
onClick = {
closeMenu()
if (onMyList) {
accountViewModel.removeRelayGroupFromMessages(channel)
} else {
accountViewModel.addRelayGroupToMessages(channel)
}
},
)
}
@@ -36,8 +36,6 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
@@ -47,6 +45,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
@@ -73,7 +72,7 @@ import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.tor.TorType
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions
import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup
@@ -100,6 +99,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelaySubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.warningColor
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_DM
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_FORUM
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -114,9 +114,37 @@ import kotlinx.coroutines.launch
/** A first screen's worth of recent messages to prefetch per visible group card, ahead of a tap. */
private const val CHANNEL_LIST_WARMUP_LIMIT = 10
/** Grace period before offering the Tor→clearnet escape hatch, so a slow-but-working relay isn't nagged. */
/**
* How long this relay's socket must stay down before the Tor→clearnet escape hatch is offered, so a
* slow first connect (or a reconnect) isn't mistaken for a relay that blocks Tor exits.
*/
private const val TOR_CLEARNET_HINT_DELAY_MS = 6_000L
/**
* Whether to offer the Tor→clearnet escape hatch for this relay ([TorClearnetBanner]).
*
* The banner accuses *this relay* of blocking Tor exits and offers a privacy downgrade to work
* around it, so it has to rule out every other explanation — and be able to deliver:
*
* - [usesTor]: this relay is *actually* routed over Tor right now (`TorRelayEvaluation.useTor`, the
* same predicate the pool dials with). Tor being enabled globally says nothing about one relay —
* onion, localhost and the per-role presets (trusted / DM / new) each decide independently.
* - [torIsUp]: the SOCKS proxy is Active. While Tor is still bootstrapping *nothing* Tor-routed
* connects, which is Tor's problem (and its own dialog's), not this relay's.
* - [trustingMovesToClearnet]: the action on offer — adding the relay to the kind-10089 Trusted list —
* would actually change its routing. Under a preset that keeps trusted relays on Tor it wouldn't,
* and the button would be inert.
* - [disconnectedLongEnough]: the socket has been continuously down for [TOR_CLEARNET_HINT_DELAY_MS].
* A relay that answers is reachable by definition, and one that merely reconnects is not a relay
* that blocks Tor — only a sustained silence is.
*/
internal fun shouldOfferTorClearnetFallback(
usesTor: Boolean,
torIsUp: Boolean,
trustingMovesToClearnet: Boolean,
disconnectedLongEnough: Boolean,
): Boolean = usesTor && torIsUp && trustingMovesToClearnet && disconnectedLongEnough
/**
* Bottom room the list leaves for the floating action button: a 56dp FAB + the Scaffold's 16dp margin
* + slack, so the last row's overflow menu stays tappable instead of sitting under the FAB. Matches
@@ -172,6 +200,11 @@ fun RelayGroupChannelListScreen(
}
}
// Channels this device has deleted (kind-9008). A delete is terminal — the relay drops the group —
// but our cached 39000 (and a Buzz relay's stale re-announced 44100) would keep it in the list, so
// filter them out everywhere below. Collected as a StateFlow so a delete removes the row live.
val deletedChannels by RelayGroupDeletions.flow.collectAsStateWithLifecycle()
// Prefer the relay's own genuine, relay-signed groups (39000 author == the NIP-11 `self`).
// Recomputes as the NIP-11 doc resolves so real groups fill in and fakes stay hidden. But if
// NIP-11 is unreachable (e.g. a Cloudflare-fronted relay that resets the plain HTTP GET while
@@ -179,20 +212,22 @@ fun RelayGroupChannelListScreen(
// its de-facto signer — so its relay-signed groups still show while a stray user-published 39000
// (a different author) stays filtered.
val channels =
remember(allChannels, relayInfo) {
remember(allChannels, relayInfo, deletedChannels) {
val nip11Known = relayInfo.self != null || relayInfo.supported_nips != null
if (nip11Known) {
allChannels.filter { isRelaySignedRelayGroup(it, relayInfo) }
} else {
val dominantSigner =
allChannels
.mapNotNull { it.event?.pubKey }
.groupingBy { it }
.eachCount()
.maxByOrNull { it.value }
?.key
if (dominantSigner != null) allChannels.filter { it.event?.pubKey == dominantSigner } else allChannels
}
val signed =
if (nip11Known) {
allChannels.filter { isRelaySignedRelayGroup(it, relayInfo) }
} else {
val dominantSigner =
allChannels
.mapNotNull { it.event?.pubKey }
.groupingBy { it }
.eachCount()
.maxByOrNull { it.value }
?.key
if (dominantSigner != null) allChannels.filter { it.event?.pubKey == dominantSigner } else allChannels
}
signed.filterNot { it.groupId.toKey() in deletedChannels }
}
// Buzz relays expose no public group directory (membership is server-side), so `channels` above
@@ -200,6 +235,15 @@ fun RelayGroupChannelListScreen(
// (kind-44100) so browsing the relay lists the channels you already belong to — each addable to
// your kind-10009 list (so it then shows in Messages / Relay Groups). Same screen, one Browse.
val isBuzz = BuzzRelayDialect.isBuzz(relay) || relayInfo.software?.contains("buzz", ignoreCase = true) == true
// A Buzz relay lets any community member create a channel/forum (the creator becomes its owner),
// and the relay rejects a non-member's kind-9007. We don't hard-gate the "+" on membership: the
// NIP-43 roster (kind 13534) isn't fetched on this screen, so gating on it hid the "+" even from
// admins. Instead we offer it on any Buzz community and let the relay enforce — the same approach
// as the workspace overflow menu (Add people / Invite), whose own doc notes "any member sees them,
// the relay only serves the owner/admin ones."
val myPubkey = accountViewModel.account.signer.pubKey
val buzzVm: BuzzRelayImportViewModel = viewModel(key = "BuzzImport-${relay.url}")
LaunchedEffect(relay, isBuzz) { if (isBuzz) buzzVm.bind(accountViewModel.account, relay.url) }
val buzzChannels by buzzVm.channels.collectAsStateWithLifecycle()
@@ -226,9 +270,13 @@ fun RelayGroupChannelListScreen(
// ids so nothing the old flat list showed disappears.
val channelsById = remember(allChannels) { allChannels.associateBy { it.groupId.id } }
val buzzGroupIds =
remember(buzzChannels, channels) {
remember(buzzChannels, channels, deletedChannels) {
val seen = LinkedHashSet<String>()
(buzzChannels + channels.map { it.groupId }).filter { seen.add(it.id) }
// `channels` is already delete-filtered; also drop deleted ids from the membership-scoped
// `buzzChannels` (kind-44100), which the relay can keep re-announcing after a delete.
(buzzChannels + channels.map { it.groupId })
.filterNot { it.toKey() in deletedChannels }
.filter { seen.add(it.id) }
}
fun buzzTypeOf(groupId: GroupId): String? = channelsById[groupId.id]?.event?.buzzChannelType()
@@ -245,21 +293,34 @@ fun RelayGroupChannelListScreen(
fun buzzSortKey(groupId: GroupId): String = channelsById[groupId.id]?.toBestDisplayName()?.lowercase() ?: groupId.id
// Archived channels (relay-signed `archived` tag on the 39000) drop out of their normal section and
// gather in a collapsed "Archived" tail — the same hide-from-the-sidebar behavior the Buzz client
// has. They stay reachable there so an admin can open one and Unarchive it from the top bar.
fun isArchived(groupId: GroupId): Boolean = channelsById[groupId.id]?.isArchived() == true
val buzzChatChannels =
remember(buzzGroupIds, channelsById, starred) {
buzzGroupIds
.filter { buzzTypeOf(it).let { t -> t != BUZZ_CHANNEL_TYPE_FORUM && t != BUZZ_CHANNEL_TYPE_DM } }
.filter { buzzTypeOf(it).let { t -> t != BUZZ_CHANNEL_TYPE_FORUM && t != BUZZ_CHANNEL_TYPE_DM } && !isArchived(it) }
.sortedWith(compareByDescending<GroupId> { it.id in starred }.thenBy { buzzSortKey(it) })
}
val buzzForumChannels =
remember(buzzGroupIds, channelsById, starred) {
buzzGroupIds
.filter { buzzTypeOf(it) == BUZZ_CHANNEL_TYPE_FORUM }
.filter { buzzTypeOf(it) == BUZZ_CHANNEL_TYPE_FORUM && !isArchived(it) }
.sortedWith(compareByDescending<GroupId> { it.id in starred }.thenBy { buzzSortKey(it) })
}
// Every archived non-DM channel (chat + forum together), newest section at the bottom.
val buzzArchivedChannels =
remember(buzzGroupIds, channelsById) {
buzzGroupIds
.filter { buzzTypeOf(it) != BUZZ_CHANNEL_TYPE_DM && isArchived(it) }
.sortedBy { buzzSortKey(it) }
}
// Which sections the user has collapsed (session-scoped). Keyed by section id below.
var collapsedSections by remember { mutableStateOf(emptySet<String>()) }
// Which sections the user has collapsed (session-scoped). Keyed by section id below. Archived
// starts collapsed — it's the out-of-the-way tail, expanded only when someone goes looking.
var collapsedSections by remember { mutableStateOf(setOf("archived")) }
fun toggleSection(key: String) {
collapsedSections = if (key in collapsedSections) collapsedSections - key else collapsedSections + key
@@ -271,19 +332,54 @@ fun RelayGroupChannelListScreen(
var showAddPeople by remember { mutableStateOf(false) }
// Tor-failure escape hatch: a Cloudflare-fronted (or otherwise Tor-hostile) relay times out over
// Tor. When Tor is on, the relay isn't an onion, it isn't already trusted, and nothing has loaded
// after a grace period, offer to reach it over clearnet — which adds it to the kind-10089 Trusted
// Relay List (connected over clearnet even while Tor stays on for everything else).
val torType by Amethyst.instance.torPrefs.torType
.collectAsStateWithLifecycle(TorType.OFF)
val isOnion = remember(relay) { relay.url.contains(".onion") }
var connectTimedOut by remember(relay) { mutableStateOf(false) }
LaunchedEffect(relay) {
delay(TOR_CLEARNET_HINT_DELAY_MS)
connectTimedOut = true
// Tor. Offer to reach it over clearnet — which adds it to the kind-10089 Trusted Relay List
// (connected over clearnet even while Tor stays on for everything else).
//
// Every input below is a live signal, because a grace timer on its own says nothing about the
// relay: the banner used to fire on `torType != OFF && !onion && !trusted` plus a 6s delay, so on
// a working, answering relay it appeared after six seconds and never went away — the socket state
// was never consulted, and neither was whether this relay is Tor-routed at all (Tor being *on*
// doesn't mean this relay goes through it; the per-role presets decide).
val torEvaluation =
Amethyst.instance.torEvaluatorFlow.flow
.collectAsStateWithLifecycle()
// The same predicate the relay pool itself dials with, so the banner can't claim Tor for a relay
// the app is reaching over clearnet (onion / localhost / trusted-off-Tor are all folded in here).
val usesTor by remember(relay) { derivedStateOf { torEvaluation.value.useTor(relay) } }
// While Tor is bootstrapping every Tor-routed relay is silent — that's Tor's own failure (and its
// own dialog), so don't let it read as "this relay blocks Tor exits".
val torStatus by Amethyst.instance.torManager.status
.collectAsStateWithLifecycle()
val torIsUp = torStatus is TorServiceStatus.Active
// The offer adds the relay to the kind-10089 Trusted list, which only moves it to clearnet while
// trusted relays are *off* Tor. Under the Small-Payloads / Full-Privacy presets they are on Tor,
// so the button would add an entry and change no routing at all — don't offer what can't help.
val trustingMovesToClearnet by remember { derivedStateOf { !torEvaluation.value.torSettings.trustedRelaysViaTor } }
// Global flow (any relay's connect/disconnect re-emits), so derive this relay's boolean.
val connectedRelays =
accountViewModel.account.client
.connectedRelaysFlow()
.collectAsStateWithLifecycle()
val isConnected by remember(relay) { derivedStateOf { relay in connectedRelays.value } }
// Debounced on the *connection* rather than armed once per screen: the countdown restarts every
// time the socket drops and clears the moment it comes back, so a reconnect can't flash the
// banner, and circuits that die mid-session still surface it. Keying the wait on cached content
// instead would have hidden the offer exactly when a working relay went dark with a full screen.
var disconnectedLongEnough by remember(relay) { mutableStateOf(false) }
LaunchedEffect(relay, isConnected) {
if (isConnected) {
disconnectedLongEnough = false
} else {
delay(TOR_CLEARNET_HINT_DELAY_MS)
disconnectedLongEnough = true
}
}
val scope = rememberCoroutineScope()
val showTorHint = torType != TorType.OFF && !isOnion && relay !in trustedRelays && connectTimedOut
val showTorHint = shouldOfferTorClearnetFallback(usesTor, torIsUp, trustingMovesToClearnet, disconnectedLongEnough)
// A pinned relay works both as a pushed detail (from the drawer or another screen) and as a
// bottom-nav tab. Read once here (it is @Composable): the back arrow shows only when pushed;
@@ -326,17 +422,20 @@ fun RelayGroupChannelListScreen(
}
},
floatingActionButton = {
FloatingActionButton(onClick = { nav.nav(Route.RelayGroupCreate(relay.url)) }, shape = CircleShape) {
Icon(
symbol = MaterialSymbols.Add,
contentDescription =
stringRes(if (isBuzz) R.string.buzz_channel_create_title else R.string.relay_group_create_title),
modifier = Modifier.size(24.dp),
)
// A Buzz community creates channels/forums from the per-section "+" in their labels (like
// Direct Messages), so no FAB there. A vanilla NIP-29 relay is a flat directory with no
// sections, so it keeps the FAB to create a group.
if (!isBuzz) {
FloatingActionButton(onClick = { nav.nav(Route.RelayGroupCreate(relay.url)) }, shape = CircleShape) {
Icon(
symbol = MaterialSymbols.Add,
contentDescription = stringRes(R.string.relay_group_create_title),
modifier = Modifier.size(24.dp),
)
}
}
},
) { padding ->
val myPubkey = accountViewModel.userProfile().pubkeyHex
// A Buzz relay is a community, so always render its sectioned list (Channels, Forums, Direct
// Messages, Agent Console) even before anything loads, rather than the generic "empty" text.
if (channels.isEmpty() && !isBuzz) {
@@ -366,9 +465,11 @@ fun RelayGroupChannelListScreen(
// overlays content by design, so clearing it is the list's job. As contentPadding (not a
// modifier) so rows scroll *through* that strip and only come to rest clear of it; the
// modifier form would shrink the viewport and leave the FAB floating over dead space.
// Only the vanilla NIP-29 path has a FAB now; a Buzz community creates from its section
// headers, so it needs no bottom clearance.
LazyColumn(
modifier = Modifier.padding(padding),
contentPadding = PaddingValues(bottom = FAB_CLEARANCE),
contentPadding = PaddingValues(bottom = if (isBuzz) 0.dp else FAB_CLEARANCE),
) {
if (showTorHint) {
item(key = "tor-hint") {
@@ -382,16 +483,15 @@ fun RelayGroupChannelListScreen(
}
if (isBuzz) {
// While the membership fetch is still running and nothing has loaded, show a
// "Loading…" line. The old "you're not a member — accept the invite in the browser"
// empty text is gone: the section labels below now each carry a "+" to create a
// channel/forum, so an empty community is a starting point, not a dead end.
val noChannelsYet = buzzChatChannels.isEmpty() && buzzForumChannels.isEmpty()
if (noChannelsYet) {
item(key = "buzz-no-channels") {
if (noChannelsYet && buzzStatus is BuzzRelayImportViewModel.Status.Loading) {
item(key = "buzz-loading") {
Text(
text =
if (buzzStatus is BuzzRelayImportViewModel.Status.Loading) {
stringRes(R.string.buzz_import_loading)
} else {
stringRes(R.string.buzz_import_empty_body)
},
text = stringRes(R.string.buzz_import_loading),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth().padding(24.dp),
@@ -399,57 +499,61 @@ fun RelayGroupChannelListScreen(
}
}
// -- CHANNELS -- (Add-all now lives in the community's top-bar overflow menu)
if (buzzChatChannels.isNotEmpty()) {
// -- CHANNELS -- The label carries a "+" to create a channel (the community's FAB
// moved here, like Direct Messages). Add-all lives in the top-bar overflow menu.
// The header always shows so the "+" is available even before any channel loads;
// the collapse toggle is offered only when there's something to collapse.
run {
val channelsCollapsed = "channels" in collapsedSections
item(key = "sec-channels") {
RelayGroupSectionHeader(
title = stringRes(R.string.relay_group_section_channels),
collapsed = channelsCollapsed,
onToggle = { toggleSection("channels") },
)
onToggle = if (buzzChatChannels.isNotEmpty()) ({ toggleSection("channels") }) else null,
) {
SectionAddButton(stringRes(R.string.buzz_channel_create_title)) {
nav.nav(Route.RelayGroupCreate(relay.url))
}
}
}
if (!channelsCollapsed) {
if (buzzChatChannels.isNotEmpty() && !channelsCollapsed) {
itemsIndexed(buzzChatChannels, key = { _, it -> "chat-${it.id}" }) { index, groupId ->
RowHairline(index)
BuzzImportRow(
groupId = groupId,
isAdded = groupId.id in buzzAdded,
onAdd = { buzzVm.add(groupId) },
onRemove = { buzzVm.remove(groupId) },
accountViewModel = accountViewModel,
onOpen = { nav.nav(Route.RelayGroup(groupId.id, relay.url)) },
isStarred = groupId.id in starred,
onToggleStar = { BuzzChannelStars.toggle(groupId.id) },
)
}
}
}
// -- FORUMS --
if (buzzForumChannels.isNotEmpty()) {
// -- FORUMS -- Same treatment: an always-visible label with a "+" that starts the
// create flow on a forum channel (threaded posts) instead of a chat one.
run {
val forumsCollapsed = "forums" in collapsedSections
item(key = "sec-forums") {
RelayGroupSectionHeader(
title = stringRes(R.string.relay_group_section_forums),
collapsed = forumsCollapsed,
onToggle = { toggleSection("forums") },
)
onToggle = if (buzzForumChannels.isNotEmpty()) ({ toggleSection("forums") }) else null,
) {
SectionAddButton(stringRes(R.string.buzz_forum_create_title)) {
nav.nav(Route.RelayGroupCreate(relay.url, isForum = true))
}
}
}
if (!forumsCollapsed) {
if (buzzForumChannels.isNotEmpty() && !forumsCollapsed) {
itemsIndexed(buzzForumChannels, key = { _, it -> "forum-${it.id}" }) { index, groupId ->
RowHairline(index)
BuzzImportRow(
groupId = groupId,
isAdded = groupId.id in buzzAdded,
onAdd = { buzzVm.add(groupId) },
onRemove = { buzzVm.remove(groupId) },
accountViewModel = accountViewModel,
// A forum channel's primary content is its threads (kind-45001 posts), not a
// kind-9 chat, so open the forum/threads view directly instead of the chat.
onOpen = { nav.nav(Route.RelayGroupThreads(groupId.id, relay.url)) },
isStarred = groupId.id in starred,
onToggleStar = { BuzzChannelStars.toggle(groupId.id) },
// Forum posts live in a separate thread store, not the chat notes the
// activity preview reads — so don't warm a kind-9 sub that returns nothing.
showActivityPreview = false,
@@ -458,6 +562,38 @@ fun RelayGroupChannelListScreen(
}
}
// -- ARCHIVED -- Channels the relay has archived (chat + forum), tucked into a
// collapsed tail. Opening one and using the top-bar Unarchive brings it back.
if (buzzArchivedChannels.isNotEmpty()) {
val archivedCollapsed = "archived" in collapsedSections
item(key = "sec-archived") {
RelayGroupSectionHeader(
title = stringRes(R.string.relay_group_section_archived),
collapsed = archivedCollapsed,
onToggle = { toggleSection("archived") },
)
}
if (!archivedCollapsed) {
itemsIndexed(buzzArchivedChannels, key = { _, it -> "archived-${it.id}" }) { index, groupId ->
RowHairline(index)
val isForum = buzzTypeOf(groupId) == BUZZ_CHANNEL_TYPE_FORUM
BuzzImportRow(
groupId = groupId,
accountViewModel = accountViewModel,
onOpen = {
if (isForum) {
nav.nav(Route.RelayGroupThreads(groupId.id, relay.url))
} else {
nav.nav(Route.RelayGroup(groupId.id, relay.url))
}
},
isStarred = groupId.id in starred,
showActivityPreview = !isForum,
)
}
}
}
// -- DIRECT MESSAGES -- (this community's private conversations, most recent first)
item(key = "sec-dms") {
RelayGroupSectionHeader(title = stringRes(R.string.buzz_dm_title)) {
@@ -488,7 +624,6 @@ fun RelayGroupChannelListScreen(
row = row,
myPubkey = myPubkey,
isHidden = false,
onToggleMessages = { dmVm.removeFromMessages(row) },
accountViewModel = accountViewModel,
nav = nav,
) {
@@ -520,7 +655,6 @@ fun RelayGroupChannelListScreen(
row = row,
myPubkey = myPubkey,
isHidden = true,
onToggleMessages = { dmVm.addToMessages(row) },
accountViewModel = accountViewModel,
nav = nav,
) {
@@ -640,27 +774,44 @@ private fun RelayGroupSectionHeader(
}
}
/**
* The trailing "+" for a section label (Channels / Forums), matching the Direct Messages header's
* New-message icon: a primary-tinted Add glyph that creates a new item of that section's type.
*/
@Composable
private fun SectionAddButton(
contentDescription: String,
onClick: () -> Unit,
) {
IconButton(onClick = onClick) {
Icon(
symbol = MaterialSymbols.Add,
contentDescription = contentDescription,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
}
}
/**
* One inline Direct-Message conversation row inside the community view: the counterpart's avatar +
* name (or a "+N" cluster label for a group DM), a preview of the last message, a compact
* last-activity time, and an overflow holding the Add/Remove-from-Messages toggle. The channel's
* recent content is warmed while the row is visible so the preview fills in ahead of a tap. Tapping
* opens the DM as its relay-group chat.
* name (or a "+N" cluster label for a group DM), a preview of the last message, and a compact
* last-activity time. The channel's recent content is warmed while the row is visible so the preview
* fills in ahead of a tap. Tapping opens the DM as its relay-group chat; the Add/Remove-from-Messages
* (hide/unhide) action lives in that chat screen's top-bar overflow, not on this row.
*
* [isHidden] renders the row faded and flips the overflow to "Add to Messages" — a hidden DM is a
* live conversation the viewer merely parked, so it stays openable and reversible.
* [isHidden] renders the row faded — a hidden DM is a live conversation the viewer merely parked, so
* it stays openable and reversible from the opened conversation.
*/
@Composable
private fun BuzzDmInlineRow(
row: BuzzDmListViewModel.DmRow,
myPubkey: HexKey,
isHidden: Boolean,
onToggleMessages: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
onClick: () -> Unit,
) {
var menuOpen by remember { mutableStateOf(false) }
val others = row.others.ifEmpty { listOf(myPubkey) }
val leadHex = others.first()
val leadUser = remember(leadHex) { LocalCache.getOrCreateUser(leadHex) }
@@ -689,7 +840,7 @@ private fun BuzzDmInlineRow(
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(start = 16.dp, end = 4.dp, top = 10.dp, bottom = 10.dp)
.padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 10.dp)
.alpha(if (isHidden) 0.55f else 1f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
@@ -718,33 +869,6 @@ private fun BuzzDmInlineRow(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Box {
IconButton(onClick = { menuOpen = true }) {
Icon(
symbol = MaterialSymbols.MoreVert,
contentDescription = stringRes(R.string.more_options),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
leadingIcon = {
Icon(
symbol = if (isHidden) MaterialSymbols.Add else MaterialSymbols.VisibilityOff,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
},
text = { Text(stringRes(if (isHidden) R.string.add_to_messages else R.string.remove_from_messages)) },
onClick = {
menuOpen = false
onToggleMessages()
},
)
}
}
}
}
@@ -97,10 +97,15 @@ fun RelayGroupCreateScreen(
relayUrl: String,
accountViewModel: AccountViewModel,
nav: INav,
// Buzz only: pre-select the `forum` channel type (the Forums section's "+" routes here with true).
isForum: Boolean = false,
) {
val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return
val viewModel: RelayGroupMetadataViewModel = viewModel(key = "RelayGroupCreate:$relayUrl")
LaunchedEffect(relay) { viewModel.initCreate(accountViewModel, relay) }
LaunchedEffect(relay) {
viewModel.initCreate(accountViewModel, relay)
if (isForum) viewModel.isForum = true
}
// A group only works if the relay actually runs NIP-29 (otherwise it stores our 9007/9002 as
// ordinary events, never emits metadata/roster, and the "group" is a dead hex id). Gate creation
@@ -197,8 +202,15 @@ private fun RelayGroupMetadataScaffold(
Scaffold(
topBar = {
if (viewModel.isNewGroup) {
// The type is fixed by the caller (the community's per-section "+"), so the title names
// it — "New forum" vs "New channel" on Buzz — rather than offering a toggle to change it.
CreatingTopBar(
titleRes = if (viewModel.isBuzzRelay) R.string.buzz_channel_create_title else R.string.relay_group_create_title,
titleRes =
when {
!viewModel.isBuzzRelay -> R.string.relay_group_create_title
viewModel.isForum -> R.string.buzz_forum_create_title
else -> R.string.buzz_channel_create_title
},
isActive = { viewModel.canPost && (nip29Support == true || viewModel.isBuzzRelay) },
onCancel = nav::popBack,
onPost = onSubmit,
@@ -410,21 +422,11 @@ private fun GroupMetadataFields(viewModel: RelayGroupMetadataViewModel) {
viewModel.markTouched()
}
if (viewModel.isBuzzRelay) {
// Buzz's `channel_type`. Only offered on create: the relay takes it on the 9007 and its
// 9002 handler has no `channel_type` key, so an existing channel cannot be converted.
if (viewModel.isNewGroup) {
LabeledSwitchRow(
label = stringRes(R.string.buzz_channel_flag_forum),
description = stringRes(R.string.buzz_channel_flag_forum_desc),
checked = viewModel.isForum,
) {
viewModel.isForum = it
viewModel.markTouched()
}
}
return
}
// A Buzz channel's type (chat vs forum) is fixed by the caller — the community's per-section "+" —
// and isn't editable (the relay's 9002 has no `channel_type` key), so there's no toggle here. The
// remaining vanilla NIP-29 flags (invite-only / restricted below) don't apply to Buzz either, so
// stop here for a Buzz relay.
if (viewModel.isBuzzRelay) return
LabeledSwitchRow(
label = stringRes(R.string.relay_group_flag_invite_only),
@@ -34,15 +34,20 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -57,6 +62,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplyCount
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -166,6 +172,37 @@ private fun RelayGroupThreads(
)
}
},
actions = {
// The forum's per-item actions, moved off the community-list row into this screen's
// top-bar overflow: Pin/Unpin and the Add/Remove-from-Messages toggle, plus the
// admin-only Archive/Unarchive (so an archived forum can be brought back from here).
// Buzz-only, which every forum channel is.
if (isBuzz) {
var menuOpen by remember { mutableStateOf(false) }
val isAdmin = channel.membershipOf(accountViewModel.userProfile().pubkeyHex) == RelayGroupMembership.ADMIN
IconButton(onClick = { menuOpen = true }) {
Icon(
symbol = MaterialSymbols.MoreVert,
contentDescription = stringRes(R.string.more_options),
modifier = Modifier.size(22.dp),
)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
BuzzPinDropdownItem(channel.groupId) { menuOpen = false }
RelayGroupMessagesDropdownItem(channel, accountViewModel) { menuOpen = false }
if (isAdmin) {
val archived = channel.isArchived()
DropdownMenuItem(
text = { Text(stringRes(if (archived) R.string.buzz_channel_unarchive else R.string.buzz_channel_archive)) },
onClick = {
menuOpen = false
accountViewModel.archiveRelayGroup(channel, !archived)
},
)
}
}
}
},
popBack = nav::popBack,
)
},
@@ -53,6 +53,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
@@ -117,6 +118,9 @@ fun RelayGroupTopBar(
// My kind-10009 list, live: drives the Add/Remove-from-Messages toggle in the overflow below.
val joinedGroupIds by accountViewModel.account.relayGroupList.liveRelayGroupIds
.collectAsStateWithLifecycle()
// A Buzz DM is hidden via its own per-viewer 30622 snapshot, not the kind-10009 list, so the
// Messages toggle branches on this for DMs.
val hiddenDms by BuzzDmRegistry.hidden.collectAsStateWithLifecycle()
// Read once here (nav.canPop() is @Composable) so the post-action navigation can pop from a menu
// callback — leaving a group shouldn't strand the user on the screen of a group they left.
val canPop = nav.canPop()
@@ -237,7 +241,9 @@ fun RelayGroupTopBar(
// Buzz `t=stream` channel it is always empty (forum posts live in `t=forum` channels, which
// the relay's channel list already surfaces in their own section), so it read as a broken
// feature on every chat. Demoted to the overflow, where the frequency of use actually is.
if (!isDm || naddr != null || showMembershipActions) {
// A Buzz DM always gets the overflow too — its hide/unhide (below) is the DM row's old
// action, and it must be reachable even where the membership actions aren't offered.
if (!isDm || naddr != null || showMembershipActions || (isDm && isBuzzRelay)) {
IconButton(onClick = { menuOpen = true }) {
Icon(
symbol = MaterialSymbols.MoreVert,
@@ -246,6 +252,33 @@ fun RelayGroupTopBar(
)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
// Pin/Unpin moved here off the community-list row. A local favorite, so it's offered
// for any Buzz channel/forum regardless of membership; DMs are never pinned.
if (isBuzzRelay && !isDm) {
BuzzPinDropdownItem(channel.groupId) { menuOpen = false }
}
// A DM's Add/Remove-from-Messages, moved off the DM list row. It rides the per-viewer
// 30622 hide snapshot (kind-41012 hide / re-open), not the kind-10009 list, and is
// shown regardless of the membership gate below.
if (isBuzzRelay && isDm) {
val dmHidden = channel.groupId.id in (hiddenDms[myPubkey] ?: emptySet())
DropdownMenuItem(
text = { Text(stringRes(if (dmHidden) R.string.add_to_messages else R.string.remove_from_messages)) },
onClick = {
menuOpen = false
if (dmHidden) {
val participants =
channel.event
?.buzzParticipants()
?.filter { it != myPubkey }
.orEmpty()
accountViewModel.unhideBuzzDm(channel.groupId.relayUrl, participants.ifEmpty { listOf(myPubkey) })
} else {
accountViewModel.hideBuzzDm(channel)
}
},
)
}
if (!isDm) {
DropdownMenuItem(
text = { Text(stringRes(R.string.relay_group_threads_title)) },
@@ -308,6 +341,8 @@ fun RelayGroupTopBar(
// Two distinct actions, never conflated: the Messages toggle adds/drops the group
// on my kind-10009 list but keeps my relay membership either way; "Leave" sends
// the kind-9022 that actually removes me. Same split as the channel-invite card.
// (A DM's Messages toggle is the hide/unhide item above — it rides a different
// mechanism and must show even when these membership actions don't.)
//
// Reads the live kind-10009 list rather than assuming the group is on it: this
// bar also opens for channels reached from the workspace browse (a Buzz relay
@@ -335,6 +370,19 @@ fun RelayGroupTopBar(
if (canPop) nav.popBack()
},
)
// Archive/Unarchive (kind-9002 `archived` tag) — a reversible hide-from-the-sidebar,
// Buzz-only and admin-gated like Delete but NOT destructive, so no confirm dialog.
// A DM is never archived (it has its own hide), so this is channels/forums only.
if (isBuzzRelay && !isDm && displayMembership == RelayGroupMembership.ADMIN) {
val archived = channel.isArchived()
DropdownMenuItem(
text = { Text(stringRes(if (archived) R.string.buzz_channel_unarchive else R.string.buzz_channel_archive)) },
onClick = {
menuOpen = false
accountViewModel.archiveRelayGroup(channel, !archived)
},
)
}
// Deleting the whole channel/group (kind-9008) is destructive for everyone, so it's
// shown ONLY to an admin/owner — the same authorization gate as Edit above — and
// routed through a confirmation dialog rather than firing on tap.
@@ -26,7 +26,6 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
@@ -112,29 +111,6 @@ fun RelayGroupChannel.newestTimelineNote(account: Account): Note? =
.sortedByDefaultFeedOrder()
.firstOrNull()
/**
* The pubkeys of the [limit] most-recent distinct posters in this group, newest first — the facepile
* shown on a channel row. One O(notes) pass keeps each author's latest post time, so a chatty author
* counts once (at their newest message) rather than crowding out quieter voices.
*/
fun RelayGroupChannel.recentAuthorHexes(
account: Account,
limit: Int,
): List<HexKey> {
val latestByAuthor = HashMap<HexKey, Long>()
for (note in notes.values()) {
if (!isRelayGroupTimelineMessage(note, account)) continue
val author = note.author?.pubkeyHex ?: continue
val at = note.createdAt() ?: continue
val prev = latestByAuthor[author]
if (prev == null || at > prev) latestByAuthor[author] = at
}
return latestByAuthor.entries
.sortedByDescending { it.value }
.take(limit)
.map { it.key }
}
/** Whether this group's message store holds any acceptable timeline message created after [sinceSecs]. */
private fun RelayGroupChannel.hasChatNewerThan(
account: Account,
@@ -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.ui.screen.loggedIn.chats.publicChannels.relayGroup
import androidx.compose.foundation.clickable
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.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.theme.ChatLabelMaxWidth
/**
* A tappable chip naming the relay a NIP-29 / Buzz group lives on. A group id is only unique within
* its host relay, so the relay is part of the room's identity — the same `#general` can exist on two
* Buzz servers and only this chip tells them apart.
*
* Wears the same highlighted wash as [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCommunityPill]
* so every "which server / community does this room belong to" chip reads the same way. Unlike the
* muted note-header [com.vitorpamplona.amethyst.commons.ui.note.HeaderPill] (PoW/OTS/location
* markers), this one is a first-class navigation entry point, so it keeps the stronger
* `secondaryContainer` highlight. Shared by the Messages row and the Notifications feed so a Buzz
* message reads the same wherever it surfaces; the width is capped at [ChatLabelMaxWidth] with a
* middle ellipsis so a long host is truncated instead of crowding the channel name out.
*/
@Composable
fun RelayNameChip(
label: String,
onClick: () -> Unit,
) {
Surface(
shape = RoundedCornerShape(6.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.widthIn(max = ChatLabelMaxWidth).clickable(onClick = onClick),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(3.dp),
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
) {
Icon(
symbol = MaterialSymbols.Dns,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(11.dp),
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
)
}
}
}
@@ -20,22 +20,16 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -98,6 +92,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordCommunityHasUnreadFlow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.rememberConcordImageModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.LoadEphemeralChatChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayNameChip
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupChannelLastReadRoute
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupServerHasUnreadFlow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ConcordServerRoomNote
@@ -746,43 +741,6 @@ private fun ConcordServerRoomCompose(
)
}
/**
* A tappable chip naming the server/community a Messages row belongs to. Unlike the muted
* note-header [HeaderPill] (PoW/OTS/location markers), this one is a first-class navigation entry
* point, so it keeps the stronger `secondaryContainer` highlight.
*/
@Composable
private fun RelayNameChip(
label: String,
onClick: () -> Unit,
) {
Surface(
shape = RoundedCornerShape(6.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.widthIn(max = ChatLabelMaxWidth).clickable(onClick = onClick),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(3.dp),
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
) {
Icon(
symbol = MaterialSymbols.Dns,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(11.dp),
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
)
}
}
}
/**
* Renders a Messages row title as the channel name followed by a muted [HeaderPill] naming the room
* type (Public Chat, Marmot Group, ...). The pill mirrors the Concord community chip so every group
@@ -139,7 +139,7 @@ fun MessagesPager(
// same state holder, so the two surfaces cannot disagree.
headerContent =
if (tabs[page].resource == R.string.new_requests) {
{ ChannelInvitesSection(accountViewModel) }
{ ChannelInvitesSection(accountViewModel, nav) }
} else {
null
},
@@ -0,0 +1,108 @@
/*
* 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.highlights
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.HighlightsFilterAssemblerSubscription
@Composable
fun HighlightsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
HighlightsScreen(
highlightsFeedContentState = accountViewModel.feedStates.highlightsFeed,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Composable
fun HighlightsScreen(
highlightsFeedContentState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(highlightsFeedContentState)
WatchAccountForHighlightsScreen(highlightsFeedContentState = highlightsFeedContentState, accountViewModel = accountViewModel)
HighlightsFilterAssemblerSubscription(accountViewModel)
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
HighlightsTopBar(accountViewModel, nav)
},
bottomBar = {
AppBottomBar(Route.Highlights, nav, accountViewModel) { route ->
if (route == Route.Highlights) {
highlightsFeedContentState.sendToTop()
} else {
nav.navBottomBar(route)
}
}
},
floatingButton = {
NewHighlightButton(nav)
},
accountViewModel = accountViewModel,
) {
RefresheableBox(highlightsFeedContentState, true) {
SaveableFeedContentState(highlightsFeedContentState, scrollStateKey = ScrollStateKeys.HIGHLIGHTS_SCREEN) { listState ->
RenderFeedContentState(
feedContentState = highlightsFeedContentState,
accountViewModel = accountViewModel,
listState = listState,
nav = nav,
routeForLastRead = "HighlightsFeed",
)
}
}
}
}
@Composable
fun WatchAccountForHighlightsScreen(
highlightsFeedContentState: FeedContentState,
accountViewModel: AccountViewModel,
) {
val listState by accountViewModel.account.liveHighlightsFollowLists.collectAsStateWithLifecycle()
val hiddenUsers =
accountViewModel.account.hiddenUsers.flow
.collectAsStateWithLifecycle()
LaunchedEffect(accountViewModel, listState, hiddenUsers) {
highlightsFeedContentState.checkKeysInvalidateDataAndSendToTop()
}
}
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun HighlightsTopBar(
accountViewModel: AccountViewModel,
nav: INav,
) {
UserDrawerSearchTopBar(accountViewModel, nav) {
val list by accountViewModel.account.settings.defaultHighlightsFollowList
.collectAsStateWithLifecycle()
HighlightsTopNavFilterBar(
followListsModel = accountViewModel.feedStates.feedListOptions,
listName = list,
accountViewModel = accountViewModel,
onChange = accountViewModel.account.settings::changeDefaultHighlightsFollowList,
)
}
}
@Composable
private fun HighlightsTopNavFilterBar(
followListsModel: TopNavFilterState,
listName: TopFilter,
accountViewModel: AccountViewModel,
onChange: (FeedDefinition) -> Unit,
) {
val allLists by followListsModel.highlightsRoutes.collectAsStateWithLifecycle()
FeedFilterSpinner(
placeholderCode = listName,
explainer = stringRes(R.string.select_list_to_filter),
options = allLists,
onSelect = onChange,
accountViewModel = accountViewModel,
)
}
@@ -0,0 +1,53 @@
/*
* 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.highlights
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size26Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
@Composable
fun NewHighlightButton(nav: INav) {
FloatingActionButton(
onClick = {
nav.nav(Route.NewHighlight())
},
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
painter = painterRes(R.drawable.ic_compose, 4),
contentDescription = stringRes(R.string.new_highlight_title),
modifier = Size26Modifier,
tint = MaterialTheme.colorScheme.onPrimary,
)
}
}
@@ -0,0 +1,212 @@
/*
* 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.highlights
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.pTags
import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag
import com.vitorpamplona.quartz.nip01Core.tags.references.references
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris
import com.vitorpamplona.quartz.nip10Notes.content.findURLs
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
/**
* Backs the "New Highlight" composer. A NIP-84 highlight is a quoted passage (the event
* `content`), its source, and an optional annotation. The annotation reuses the short-note
* composer's rich [message] field via [IMessageField], so it gets @-mention and custom-emoji
* autocomplete and inline previews; on publish it becomes the highlight's `comment` tag with
* the mentions, emoji, URLs, hashtags and quotes it references emitted as their own tags.
*
* The passage, the `textquoteselector` prefix/suffix, the surrounding `context`, and the
* nostr source (`a`/`e`/`p`) are carried through from the share or the "Highlight this note"
* action. When a nostr event is the source, [originalNote] is resolved so the screen can
* render it as a reply-style preview instead of showing a URL field.
*/
@Stable
class NewHighlightPostViewModel :
ViewModel(),
IMessageField {
private var accountViewModel: AccountViewModel? = null
private var account: Account? = null
/** The highlighted passage — becomes the event `content`. */
var quote by mutableStateOf("")
/** The source URL — becomes an `r` tag. Hidden when a nostr event is the source. */
var url by mutableStateOf("")
/** The user's annotation — the rich comment field; becomes a `comment` tag. */
override val message = TextFieldState()
/** The source note, when highlighting a nostr article/note, for the reply-style preview. */
var originalNote by mutableStateOf<Note?>(null)
private set
var userSuggestions: UserSuggestionState? = null
var emojiSuggestions: EmojiSuggestionState? = null
private var prefix: String? = null
private var suffix: String? = null
private var context: String? = null
private var sourceAddress: String? = null
private var sourceEventId: String? = null
private var author: String? = null
private var loaded = false
fun init(accountViewModel: AccountViewModel) {
if (this.accountViewModel == accountViewModel) return
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
userSuggestions = UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder())
emojiSuggestions = EmojiSuggestionState(accountViewModel.account.emoji)
}
/**
* Applies the incoming source once, and resolves [originalNote] for a nostr source. Guarded
* so a recomposition can't clobber edits the user already made.
*/
fun load(
quote: String?,
url: String?,
prefix: String?,
suffix: String?,
comment: String?,
context: String?,
sourceAddress: String?,
sourceEventId: String?,
author: String?,
) {
if (loaded) return
loaded = true
this.quote = quote.orEmpty()
this.url = url.orEmpty()
comment?.ifBlank { null }?.let { message.setTextAndPlaceCursorAtEnd(it) }
this.prefix = prefix
this.suffix = suffix
this.context = context
this.sourceAddress = sourceAddress
this.sourceEventId = sourceEventId
this.author = author
val accountViewModel = accountViewModel
if (accountViewModel != null) {
originalNote =
when {
!sourceAddress.isNullOrBlank() -> Address.parse(sourceAddress)?.let { accountViewModel.getOrCreateAddressableNote(it) }
!sourceEventId.isNullOrBlank() -> accountViewModel.getOrCreateNote(sourceEventId)
else -> null
}
}
}
override fun onMessageChanged() {
if (message.selection.collapsed) {
val lastWord = message.currentWord()
if (lastWord.startsWith("@")) {
userSuggestions?.processCurrentWord(lastWord)
} else {
userSuggestions?.reset()
}
emojiSuggestions?.processCurrentWord(lastWord)
}
}
fun autocompleteWithUser(item: User) {
userSuggestions?.let {
val lastWord = message.currentWord()
it.replaceCurrentWord(message, lastWord, item)
it.reset()
}
}
fun autocompleteWithEmoji(item: EmojiMedia) {
emojiSuggestions?.autocompleteInto(message, item)
}
fun autocompleteWithEmojiUrl(item: EmojiMedia) {
message.replaceCurrentWord(item.link + " ")
emojiSuggestions?.reset()
}
fun canPost(): Boolean = quote.isNotBlank()
suspend fun sendHighlight() {
val account = account ?: return
val dao = accountViewModel ?: return
if (!canPost()) return
// Resolve @mentions, nostr: refs, emoji, URLs and hashtags out of the annotation the same
// way the short-note composer does, so a highlight comment behaves like any other note.
val tagger = NewMessageTagger(message.text.toString().trim(), null, null, dao)
tagger.run()
val commentText = tagger.message.ifBlank { null }
val mentions = tagger.directMentionsUsers.map { it.toPTag() }
val emojiTags = account.emoji.findEmojiTags(tagger.message)
val urls = findURLs(tagger.message)
val tags = findHashtags(tagger.message)
val quotes = findNostrUris(tagger.message)
account.signAndComputeBroadcast(
HighlightEvent.build(
quote = quote.trim(),
url = url.trim().ifBlank { null },
prefix = prefix,
suffix = suffix,
comment = commentText,
context = context,
address = sourceAddress,
event = sourceEventId,
author = author,
) {
if (mentions.isNotEmpty()) pTags(mentions)
references(urls)
hashtags(tags)
quotes(quotes)
emojis(emojiTags)
},
)
}
}
@@ -0,0 +1,329 @@
/*
* 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.highlights
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.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.amethyst.ui.theme.replyModifier
/** A warm highlighter amber — the highlight metaphor reads as yellow regardless of theme. */
private val MarkerAccent = Color(0xFFF5C518)
/**
* The "New Highlight" composer. Reached either from the "Add highlight" action, a browser
* share, or the "Highlight" note-action, routed in as
* [com.vitorpamplona.amethyst.ui.navigation.routes.Route.NewHighlight].
*
* A NIP-84 highlight is a quoted passage, its source, and an optional annotation:
* - the passage is a pull-quote you craft (accent bar + quotation-mark watermark),
* - the source is either a nostr event rendered as a reply-style preview or a web URL,
* - the annotation is the same rich composer field the short-note screen uses, so it gets
* @-mention and custom-emoji autocomplete and inline previews.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewHighlightScreen(
quote: String? = null,
url: String? = null,
prefix: String? = null,
suffix: String? = null,
comment: String? = null,
context: String? = null,
sourceAddress: String? = null,
sourceEventId: String? = null,
author: String? = null,
accountViewModel: AccountViewModel,
nav: Nav,
) {
val postViewModel: NewHighlightPostViewModel = viewModel()
postViewModel.init(accountViewModel)
WatchAndLoadMyEmojiList(accountViewModel)
LaunchedEffect(Unit) {
postViewModel.load(quote, url, prefix, suffix, comment, context, sourceAddress, sourceEventId, author)
}
Scaffold(
topBar = {
PostingTopBar(
titleRes = R.string.new_highlight_title,
isActive = postViewModel::canPost,
onCancel = { nav.popBack() },
onPost = {
// Uses the accountViewModel scope so releasing the post ViewModel on
// popBack can't cancel the in-flight publish.
accountViewModel.launchSigner {
postViewModel.sendHighlight()
nav.popBack()
}
},
)
},
) { pad ->
Column(
modifier =
Modifier
.padding(pad)
.consumeWindowInsets(pad)
.imePadding()
.fillMaxSize(),
) {
Column(
modifier =
Modifier
.weight(1f)
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
HighlightEditorCard(
passage = postViewModel.quote,
onPassageChange = { postViewModel.quote = it },
)
val source = postViewModel.originalNote
if (source != null) {
NoteCompose(
baseNote = source,
modifier = MaterialTheme.colorScheme.replyModifier,
isQuotedNote = true,
unPackReply = ReplyRenderType.NONE,
makeItShort = true,
quotesLeft = 1,
accountViewModel = accountViewModel,
nav = nav,
)
} else {
IconField(
symbol = MaterialSymbols.Link,
value = postViewModel.url,
onValueChange = { postViewModel.url = it },
label = stringRes(R.string.new_highlight_source_label),
placeholder = "https://example.com/article",
singleLine = true,
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
symbol = MaterialSymbols.EditNote,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(8.dp))
Text(
text = stringRes(R.string.new_highlight_note_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
MessageField(
placeholder = R.string.new_highlight_note_placeholder,
viewModel = postViewModel,
requestFocus = false,
)
}
postViewModel.userSuggestions?.let {
ShowUserSuggestionList(
it,
postViewModel::autocompleteWithUser,
accountViewModel,
modifier = SuggestionListDefaultHeightPage,
)
}
postViewModel.emojiSuggestions?.let {
ShowEmojiSuggestionList(
it,
postViewModel::autocompleteWithEmoji,
postViewModel::autocompleteWithEmojiUrl,
modifier = SuggestionListDefaultHeightPage,
)
}
}
}
}
/**
* The hero: a rounded, tonal card carrying a quotation-mark watermark, a highlighter-yellow
* accent bar, and the editable passage set in a large, comfortable type.
*/
@Composable
private fun HighlightEditorCard(
passage: String,
onPassageChange: (String) -> Unit,
) {
Surface(
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
tonalElevation = 2.dp,
modifier = Modifier.fillMaxWidth(),
) {
Box(modifier = Modifier.fillMaxWidth()) {
Icon(
symbol = MaterialSymbols.FormatQuote,
contentDescription = null,
tint = MarkerAccent.copy(alpha = 0.20f),
modifier =
Modifier
.align(Alignment.TopStart)
.offset(x = 8.dp, y = (-6).dp)
.size(80.dp),
)
Row(
modifier =
Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min)
.padding(20.dp),
) {
Spacer(
Modifier
.width(4.dp)
.fillMaxHeight()
.clip(RoundedCornerShape(2.dp))
.background(MarkerAccent),
)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
BasicTextField(
value = passage,
onValueChange = onPassageChange,
textStyle =
LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.onSurface,
fontSize = 20.sp,
lineHeight = 1.4.em,
fontWeight = FontWeight.Medium,
),
cursorBrush = SolidColor(MarkerAccent),
modifier = Modifier.fillMaxWidth().heightIn(min = 88.dp),
decorationBox = { inner ->
if (passage.isEmpty()) {
Text(
text = stringRes(R.string.new_highlight_passage_placeholder),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
fontSize = 20.sp,
lineHeight = 1.4.em,
)
}
inner()
},
)
Spacer(Modifier.height(10.dp))
Text(
text = "${passage.length}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.End,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun IconField(
symbol: MaterialSymbol,
value: String,
onValueChange: (String) -> Unit,
label: String,
placeholder: String,
singleLine: Boolean,
minLines: Int = 1,
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth(),
label = { Text(label) },
placeholder = { Text(placeholder) },
leadingIcon = {
Icon(symbol = symbol, contentDescription = null, modifier = Modifier.size(20.dp))
},
singleLine = singleLine,
minLines = minLines,
shape = RoundedCornerShape(16.dp),
)
}
@@ -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.ui.screen.loggedIn.highlights.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
/**
* The client-side data-access layer for the Highlights feed. Mirrors
* `GitRepositoriesFeedFilter`, but highlights (kind 9802) are *regular* events rather than
* addressable ones, so it scans `LocalCache.notes` instead of `LocalCache.addressables`.
* The `FilterByListParams` handles the selected top-nav follow-list, hidden users and
* future-dated / spam checks.
*/
class HighlightsFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code
override fun limit() = 200
fun followList(): TopFilter = account.settings.defaultHighlightsFollowList.value
fun TopFilter.isMuteList() = this is TopFilter.MuteList
fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress()
fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList()
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
override fun feed(): List<Note> {
val params = buildFilterParams(account)
val notes =
LocalCache.notes.filterIntoSet { _, it ->
val noteEvent = it.event
noteEvent is HighlightEvent && params.match(noteEvent, it.relays)
}
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
account.liveHighlightsFollowLists.value,
account.hiddenUsers.flow.value,
)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val params = buildFilterParams(account)
return collection.filterTo(HashSet()) {
val noteEvent = it.event
noteEvent is HighlightEvent && params.match(noteEvent, it.relays)
}
}
override fun sort(items: Set<Note>): List<Note> = items.sortedByDefaultFeedOrder()
}
@@ -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.highlights.datasource
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies.filterHighlightsByAuthors
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies.filterHighlightsByFollows
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies.filterHighlightsByGeohashes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies.filterHighlightsByHashtag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies.filterHighlightsByMutedAuthors
import com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies.filterHighlightsGlobal
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
/**
* Routes the resolved top-nav filter set (author/hashtag/geohash/global/follows, per relay,
* via the outbox model) to the matching subassembly that pins kind 9802 to specific relays.
*
* Highlights are not community-scoped, so unlike git repositories the community filter
* sets fall through to [emptyList]; those options aren't offered in the top-nav catalog
* anyway (see TopNavFilterState._highlightsRoutes).
*/
fun makeHighlightsFilter(
feedSettings: IFeedTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> =
when (feedSettings) {
is AllFollowsTopNavPerRelayFilterSet -> filterHighlightsByFollows(feedSettings, since, defaultSince)
is AuthorsTopNavPerRelayFilterSet -> filterHighlightsByAuthors(feedSettings, since, defaultSince)
is GlobalTopNavPerRelayFilterSet -> filterHighlightsGlobal(feedSettings, since, defaultSince)
is HashtagTopNavPerRelayFilterSet -> filterHighlightsByHashtag(feedSettings, since, defaultSince)
is LocationTopNavPerRelayFilterSet -> filterHighlightsByGeohashes(feedSettings, since, defaultSince)
is MutedAuthorsTopNavPerRelayFilterSet -> filterHighlightsByMutedAuthors(feedSettings, since, defaultSince)
else -> emptyList()
}
@@ -0,0 +1,50 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class HighlightsQueryState(
val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
@Stable
class HighlightsFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<HighlightsQueryState>() {
val group =
listOf(
HighlightsSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
@@ -0,0 +1,48 @@
/*
* 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.highlights.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun HighlightsFilterAssemblerSubscription(accountViewModel: AccountViewModel) {
HighlightsFilterAssemblerSubscription(
accountViewModel.dataSources().highlights,
accountViewModel,
)
}
@Composable
fun HighlightsFilterAssemblerSubscription(
dataSource: HighlightsFilterAssembler,
accountViewModel: AccountViewModel,
) {
val state =
remember(accountViewModel.account) {
HighlightsQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope)
}
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
}
@@ -0,0 +1,100 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
class HighlightsSubAssembler(
client: INostrClient,
allKeys: () -> Set<HighlightsQueryState>,
) : PerUserAndFollowListEoseManager<HighlightsQueryState, TopFilter>(client, allKeys) {
override fun updateFilter(
key: HighlightsQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
// "Mine" needs no special-case: the shared TopFilter.Mine flow resolves to an author
// filter scoped to the user, so followsPerRelay() already carries authors=[me] against the
// user's own outbox.
val feedSettings = key.followsPerRelay()
return makeHighlightsFilter(feedSettings, since, key.feedStates.highlightsFeed.lastNoteCreatedAtIfFilled())
}
override fun user(key: HighlightsQueryState) = key.account.userProfile()
override fun list(key: HighlightsQueryState) = key.listName()
fun HighlightsQueryState.listNameFlow() = account.settings.defaultHighlightsFollowList
fun HighlightsQueryState.listName() = listNameFlow().value
fun HighlightsQueryState.followsPerRelayFlow() = account.liveHighlightsFollowListsPerRelay
fun HighlightsQueryState.followsPerRelay() = followsPerRelayFlow().value
val userJobMap = mutableMapOf<User, List<Job>>()
@OptIn(FlowPreview::class)
override fun newSub(key: HighlightsQueryState): Subscription {
val user = user(key)
userJobMap[user]?.forEach { it.cancel() }
userJobMap[user] =
listOf(
key.scope.launch(Dispatchers.IO) {
key.listNameFlow().collectLatest {
invalidateFilters()
}
},
key.scope.launch(Dispatchers.IO) {
key.followsPerRelayFlow().sample(500).collectLatest {
invalidateFilters()
}
},
key.account.scope.launch(Dispatchers.IO) {
key.feedStates.highlightsFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
invalidateFilters()
}
},
)
return super.newSub(key)
}
override fun endSub(
key: User,
subId: String,
) {
super.endSub(key, subId)
userJobMap[key]?.forEach { it.cancel() }
}
}
@@ -0,0 +1,92 @@
/*
* 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.highlights.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
fun filterHighlightsByAuthors(
relay: NormalizedRelayUrl,
authors: Set<HexKey>,
since: Long? = null,
): List<RelayBasedFilter> {
val authorList = authors.sorted()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
authors = authorList,
kinds = listOf(HighlightEvent.KIND),
limit = 200,
since = since,
),
),
)
}
fun filterHighlightsByAuthors(
authorSet: AuthorsTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (authorSet.set.isEmpty()) return emptyList()
return authorSet.set
.mapNotNull {
if (it.value.authors.isEmpty()) {
null
} else {
filterHighlightsByAuthors(
relay = it.key,
authors = it.value.authors,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}.flatten()
}
fun filterHighlightsByMutedAuthors(
authorSet: MutedAuthorsTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (authorSet.set.isEmpty()) return emptyList()
return authorSet.set
.mapNotNull {
if (it.value.authors.isEmpty()) {
null
} else {
filterHighlightsByAuthors(
relay = it.key,
authors = it.value.authors,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}.flatten()
}
@@ -18,6 +18,27 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback.diskCache
package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies
fun isLiveStreaming(url: String) = url.contains(".m3u8", true)
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
fun filterHighlightsByFollows(
followsSet: AllFollowsTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (followsSet.set.isEmpty()) return emptyList()
return followsSet.set.flatMap {
val since = since?.get(it.key)?.time ?: defaultSince
val relay = it.key
listOfNotNull(
it.value.authors?.let {
filterHighlightsByAuthors(relay, it, since)
},
).flatten()
}
}
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
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.nip84Highlights.HighlightEvent
fun filterHighlightsByGeohashes(
relay: NormalizedRelayUrl,
geotags: Set<String>,
since: Long?,
): List<RelayBasedFilter> {
if (geotags.isEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(HighlightEvent.KIND),
tags = mapOf("g" to geotags.sorted()),
limit = 100,
since = since,
),
),
)
}
fun filterHighlightsByGeohashes(
geoSet: LocationTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long?,
): List<RelayBasedFilter> {
if (geoSet.set.isEmpty()) return emptyList()
return geoSet.set
.mapNotNull {
if (it.value.geotags.isEmpty()) {
null
} else {
filterHighlightsByGeohashes(
relay = it.key,
geotags = it.value.geotags,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}.flatten()
}
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
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.nip84Highlights.HighlightEvent
fun filterHighlightsByHashtag(
relay: NormalizedRelayUrl,
hashtags: Set<String>,
since: Long? = null,
): List<RelayBasedFilter> =
listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(HighlightEvent.KIND),
tags = mapOf("t" to hashtags.toList()),
limit = 200,
since = since,
),
),
)
fun filterHighlightsByHashtag(
hashtagSet: HashtagTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (hashtagSet.set.isEmpty()) return emptyList()
return hashtagSet.set
.mapNotNull { relayHashSet ->
if (relayHashSet.value.hashtags.isEmpty()) {
null
} else {
filterHighlightsByHashtag(
relay = relayHashSet.key,
hashtags = relayHashSet.value.hashtags,
since = since?.get(relayHashSet.key)?.time ?: defaultSince,
)
}
}.flatten()
}
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.highlights.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.utils.TimeUtils
fun filterHighlightsGlobal(
relays: GlobalTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (relays.set.isEmpty()) return emptyList()
return relays.set.map {
val since = since?.get(it.key)?.time ?: defaultSince ?: TimeUtils.oneWeekAgo()
RelayBasedFilter(
relay = it.key,
filter =
Filter(
kinds = listOf(HighlightEvent.KIND),
limit = 200,
since = since,
),
)
}
}
@@ -49,9 +49,15 @@ import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
@@ -77,6 +83,8 @@ class HomeNewThreadFeedFilter(
LongTextNoteEvent.KIND,
LiveChessGameEndEvent.KIND,
AttestationEvent.KIND,
VideoHorizontalEvent.KIND,
VideoVerticalEvent.KIND,
)
}
@@ -153,6 +161,12 @@ class HomeNewThreadFeedFilter(
noteEvent is AudioHeaderEvent ||
noteEvent is ChessGameEvent ||
noteEvent is LiveChessGameEndEvent ||
noteEvent is PictureEvent ||
noteEvent is VideoNormalEvent ||
noteEvent is VideoShortEvent ||
noteEvent is VideoHorizontalEvent ||
noteEvent is VideoVerticalEvent ||
noteEvent is TorrentEvent ||
noteEvent is AttestationEvent ||
noteEvent is AttestationRequestEvent ||
noteEvent is AttestorRecommendationEvent ||
@@ -39,12 +39,18 @@ import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
@@ -66,6 +72,11 @@ val HomePostsNewThreadKinds1 =
WikiNoteEvent.KIND,
AttestationEvent.KIND,
NipTextEvent.KIND,
PictureEvent.KIND,
VideoNormalEvent.KIND,
VideoShortEvent.KIND,
VideoHorizontalEvent.KIND,
VideoVerticalEvent.KIND,
)
val HomePostsNewThreadKinds2 =
@@ -76,6 +87,7 @@ val HomePostsNewThreadKinds2 =
LiveChessGameEndEvent.KIND,
BirdDetectionEvent.KIND,
BirdexEvent.KIND,
TorrentEvent.KIND,
)
val HomePostsConversationKinds =
@@ -45,6 +45,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.VideoPostKind
import com.vitorpamplona.amethyst.ui.actions.NewMediaModel
import com.vitorpamplona.amethyst.ui.actions.NewMediaView
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect
@@ -63,6 +64,12 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* The Longs feed's composer. Every *video* posted from here is a NIP-71 kind-21 video recorded or
* picked alike so portrait footage lands in the Longs feed too instead of being routed to kind 22
* by its orientation. Images are unaffected: the gallery picker accepts them and they still post as
* NIP-68 kind-20 pictures, which the Longs feed does not read.
*/
@Composable
fun NewLongVideoButton(
accountViewModel: AccountViewModel,
@@ -106,6 +113,7 @@ fun NewLongVideoButton(
postViewModel = postViewModel,
accountViewModel = accountViewModel,
nav = nav,
videoKind = VideoPostKind.NORMAL,
)
}
@@ -21,29 +21,47 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.ui.layouts.NoteComposeLayout
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.DisplayBlankAuthor
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
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.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.UserNameRowHeight
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
@@ -60,6 +78,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
@Composable
fun ChannelInvitesSection(
accountViewModel: AccountViewModel,
nav: INav,
modifier: Modifier = Modifier,
) {
val invites by accountViewModel.feedStates.channelInvites.flow
@@ -69,48 +88,110 @@ fun ChannelInvitesSection(
Column(modifier) {
invites.forEach { invite ->
ChannelInviteCard(invite, accountViewModel)
// Keyed by channel: the list is sorted newest-first, so an arriving invite shifts every row
// below it. Without a key Compose matches children by position and each shifted row would
// recompose against a different invite — re-resolving the actor and reloading their avatar.
key(invite.channelId) {
ChannelInviteCard(invite, accountViewModel, nav)
HorizontalDivider(thickness = DividerThickness)
}
}
}
}
/**
* One pending add, drawn as a feed row instead of a floating Material card: the actor is the row's
* author picture, name and time in the usual note header and "added you to X" is the row's content,
* so the prompt reads like the reply/mention notifications it sits next to. The three choices take the
* reactions slot, which spans the full width and therefore fits "Add to Messages" without wrapping.
*/
@Composable
fun ChannelInviteCard(
invite: BuzzChannelInvite,
accountViewModel: AccountViewModel,
nav: INav,
) {
val channel = remember(invite.channelId, invite.relay) { LocalCache.getOrCreateRelayGroupChannel(GroupId(invite.channelId, invite.relay)) }
val baseChannel =
remember(invite.channelId, invite.relay) {
LocalCache.getOrCreateRelayGroupChannel(GroupId(invite.channelId, invite.relay))
}
// The channel's own metadata flow, collected directly rather than through `observeChannel`. That
// helper also registers a ChannelFinder query, and every assembler under it is gated on
// `is PublicChatChannel` / `is LiveActivitiesChannel` — a RelayGroupChannel yields no filter at all,
// so the registration buys nothing and only churns the app-wide key set on mount/unmount. The flow
// still fills the name in when the group's kind-39000 lands from the directory subscription, and
// nothing here opens the channel's *message* subscription — holding that back until the viewer
// answers is the whole point of the prompt.
val channelState by
remember(baseChannel) { baseChannel.flow().metadata.stateFlow }
.collectAsStateWithLifecycle()
val channel = channelState.channel as? RelayGroupChannel ?: baseChannel
val actorUser = remember(invite.actor) { invite.actor?.let { LocalCache.getOrCreateUser(it) } }
val actorName = actorUser?.let { observeUserName(it, accountViewModel).value }
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 5.dp),
) {
Column(Modifier.padding(12.dp)) {
Text(
text = stringRes(R.string.channel_invite_title, channel.toBestDisplayName()),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
)
Text(
// A pending invite is by definition unanswered, so it always carries the new-item wash rather than
// fading with a last-read marker: it is a standing question, not a dated event.
val backgroundColor =
MaterialTheme.colorScheme.newItemBackgroundColor
.compositeOver(MaterialTheme.colorScheme.background)
NoteComposeLayout(
modifier =
remember(backgroundColor) {
Modifier.drawBehind { drawRect(backgroundColor) }.fillMaxWidth()
},
authorPicture = {
Box(Size55Modifier, contentAlignment = Alignment.BottomEnd) {
if (actorUser != null) {
UserPicture(actorUser, Size55dp, accountViewModel = accountViewModel, nav = nav)
} else {
DisplayBlankAuthor(Size55dp, accountViewModel = accountViewModel)
}
}
},
firstRow = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Size5dp),
modifier = UserNameRowHeight,
) {
// Who did it matters: the relay reports a self-join with the same event, so naming the
// actor is what tells "I joined this" apart from "a stranger put me here".
text =
stringRes(
R.string.channel_invite_body,
actorName ?: stringRes(R.string.channel_invite_unknown_actor),
invite.relay.displayUrl(),
),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp),
)
if (actorUser != null) {
UsernameDisplay(actorUser, Modifier.weight(1f), accountViewModel = accountViewModel)
} else {
Text(
text = stringRes(R.string.channel_invite_unknown_actor),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
// DottedTight, not Dotted: the row's `spacedBy` already supplies the gap, so the
// dotted variant's own leading space would double it. Same choice the note header makes.
TimeAgo(invite.createdAt, style = TimeAgoStyle.DottedTight)
}
},
secondRow = {},
noteContent = {
Text(text = stringRes(R.string.channel_invite_title, channel.toBestDisplayName()))
Text(
text = invite.relay.displayUrl(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
reactionsRow = {
Row(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = Size10dp),
) {
// Leave is separate from Ignore on purpose: Ignore is a local display choice that leaves
// you in the roster, Leave is the kind-9022 that actually removes you from the channel.
@@ -120,10 +201,12 @@ fun ChannelInviteCard(
TextButton(onClick = { accountViewModel.dismissChannelInvite(invite.channelId) }) {
Text(stringRes(R.string.channel_invite_ignore))
}
// Accepting *is* `addRelayGroupToMessages`, the same call behind the channel top bar's
// "Add to Messages", so it carries that label rather than a second word for one action.
TextButton(onClick = { accountViewModel.acceptChannelInvite(channel) }) {
Text(stringRes(R.string.channel_invite_accept), fontWeight = FontWeight.Bold)
Text(stringRes(R.string.add_to_messages), fontWeight = FontWeight.Bold)
}
}
}
}
},
)
}
@@ -246,7 +246,7 @@ internal fun SingleNotificationsBody(
ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav)
// "X added you to #channel" prompts sit above the feed rather than inside it: they are a
// standing decision, not a dated event, so they must not scroll away into history.
ChannelInvitesSection(accountViewModel)
ChannelInvitesSection(accountViewModel, nav)
},
)
}
@@ -34,12 +34,14 @@ import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
@@ -50,6 +52,7 @@ import com.vitorpamplona.amethyst.ui.actions.NewMediaView
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePicture
import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -63,11 +66,23 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* The picture feed's composer. Images post as NIP-68 kind-20 pictures and land in this feed; a
* video picked from the gallery still posts as a NIP-71 video and shows up in the video feeds
* instead, since the file's mime type not the host feed decides picture vs. video.
*
* @param sharedAttachments content URIs handed over by the "New Picture" share target. When
* non-empty the composer opens on them right away, so the share lands as a picture post instead
* of a text note with a link.
* @param sharedCaption text Android sent alongside the files; pre-fills the caption field.
*/
@Composable
fun NewPictureButton(
accountViewModel: AccountViewModel,
nav: INav,
navScrollToTop: () -> Unit,
sharedAttachments: List<String> = emptyList(),
sharedCaption: String? = null,
) {
var isOpen by remember { mutableStateOf(false) }
var wantsToPostFromCamera by remember { mutableStateOf(false) }
@@ -83,6 +98,11 @@ fun NewPictureButton(
}
}
val context = LocalContext.current
LaunchedEffect(sharedAttachments) {
resolveSharedMedia(context, sharedAttachments).takeIf { it.isNotEmpty() }?.let { pickedURIs = it }
}
if (wantsToPostFromCamera) {
TakePicture { uri ->
wantsToPostFromCamera = false
@@ -106,6 +126,7 @@ fun NewPictureButton(
postViewModel = postViewModel,
accountViewModel = accountViewModel,
nav = nav,
initialCaption = sharedCaption.orEmpty(),
)
}
@@ -42,11 +42,15 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.Picture
fun PicturesScreen(
accountViewModel: AccountViewModel,
nav: INav,
attachments: List<String> = emptyList(),
message: String? = null,
) {
PicturesScreen(
picturesFeedContentState = accountViewModel.feedStates.picturesFeed,
accountViewModel = accountViewModel,
nav = nav,
attachments = attachments,
message = message,
)
}
@@ -55,6 +59,8 @@ fun PicturesScreen(
picturesFeedContentState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
attachments: List<String> = emptyList(),
message: String? = null,
) {
WatchLifecycleAndUpdateModel(picturesFeedContentState)
WatchAccountForPicturesScreen(picturesFeedContentState = picturesFeedContentState, accountViewModel = accountViewModel)
@@ -66,8 +72,8 @@ fun PicturesScreen(
PicturesTopBar(accountViewModel, nav)
},
bottomBar = {
AppBottomBar(Route.Pictures, nav, accountViewModel) { route ->
if (route == Route.Pictures) {
AppBottomBar(Route.Pictures(), nav, accountViewModel) { route ->
if (route is Route.Pictures) {
picturesFeedContentState.sendToTop()
} else {
nav.navBottomBar(route)
@@ -76,7 +82,7 @@ fun PicturesScreen(
},
floatingButton = {
FabBottomBarPadded(nav) {
NewPictureButton(accountViewModel, nav, picturesFeedContentState::sendToTop)
NewPictureButton(accountViewModel, nav, picturesFeedContentState::sendToTop, attachments, message)
}
},
accountViewModel = accountViewModel,
@@ -51,7 +51,7 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isVi
import com.vitorpamplona.amethyst.commons.richtext.toCoilModel
import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid
@@ -265,7 +265,7 @@ fun UrlImageView(
content.toCoilModel(useLocalBlossomBridge)
}
val imageModelUrl = artworkUri ?: bridgedUrl
val canLoadAsImage = !isVideo || artworkUri != null || !isLiveStreaming(content.url)
val canLoadAsImage = !isVideo || artworkUri != null || !isHlsMedia(content.url, content.mimeType)
CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) {
if (it && canLoadAsImage) {

Some files were not shown because too many files have changed in this diff Show More