Compare commits

...
143 Commits
Author SHA1 Message Date
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
144 changed files with 8032 additions and 1119 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.
@@ -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
@@ -287,6 +288,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()
@@ -1224,6 +1230,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
@@ -325,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()
@@ -712,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)) {
@@ -1017,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
}
@@ -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
@@ -3465,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)
}
/**
@@ -3671,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,
@@ -3682,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))
@@ -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,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")
}
}
@@ -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
@@ -1,23 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback.diskCache
fun isLiveStreaming(url: String) = url.contains(".m3u8", true)
@@ -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 =====")
}
}
@@ -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()
@@ -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)
@@ -790,6 +790,7 @@ fun BuildNavigation(
composableFromEndArgs<Route.RelayGroupCreate> {
RelayGroupCreateScreen(
relayUrl = it.relayUrl,
isForum = it.isForum,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -741,6 +741,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(
@@ -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)
}
@@ -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,
@@ -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.
@@ -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 Torclearnet 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 Torclearnet 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
},
@@ -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 =
@@ -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)
},
)
}
@@ -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) {
@@ -131,11 +131,15 @@ private val HOME_FEED_TYPES =
HomeFeedTypeUi(HomeFeedType.TEXT_NOTES, R.string.home_content_type_text_notes, MaterialSymbols.EditNote),
HomeFeedTypeUi(HomeFeedType.REPOSTS, R.string.home_content_type_reposts, MaterialSymbols.Forward),
HomeFeedTypeUi(HomeFeedType.COMMENTS, R.string.home_content_type_comments, MaterialSymbols.Chat),
HomeFeedTypeUi(HomeFeedType.PICTURES, R.string.home_content_type_pictures, MaterialSymbols.Image),
HomeFeedTypeUi(HomeFeedType.VIDEOS, R.string.home_content_type_videos, MaterialSymbols.Videocam),
HomeFeedTypeUi(HomeFeedType.SHORTS, R.string.home_content_type_shorts, MaterialSymbols.SmartDisplay),
HomeFeedTypeUi(HomeFeedType.ARTICLES, R.string.home_content_type_articles, MaterialSymbols.AutoMirrored.Article),
HomeFeedTypeUi(HomeFeedType.WIKI, R.string.home_content_type_wiki, MaterialSymbols.MenuBook),
HomeFeedTypeUi(HomeFeedType.HIGHLIGHTS, R.string.home_content_type_highlights, MaterialSymbols.FormatQuote),
HomeFeedTypeUi(HomeFeedType.POLLS, R.string.home_content_type_polls, MaterialSymbols.Poll),
HomeFeedTypeUi(HomeFeedType.CLASSIFIEDS, R.string.home_content_type_classifieds, MaterialSymbols.Storefront),
HomeFeedTypeUi(HomeFeedType.TORRENTS, R.string.home_content_type_torrents, MaterialSymbols.Download),
HomeFeedTypeUi(HomeFeedType.VOICE, R.string.home_content_type_voice, MaterialSymbols.Mic),
HomeFeedTypeUi(HomeFeedType.LIVE_ACTIVITIES, R.string.home_content_type_live_activities, MaterialSymbols.Sensors),
HomeFeedTypeUi(HomeFeedType.EPHEMERAL_CHAT, R.string.home_content_type_ephemeral_chat, MaterialSymbols.Forum),
@@ -95,6 +95,7 @@ import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage
import com.vitorpamplona.amethyst.ui.note.CheckAndDisplayEditStatus
@@ -132,6 +133,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.Reward
import com.vitorpamplona.amethyst.ui.note.elements.ShowForkInformation
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.ui.note.nip22Comments.DisplayExternalId
import com.vitorpamplona.amethyst.ui.note.observeEdits
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.note.types.AudioHeader
@@ -280,6 +282,7 @@ import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEven
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
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.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
@@ -330,6 +333,7 @@ import com.vitorpamplona.quartz.nip71Video.VideoEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress
import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost
import com.vitorpamplona.quartz.nip73ExternalIds.scope
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
@@ -512,7 +516,23 @@ fun RenderThreadFeed(
if (item.idHex == noteId) mutableStateOf(selectedNoteColor) else null
}
val onCollapse = remember(item) { { viewModel.toggleCollapsed(item.idHex) } }
// Tapping a reply collapses/expands its children, except for the user's own
// drafts: those open the edit-draft screen so the post can be resumed, matching
// the behavior everywhere else a draft is tapped.
val onClick =
remember(item) {
{
if (item.isDraft()) {
nav.nav {
withContext(Dispatchers.IO) {
routeEditDraftTo(item, accountViewModel.account)
}
}
} else {
viewModel.toggleCollapsed(item.idHex)
}
}
}
NoteCompose(
baseNote = item,
@@ -523,7 +543,7 @@ fun RenderThreadFeed(
parentBackgroundColor = background,
accountViewModel = accountViewModel,
nav = nav,
onClick = onCollapse,
onClick = onClick,
)
}
@@ -1116,6 +1136,27 @@ private fun FullBleedNoteCompose(
accountViewModel,
nav,
)
} else if (noteEvent is CommentEvent) {
// A comment scoped to external content (NIP-73 `I` tag, e.g. a URL) has no
// in-cache parent note, so it surfaces here as the thread's own "master" note.
// Show its external scope for context, same as the reply-context branch in
// RenderTextEvent does for non-root occurrences of the same comment.
val scope = remember(baseNote) { noteEvent.scope() }
if (scope != null) {
DisplayExternalId(scope, accountViewModel, nav)
Spacer(modifier = StdVertSpacer)
}
RenderTextEvent(
baseNote,
false,
canPreview,
quotesLeft = 3,
unPackReply = ReplyRenderType.NONE,
backgroundColor,
editState,
accountViewModel,
nav,
)
} else {
RenderTextEvent(
baseNote,
@@ -23,19 +23,28 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.url
import android.annotation.SuppressLint
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
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.feeds.FeedLoaded
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
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.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.note.nip22Comments.LocalCurrentExternalScope
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.url.dal.UrlFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.url.datasource.UrlFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
import com.vitorpamplona.quartz.nip73ExternalIds.urls.UrlId
@Composable
@@ -85,7 +94,7 @@ fun UrlScreen(
topBar = {
TopBarExtensibleWithBackButton(
title = {
DisplayUrlHeader(url, Modifier.weight(1f))
Text(stringRes(R.string.external_content_title))
},
popBack = nav::popBack,
)
@@ -97,12 +106,47 @@ fun UrlScreen(
},
accountViewModel = accountViewModel,
) {
RefresheableFeedView(
feedViewModel,
null,
accountViewModel = accountViewModel,
nav = nav,
)
CompositionLocalProvider(LocalCurrentExternalScope provides url) {
RefresheableFeedView(
feedViewModel,
null,
accountViewModel = accountViewModel,
nav = nav,
onLoaded = { state, listState ->
FeedLoaded(
state,
listState,
null,
accountViewModel,
nav,
header = { UrlScreenPreview(url, accountViewModel) },
)
},
)
}
}
}
@Composable
fun UrlScreenPreview(
url: String,
accountViewModel: AccountViewModel,
) {
// Respect the data-saver/privacy gate: fetching the preview reaches out to the
// third-party page, so when previews are off this stays the plain URL header.
if (!accountViewModel.settings.showUrlPreview()) {
DisplayUrlHeader(url, MaxWidthWithHorzPadding)
return
}
when (val state = rememberUrlPreviewState(url, accountViewModel)) {
is UrlPreviewState.Loaded -> {
UrlPreviewCard(url, state.previewInfo)
}
else -> {
DisplayUrlHeader(url, MaxWidthWithHorzPadding)
}
}
}
@@ -98,6 +98,24 @@ class TorService(
private val _status = MutableStateFlow<TorServiceStatus>(TorServiceStatus.Off)
override val status: StateFlow<TorServiceStatus> = _status.asStateFlow()
/**
* Every status change goes through here so the transition is logged exactly once, at INFO, with
* the time since bootstrap started.
*
* Tor state is the single biggest determinant of whether a boot works a stuck `Connecting`,
* an `Active` that took 40s, and a silent drop back to `Off` are three completely different
* bugs that used to look identical in the log, because the per-status detail lived only in
* Arti's own DEBUG chatter. Self-transitions are not logged: the reset paths reassign `Off`
* defensively and repeating it adds nothing.
*/
private fun setStatus(next: TorServiceStatus) {
val prev = _status.value
_status.value = next
if (prev::class == next::class && prev.toString() == next.toString()) return
val since = if (bootstrapStartedAtMs > 0) " after ${System.currentTimeMillis() - bootstrapStartedAtMs}ms" else ""
Log.i("TorService") { "${prev::class.simpleName} -> ${next::class.simpleName}$since" }
}
/**
* Runtime detector for a rotten guard sample. Arti logs [ALL_GUARDS_DOWN_MARKER] every time it
* fails to find a usable guard; [GUARDS_DOWN_THRESHOLD] of those inside [GUARDS_DOWN_WINDOW_MS]
@@ -218,11 +236,11 @@ class TorService(
lifecycleMutex.withLock {
if (proxyRunning.get()) {
if (_status.value is TorServiceStatus.Active) return@withLock
_status.value = TorServiceStatus.Connecting
setStatus(TorServiceStatus.Connecting)
return@withLock
}
_status.value = TorServiceStatus.Connecting
setStatus(TorServiceStatus.Connecting)
withContext(Dispatchers.IO) {
// Initialize TorClient once — this bootstraps the Tor network.
@@ -292,7 +310,7 @@ class TorService(
if (initResult != 0) {
Log.e("TorService") { "Failed to initialize Arti on retry: error $initResult" }
initialized.set(false)
_status.value = TorServiceStatus.Off
setStatus(TorServiceStatus.Off)
return@withContext
}
}
@@ -313,7 +331,7 @@ class TorService(
if (!started) {
Log.e("TorService") { "Failed to start SOCKS proxy after $MAX_PORT_RETRIES attempts" }
_status.value = TorServiceStatus.Off
setStatus(TorServiceStatus.Off)
return@withContext
}
@@ -332,7 +350,7 @@ class TorService(
// reset/stop can't clobber it.
val startedAt = bootstrapStartedAtMs
val elapsed = if (startedAt > 0) System.currentTimeMillis() - startedAt else -1
_status.value = TorServiceStatus.Active(socksPort)
setStatus(TorServiceStatus.Active(socksPort))
Log.d("TorService") { "Arti SOCKS proxy active on port $socksPort (bootstrap took ${elapsed}ms)" }
}
}
@@ -350,7 +368,7 @@ class TorService(
Log.d("TorService") { "SOCKS proxy stopped" }
}
_status.value = TorServiceStatus.Off
setStatus(TorServiceStatus.Off)
}
}
@@ -365,7 +383,7 @@ class TorService(
lifecycleMutex.withLock {
resetLocked()
}
_status.value = TorServiceStatus.Off
setStatus(TorServiceStatus.Off)
}
/**
@@ -381,7 +399,7 @@ class TorService(
clearAllArtiData()
}
}
_status.value = TorServiceStatus.Off
setStatus(TorServiceStatus.Off)
}
/**
@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M20,2H4c-1.1,0 -2,0.9 -2,2v18l4,-4h14c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2z"/>
</vector>
@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M10,9V5l-7,7 7,7v-4.1c5,0 8.5,1.6 11,5.1 -1,-5 -4,-10 -11,-11z"/>
</vector>
@@ -4,5 +4,16 @@
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M19,3H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2zM14,17H7v-2h7v2zM17,13H7v-2h10v2zM17,9H7V7h10v2z"/>
<path android:fillColor="#000000" android:fillType="evenOdd" android:pathData="M5.9,2.5H18.1A2.3,2.3 0 0 1 20.4,4.8V19.2A2.3,2.3 0 0 1 18.1,21.5H5.9A2.3,2.3 0 0 1 3.6,19.2V4.8A2.3,2.3 0 0 1 5.9,2.5Z M6.7,4.6H17.3A1.1,1.1 0 0 1 18.4,5.7V18.3A1.1,1.1 0 0 1 17.3,19.4H6.7A1.1,1.1 0 0 1 5.6,18.3V5.7A1.1,1.1 0 0 1 6.7,4.6Z"/>
<path android:fillColor="#000000" android:pathData="M11.99,7H16.75A0.65,0.65 0 0 1 17.4,7.65V7.65A0.65,0.65 0 0 1 16.75,8.3H11.99A0.65,0.65 0 0 1 11.34,7.65V7.65A0.65,0.65 0 0 1 11.99,7Z"/>
<path android:fillColor="#000000" android:pathData="M11.99,9.9H16.75A0.65,0.65 0 0 1 17.4,10.55V10.55A0.65,0.65 0 0 1 16.75,11.2H11.99A0.65,0.65 0 0 1 11.34,10.55V10.55A0.65,0.65 0 0 1 11.99,9.9Z"/>
<path android:fillColor="#000000" android:pathData="M7.25,12.8H16.75A0.65,0.65 0 0 1 17.4,13.45V13.45A0.65,0.65 0 0 1 16.75,14.1H7.25A0.65,0.65 0 0 1 6.6,13.45V13.45A0.65,0.65 0 0 1 7.25,12.8Z"/>
<path android:fillColor="#000000" android:pathData="M7.25,15.7H16.75A0.65,0.65 0 0 1 17.4,16.35V16.35A0.65,0.65 0 0 1 16.75,17H7.25A0.65,0.65 0 0 1 6.6,16.35V16.35A0.65,0.65 0 0 1 7.25,15.7Z"/>
<group
android:scaleX="0.017"
android:scaleY="0.017"
android:translateX="4.216"
android:translateY="4.718">
<path android:fillColor="#000000" android:pathData="M370.18,326.86C369.91,326.04 307.3,176.97 290.09,135.63c-20.96,0 -65.23,0 -96.46,0 -9.17,22.67 -32.82,80.44 -51.8,126.98 10.4,24.81 19.74,47.1 26.95,64.29l39.28,0a2.68,2.68 135,0 0,2.51 -3.15c-0.34,-7.93 -21.93,-36.76 -15.49,-67.94 1.72,-6.71 3.03,-13.53 4.91,-20.21a235.88,235.88 135,0 1,18.04 -46.7c1.19,-2.27 1.74,-3.29 4.5,-1.57 5.86,3.65 12.41,3.44 18.94,2.97 5.62,-1.22 8.36,-3.92 18.67,-1.18 2.04,0 4.03,0 6.11,0.19 4.95,0.39 9.6,1.57 12.57,6.05 3.25,4.9 3.77,10.44 3.25,16.1 -0.79,8.47 -0.23,16.36 6.94,22.33a77.19,77.19 0,0 0,8.05 5.37c3.56,2.28 7.85,3.27 10.9,6.44 1.8,1.87 2.86,3.85 2.08,6.51 -0.78,2.66 -2.86,3.91 -5.6,4.2 -6.92,0.75 -13.69,-0.66 -20.49,-1.48 -0.89,-0.11 -1.76,-0.17 -2.68,-0.23a15.39,15.39 0,0 1,-5.11 0.17l-1.6,0a35.83,35.83 0,0 0,-7.92 1.89c-6.41,2.34 -12.25,4.91 -19.82,4.78a5.8,5.8 0,0 0,-2.75 1.19c-5.93,5.27 -9.5,11.93 -6.54,21.66 8.13,22.72 29,77.53 37.96,101.18 -0.25,-16.02 -0.32,-44.71 -0.38,-58.64 35.8,0.05 90.7,0.03 95.08,0.03z"/>
</group>
</vector>
@@ -4,5 +4,7 @@
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M12,17.27L18.18,21l-1.64,-7.03L22,9.24l-7.19,-0.61L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21z"/>
<path android:fillColor="#000000" android:fillType="evenOdd" android:pathData="M19.8,9.8L19.733,10.818L19.534,11.819L19.206,12.785L18.755,13.7L18.188,14.548L17.515,15.315L16.748,15.988L15.9,16.555L14.985,17.006L14.019,17.334L13.018,17.533L12,17.6L10.982,17.533L9.981,17.334L9.015,17.006L8.1,16.555L7.252,15.988L6.485,15.315L5.812,14.548L5.245,13.7L4.794,12.785L4.466,11.819L4.267,10.818L4.2,9.8L4.267,8.782L4.466,7.781L4.794,6.815L5.245,5.9L5.812,5.052L6.485,4.285L7.252,3.612L8.1,3.045L9.015,2.594L9.981,2.266L10.982,2.067L12,2L13.018,2.067L14.019,2.266L14.985,2.594L15.9,3.045L16.748,3.612L17.515,4.285L18.188,5.052L18.755,5.9L19.206,6.815L19.534,7.781L19.733,8.782Z M 16.250896717373898,12.066845476381104 C 16.240844675740593,12.036317053642914 13.909887910328264,6.486473178542833 13.269163330664531,4.947393915132104 C 12.488827061649319,4.947393915132104 10.840664531625299,4.947393915132104 9.677978382706165,4.947393915132104 C 9.336581265012011,5.791393114491592 8.456096877502002,7.942157726180943 7.749475580464371,9.674831865492393 C 8.136665332265814,10.598502802241793 8.484391513210568,11.428354683747 8.752818254603682,12.068334667734188 L 10.215204163330664,12.068334667734188 A 0.09977582065652522,0.09977582065652522 135.0 0,0 10.308650920736588,11.951060848678944 C 10.295992794235387,11.655828662930345 9.492201761409126,10.582493995196158 9.731961569255404,9.421669335468376 C 9.79599679743795,9.171857485988792 9.8447678142514,8.917950360288232 9.914759807846275,8.66925540432346 A 8.781761409127302,8.781761409127302 135.0 0,1 10.586385108086468,6.93062449959968 C 10.630688550840672,6.8461128903122495 10.651164931945555,6.808138510808647 10.753919135308244,6.872173738991193 C 10.972085668534826,7.008062449959968 11.21594075260208,7.000244195356284 11.459051240992792,6.982746196957566 C 11.66828262610088,6.937325860688551 11.770292233787027,6.836805444355485 12.154131305044032,6.938815052041633 C 12.230080064051238,6.938815052041633 12.30416733386709,6.938815052041633 12.38160528422738,6.945888710968775 C 12.565892714171335,6.960408326661328 12.739011208967172,7.004339471577261 12.849583666933544,7.171128903122498 C 12.970580464371494,7.3535548438751 12.989939951961565,7.559807846277022 12.970580464371494,7.770528422738191 C 12.941168935148115,8.085864691753402 12.96201761409127,8.379607686148919 13.228955164131303,8.601869495596477 A 2.873767013610889,2.873767013610889 0.0 0,0 13.528654923939149,8.801793434747797 C 13.661192954363488,8.886677341873497 13.820908726981584,8.923534827862289 13.93445956765412,9.041553242594075 C 14.001473178542831,9.11117293835068 14.040936749399517,9.184887910328262 14.011897518014408,9.283919135308246 C 13.9828582866293,9.382950360288229 13.90542033626901,9.429487590072057 13.803410728582863,9.440284227381904 C 13.545780624499596,9.4682065652522 13.293734987990389,9.415712570056042 13.040572457966368,9.385184147317853 C 13.007437950360284,9.381088871096875 12.97504803843074,9.378855084067252 12.940796637309843,9.37662129703763 A 0.5729663730984789,0.5729663730984789 0.0 0,1 12.750552441953557,9.382950360288229 L 12.690984787830258,9.382950360288229 A 1.333943154523619,1.333943154523619 0.0 0,0 12.39612489991993,9.453314651721376 C 12.157481985588463,9.540432345876699 11.940060048038424,9.63611289031225 11.658230584467567,9.63127301841473 A 0.21593274619695757,0.21593274619695757 0.0 0,0 11.555848678943148,9.675576461168934 C 11.335076060848673,9.871777421937548 11.202165732586062,10.11972778222578 11.312365892714165,10.481973578863089 C 11.615044035228177,11.327834267413929 12.392029623698955,13.36839871897518 12.725608486789426,14.248883106485188 C 12.716301040832661,13.652461969575661 12.713694955964767,12.584339471577263 12.711461168935143,12.065728582866294 C 14.044287429943951,12.067590072057648 16.088202562049634,12.066845476381104 16.251269015212163,12.066845476381104 L 16.250896717373898,12.066845476381104"/>
<path android:fillColor="#000000" android:pathData="M9.4,15L8.1,22.4L11.5,18.6Z"/>
<path android:fillColor="#000000" android:pathData="M14.6,15L15.9,22.4L12.5,18.6Z"/>
</vector>
@@ -4,5 +4,18 @@
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M19,22H5v-2h14v2zM17.16,8.26C18.22,9.63 18.7,11.4 18.5,13.16C18.15,15.98 15.67,18 12.83,18H11.5C8.66,18 6.35,15.98 6,13.16c-0.2,-1.79 0.29,-3.56 1.34,-4.93C6.5,7.68 6,6.65 6,5.5 6,3.57 7.57,2 9.5,2c1.03,0 1.95,0.45 2.59,1.15C12.71,2.44 13.58,2 14.5,2 16.43,2 18,3.57 18,5.5c0,1.15 -0.5,2.18 -0.84,2.76z"/>
<group
android:scaleX="0.76"
android:scaleY="0.76"
android:translateX="-0.399"
android:translateY="1.08">
<path android:fillColor="#000000" android:pathData="M 5.0,22.0 L 5.0,18.0 Q 5.0,17.43 5.3,16.96 Q 5.6,16.5 6.1,16.23 L 11.0,13.75 L 11.0,12.0 L 7.53,13.72 Q 7.23,13.88 6.9,13.95 Q 6.58,14.02 6.25,14.02 Q 5.47,14.02 4.79,13.62 Q 4.1,13.22 3.73,12.47 Q 3.38,11.8 3.43,11.04 Q 3.48,10.28 3.9,9.62 L 7.0,5.0 L 5.0,2.0 L 11.0,2.0 Q 14.32,2.0 16.66,4.32 Q 19.0,6.65 19.0,10.0 L 19.0,22.0 L 5.0,22.0 M 7.0,20.0 L 17.0,20.0 L 17.0,10.0 Q 17.0,7.5 15.25,5.75 Q 13.5,4.0 11.0,4.0 L 8.75,4.0 L 9.4,5.0 L 5.58,10.75 Q 5.45,10.95 5.44,11.16 Q 5.42,11.38 5.53,11.57 Q 5.65,11.85 5.86,11.94 Q 6.08,12.03 6.28,12.02 Q 6.35,12.02 6.65,11.95 L 13.0,8.75 L 13.0,15.0 L 7.0,18.0 L 7.0,20.0"/>
</group>
<group
android:scaleX="0.037"
android:scaleY="0.037"
android:translateX="8.092"
android:translateY="6.423">
<path android:fillColor="#000000" android:pathData="M370.18,326.86C369.91,326.04 307.3,176.97 290.09,135.63c-20.96,0 -65.23,0 -96.46,0 -9.17,22.67 -32.82,80.44 -51.8,126.98 10.4,24.81 19.74,47.1 26.95,64.29l39.28,0a2.68,2.68 135,0 0,2.51 -3.15c-0.34,-7.93 -21.93,-36.76 -15.49,-67.94 1.72,-6.71 3.03,-13.53 4.91,-20.21a235.88,235.88 135,0 1,18.04 -46.7c1.19,-2.27 1.74,-3.29 4.5,-1.57 5.86,3.65 12.41,3.44 18.94,2.97 5.62,-1.22 8.36,-3.92 18.67,-1.18 2.04,0 4.03,0 6.11,0.19 4.95,0.39 9.6,1.57 12.57,6.05 3.25,4.9 3.77,10.44 3.25,16.1 -0.79,8.47 -0.23,16.36 6.94,22.33a77.19,77.19 0,0 0,8.05 5.37c3.56,2.28 7.85,3.27 10.9,6.44 1.8,1.87 2.86,3.85 2.08,6.51 -0.78,2.66 -2.86,3.91 -5.6,4.2 -6.92,0.75 -13.69,-0.66 -20.49,-1.48 -0.89,-0.11 -1.76,-0.17 -2.68,-0.23a15.39,15.39 0,0 1,-5.11 0.17l-1.6,0a35.83,35.83 0,0 0,-7.92 1.89c-6.41,2.34 -12.25,4.91 -19.82,4.78a5.8,5.8 0,0 0,-2.75 1.19c-5.93,5.27 -9.5,11.93 -6.54,21.66 8.13,22.72 29,77.53 37.96,101.18 -0.25,-16.02 -0.32,-44.71 -0.38,-58.64 35.8,0.05 90.7,0.03 95.08,0.03z"/>
</group>
</vector>
@@ -4,5 +4,12 @@
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M9.4,16.6L4.8,12l4.6,-4.6L8,6l-6,6 6,6 1.4,-1.4zM14.6,16.6l4.6,-4.6 -4.6,-4.6L16,6l6,6 -6,6 -1.4,-1.4z"/>
<path android:fillColor="#000000" android:pathData="M 6.49,16.6 L 2.58,12.0 L 6.489999999999998,7.4 L 5.3,6.0 L 0.19999999999999996,12.0 L 5.3,18.0 L 6.49,16.6 M 17.51,16.6 L 21.42,12.000000000000002 L 17.51,7.400000000000002 L 18.700000000000003,6.0 L 23.8,12.0 L 18.700000000000003,18.0 L 17.51,16.6"/>
<group
android:scaleX="0.04"
android:scaleY="0.04"
android:translateX="1.752"
android:translateY="1.568">
<path android:fillColor="#000000" android:pathData="M370.18,326.86C369.91,326.04 307.3,176.97 290.09,135.63c-20.96,0 -65.23,0 -96.46,0 -9.17,22.67 -32.82,80.44 -51.8,126.98 10.4,24.81 19.74,47.1 26.95,64.29l39.28,0a2.68,2.68 135,0 0,2.51 -3.15c-0.34,-7.93 -21.93,-36.76 -15.49,-67.94 1.72,-6.71 3.03,-13.53 4.91,-20.21a235.88,235.88 135,0 1,18.04 -46.7c1.19,-2.27 1.74,-3.29 4.5,-1.57 5.86,3.65 12.41,3.44 18.94,2.97 5.62,-1.22 8.36,-3.92 18.67,-1.18 2.04,0 4.03,0 6.11,0.19 4.95,0.39 9.6,1.57 12.57,6.05 3.25,4.9 3.77,10.44 3.25,16.1 -0.79,8.47 -0.23,16.36 6.94,22.33a77.19,77.19 0,0 0,8.05 5.37c3.56,2.28 7.85,3.27 10.9,6.44 1.8,1.87 2.86,3.85 2.08,6.51 -0.78,2.66 -2.86,3.91 -5.6,4.2 -6.92,0.75 -13.69,-0.66 -20.49,-1.48 -0.89,-0.11 -1.76,-0.17 -2.68,-0.23a15.39,15.39 0,0 1,-5.11 0.17l-1.6,0a35.83,35.83 0,0 0,-7.92 1.89c-6.41,2.34 -12.25,4.91 -19.82,4.78a5.8,5.8 0,0 0,-2.75 1.19c-5.93,5.27 -9.5,11.93 -6.54,21.66 8.13,22.72 29,77.53 37.96,101.18 -0.25,-16.02 -0.32,-44.71 -0.38,-58.64 35.8,0.05 90.7,0.03 95.08,0.03z"/>
</group>
</vector>
@@ -4,5 +4,13 @@
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M21,19V5c0,-1.1 -0.9,-2 -2,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2zM8.5,13.5l2.5,3.01L14.5,12l4.5,6H5l3.5,-4.5z"/>
<path android:fillColor="#000000" android:fillType="evenOdd" android:pathData="M5.4,3.4H18.6A3,3 0 0 1 21.6,6.4V17.6A3,3 0 0 1 18.6,20.6H5.4A3,3 0 0 1 2.4,17.6V6.4A3,3 0 0 1 5.4,3.4Z M6.2,6H17.8A1.2,1.2 0 0 1 19,7.2V16.8A1.2,1.2 0 0 1 17.8,18H6.2A1.2,1.2 0 0 1 5,16.8V7.2A1.2,1.2 0 0 1 6.2,6Z"/>
<path android:fillColor="#000000" android:pathData="M4.7,18L9,10.8L13,18Z"/>
<group
android:scaleX="0.043"
android:scaleY="0.043"
android:translateX="4.846"
android:translateY="4.03">
<path android:fillColor="#000000" android:pathData="M370.18,326.86C369.91,326.04 307.3,176.97 290.09,135.63c-20.96,0 -65.23,0 -96.46,0 -9.17,22.67 -32.82,80.44 -51.8,126.98 10.4,24.81 19.74,47.1 26.95,64.29l39.28,0a2.68,2.68 135,0 0,2.51 -3.15c-0.34,-7.93 -21.93,-36.76 -15.49,-67.94 1.72,-6.71 3.03,-13.53 4.91,-20.21a235.88,235.88 135,0 1,18.04 -46.7c1.19,-2.27 1.74,-3.29 4.5,-1.57 5.86,3.65 12.41,3.44 18.94,2.97 5.62,-1.22 8.36,-3.92 18.67,-1.18 2.04,0 4.03,0 6.11,0.19 4.95,0.39 9.6,1.57 12.57,6.05 3.25,4.9 3.77,10.44 3.25,16.1 -0.79,8.47 -0.23,16.36 6.94,22.33a77.19,77.19 0,0 0,8.05 5.37c3.56,2.28 7.85,3.27 10.9,6.44 1.8,1.87 2.86,3.85 2.08,6.51 -0.78,2.66 -2.86,3.91 -5.6,4.2 -6.92,0.75 -13.69,-0.66 -20.49,-1.48 -0.89,-0.11 -1.76,-0.17 -2.68,-0.23a15.39,15.39 0,0 1,-5.11 0.17l-1.6,0a35.83,35.83 0,0 0,-7.92 1.89c-6.41,2.34 -12.25,4.91 -19.82,4.78a5.8,5.8 0,0 0,-2.75 1.19c-5.93,5.27 -9.5,11.93 -6.54,21.66 8.13,22.72 29,77.53 37.96,101.18 -0.25,-16.02 -0.32,-44.71 -0.38,-58.64 35.8,0.05 90.7,0.03 95.08,0.03z"/>
</group>
</vector>
@@ -4,5 +4,12 @@
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10c1.87,0 3.63,-0.52 5.13,-1.42l-0.9,-1.55C15,19.65 13.55,20 12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8v0.72c0,0.79 -0.71,1.78 -1.5,1.78s-1.5,-0.99 -1.5,-1.78V12c0,-2.76 -2.24,-5 -5,-5S7,9.24 7,12s2.24,5 5,5c1.38,0 2.64,-0.56 3.54,-1.47 0.65,0.89 1.77,1.47 2.96,1.47 1.97,0 3.5,-1.6 3.5,-3.78V12c0,-5.52 -4.48,-10 -10,-10zM12,15c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3z"/>
<path android:fillColor="#000000" android:pathData="M 12.0,2.0 C 6.48,2.0 2.0,6.48 2.0,12.0 C 2.0,17.52 6.48,22.0 12.0,22.0 C 13.870000000000001,22.0 15.629999999999999,21.48 17.13,20.58 L 16.23,19.029999999999998 C 15.0,19.65 13.55,20.0 12.0,20.0 C 7.58,20.0 4.0,16.42 4.0,12.0 C 4.0,7.579999999999998 7.58,4.0 12.0,4.0 C 16.42,4.0 20.0,7.58 20.0,12.0 L 20.0,12.72 C 20.0,13.510000000000002 19.29,14.5 18.5,14.5 C 17.71,14.5 17.0,13.51 16.5,12.72 C 14.3,12.5 14.0,15.0 17.7,17.0 C 20.47,17.0 22.0,15.4 22.0,13.22 L 22.0,12.0 C 22.0,6.48 17.52,2.0 12.0,2.0"/>
<group
android:scaleX="0.042"
android:scaleY="0.042"
android:translateX="1.822"
android:translateY="1.15">
<path android:fillColor="#000000" android:pathData="M370.18,326.86C369.91,326.04 307.3,176.97 290.09,135.63c-20.96,0 -65.23,0 -96.46,0 -9.17,22.67 -32.82,80.44 -51.8,126.98 10.4,24.81 19.74,47.1 26.95,64.29l39.28,0a2.68,2.68 135,0 0,2.51 -3.15c-0.34,-7.93 -21.93,-36.76 -15.49,-67.94 1.72,-6.71 3.03,-13.53 4.91,-20.21a235.88,235.88 135,0 1,18.04 -46.7c1.19,-2.27 1.74,-3.29 4.5,-1.57 5.86,3.65 12.41,3.44 18.94,2.97 5.62,-1.22 8.36,-3.92 18.67,-1.18 2.04,0 4.03,0 6.11,0.19 4.95,0.39 9.6,1.57 12.57,6.05 3.25,4.9 3.77,10.44 3.25,16.1 -0.79,8.47 -0.23,16.36 6.94,22.33a77.19,77.19 0,0 0,8.05 5.37c3.56,2.28 7.85,3.27 10.9,6.44 1.8,1.87 2.86,3.85 2.08,6.51 -0.78,2.66 -2.86,3.91 -5.6,4.2 -6.92,0.75 -13.69,-0.66 -20.49,-1.48 -0.89,-0.11 -1.76,-0.17 -2.68,-0.23a15.39,15.39 0,0 1,-5.11 0.17l-1.6,0a35.83,35.83 0,0 0,-7.92 1.89c-6.41,2.34 -12.25,4.91 -19.82,4.78a5.8,5.8 0,0 0,-2.75 1.19c-5.93,5.27 -9.5,11.93 -6.54,21.66 8.13,22.72 29,77.53 37.96,101.18 -0.25,-16.02 -0.32,-44.71 -0.38,-58.64 35.8,0.05 90.7,0.03 95.08,0.03z"/>
</group>
</vector>
@@ -4,5 +4,6 @@
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M20,2H4c-1.1,0 -2,0.9 -2,2v18l4,-4h14c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2z"/>
<path android:fillColor="#000000" android:fillType="evenOdd" android:pathData="M5.7,1.5H18.3A4.2,4.2 0 0 1 22.5,5.7V13.3A4.2,4.2 0 0 1 18.3,17.5H5.7A4.2,4.2 0 0 1 1.5,13.3V5.7A4.2,4.2 0 0 1 5.7,1.5Z M 11.022353883106485,11.834219375500401 C 11.012842273819055,11.805332265812652 8.80720576461169,6.55386709367494 8.200928742994394,5.097534027221777 C 7.462546036829464,5.097534027221777 5.902994395516411,5.097534027221777 4.802818254603682,5.097534027221777 C 4.479775820656525,5.896156925540433 3.6466293034427535,7.9312890312249795 2.9779983987189746,9.570808646917534 C 3.3443714971977583,10.444819855884708 3.673402722177742,11.23005604483587 3.927397918334667,11.835628502802242 L 5.311160928742994,11.835628502802242 A 0.09441152922337871,0.09441152922337871 135.0 0,0 5.3995836669335455,11.724659727782228 C 5.387606084867893,11.445300240192156 4.627029623698958,10.429671737389915 4.853899119295435,9.331257005604485 C 4.914491593274619,9.094875900720577 4.960640512409927,8.854619695756606 5.0268694955964754,8.61929543634908 A 8.309623698959168,8.309623698959168 135.0 0,1 5.6623859087269794,6.974139311449161 C 5.704307445956763,6.894171337069657 5.7236829463570835,6.8582385908727 5.820912730184145,6.918831064851883 C 6.027349879903921,7.047413931144917 6.258094475580462,7.040016012810249 6.488134507606082,7.023458767013612 C 6.686116893514809,6.980480384307447 6.782642113690949,6.885364291433149 7.145844675740589,6.981889511609288 C 7.217710168134506,6.981889511609288 7.287814251401116,6.981889511609288 7.361088871096875,6.988582866293036 C 7.535468374699757,7.002321857485989 7.6992794235388295,7.043891112890313 7.803907125700558,7.201713370696559 C 7.918398718975178,7.37433146517214 7.936717373899116,7.569495596477183 7.918398718975178,7.768887109687752 C 7.890568454763809,8.067269815852683 7.9102962369895895,8.345220176140915 8.162882305844674,8.555532425940754 A 2.719263410728583,2.719263410728583 0.0 0,0 8.44646917534027,8.74470776621297 C 8.571881505204162,8.825028022417934 8.72301040832666,8.85990392313851 8.83045636509207,8.971577261809449 C 8.893867093674936,9.037453963170538 8.931208967173736,9.10720576461169 8.903730984787828,9.200912730184148 C 8.87625300240192,9.294619695756607 8.802978382706161,9.338654923939153 8.70645316253002,9.348871096877502 C 8.462674139311444,9.37529223378703 8.224179343474775,9.325620496397118 7.984627702161726,9.296733386709368 C 7.953274619695753,9.292858286629304 7.9226261008807,9.290744595676543 7.890216172938347,9.28863090472378 A 0.5421617293835068,0.5421617293835068 0.0 0,1 7.710200160128098,9.294619695756605 L 7.653835068054438,9.294619695756605 A 1.2622257806244996,1.2622257806244996 0.0 0,0 7.374827862289825,9.361200960768615 C 7.1490152121697275,9.44363490792634 6.943282626100874,9.534171337069656 6.676605284227376,9.52959167333867 A 0.2043234587670136,0.2043234587670136 0.0 0,0 6.579727782225774,9.571513210568455 C 6.370824659727775,9.757165732586069 6.2450600480384235,9.991785428342673 6.349335468374694,10.334555644515612 C 6.635740592473972,11.134939951961568 7.370952762209763,13.065796637309848 7.686597277822251,13.89894315452362 C 7.6777902321857425,13.334587670136111 7.675324259407519,12.323891112890314 7.673210568454758,11.83316253002402 C 8.934379503602877,11.834923939151324 10.868406725380298,11.834219375500401 11.02270616493194,11.834219375500401 L 11.022353883106485,11.834219375500401 M14.1,5.1H19.4A0.8,0.8 0 0 1 20.2,5.9V5.9A0.8,0.8 0 0 1 19.4,6.7H14.1A0.8,0.8 0 0 1 13.3,5.9V5.9A0.8,0.8 0 0 1 14.1,5.1Z M14.1,8.7H19.4A0.8,0.8 0 0 1 20.2,9.5V9.5A0.8,0.8 0 0 1 19.4,10.3H14.1A0.8,0.8 0 0 1 13.3,9.5V9.5A0.8,0.8 0 0 1 14.1,8.7Z M14.1,12.3H19.4A0.8,0.8 0 0 1 20.2,13.1V13.1A0.8,0.8 0 0 1 19.4,13.9H14.1A0.8,0.8 0 0 1 13.3,13.1V13.1A0.8,0.8 0 0 1 14.1,12.3Z"/>
<path android:fillColor="#000000" android:pathData="M6,16.5L6,22.3L11.6,16.5Z"/>
</vector>
@@ -4,5 +4,5 @@
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
<path android:fillColor="#000000" android:fillType="evenOdd" android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z M 16.525148118494798,14.52599679743795 C 16.514447558046438,14.49349879903923 14.033106485188151,8.585600480384304 13.351044835868693,6.947225780624498 C 12.520364291433145,6.947225780624498 10.765868694955962,6.947225780624498 9.528170536429144,6.947225780624498 C 9.16474779823859,7.845676541232986 8.227457966373098,10.1352001601281 7.475248198558846,11.979659727782225 C 7.887417934347478,12.962922337870296 8.257578062449959,13.846313050440353 8.543322658126499,14.527582065652522 L 10.100056044835867,14.527582065652522 A 0.10621297037630105,0.10621297037630105 135.0 0,0 10.199531625300239,14.402742193755005 C 10.18605684547638,14.088462770216173 9.330408326661328,12.945880704563653 9.585636509207365,11.710164131305046 C 9.653803042433946,11.44423538831065 9.705720576461168,11.173947157726182 9.780228182546036,10.909207365892716 A 9.348326661329063,9.348326661329063 135.0 0,1 10.495184147317852,9.058406725380305 C 10.54234587670136,8.968442754203362 10.56414331465172,8.928018414731785 10.673526821457164,8.996184947958367 C 10.905768614891914,9.140840672538031 11.165356285028022,9.13251801441153 11.424151321056843,9.113891112890313 C 11.646881505204162,9.065540432345877 11.755472377902318,8.958534827862291 12.164075260208163,9.06712570056045 C 12.244923939151318,9.06712570056045 12.323791032826257,9.06712570056045 12.406224979983985,9.074655724579664 C 12.602401921537227,9.090112089671738 12.786689351481185,9.136877502001601 12.904395516413128,9.314427542033627 C 13.033198558847076,9.508622898318656 13.053807045636507,9.72818254603683 13.033198558847076,9.95249799839872 C 13.001889511609285,10.288178542834268 13.024083266613289,10.600872698158527 13.308242594075258,10.837473979183347 A 3.059171337069656,3.059171337069656 0.0 0,0 13.627277822257804,11.050296236989592 C 13.768366693354682,11.140656525220177 13.938386709367492,11.179891913530826 14.05926341072858,11.305524419535628 C 14.130600480384304,11.379635708566854 14.172610088070453,11.45810648518815 14.141697357886306,11.563526821457165 C 14.11078462770216,11.66894715772618 14.028350680544433,11.718486789431546 13.919759807846273,11.729979983987189 C 13.645508406725375,11.759703763010407 13.377201761409124,11.703823058446757 13.10770616493194,11.671325060048037 C 13.072433947157721,11.666965572457965 13.037954363490789,11.664587670136108 13.00149319455564,11.66220976781425 A 0.6099319455564451,0.6099319455564451 0.0 0,1 12.79897518014411,11.66894715772618 L 12.735564451561242,11.66894715772618 A 1.420004003202562,1.420004003202562 0.0 0,0 12.421681345076054,11.743851080864692 C 12.167642113690945,11.836589271417132 11.936192954363484,11.938442754203365 11.636180944755798,11.933290632506003 A 0.2298638911128903,0.2298638911128903 0.0 0,0 11.527193755003996,11.98045236188951 C 11.292177742193747,12.189311449159325 11.150692554043227,12.453258606885507 11.26800240192153,12.838875100080063 C 11.59020816653322,13.739307445956763 12.417321857485982,15.91152121697358 12.772421937550034,16.84881104883907 C 12.76251401120896,16.21391112890312 12.75973979183346,15.076877502001603 12.757361889511603,14.524807846277023 C 14.176176941553237,14.526789431545238 16.35195756605284,14.52599679743795 16.525544435548433,14.52599679743795 L 16.525148118494798,14.52599679743795"/>
</vector>
@@ -4,5 +4,18 @@
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M10,9V5l-7,7 7,7v-4.1c5,0 8.5,1.6 11,5.1 -1,-5 -4,-10 -11,-11z"/>
<group
android:scaleX="0.9"
android:scaleY="0.9"
android:translateX="-1.5"
android:translateY="4.6">
<path android:fillColor="#000000" android:pathData="M10,9V5l-7,7 7,7v-4.1c5,0 8.5,1.6 11,5.1 -1,-5 -4,-10 -11,-11z"/>
</group>
<group
android:scaleX="0.052"
android:scaleY="0.052"
android:translateX="3.65"
android:translateY="-6.25">
<path android:fillColor="#000000" android:pathData="M370.18,326.86C369.91,326.04 307.3,176.97 290.09,135.63c-20.96,0 -65.23,0 -96.46,0 -9.17,22.67 -32.82,80.44 -51.8,126.98 10.4,24.81 19.74,47.1 26.95,64.29l39.28,0a2.68,2.68 135,0 0,2.51 -3.15c-0.34,-7.93 -21.93,-36.76 -15.49,-67.94 1.72,-6.71 3.03,-13.53 4.91,-20.21a235.88,235.88 135,0 1,18.04 -46.7c1.19,-2.27 1.74,-3.29 4.5,-1.57 5.86,3.65 12.41,3.44 18.94,2.97 5.62,-1.22 8.36,-3.92 18.67,-1.18 2.04,0 4.03,0 6.11,0.19 4.95,0.39 9.6,1.57 12.57,6.05 3.25,4.9 3.77,10.44 3.25,16.1 -0.79,8.47 -0.23,16.36 6.94,22.33a77.19,77.19 0,0 0,8.05 5.37c3.56,2.28 7.85,3.27 10.9,6.44 1.8,1.87 2.86,3.85 2.08,6.51 -0.78,2.66 -2.86,3.91 -5.6,4.2 -6.92,0.75 -13.69,-0.66 -20.49,-1.48 -0.89,-0.11 -1.76,-0.17 -2.68,-0.23a15.39,15.39 0,0 1,-5.11 0.17l-1.6,0a35.83,35.83 0,0 0,-7.92 1.89c-6.41,2.34 -12.25,4.91 -19.82,4.78a5.8,5.8 0,0 0,-2.75 1.19c-5.93,5.27 -9.5,11.93 -6.54,21.66 8.13,22.72 29,77.53 37.96,101.18 -0.25,-16.02 -0.32,-44.71 -0.38,-58.64 35.8,0.05 90.7,0.03 95.08,0.03z"/>
</group>
</vector>
@@ -4,5 +4,18 @@
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M7,7h10v3l4,-4 -4,-4v3H5v6h2V7zM17,17H7v-3l-4,4 4,4v-3h12v-6h-2v4z"/>
<group
android:scaleX="1.15"
android:scaleY="1.15"
android:translateX="-1.8"
android:translateY="-1.8">
<path android:fillColor="#000000" android:pathData="M7,7h10v3l4,-4 -4,-4v3H5v6h2V7zM17,17H7v-3l-4,4 4,4v-3h12v-6h-2v4z"/>
</group>
<group
android:scaleX="0.029"
android:scaleY="0.029"
android:translateX="4.621"
android:translateY="4.489">
<path android:fillColor="#000000" android:pathData="M370.18,326.86C369.91,326.04 307.3,176.97 290.09,135.63c-20.96,0 -65.23,0 -96.46,0 -9.17,22.67 -32.82,80.44 -51.8,126.98 10.4,24.81 19.74,47.1 26.95,64.29l39.28,0a2.68,2.68 135,0 0,2.51 -3.15c-0.34,-7.93 -21.93,-36.76 -15.49,-67.94 1.72,-6.71 3.03,-13.53 4.91,-20.21a235.88,235.88 135,0 1,18.04 -46.7c1.19,-2.27 1.74,-3.29 4.5,-1.57 5.86,3.65 12.41,3.44 18.94,2.97 5.62,-1.22 8.36,-3.92 18.67,-1.18 2.04,0 4.03,0 6.11,0.19 4.95,0.39 9.6,1.57 12.57,6.05 3.25,4.9 3.77,10.44 3.25,16.1 -0.79,8.47 -0.23,16.36 6.94,22.33a77.19,77.19 0,0 0,8.05 5.37c3.56,2.28 7.85,3.27 10.9,6.44 1.8,1.87 2.86,3.85 2.08,6.51 -0.78,2.66 -2.86,3.91 -5.6,4.2 -6.92,0.75 -13.69,-0.66 -20.49,-1.48 -0.89,-0.11 -1.76,-0.17 -2.68,-0.23a15.39,15.39 0,0 1,-5.11 0.17l-1.6,0a35.83,35.83 0,0 0,-7.92 1.89c-6.41,2.34 -12.25,4.91 -19.82,4.78a5.8,5.8 0,0 0,-2.75 1.19c-5.93,5.27 -9.5,11.93 -6.54,21.66 8.13,22.72 29,77.53 37.96,101.18 -0.25,-16.02 -0.32,-44.71 -0.38,-58.64 35.8,0.05 90.7,0.03 95.08,0.03z"/>
</group>
</vector>
@@ -4,5 +4,18 @@
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#000000">
<path android:fillColor="#000000" android:pathData="M11,21h-1l1,-7H7.5c-0.58,0 -0.57,-0.32 -0.38,-0.66c0.19,-0.34 0.05,-0.08 0.07,-0.12C8.48,10.94 10.42,7.54 13,3h1l-1,7h3.5c0.49,0 0.56,0.33 0.47,0.51l-0.07,0.15C12.96,17.55 11,21 11,21z"/>
<group
android:scaleX="1.15"
android:scaleY="1.15"
android:translateX="-7.05"
android:translateY="-1.95">
<path android:fillColor="#000000" android:pathData="M11,21h-1l1,-7H7.5c-0.58,0 -0.57,-0.32 -0.38,-0.66c0.19,-0.34 0.05,-0.08 0.07,-0.12C8.48,10.94 10.42,7.54 13,3h1l-1,7h3.5c0.49,0 0.56,0.33 0.47,0.51l-0.07,0.15C12.96,17.55 11,21 11,21z"/>
</group>
<group
android:scaleX="0.042"
android:scaleY="0.042"
android:translateX="7.35"
android:translateY="-4.5">
<path android:fillColor="#000000" android:pathData="M370.18,326.86C369.91,326.04 307.3,176.97 290.09,135.63c-20.96,0 -65.23,0 -96.46,0 -9.17,22.67 -32.82,80.44 -51.8,126.98 10.4,24.81 19.74,47.1 26.95,64.29l39.28,0a2.68,2.68 135,0 0,2.51 -3.15c-0.34,-7.93 -21.93,-36.76 -15.49,-67.94 1.72,-6.71 3.03,-13.53 4.91,-20.21a235.88,235.88 135,0 1,18.04 -46.7c1.19,-2.27 1.74,-3.29 4.5,-1.57 5.86,3.65 12.41,3.44 18.94,2.97 5.62,-1.22 8.36,-3.92 18.67,-1.18 2.04,0 4.03,0 6.11,0.19 4.95,0.39 9.6,1.57 12.57,6.05 3.25,4.9 3.77,10.44 3.25,16.1 -0.79,8.47 -0.23,16.36 6.94,22.33a77.19,77.19 0,0 0,8.05 5.37c3.56,2.28 7.85,3.27 10.9,6.44 1.8,1.87 2.86,3.85 2.08,6.51 -0.78,2.66 -2.86,3.91 -5.6,4.2 -6.92,0.75 -13.69,-0.66 -20.49,-1.48 -0.89,-0.11 -1.76,-0.17 -2.68,-0.23a15.39,15.39 0,0 1,-5.11 0.17l-1.6,0a35.83,35.83 0,0 0,-7.92 1.89c-6.41,2.34 -12.25,4.91 -19.82,4.78a5.8,5.8 0,0 0,-2.75 1.19c-5.93,5.27 -9.5,11.93 -6.54,21.66 8.13,22.72 29,77.53 37.96,101.18 -0.25,-16.02 -0.32,-44.71 -0.38,-58.64 35.8,0.05 90.7,0.03 95.08,0.03z"/>
</group>
</vector>
+46 -2
View File
@@ -458,6 +458,8 @@
<string name="refresh">Obnovit</string>
<string name="changed_chat_profile_to">Nový chatový profil:</string>
<string name="leave">Opustit</string>
<string name="remove_from_messages">Odebrat ze Zpráv</string>
<string name="add_to_messages">Přidat do Zpráv</string>
<string name="unfollow">Přestat sledovat</string>
<string name="channel_created">Kanál vytvořen</string>
<string name="channel_information_changed_to">"Informace kanálu změněna na"</string>
@@ -2216,6 +2218,30 @@
<string name="settings_section_tor_routing">Směrovat přes Tor</string>
<string name="settings_section_profile_sections">Sekce a kanály</string>
<string name="settings_section_home_tabs">Viditelné karty</string>
<string name="settings_section_home_content_types">Obsah v kanálu</string>
<string name="home_content_type_text_notes">Textové poznámky</string>
<string name="home_content_type_reposts">Sdílení</string>
<string name="home_content_type_comments">Komentáře a odpovědi</string>
<string name="home_content_type_pictures">Obrázky</string>
<string name="home_content_type_videos">Videa</string>
<string name="home_content_type_shorts">Krátká videa</string>
<string name="home_content_type_articles">Články</string>
<string name="home_content_type_wiki">Wiki stránky</string>
<string name="home_content_type_highlights">Zvýraznění</string>
<string name="home_content_type_polls">Ankety</string>
<string name="home_content_type_classifieds">Inzeráty</string>
<string name="home_content_type_torrents">Torrenty</string>
<string name="home_content_type_voice">Hlasové zprávy</string>
<string name="home_content_type_live_activities">Živé aktivity</string>
<string name="home_content_type_ephemeral_chat">Dočasné chaty</string>
<string name="home_content_type_interactive_stories">Interaktivní příběhy</string>
<string name="home_content_type_chess">Šachové partie</string>
<string name="home_content_type_birds">Pozorování ptáků</string>
<string name="home_content_type_attestations">Atestace</string>
<string name="home_content_type_nips">Návrhy NIP</string>
<string name="home_content_type_music">Hudba a zvuk</string>
<string name="home_content_type_podcasts">Podcasty</string>
<string name="home_content_type_fundraisers">Sbírky</string>
<string name="settings_section_reminders">Připomenutí</string>
<string name="wallet_connect">Připojení peněženky</string>
<string name="language">Jazyk</string>
@@ -2452,6 +2478,10 @@
<string name="relay_group_edit_confirm">Uložit</string>
<string name="relay_group_menu_members">Členové</string>
<string name="relay_group_menu_edit">Upravit skupinu</string>
<string name="relay_group_delete">Smazat skupinu</string>
<string name="relay_group_delete_confirm">Smazat „%1$s“? Odstraní to skupinu a její historii pro všechny a nelze to vrátit zpět.</string>
<string name="buzz_channel_delete">Smazat kanál</string>
<string name="buzz_channel_delete_confirm">Smazat „%1$s“? Odstraní to kanál a jeho zprávy pro všechny a nelze to vrátit zpět.</string>
<string name="relay_group_threads_title">Vlákna</string>
<string name="relay_group_pin_message">Připnout zprávu</string>
<string name="relay_group_unpin_message">Odepnout zprávu</string>
@@ -2533,9 +2563,7 @@
<string name="relay_group_browse_popular">Oblíbené relaye</string>
<string name="relay_group_no_messages_yet">Zatím žádné zprávy</string>
<string name="channel_invite_title">Přidáno do %1$s</string>
<string name="channel_invite_body">%1$s vás přidal do tohoto kanálu na %2$s. Zobrazit ho ve Zprávách?</string>
<string name="channel_invite_unknown_actor">Někdo</string>
<string name="channel_invite_accept">Zobrazit</string>
<string name="channel_invite_ignore">Ignorovat</string>
<string name="channel_invite_leave">Opustit</string>
<string name="relay_group_join_to_post">Připojte se k této skupině pro odesílání zpráv.</string>
@@ -2605,6 +2633,14 @@
<string name="share_target_as_dm">Odeslat jako DM</string>
<string name="share_to_dm_title">Odeslat…</string>
<string name="share_to_dm_start_new">Nová zpráva</string>
<string name="share_target_as_highlight">Nové zvýraznění</string>
<string name="new_highlight_title">Nové zvýraznění</string>
<string name="new_highlight_passage_label">Zvýrazněný text</string>
<string name="new_highlight_passage_placeholder">Co vás zaujalo?</string>
<string name="new_highlight_source_label">URL zdroje</string>
<string name="new_highlight_note_label">Vaše poznámka (volitelné)</string>
<string name="new_highlight_note_placeholder">Přidejte své myšlenky…</string>
<string name="highlight_action">Zvýraznit</string>
<string name="copy_url_to_clipboard">Kopírovat URL do schránky</string>
<string name="copy_the_note_id_to_the_clipboard">Kopírovat ID poznámky do schránky</string>
<string name="add_media_to_gallery">Přidat média do galerie</string>
@@ -3392,6 +3428,12 @@
<string name="buzz_pin">Připnout kanál</string>
<string name="buzz_unpin">Odepnout kanál</string>
<string name="buzz_dm_section_empty">Zatím žádné konverzace</string>
<plurals name="buzz_dm_hidden_count">
<item quantity="one">%1$d skrytá konverzace</item>
<item quantity="few">%1$d skryté konverzace</item>
<item quantity="many">%1$d skryté konverzace</item>
<item quantity="other">%1$d skrytých konverzací</item>
</plurals>
<plurals name="buzz_dm_see_all_count">
<item quantity="one">Zobrazit další %1$d konverzaci</item>
<item quantity="few">Zobrazit všechny %1$d konverzace</item>
@@ -3572,6 +3614,7 @@
<string name="git_repo_settings_topics">Témata (oddělená čárkami)</string>
<string name="git_repo_settings_save">Uložit</string>
<string name="git_repositories">Git repozitáře</string>
<string name="highlights">Zvýraznění</string>
<string name="nsite_title">Statický web: %1$s</string>
<string name="napplet_card_title">nApplet: %1$s</string>
<string name="napplet_card_permissions">Oprávnění:</string>
@@ -4653,6 +4696,7 @@
<string name="new_conversation_location_pro_2">Kompatibilní s Bitchatem; zasáhne blízké uživatele na stejných relayích.</string>
<string name="new_conversation_location_con_1">Veřejné a dočasné — žádná historie, může to číst kdokoli v buňce.</string>
<!-- Buzz workflow run board -->
<string name="buzz_job_board_title">Fronta úloh</string>
<string name="buzz_workflow_runs_title">Běhy workflow</string>
<string name="buzz_agent_work_title">Práce agenta</string>
<string name="buzz_workflow_new_run">Nový běh</string>
@@ -438,6 +438,8 @@
<string name="refresh">Aktualisieren</string>
<string name="changed_chat_profile_to">Neues Chat-Profil:</string>
<string name="leave">Verlassen</string>
<string name="remove_from_messages">Aus Nachrichten entfernen</string>
<string name="add_to_messages">Zu Nachrichten hinzufügen</string>
<string name="unfollow">Entfolgen</string>
<string name="channel_created">Kanal erstellt</string>
<string name="channel_information_changed_to">"Kanalinformationen geändert in"</string>
@@ -2134,6 +2136,25 @@
<string name="settings_section_tor_routing">Über Tor leiten</string>
<string name="settings_section_profile_sections">Abschnitte &amp; Feeds</string>
<string name="settings_section_home_tabs">Sichtbare Tabs</string>
<string name="settings_section_home_content_types">Inhalte im Feed</string>
<string name="home_content_type_text_notes">Textnotizen</string>
<string name="home_content_type_comments">Kommentare &amp; Antworten</string>
<string name="home_content_type_pictures">Bilder</string>
<string name="home_content_type_shorts">Kurzvideos</string>
<string name="home_content_type_articles">Artikel</string>
<string name="home_content_type_wiki">Wiki-Seiten</string>
<string name="home_content_type_polls">Umfragen</string>
<string name="home_content_type_classifieds">Kleinanzeigen</string>
<string name="home_content_type_voice">Sprachnachrichten</string>
<string name="home_content_type_live_activities">Live-Aktivitäten</string>
<string name="home_content_type_ephemeral_chat">Ephemere Chats</string>
<string name="home_content_type_interactive_stories">Interaktive Geschichten</string>
<string name="home_content_type_chess">Schachpartien</string>
<string name="home_content_type_birds">Vogelbeobachtungen</string>
<string name="home_content_type_attestations">Attestierungen</string>
<string name="home_content_type_nips">NIP-Entwürfe</string>
<string name="home_content_type_music">Musik &amp; Audio</string>
<string name="home_content_type_fundraisers">Spendenaktionen</string>
<string name="settings_section_reminders">Erinnerungen</string>
<string name="wallet_connect">Wallet Verbindung</string>
<string name="language">Sprache</string>
@@ -2366,6 +2387,10 @@
<string name="relay_group_edit_confirm">Speichern</string>
<string name="relay_group_menu_members">Mitglieder</string>
<string name="relay_group_menu_edit">Gruppe bearbeiten</string>
<string name="relay_group_delete">Gruppe löschen</string>
<string name="relay_group_delete_confirm">„%1$s“ löschen? Damit werden die Gruppe und ihr Verlauf für alle entfernt, und das lässt sich nicht rückgängig machen.</string>
<string name="buzz_channel_delete">Kanal löschen</string>
<string name="buzz_channel_delete_confirm">„%1$s“ löschen? Damit werden der Kanal und seine Nachrichten für alle entfernt, und das lässt sich nicht rückgängig machen.</string>
<string name="relay_group_threads_title">Threads</string>
<string name="relay_group_pin_message">Nachricht anheften</string>
<string name="relay_group_unpin_message">Nachricht loslösen</string>
@@ -2447,9 +2472,7 @@
<string name="relay_group_browse_popular">Beliebte Relays</string>
<string name="relay_group_no_messages_yet">Noch keine Nachrichten</string>
<string name="channel_invite_title">Zu %1$s hinzugefügt</string>
<string name="channel_invite_body">%1$s hat dich auf %2$s zu diesem Kanal hinzugefügt. In Nachrichten anzeigen?</string>
<string name="channel_invite_unknown_actor">Jemand</string>
<string name="channel_invite_accept">Anzeigen</string>
<string name="channel_invite_ignore">Ignorieren</string>
<string name="channel_invite_leave">Verlassen</string>
<string name="relay_group_join_to_post">Tritt dieser Gruppe bei, um Nachrichten zu senden.</string>
@@ -2509,6 +2532,14 @@
<string name="share_target_as_dm">Als DM senden</string>
<string name="share_to_dm_title">Senden an…</string>
<string name="share_to_dm_start_new">Neue Nachricht</string>
<string name="share_target_as_highlight">Neues Highlight</string>
<string name="new_highlight_title">Neues Highlight</string>
<string name="new_highlight_passage_label">Hervorgehobener Text</string>
<string name="new_highlight_passage_placeholder">Was ist dir aufgefallen?</string>
<string name="new_highlight_source_label">Quell-URL</string>
<string name="new_highlight_note_label">Deine Notiz (optional)</string>
<string name="new_highlight_note_placeholder">Füge deine Gedanken hinzu…</string>
<string name="highlight_action">Hervorheben</string>
<string name="copy_url_to_clipboard">URL in die Zwischenablage kopieren</string>
<string name="copy_the_note_id_to_the_clipboard">Notiz-ID in die Zwischenablage kopieren</string>
<string name="add_media_to_gallery">Medien zur Galerie hinzufügen</string>
@@ -3276,6 +3307,10 @@
<string name="buzz_pin">Kanal anheften</string>
<string name="buzz_unpin">Kanal nicht mehr anheften</string>
<string name="buzz_dm_section_empty">Noch keine Unterhaltungen</string>
<plurals name="buzz_dm_hidden_count">
<item quantity="one">%1$d ausgeblendete Unterhaltung</item>
<item quantity="other">%1$d ausgeblendete Unterhaltungen</item>
</plurals>
<plurals name="buzz_dm_see_all_count">
<item quantity="one">%1$d weitere Unterhaltung anzeigen</item>
<item quantity="other">Alle %1$d Unterhaltungen anzeigen</item>
@@ -4577,6 +4612,7 @@
<string name="buzz_persona_system_prompt">System-Prompt</string>
<string name="buzz_persona_model">Modell (optional)</string>
<string name="buzz_persona_provider">Anbieter (optional)</string>
<string name="buzz_persona_runtime">Laufzeitumgebung (optional)</string>
<string name="buzz_persona_avatar">Avatar-URL (optional)</string>
<string name="buzz_persona_publishing">Wird veröffentlicht…</string>
<string name="buzz_persona_publish">Persona veröffentlichen</string>
@@ -333,6 +333,7 @@
<string name="concord_leave_message">क्या %1$s छोडें। इसे हटाया जाएगा इस लेखा की सूची से तथा आपके यन्त्रों पर समचरणीकरण रुक जाएगा। समुदाय को सूचित नहीं किया जाएगा। तथा आपको उसके सदस्य कार्यसूची से हटाया नहीं जाएगा। सन्देश जिनका आप अरहस्यीकरण नहीं कर सकेंगे सम्भाव्यतः पुनःप्राप्तव्य नहीं होंगे। तथा आप केवल नए आमन्त्रण के साथ लौट सकेंगे।</string>
<string name="concord_leave_owner_warning">आपने इस समुदाय को बनाया। छोड जाने से यह मिटेगा नहीं। किसी अन्य के हाथ सौंपा नहीं जाएगा। परन्तु स्वत्वधारी कुंचिका जो आपकी सूची में हैं वह हटाया जाएगा। आप आगे से इसका प्रबन्धन नहीं कर पाएँगे।</string>
<string name="concord_edit_relays_desc">जहाँ इस समुदाय के रहस्यीकृत पत्रों का प्रकाशन तथा पठन किया जाता है।</string>
<string name="concord_dissolved_read_only">इस समुदाय को विघटित किया गया है तथा अब पठनेवशक्य है। आप इसका इतिहास पढ सकते हैं परन्तु कोई नए सन्देश नहीं भेज सकते।</string>
<string name="concord_typing_one">%1$s टंकण मध्य…</string>
<string name="concord_typing_two">%1$s तथा %2$s टंकण मध्य…</string>
<string name="concord_typing_many">अनेक लोग टंकण मध्य…</string>
@@ -437,6 +438,8 @@
<string name="refresh">नवीकरण</string>
<string name="changed_chat_profile_to">नया चर्चा परिचय :</string>
<string name="leave">निर्गमन</string>
<string name="remove_from_messages">सन्देशों से हटाएँ</string>
<string name="add_to_messages">सन्देशों में जोडें</string>
<string name="unfollow">अनुचरण ना करें</string>
<string name="channel_created">प्रणाली बनायी गयी</string>
<string name="channel_information_changed_to">"प्रणाली जानकारी परिवर्तित की गयी"</string>
@@ -2133,6 +2136,26 @@
<string name="settings_section_tor_routing">टोर॰ के माध्यम से</string>
<string name="settings_section_profile_sections">विभाग तथा सूचनावलियाँ</string>
<string name="settings_section_home_tabs">दृश्य पृष्ठसूचक</string>
<string name="settings_section_home_content_types">सूचनावली में विषयवस्तु</string>
<string name="home_content_type_text_notes">लेखीय टीकाएँ</string>
<string name="home_content_type_reposts">पुनःप्रकाशन</string>
<string name="home_content_type_comments">टिप्पणियाँ तथा प्रत्युत्तर</string>
<string name="home_content_type_articles">लेख</string>
<string name="home_content_type_wiki">समूहसम्पाद्य पृष्ठ</string>
<string name="home_content_type_highlights">प्रमुखताएँ</string>
<string name="home_content_type_polls">मतदान</string>
<string name="home_content_type_classifieds">वर्गीकृत विज्ञापन</string>
<string name="home_content_type_voice">ध्वनि सन्देश</string>
<string name="home_content_type_live_activities">वर्तमान गतिविधियाँ</string>
<string name="home_content_type_ephemeral_chat">अस्थायी चर्चाएँ</string>
<string name="home_content_type_interactive_stories">अन्तरक्रियाशील कहानियाँ</string>
<string name="home_content_type_chess">चतुरंग खेल</string>
<string name="home_content_type_birds">पक्षी अवलोकन घटनाएँ</string>
<string name="home_content_type_attestations">साक्ष्यांकन सूची</string>
<string name="home_content_type_nips">निप॰ पाण्डुलिपियाँ</string>
<string name="home_content_type_music">संगीत तथा ध्वनि</string>
<string name="home_content_type_podcasts">पुटप्रसार</string>
<string name="home_content_type_fundraisers">धनसंग्रहण</string>
<string name="settings_section_reminders">अनुस्मारकसूची</string>
<string name="wallet_connect">धनकोष संयोजन</string>
<string name="language">भाषा</string>
@@ -2365,6 +2388,10 @@
<string name="relay_group_edit_confirm">अभिलेखन</string>
<string name="relay_group_menu_members">सदस्य सूची</string>
<string name="relay_group_menu_edit">समूह सम्पादन</string>
<string name="relay_group_delete">समूह मिटाएँ</string>
<string name="relay_group_delete_confirm">क्या %1$s को मिटा दें। यह इस समूह को तथा इसके इतिहास को हटा देगा सब के लिए। इसे पूर्ववत नहीं किया जा सकता।</string>
<string name="buzz_channel_delete">प्रणाली मिटाएँ</string>
<string name="buzz_channel_delete_confirm">क्या %1$s को मिटा दें। यह इस प्रणाली को तथा इसके सन्देशों को हटा देगा सब के लिए। इसे पूर्ववत नहीं किया जा सकता।</string>
<string name="relay_group_threads_title">सूत्र</string>
<string name="relay_group_pin_message">सन्देश टाँकें</string>
<string name="relay_group_unpin_message">सन्देश से टाँका हटाएँ</string>
@@ -2446,9 +2473,7 @@
<string name="relay_group_browse_popular">लोकप्रिय पुनःप्रसारक</string>
<string name="relay_group_no_messages_yet">कोई सन्देश नहीं अब तक</string>
<string name="channel_invite_title">%1$s में जोडा गया</string>
<string name="channel_invite_body">%1$s ने आपको इस प्रणाली में जोडा %2$s पर। क्या इसे सन्देशों में दिखाया जाए।</string>
<string name="channel_invite_unknown_actor">कोई व्यक्ति</string>
<string name="channel_invite_accept">दिखाएँ</string>
<string name="channel_invite_ignore">उपेक्षा करें</string>
<string name="channel_invite_leave">छोडें</string>
<string name="relay_group_join_to_post">इस समूह से जुडें सन्देशों को भेजने के लिए।</string>
@@ -2509,6 +2534,14 @@
<string name="share_target_as_dm">सीधेसन्देश के रूप में भेजें</string>
<string name="share_to_dm_title">को भेजें…</string>
<string name="share_to_dm_start_new">नया सन्देश</string>
<string name="share_target_as_highlight">नया उद्दीप्तव्य</string>
<string name="new_highlight_title">नया उद्दीप्तव्य</string>
<string name="new_highlight_passage_label">उद्दीप्त लेख</string>
<string name="new_highlight_passage_placeholder">क्या था प्रमुख आपके लिए।</string>
<string name="new_highlight_source_label">स्रोत जालपता</string>
<string name="new_highlight_note_label">आपकी टीका (विकल्पात्मक)</string>
<string name="new_highlight_note_placeholder">आपके विचार जोडें…</string>
<string name="highlight_action">उद्दीप्तव्य</string>
<string name="copy_url_to_clipboard">टाँकाफलक में जालपता की अनुकृति करें</string>
<string name="copy_the_note_id_to_the_clipboard">टाँकाफलक में टीका विभेदक की अनुकृति करें</string>
<string name="add_media_to_gallery">अभिलेख को चित्रालय में जोडें</string>
@@ -3276,6 +3309,10 @@
<string name="buzz_pin">प्रणाली टाँकें</string>
<string name="buzz_unpin">प्रणाली टाँका हटाएँ</string>
<string name="buzz_dm_section_empty">कोई संवाद नहीं अब तक</string>
<plurals name="buzz_dm_hidden_count">
<item quantity="one">%1$d छिपाया गया संवाद</item>
<item quantity="other">%1$d छिपाए गए संवाद</item>
</plurals>
<plurals name="buzz_dm_see_all_count">
<item quantity="one">देखें %1$d और संवाद</item>
<item quantity="other">देखें सभी %1$d संवाद</item>
@@ -3452,6 +3489,7 @@
<string name="git_repo_settings_topics">विषय (अल्पविराम विभाजित)</string>
<string name="git_repo_settings_save">अभिलेखन</string>
<string name="git_repositories">गिट क्रमलेखकोश</string>
<string name="highlights">प्रमुखताएँ</string>
<string name="nsite_title">नोस्टरस्थान : %1$s</string>
<string name="napplet_card_title">नोस्टर संलग्नक्रमक : %1$s</string>
<string name="napplet_card_permissions">अनुमतियाँ :</string>
File diff suppressed because it is too large Load Diff
+125 -1
View File
@@ -341,6 +341,7 @@
<string name="concord_leave_message">Chcesz opuścić %1$s? Zostaniesz usunięty z listy członków tej społeczności, a synchronizacja z Twoimi urządzeniami zostanie wstrzymana. Społeczność nie zostanie o tym powiadomiona, a Ty nie zostaniesz usunięty z listy jej członków. Wiadomości, których nie będziesz już mógł odszyfrować, mogą okazać się nie do odzyskania, a powrót do społeczności będzie możliwy wyłącznie po otrzymaniu nowego zaproszenia.</string>
<string name="concord_leave_owner_warning">To Ty stworzyłeś tę społeczność. Odejście nie powoduje jej usunięcia ani przekazania komukolwiek innemu, ale powoduje usunięcie klucza właściciela przechowywanego na Twojej liście — nie będziesz mógł już nią zarządzać.</string>
<string name="concord_edit_relays_desc">Miejsce, w którym publikowane i czytane są zaszyfrowane plany tej społeczności.</string>
<string name="concord_dissolved_read_only">Ta społeczność została rozwiązana i jest tylko do odczytu. Nadal możesz przeczytać jej historię, ale nie można opublikować żadnych nowych wiadomości.</string>
<string name="concord_typing_one">%1$s pisze…</string>
<string name="concord_typing_two">%1$s i %2$s piszą…</string>
<string name="concord_typing_many">Kilka osób pisze…</string>
@@ -457,6 +458,8 @@
<string name="refresh">Odśwież</string>
<string name="changed_chat_profile_to">Nowy profil czatu:</string>
<string name="leave">Wyjdź</string>
<string name="remove_from_messages">Usuń z wiadomości</string>
<string name="add_to_messages">Dodaj do wiadomości</string>
<string name="unfollow">Nie obserwuj</string>
<string name="channel_created">Kanał utworzony</string>
<string name="channel_information_changed_to">"Informacje o kanale zmienione na"</string>
@@ -790,6 +793,12 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="workout_suggestion_connect_button">Połącz</string>
<string name="workout_suggestion_distance_km">%1$s km</string>
<string name="workout_from_health_connect">Z Health Connect</string>
<plurals name="workout_suggestion_combined_sessions">
<item quantity="one">%1$d aktywność</item>
<item quantity="few">%1$d aktywności</item>
<item quantity="many">%1$d aktywności</item>
<item quantity="other">%1$d aktywności</item>
</plurals>
<string name="exercise_running">Bieganie</string>
<string name="exercise_walking">Spacer</string>
<string name="exercise_cycling">Jazda na rowerze</string>
@@ -2209,6 +2218,29 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="settings_section_tor_routing">Przejście przez Tor</string>
<string name="settings_section_profile_sections">Sekcje &amp; Wątki</string>
<string name="settings_section_home_tabs">Widoczne karty</string>
<string name="settings_section_home_content_types">Zawartość kanału</string>
<string name="home_content_type_text_notes">Notatki tekstowe</string>
<string name="home_content_type_reposts">Reposty</string>
<string name="home_content_type_comments">Komentarze &amp; odpowiedzi</string>
<string name="home_content_type_pictures">Zdjęcia</string>
<string name="home_content_type_videos">Filmy wideo</string>
<string name="home_content_type_shorts">Filmiki</string>
<string name="home_content_type_articles">Artykuły</string>
<string name="home_content_type_wiki">Strony Wiki</string>
<string name="home_content_type_highlights">Wyróżnienia</string>
<string name="home_content_type_polls">Ankiety</string>
<string name="home_content_type_classifieds">Ogłoszenia</string>
<string name="home_content_type_torrents">Torrenty</string>
<string name="home_content_type_voice">Wiadomości głosowe</string>
<string name="home_content_type_live_activities">Bieżąca aktywność</string>
<string name="home_content_type_ephemeral_chat">Czaty efemeryczne</string>
<string name="home_content_type_interactive_stories">Interaktywne opowieści</string>
<string name="home_content_type_chess">Szachy</string>
<string name="home_content_type_attestations">Świadectwa</string>
<string name="home_content_type_nips">Szkice NIP</string>
<string name="home_content_type_music">Muzyka &amp; audio</string>
<string name="home_content_type_podcasts">Podcasty</string>
<string name="home_content_type_fundraisers">Zbiórki funduszy</string>
<string name="settings_section_reminders">Przypomnienia</string>
<string name="wallet_connect">Podłącz portfel</string>
<string name="language">Język</string>
@@ -2406,6 +2438,12 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="chat_type_ephemeral_desc">Lekkie pokoje, które nie utrzymują się w historii.</string>
<string name="relay_group_create_title">Utwórz grupę</string>
<!-- Buzz calls its groups channels, and a Buzz relay is one workspace rather than a directory of groups. -->
<string name="buzz_channel_create_title">Nowy kanał</string>
<string name="buzz_forum_create_title">Nowe forum</string>
<string name="buzz_channel_flag_private">Kanał prywatny</string>
<string name="buzz_channel_flag_private_desc">Ukryty na liście kanałów i dostępny wyłącznie na zaproszenie. Opcja „Wyłączone” oznacza, że każdy użytkownik tego transmitera może go znaleźć i dołączyć do niego.</string>
<string name="buzz_channel_flag_forum">Kanał forum</string>
<string name="buzz_channel_flag_forum_desc">Wpisy w wątku zamiast osi czasu czatu. Nie można tego później zmienić.</string>
<string name="relay_group_relay_no_nip29">Ten transmiter nie obsługuje grup zgodnych ze standardem NIP-29. Grupa utworzona tutaj nie będzie działać — transmiter nie będzie zarządzał jej nazwą, członkami ani wiadomościami. Wybierz transmiter, który obsługuje grupy oparte na rekomendacjach.</string>
<string name="relay_group_create_name">Nazwa grupy</string>
<string name="relay_group_create_topic">Temat (opcjonalnie)</string>
@@ -2440,6 +2478,12 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="relay_group_edit_confirm">Zapisz</string>
<string name="relay_group_menu_members">Członkowie</string>
<string name="relay_group_menu_edit">Edytuj grupę</string>
<string name="relay_group_delete">Usuń grupę</string>
<string name="relay_group_delete_confirm">Usunąć \"%1$s\"? To usuwa grupę i jej historię dla wszystkich i nie może być cofnięte.</string>
<string name="buzz_channel_delete">Usuń kanał</string>
<string name="buzz_channel_delete_confirm">Usunąć \"%1$s\"? To usuwa kanał i jego wiadomości dla wszystkich i nie może być cofnięte.</string>
<string name="buzz_channel_archive">Archiwizuj kanał</string>
<string name="buzz_channel_unarchive">Odarchiwizuj kanał</string>
<string name="relay_group_threads_title">Wątki</string>
<string name="relay_group_pin_message">Przypnij wiadomość</string>
<string name="relay_group_unpin_message">Odepnij wiadomość</string>
@@ -2502,6 +2546,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="relay_group_favorite_relay">Faworyzuj ten transmiter</string>
<string name="relay_group_threads_empty">Brak wątków. Zacznij od przycisku +.</string>
<!-- Same, where this viewer cannot start one: not a member, or a Buzz chat channel (forum posts belong to forum channels). -->
<string name="relay_group_threads_empty_read_only">Brak wątków.</string>
<string name="relay_group_threads_loading_older">Ładowanie starych wątków…</string>
<string name="relay_group_threads_all_caught_up">Brak starszych wątków</string>
<string name="relay_group_thread_new">Nowy wątek</string>
@@ -2519,6 +2564,10 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="relay_group_browse_your_relays">Transmitery, których używasz</string>
<string name="relay_group_browse_popular">Popularne transmitery</string>
<string name="relay_group_no_messages_yet">Brak wiadomości</string>
<string name="channel_invite_title">Dodano do %1$s</string>
<string name="channel_invite_unknown_actor">Ktoś</string>
<string name="channel_invite_ignore">Ignoruj</string>
<string name="channel_invite_leave">Wyjdź</string>
<string name="relay_group_join_to_post">Dołącz do tej grupy, aby wysyłać wiadomości.</string>
<string name="relay_group_invite_only_to_post">Ta grupa jest dostępna wyłącznie dla zaproszonych osób — aby móc publikować posty, potrzebujesz zaproszenia.</string>
<plurals name="relay_group_member_count">
@@ -2587,6 +2636,14 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="share_target_as_dm">Wyślij jako DM</string>
<string name="share_to_dm_title">Wyślij do…</string>
<string name="share_to_dm_start_new">Nowa wiadomość</string>
<string name="share_target_as_highlight">Nowe wyróżnienie</string>
<string name="new_highlight_title">Nowe wyróżnienie</string>
<string name="new_highlight_passage_label">Wyróżniony tekst</string>
<string name="new_highlight_passage_placeholder">Co szczególnie zwróciło twoją uwagę?</string>
<string name="new_highlight_source_label">Adres źródła</string>
<string name="new_highlight_note_label">Twoja notatka (opcjonalnie)</string>
<string name="new_highlight_note_placeholder">Dodaj swoje przemyślenia…</string>
<string name="highlight_action">Wyróżnienie</string>
<string name="copy_url_to_clipboard">Skopiuj adres do schowka</string>
<string name="copy_the_note_id_to_the_clipboard">Kopiuj ID wpisu do schowka</string>
<string name="add_media_to_gallery">Dodaj pliki do Galerii</string>
@@ -3259,7 +3316,28 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="buzz_message_edited">(edytowane)</string>
<string name="buzz_diff_truncated">(diff skrócony)</string>
<!-- Buzz kind-40099 system messages: relay-authored narration of a channel state change. -->
<string name="buzz_system_member_joined">%1$s dołączył(a)</string>
<string name="buzz_system_member_added">%1$s został(a) dodany(a) przez %2$s</string>
<string name="buzz_system_member_left">%1$s zostało</string>
<string name="buzz_system_member_removed">%1$s został usunięty przez %2$s</string>
<string name="buzz_system_topic_changed">%1$s ustawił(a) temat na \"%2$s\"</string>
<string name="buzz_system_topic_cleared">%1$s wyczyszczono temat</string>
<string name="buzz_system_purpose_changed">%1$s ustawił cel na \"%2$s\"</string>
<string name="buzz_system_purpose_cleared">%1$s doprecyzował cel</string>
<string name="buzz_system_visibility_open">%1$s otworzył ten kanał — każdy może go znaleźć i dołączyć do niego</string>
<string name="buzz_system_visibility_private">%1$s ustawił ten kanał jako prywatny — dostępny tylko dla zaproszonych osób</string>
<string name="buzz_system_visibility_other">%1$s zmieniono widoczność na %2$s</string>
<string name="buzz_system_ttl_set">%1$s ustawił wiadomości do zniknięcia po %2$s</string>
<string name="buzz_system_ttl_cleared">%1$s wyłączył(a) funkcję znikających wiadomości</string>
<string name="buzz_system_channel_archived">%1$s zarchiwizował ten kanał</string>
<string name="buzz_system_channel_unarchived">%1$s przywrócił ten kanał</string>
<string name="buzz_system_channel_created">%1$s utworzył ten kanał</string>
<string name="buzz_system_channel_deleted">%1$s usunął ten kanał</string>
<string name="buzz_system_message_deleted">%1$s usunął wiadomość</string>
<string name="buzz_system_message_deleted_reason">%1$s usunął wiadomość: %2$s</string>
<string name="buzz_system_dm_created">%1$s rozpoczął tę rozmowę</string>
<!-- Fallback for a system message type this version does not know yet: "alice: some_new_type". -->
<string name="buzz_system_unknown">%1$s: %2$s</string>
<!-- Buzz huddles (kind 481xx): live audio-room lifecycle, narrated in the chat timeline. -->
<!-- Buzz agent jobs (kind 43xxx): an agent task's lifecycle, narrated in the chat timeline. -->
<!-- Buzz forum votes (kind 45002). -->
@@ -3519,6 +3597,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="git_repo_settings_topics">Tematy (oddzielone przecinkami)</string>
<string name="git_repo_settings_save">Zapisz</string>
<string name="git_repositories">Repozytoria Git</string>
<string name="highlights">Wyróżnienia</string>
<string name="nsite_title">Statyczna Witryna: %1$s</string>
<string name="napplet_card_title">nApplet: %1$s</string>
<string name="napplet_card_permissions">Uprawnienia:</string>
@@ -3916,7 +3995,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="kind_git_pr_update">Aktualizacja PR</string>
<string name="kind_zap_goals">Cele Zap-a</string>
<string name="kind_hashtag_follows">Obserwowane hashtagi</string>
<string name="kind_highlights">Najważniejsze informacje</string>
<string name="kind_highlights">Wyróżnienia</string>
<string name="kind_http_auth">Autoryzacja http</string>
<string name="kind_index_relay_list">Indeks listy transmiterów</string>
<string name="kind_adventure_prologue">Wstęp do przygody</string>
@@ -4600,6 +4679,51 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="new_conversation_location_pro_2">Kompatybilny z Bitchatem; dociera do pobliskich użytkowników na tych samych transmiterach.</string>
<string name="new_conversation_location_con_1">Publiczny i efemeryczny brak historii, każdy uczestnik może go odczytać.</string>
<!-- Buzz workflow run board -->
<string name="buzz_workflow_section_active">Pracuje teraz</string>
<string name="buzz_workflow_section_shipped">Wysłano</string>
<string name="buzz_workflow_section_closed">Zamknięte</string>
<string name="buzz_workflow_gate_needs_you">Wymaga zatwierdzenia</string>
<string name="buzz_workflow_gate_awaiting">Oczekiwanie na zatwierdzenie</string>
<string name="buzz_workflow_no_description">(brak opisu)</string>
<string name="buzz_workflow_by">przez</string>
<string name="buzz_workflow_cancel">Anuluj</string>
<string name="buzz_workflow_trigger_failed_toast">Nie udało się uruchomić zadania — sprawdź, czy możesz publikować posty w tej przestrzeni roboczej</string>
<string name="buzz_workflow_approved_toast">Zatwierdzono — autor tworzy pull request</string>
<string name="buzz_workflow_denied_toast">Odmowa prace zostały odrzucone</string>
<string name="buzz_workflow_decision_failed_toast">Nie można opublikować Twojej decyzji — sprawdź, czy możesz publikować posty w tej przestrzeni roboczej</string>
<string name="buzz_workflow_view_pr">Zobacz PR</string>
<string name="buzz_workflow_closed_denied">Odmowa odrzucona praca</string>
<string name="buzz_workflow_closed_cancelled">Anulowano</string>
<string name="buzz_workflow_closed_failed_prefix">Nieudane: %1$s</string>
<string name="buzz_workflow_closed_failed">Niepowodzenie</string>
<string name="buzz_workflow_pill_queued">W kolejce</string>
<string name="buzz_workflow_pill_working">Pracuję</string>
<string name="buzz_workflow_pill_needs_approval">Wymaga zatwierdzenia</string>
<string name="buzz_workflow_pill_approved">Zatwierdzono</string>
<string name="buzz_workflow_pill_shipped">Wysłano</string>
<string name="buzz_workflow_pill_failed">Niepowodzenie</string>
<string name="buzz_workflow_pill_cancelled">Anulowano</string>
<string name="buzz_workflow_pill_denied">Odmowa</string>
<string name="buzz_workflow_trigger_title">Uruchom proces</string>
<string name="buzz_workflow_trigger_desc">Program wykonuje zadanie, a następnie wstrzymuje się, czekając na zatwierdzenie przez użytkownika, zanim utworzy zgłoszenie PR. Cały kanał ma wgląd w przebieg zadania.</string>
<string name="buzz_workflow_no_defs_hint">Nie ma jeszcze żadnych przepływów pracy. Otwórz menu powyżej i wybierz opcję „Nowa definicja…”, aby utworzyć przepływ pracy, a następnie go uruchom.</string>
<string name="buzz_workflow_task_label">Co należy zrobić?</string>
<string name="buzz_workflow_trigger_run">Uruchom</string>
<string name="buzz_workflow_picker_label">Proces</string>
<string name="buzz_workflow_picker_empty">Nie zdefiniowano jeszcze procesów</string>
<string name="buzz_workflow_picker_choose">Wybierz proces</string>
<string name="buzz_workflow_new_definition">Nowa definicja…</string>
<string name="buzz_workflow_def_title">Nowa definicja procesu</string>
<string name="buzz_workflow_def_desc">Nadaje nazwę dla kanału i publikuje jego recepturę YAML (kind-30620). Rzeczywisty transmiter Buzz uruchamia YAML. W przypadku hostingu własnego moduł uruchamiający wykonuje skonfigurowane polecenie — w tym przypadku definicja służy jedynie do nazwania i skatalogowania uruchomienia.</string>
<string name="buzz_workflow_def_name">Nazwa</string>
<string name="buzz_workflow_def_name_hint">zbuduj i przetestuj</string>
<string name="buzz_workflow_def_yaml">Receptura YAML</string>
<string name="buzz_workflow_def_publish_failed">Nie można opublikować definicji — sprawdź czy możesz opublikować wpis w tym obszarze roboczym.</string>
<string name="buzz_workflow_publishing">Publikowanie…</string>
<string name="buzz_workflow_create_definition">Utwórz definicję</string>
<string name="buzz_workflow_empty_title">Jeszcze nie uruchomiono</string>
<string name="buzz_workflow_empty_desc">Naciśnij „Nowe uruchomienie” w celu uruchomienia procesu. Moduł wykonawczy realizuje zadanie i zatrzymuje się na etapie zatwierdzenia — przed wysłaniem jakichkolwiek danych zatwierdzenie musi udzielić osoba.</string>
<!-- Buzz agent attestation -->
<string name="buzz_attest_topbar">Świadectwa</string>
<!-- Buzz agent persona editor -->
</resources>
@@ -438,6 +438,8 @@
<string name="refresh">Atualizar</string>
<string name="changed_chat_profile_to">Novo perfil de chat:</string>
<string name="leave">Sair</string>
<string name="remove_from_messages">Remover das Mensagens</string>
<string name="add_to_messages">Adicionar às Mensagens</string>
<string name="unfollow">Desseguir</string>
<string name="channel_created">Canal criado</string>
<string name="channel_information_changed_to">"Informação do canal mudou para"</string>
@@ -2132,6 +2134,28 @@
<string name="settings_section_tor_routing">Rotear pelo Tor</string>
<string name="settings_section_profile_sections">Seções e feeds</string>
<string name="settings_section_home_tabs">Abas visíveis</string>
<string name="settings_section_home_content_types">Conteúdo no feed</string>
<string name="home_content_type_text_notes">Notas de texto</string>
<string name="home_content_type_reposts">Repostagens</string>
<string name="home_content_type_comments">Comentários e respostas</string>
<string name="home_content_type_pictures">Imagens</string>
<string name="home_content_type_videos">Vídeos</string>
<string name="home_content_type_shorts">Curtas</string>
<string name="home_content_type_articles">Artigos</string>
<string name="home_content_type_wiki">Páginas wiki</string>
<string name="home_content_type_highlights">Destaques</string>
<string name="home_content_type_polls">Enquetes</string>
<string name="home_content_type_classifieds">Classificados</string>
<string name="home_content_type_voice">Mensagens de voz</string>
<string name="home_content_type_live_activities">Atividades ao vivo</string>
<string name="home_content_type_ephemeral_chat">Chats efêmeros</string>
<string name="home_content_type_interactive_stories">Histórias interativas</string>
<string name="home_content_type_chess">Partidas de xadrez</string>
<string name="home_content_type_birds">Observações de aves</string>
<string name="home_content_type_attestations">Atestações</string>
<string name="home_content_type_nips">Rascunhos de NIP</string>
<string name="home_content_type_music">Música e áudio</string>
<string name="home_content_type_fundraisers">Arrecadações</string>
<string name="settings_section_reminders">Lembretes</string>
<string name="wallet_connect">Conectar Carteira</string>
<string name="language">Linguagem</string>
@@ -2364,6 +2388,10 @@
<string name="relay_group_edit_confirm">Salvar</string>
<string name="relay_group_menu_members">Membros</string>
<string name="relay_group_menu_edit">Editar grupo</string>
<string name="relay_group_delete">Excluir grupo</string>
<string name="relay_group_delete_confirm">Excluir “%1$s”? Isso remove o grupo e seu histórico para todos e não pode ser desfeito.</string>
<string name="buzz_channel_delete">Excluir canal</string>
<string name="buzz_channel_delete_confirm">Excluir “%1$s”? Isso remove o canal e suas mensagens para todos e não pode ser desfeito.</string>
<string name="relay_group_threads_title">Tópicos</string>
<string name="relay_group_pin_message">Fixar mensagem</string>
<string name="relay_group_unpin_message">Desafixar mensagem</string>
@@ -2445,9 +2473,7 @@
<string name="relay_group_browse_popular">Relays populares</string>
<string name="relay_group_no_messages_yet">Nenhuma mensagem ainda</string>
<string name="channel_invite_title">Adicionado a %1$s</string>
<string name="channel_invite_body">%1$s adicionou você a este canal em %2$s. Mostrar em Mensagens?</string>
<string name="channel_invite_unknown_actor">Alguém</string>
<string name="channel_invite_accept">Mostrar</string>
<string name="channel_invite_ignore">Ignorar</string>
<string name="channel_invite_leave">Sair</string>
<string name="relay_group_join_to_post">Entre neste grupo para enviar mensagens.</string>
@@ -2507,6 +2533,14 @@
<string name="share_target_as_dm">Enviar como DM</string>
<string name="share_to_dm_title">Enviar para…</string>
<string name="share_to_dm_start_new">Nova mensagem</string>
<string name="share_target_as_highlight">Novo destaque</string>
<string name="new_highlight_title">Novo destaque</string>
<string name="new_highlight_passage_label">Texto destacado</string>
<string name="new_highlight_passage_placeholder">O que chamou sua atenção?</string>
<string name="new_highlight_source_label">URL de origem</string>
<string name="new_highlight_note_label">Sua nota (opcional)</string>
<string name="new_highlight_note_placeholder">Adicione seus comentários…</string>
<string name="highlight_action">Destacar</string>
<string name="copy_url_to_clipboard">Copiar URL para a área de transferência</string>
<string name="copy_the_note_id_to_the_clipboard">Copiar ID da nota para a área de transferência</string>
<string name="add_media_to_gallery">Adicionar Mídia à Galeria</string>
@@ -3274,6 +3308,10 @@
<string name="buzz_pin">Fixar canal</string>
<string name="buzz_unpin">Desafixar canal</string>
<string name="buzz_dm_section_empty">Nenhuma conversa ainda</string>
<plurals name="buzz_dm_hidden_count">
<item quantity="one">%1$d conversa oculta</item>
<item quantity="other">%1$d conversas ocultas</item>
</plurals>
<plurals name="buzz_dm_see_all_count">
<item quantity="one">Ver mais %1$d conversa</item>
<item quantity="other">Ver todas as %1$d conversas</item>
@@ -3450,6 +3488,7 @@
<string name="git_repo_settings_topics">Tópicos (separados por vírgula)</string>
<string name="git_repo_settings_save">Salvar</string>
<string name="git_repositories">Repositórios Git</string>
<string name="highlights">Destaques</string>
<string name="nsite_title">Site Estático: %1$s</string>
<string name="napplet_card_title">nApplet: %1$s</string>
<string name="napplet_card_permissions">Permissões:</string>
@@ -2544,9 +2544,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="relay_group_browse_popular">Popularni releji</string>
<string name="relay_group_no_messages_yet">Ni sporočil</string>
<string name="channel_invite_title">Dodano v: %1$s</string>
<string name="channel_invite_body">%1$s vas je dodal v ta kanal na %2$s. Želite to prikazati med sporočili?</string>
<string name="channel_invite_unknown_actor">Nekdo</string>
<string name="channel_invite_accept">Prikaži</string>
<string name="channel_invite_ignore">Prezri</string>
<string name="channel_invite_leave">Zapusti</string>
<string name="relay_group_join_to_post">Pridružite se tej skupini, da boste lahko pošiljali sporočila.</string>
@@ -438,6 +438,8 @@
<string name="refresh">Uppdatera</string>
<string name="changed_chat_profile_to">Ny chattprofil:</string>
<string name="leave">Lämna</string>
<string name="remove_from_messages">Ta bort från Meddelanden</string>
<string name="add_to_messages">Lägg till i Meddelanden</string>
<string name="unfollow">Sluta följa</string>
<string name="channel_created">Kanal skapad</string>
<string name="channel_information_changed_to">"Kanalinformation ändrad till"</string>
@@ -2132,6 +2134,30 @@
<string name="settings_section_tor_routing">Dirigera via Tor</string>
<string name="settings_section_profile_sections">Sektioner &amp; flöden</string>
<string name="settings_section_home_tabs">Synliga flikar</string>
<string name="settings_section_home_content_types">Innehåll i flödet</string>
<string name="home_content_type_text_notes">Textanteckningar</string>
<string name="home_content_type_reposts">Återinlägg</string>
<string name="home_content_type_comments">Kommentarer &amp; svar</string>
<string name="home_content_type_pictures">Bilder</string>
<string name="home_content_type_videos">Videor</string>
<string name="home_content_type_shorts">Kortfilmer</string>
<string name="home_content_type_articles">Artiklar</string>
<string name="home_content_type_wiki">Wiki-sidor</string>
<string name="home_content_type_highlights">Höjdpunkter</string>
<string name="home_content_type_polls">Omröstningar</string>
<string name="home_content_type_classifieds">Annonser</string>
<string name="home_content_type_torrents">Torrenter</string>
<string name="home_content_type_voice">Röstmeddelanden</string>
<string name="home_content_type_live_activities">Liveaktiviteter</string>
<string name="home_content_type_ephemeral_chat">Tillfälliga chattar</string>
<string name="home_content_type_interactive_stories">Interaktiva berättelser</string>
<string name="home_content_type_chess">Schackpartier</string>
<string name="home_content_type_birds">Fågelobservationer</string>
<string name="home_content_type_attestations">Attesteringar</string>
<string name="home_content_type_nips">NIP-utkast</string>
<string name="home_content_type_music">Musik &amp; ljud</string>
<string name="home_content_type_podcasts">Poddar</string>
<string name="home_content_type_fundraisers">Insamlingar</string>
<string name="settings_section_reminders">Påminnelser</string>
<string name="wallet_connect">Plånboksanslut</string>
<string name="language">Språk</string>
@@ -2364,6 +2390,10 @@
<string name="relay_group_edit_confirm">Spara</string>
<string name="relay_group_menu_members">Medlemmar</string>
<string name="relay_group_menu_edit">Redigera grupp</string>
<string name="relay_group_delete">Radera grupp</string>
<string name="relay_group_delete_confirm">Radera ”%1$s”? Det tar bort gruppen och dess historik för alla, och kan inte ångras.</string>
<string name="buzz_channel_delete">Radera kanal</string>
<string name="buzz_channel_delete_confirm">Radera ”%1$s”? Det tar bort kanalen och dess meddelanden för alla, och kan inte ångras.</string>
<string name="relay_group_threads_title">Trådar</string>
<string name="relay_group_pin_message">Fäst meddelande</string>
<string name="relay_group_unpin_message">Lossa meddelande</string>
@@ -2445,9 +2475,7 @@
<string name="relay_group_browse_popular">Populära reläer</string>
<string name="relay_group_no_messages_yet">Inga meddelanden ännu</string>
<string name="channel_invite_title">Tillagd i %1$s</string>
<string name="channel_invite_body">%1$s lade till dig i den här kanalen på %2$s. Visa den i Meddelanden?</string>
<string name="channel_invite_unknown_actor">Någon</string>
<string name="channel_invite_accept">Visa</string>
<string name="channel_invite_ignore">Ignorera</string>
<string name="channel_invite_leave">Lämna</string>
<string name="relay_group_join_to_post">Gå med i den här gruppen för att skicka meddelanden.</string>
@@ -2507,6 +2535,14 @@
<string name="share_target_as_dm">Skicka som DM</string>
<string name="share_to_dm_title">Skicka till…</string>
<string name="share_to_dm_start_new">Nytt meddelande</string>
<string name="share_target_as_highlight">Ny höjdpunkt</string>
<string name="new_highlight_title">Ny höjdpunkt</string>
<string name="new_highlight_passage_label">Markerad text</string>
<string name="new_highlight_passage_placeholder">Vad fastnade du för?</string>
<string name="new_highlight_source_label">Käll-URL</string>
<string name="new_highlight_note_label">Din anteckning (valfritt)</string>
<string name="new_highlight_note_placeholder">Lägg till dina tankar…</string>
<string name="highlight_action">Markera</string>
<string name="copy_url_to_clipboard">Kopiera URL till urklipp</string>
<string name="copy_the_note_id_to_the_clipboard">Kopiera anteckningens ID till urklipp</string>
<string name="add_media_to_gallery">Lägg till media i galleriet</string>
@@ -3274,6 +3310,10 @@
<string name="buzz_pin">Fäst kanal</string>
<string name="buzz_unpin">Lossa kanal</string>
<string name="buzz_dm_section_empty">Inga konversationer än</string>
<plurals name="buzz_dm_hidden_count">
<item quantity="one">%1$d dold konversation</item>
<item quantity="other">%1$d dolda konversationer</item>
</plurals>
<plurals name="buzz_dm_see_all_count">
<item quantity="one">Visa %1$d konversation till</item>
<item quantity="other">Visa alla %1$d konversationer</item>
@@ -3450,6 +3490,7 @@
<string name="git_repo_settings_topics">Ämnen (kommaseparerade)</string>
<string name="git_repo_settings_save">Spara</string>
<string name="git_repositories">Git-repositories</string>
<string name="highlights">Höjdpunkter</string>
<string name="nsite_title">Statisk webbplats: %1$s</string>
<string name="napplet_card_title">nApplet: %1$s</string>
<string name="napplet_card_permissions">Behörigheter:</string>
+10 -2
View File
@@ -2334,11 +2334,15 @@
<string name="home_content_type_text_notes">Text notes</string>
<string name="home_content_type_reposts">Reposts</string>
<string name="home_content_type_comments">Comments &amp; replies</string>
<string name="home_content_type_pictures">Pictures</string>
<string name="home_content_type_videos">Videos</string>
<string name="home_content_type_shorts">Shorts</string>
<string name="home_content_type_articles">Articles</string>
<string name="home_content_type_wiki">Wiki pages</string>
<string name="home_content_type_highlights">Highlights</string>
<string name="home_content_type_polls">Polls</string>
<string name="home_content_type_classifieds">Classifieds</string>
<string name="home_content_type_torrents">Torrents</string>
<string name="home_content_type_voice">Voice messages</string>
<string name="home_content_type_live_activities">Live activities</string>
<string name="home_content_type_ephemeral_chat">Ephemeral chats</string>
@@ -2422,8 +2426,10 @@
<string name="hashtag_exclusive">Hashtag-exclusive Post</string>
<string name="external_content_title">External Content</string>
<string name="external_url_scope">Comment on a website</string>
<string name="external_id_scope">Comment on an external resource</string>
<string name="url_preview_open_in_browser">Open in browser</string>
<string name="long_form_reading_minutes">%1$d min read</string>
@@ -2570,6 +2576,7 @@
<string name="relay_group_create_title">Create a group</string>
<!-- Buzz calls its groups channels, and a Buzz relay is one workspace rather than a directory of groups. -->
<string name="buzz_channel_create_title">New channel</string>
<string name="buzz_forum_create_title">New forum</string>
<string name="buzz_channel_flag_private">Private channel</string>
<string name="buzz_channel_flag_private_desc">Hidden from the channel list and invite-only. Off means anyone on this relay can find and join it.</string>
<string name="buzz_channel_flag_forum">Forum channel</string>
@@ -2612,6 +2619,8 @@
<string name="relay_group_delete_confirm">Delete \"%1$s\"? This removes the group and its history for everyone, and cannot be undone.</string>
<string name="buzz_channel_delete">Delete channel</string>
<string name="buzz_channel_delete_confirm">Delete \"%1$s\"? This removes the channel and its messages for everyone, and cannot be undone.</string>
<string name="buzz_channel_archive">Archive channel</string>
<string name="buzz_channel_unarchive">Unarchive channel</string>
<string name="relay_group_threads_title">Threads</string>
<string name="relay_group_pin_message">Pin message</string>
<string name="relay_group_unpin_message">Unpin message</string>
@@ -2693,9 +2702,7 @@
<string name="relay_group_browse_popular">Popular relays</string>
<string name="relay_group_no_messages_yet">No messages yet</string>
<string name="channel_invite_title">Added to %1$s</string>
<string name="channel_invite_body">%1$s added you to this channel on %2$s. Show it in Messages?</string>
<string name="channel_invite_unknown_actor">Someone</string>
<string name="channel_invite_accept">Show</string>
<string name="channel_invite_ignore">Ignore</string>
<string name="channel_invite_leave">Leave</string>
<string name="relay_group_join_to_post">Join this group to send messages.</string>
@@ -3603,6 +3610,7 @@
<string name="relay_group_section_channels">Channels</string>
<string name="relay_group_section_forums">Forums</string>
<string name="relay_group_section_archived">Archived</string>
<string name="buzz_agent_working">Working…</string>
<string name="relay_group_add_member">Add member</string>
<string name="buzz_community_add_people">Add people to this workspace</string>
@@ -21,6 +21,12 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
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 org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
@@ -65,6 +71,24 @@ class HomeFeedTypeTest {
assertNull(HomeFeedType.fromCode("future-kind"))
}
@Test
fun picturesVideosShortsAndTorrentsOwnTheirKinds() {
assertEquals(listOf(PictureEvent.KIND), HomeFeedType.PICTURES.kinds)
// Long-form videos are the horizontal/normal kinds; vertical/short kinds belong to Shorts.
assertEquals(listOf(VideoNormalEvent.KIND, VideoHorizontalEvent.KIND), HomeFeedType.VIDEOS.kinds)
assertEquals(listOf(VideoShortEvent.KIND, VideoVerticalEvent.KIND), HomeFeedType.SHORTS.kinds)
assertEquals(listOf(TorrentEvent.KIND), HomeFeedType.TORRENTS.kinds)
}
@Test
fun disablingVideosLeavesShortsUntouched() {
val disabled = HomeFeedType.disabledKinds(HomeFeedType.ALL - HomeFeedType.VIDEOS)
HomeFeedType.VIDEOS.kinds.forEach { assertTrue(it in disabled) }
// Shorts is a separate toggle, so its kinds stay live.
HomeFeedType.SHORTS.kinds.forEach { assertFalse(it in disabled) }
assertFalse(PictureEvent.KIND in disabled)
}
@Test
fun disabledKindsEmptyWhenEverythingEnabled() {
assertTrue(HomeFeedType.disabledKinds(HomeFeedType.ALL).isEmpty())
@@ -0,0 +1,73 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The two cases that motivated replacing the old `.m3u8`-substring predicate are
* [recognisesAnExtensionlessBlossomPlaylistByMime] and [ignoresM3u8InAQueryString]; the rest pin the
* behaviour that must not regress while fixing them.
*/
class IsHlsMediaTest {
@Test
fun recognisesAnExtensionlessBlossomPlaylistByMime() {
// BUD-10: the URL is a bare sha256 with no extension, so the mime is the only signal. The
// old substring predicate answered false here and the item was routed to the disk cache —
// fatal for a live playlist.
val blossom = "https://blossom.example.com/b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"
assertTrue(isHlsMedia(blossom, "application/x-mpegurl"))
assertTrue(isHlsMedia(blossom, "application/vnd.apple.mpegurl"))
assertFalse("no mime and no extension leaves nothing to go on", isHlsMedia(blossom, null))
}
@Test
fun ignoresM3u8InAQueryString() {
// The other direction: progressive media permanently excluded from the cache because its
// query string mentioned a playlist.
assertFalse(isHlsMedia("https://host/video.mp4?ref=a.m3u8", null))
assertFalse(isHlsMedia("https://host/video.mp4#a.m3u8", null))
}
@Test
fun recognisesAPlainM3u8Path() {
assertTrue(isHlsMedia("https://host/live.m3u8", null))
assertTrue(isHlsMedia("https://host/live.M3U8", null))
assertTrue("query strings don't hide the path", isHlsMedia("https://host/live.m3u8?vt=abc", null))
}
@Test
fun anExplicitMimeWins() {
// A non-HLS mime is respected even on an .m3u8 path — the mime is the more specific signal,
// and toExoPlayerMimeType only consults the path when no mime was supplied.
assertFalse(isHlsMedia("https://host/odd.m3u8", "video/mp4"))
}
@Test
fun progressiveMediaIsNotHls() {
assertFalse(isHlsMedia("https://host/video.mp4", null))
assertFalse(isHlsMedia("https://host/video.mp4", "video/mp4"))
assertFalse(isHlsMedia("https://host/audio.m4a", "audio/mp4"))
}
}
@@ -0,0 +1,201 @@
/*
* 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 org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Verbatim excerpt of a zap-stream-core LL-HLS media playlist (api-uk.zap.stream, 2026-07-26) the
* playlist that crashes media3 1.10.1 in `HlsMediaChunk.feedDataToExtractor`. The byte-range parts
* are what produce the bounded chunk behind that crash.
*/
private val ZAP_STREAM_LL_PLAYLIST =
"""
#EXTM3U
#EXT-X-VERSION:6
#EXT-X-PART-INF:PART-TARGET=0.49007290601730347
#EXT-X-TARGETDURATION:2
#EXT-X-MEDIA-SEQUENCE:191184
#EXT-X-MAP:URI="init.mp4"
#EXT-X-SERVER-CONTROL:PART-HOLD-BACK=1.715,CAN-BLOCK-RELOAD=YES
#EXT-X-PROGRAM-DATE-TIME:2026-07-26T20:18:32.611Z
#EXTINF:1.961,
191198.m4s
#EXT-X-PART:URI="191199.m4s",DURATION=0.5009999999892898,INDEPENDENT=YES,BYTERANGE="359712@0"
#EXT-X-PART:URI="191199.m4s",DURATION=0.5,BYTERANGE="153946@359712"
#EXT-X-PROGRAM-DATE-TIME:2026-07-26T20:18:34.572Z
#EXTINF:1.96,
191199.m4s
""".trimIndent()
class LowLatencyHlsStripperTest {
@Test
fun removesEveryLowLatencyTagFromARealPlaylist() {
val result = stripLowLatencyTags(ZAP_STREAM_LL_PLAYLIST)
assertFalse("byte-range parts are the crash trigger", result.contains("#EXT-X-PART:"))
assertFalse(result.contains("#EXT-X-PART-INF:"))
assertFalse(result.contains("#EXT-X-SERVER-CONTROL:"))
assertFalse("no BYTERANGE should survive", result.contains("BYTERANGE"))
}
@Test
fun keepsSegmentAndTagOrdering() {
val result = stripLowLatencyTags(ZAP_STREAM_LL_PLAYLIST)
// Exact equality, so this also pins that everything a plain HLS player needs survives
// untouched and in order: the header, MAP, PROGRAM-DATE-TIME, EXTINF and both segments.
assertEquals(
listOf(
"#EXTM3U",
"#EXT-X-VERSION:6",
"#EXT-X-TARGETDURATION:2",
"#EXT-X-MEDIA-SEQUENCE:191184",
"""#EXT-X-MAP:URI="init.mp4"""",
"#EXT-X-PROGRAM-DATE-TIME:2026-07-26T20:18:32.611Z",
"#EXTINF:1.961,",
"191198.m4s",
"#EXT-X-PROGRAM-DATE-TIME:2026-07-26T20:18:34.572Z",
"#EXTINF:1.96,",
"191199.m4s",
),
result.split("\n"),
)
}
@Test
fun leavesAPlainPlaylistByteIdentical() {
// The common case, on every playlist reload of every live stream: nothing to do.
val plain =
"""
#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:2
#EXT-X-MEDIA-SEQUENCE:4230
#EXT-X-MAP:URI="init.mp4"
#EXTINF:2,
4230.m4s
""".trimIndent()
assertSame("unchanged playlists should not be rebuilt", plain, stripLowLatencyTags(plain))
}
@Test
fun preservesTrailingNewlineAndBlankLines() {
val input = "#EXTM3U\n#EXT-X-PART:URI=\"a.m4s\",BYTERANGE=\"1@0\"\n\n#EXTINF:2,\na.m4s\n"
assertEquals("#EXTM3U\n\n#EXTINF:2,\na.m4s\n", stripLowLatencyTags(input))
}
@Test
fun preservesCrLfLineEndings() {
val input = "#EXTM3U\r\n#EXT-X-PART:URI=\"a.m4s\",BYTERANGE=\"1@0\"\r\n#EXTINF:2,\r\na.m4s\r\n"
assertEquals("#EXTM3U\r\n#EXTINF:2,\r\na.m4s\r\n", stripLowLatencyTags(input))
}
@Test
fun keepsDeltaPlaylistAndRenditionReportTags() {
// EXT-X-SKIP marks legitimately omitted segments; removing it would corrupt the playlist.
// EXT-X-RENDITION-REPORT is inert once the parts are gone.
val input =
"""
#EXTM3U
#EXT-X-SKIP:SKIPPED-SEGMENTS=10
#EXT-X-PART:URI="a.m4s",BYTERANGE="1@0"
#EXT-X-RENDITION-REPORT:URI="../b/live.m3u8",LAST-MSN=42
""".trimIndent()
val result = stripLowLatencyTags(input)
assertTrue(result.contains("#EXT-X-SKIP:SKIPPED-SEGMENTS=10"))
assertTrue(result.contains("#EXT-X-RENDITION-REPORT:"))
assertFalse(result.contains("#EXT-X-PART:"))
}
@Test
fun stripsPreloadHint() {
// The *other* tag that yields a byte-range chunk, so it matters as much as EXT-X-PART.
// Synthetic rather than folded into the capture above, which is labelled verbatim and did
// not carry a hint; shaped per RFC 8216 §4.4.5.3.
val input =
"""
#EXTM3U
#EXTINF:1.96,
191199.m4s
#EXT-X-PRELOAD-HINT:TYPE=PART,URI="191200.m4s",BYTERANGE-START=402501
""".trimIndent()
val result = stripLowLatencyTags(input)
assertFalse(result.contains("#EXT-X-PRELOAD-HINT"))
assertFalse("no byte range should survive", result.contains("BYTERANGE-START"))
assertEquals("#EXTM3U\n#EXTINF:1.96,\n191199.m4s", result)
}
@Test
fun byteFormPreservesBomAndMultibyteWhenStripping() {
// Written as raw bytes rather than a "" literal so the fixture states the on-the-wire
// encoding directly. 🎵 is a 4-byte sequence / surrogate pair, so it also covers the
// decode-modify-re-encode round trip beyond the BMP.
val bom = byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte())
val input =
bom +
(
"#EXTM3U\n" +
"#EXT-X-PART:URI=\"a.m4s\",BYTERANGE=\"1@0\"\n" +
"#EXTINF:2,caffè 🎵\n" +
"a.m4s\n"
).toByteArray(Charsets.UTF_8)
val result = stripLowLatencyTags(input)
assertArrayEquals(
bom + "#EXTM3U\n#EXTINF:2,caffè 🎵\na.m4s\n".toByteArray(Charsets.UTF_8),
result,
)
}
@Test
fun byteFormForwardsTheOriginalArrayWhenNothingToStrip() {
val input = "#EXTM3U\n#EXTINF:2,\na.m4s\n".toByteArray(Charsets.UTF_8)
// Same array, not an equal copy: the common path must not re-encode.
assertSame(input, stripLowLatencyTags(input))
}
@Test
fun doesNotMatchTagsBySubstring() {
// EXT-X-PARTY-TIME is fictional, but the point is that the match must be anchored: a bare
// `contains("#EXT-X-PART")` without the colon would eat unrelated tags.
val input = "#EXTM3U\n#EXT-X-PARTY-TIME:1\n#EXT-X-PART:URI=\"a.m4s\""
val result = stripLowLatencyTags(input)
assertTrue(result.contains("#EXT-X-PARTY-TIME:1"))
assertFalse(result.contains("#EXT-X-PART:"))
}
}
@@ -0,0 +1,109 @@
/*
* 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 com.vitorpamplona.amethyst.commons.tor.TorRelayEvaluation
import com.vitorpamplona.amethyst.commons.tor.TorRelaySettings
import com.vitorpamplona.amethyst.commons.tor.TorSettings
import com.vitorpamplona.amethyst.commons.tor.torDefaultPreset
import com.vitorpamplona.amethyst.commons.tor.torFullyPrivate
import com.vitorpamplona.amethyst.commons.tor.torSmallPayloadsPreset
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The "Can't reach this relay over Tor" banner used to be driven by a bare 6s timer, so it appeared on
* a relay that was connected and delivering. These pin the gates that replaced it.
*/
class TorClearnetFallbackTest {
private fun offer(
usesTor: Boolean = true,
torIsUp: Boolean = true,
trustingMovesToClearnet: Boolean = true,
disconnectedLongEnough: Boolean = true,
) = shouldOfferTorClearnetFallback(usesTor, torIsUp, trustingMovesToClearnet, disconnectedLongEnough)
@Test
fun offersWhenTorRoutedRelayStaysSilent() {
assertTrue(offer())
}
@Test
fun silentWhileTheRelayIsReachable() {
// The regression: the socket is open and the relay is replying, so there is nothing to escape.
// The screen models that as the debounce never completing.
assertFalse(offer(disconnectedLongEnough = false))
}
@Test
fun silentWhenTheRelayIsNotRoutedOverTor() {
// Tor can be on globally while this relay is dialed over clearnet (onion/localhost/trusted
// presets) — blaming Tor for that relay's silence would be wrong.
assertFalse(offer(usesTor = false))
}
@Test
fun silentWhileTorItselfIsStillBootstrapping() {
// Nothing Tor-routed connects during bootstrap — that's Tor's failure, not the relay's.
assertFalse(offer(torIsUp = false))
}
@Test
fun silentWhenTrustingWouldNotChangeRouting() {
assertFalse(offer(trustingMovesToClearnet = false))
}
// --- the routing inputs the screen feeds the predicate, read off the real presets ---
private val relay = NormalizedRelayUrl("wss://buzz.example.com/")
private fun evaluation(preset: TorSettings) =
TorRelayEvaluation(
torSettings =
TorRelaySettings(
torType = preset.torType,
onionRelaysViaTor = preset.onionRelaysViaTor,
dmRelaysViaTor = preset.dmRelaysViaTor,
newRelaysViaTor = preset.newRelaysViaTor,
trustedRelaysViaTor = preset.trustedRelaysViaTor,
moneyOperationsViaTor = preset.moneyOperationsViaTor,
),
trustedRelayList = emptySet(),
dmRelayList = emptySet(),
)
@Test
fun defaultPresetRoutesANewRelayOverTorAndTrustingEscapesIt() {
val eval = evaluation(torDefaultPreset)
assertTrue(eval.useTor(relay))
assertTrue(!eval.torSettings.trustedRelaysViaTor)
}
@Test
fun privacyPresetsKeepTrustedRelaysOnTorSoTheOfferIsWithheld() {
// Both keep trusted relays on Tor: adding this relay to the Trusted list would move nothing,
// so the banner must not offer it (the button would be inert).
assertTrue(evaluation(torSmallPayloadsPreset).torSettings.trustedRelaysViaTor)
assertTrue(evaluation(torFullyPrivate).torSettings.trustedRelaysViaTor)
}
}
+11
View File
@@ -29,6 +29,17 @@ tasks.withType<ProcessResources>().configureEach {
duplicatesStrategy = DuplicatesStrategy.INCLUDE
}
// BuzzAgentWrapperSyncTest compares the bundled buzz-agent wrappers against their
// tools/ reference copies. The reference tree lives outside this module, so without
// declaring it Gradle calls :cli:test up-to-date after a tools/-only edit — exactly the
// drift the test exists to catch.
tasks.named<Test>("test") {
inputs
.dir(rootProject.layout.projectDirectory.dir("tools/buzz-agent"))
.withPropertyName("buzzAgentWrapperReference")
.withPathSensitivity(PathSensitivity.RELATIVE)
}
dependencies {
implementation(project(":quartz"))
implementation(project(":commons"))
+2 -2
View File
@@ -21,8 +21,8 @@
class Amy < Formula
desc "Nostr client from the Amethyst project"
homepage "https://github.com/vitorpamplona/amethyst"
url "https://github.com/vitorpamplona/amethyst/releases/download/v1.12.6/amy-1.12.6-jvm.tar.gz"
sha256 "209316d704a4622ddef1fd86b958b7619e9d049c20f3543dff60348ec73affd6"
url "https://github.com/vitorpamplona/amethyst/releases/download/v1.13.1/amy-1.13.1-jvm.tar.gz"
sha256 "5cdf5f30c52722a382de45b86919746a2fd733494294436963b6ec39a7220f45"
license "MIT"
# Lets homebrew-core's BrewTestBot auto-open version-bump PRs when a new
@@ -46,7 +46,10 @@ import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
@@ -147,7 +150,7 @@ object BuzzCommands {
ctx.prepare()
val me = ctx.identity.pubKeyHex
val relays = relaysFor(ctx, relaysFlag)
if (relays.isEmpty()) return Output.error("no_relays", NO_RELAYS_MSG)
if (relays.isEmpty()) return Output.error("no_relays", noRelaysMsg(relaysFlag))
// The deployed Buzz relay does NOT emit kind-41001; instead it (a) confirms a DM's
// channel id synchronously in the open OK, and (b) addresses each member a kind-44100
@@ -177,25 +180,9 @@ object BuzzCommands {
.sortedByDescending { it.createdAt }
.take(limit)
.map { sys ->
// The relay's dm_created content carries a `participants` array our
// SystemMessagePayload model drops; read it from the raw content.
val participants =
runCatching {
jsonParser
.parseToJsonElement(sys.content)
.jsonObject["participants"]
?.let { arr ->
arr
.toString()
.trim('[', ']')
.split(",")
.map { it.trim().trim('"') }
.filter { it.isNotBlank() }
}
}.getOrNull().orEmpty()
mapOf(
"dm_id" to sys.channel(),
"participants" to participants,
"participants" to participantsOf(sys.content),
"created_at" to sys.createdAt,
)
}
@@ -518,7 +505,7 @@ object BuzzCommands {
ctx.prepare()
val me = ctx.identity.pubKeyHex
val relays = relaysFor(ctx, relaysFlag)
if (relays.isEmpty()) return Output.error("no_relays", NO_RELAYS_MSG)
if (relays.isEmpty()) return Output.error("no_relays", noRelaysMsg(relaysFlag))
val filter = Filter(kinds = listOf(AgentTurnMetricEvent.KIND), tags = mapOf("p" to listOf(me)))
val decrypted =
@@ -572,7 +559,7 @@ object BuzzCommands {
ctx.prepare()
val me = ctx.identity.pubKeyHex
val relays = relaysFor(ctx, relaysFlag)
if (relays.isEmpty()) return Output.error("no_relays", NO_RELAYS_MSG)
if (relays.isEmpty()) return Output.error("no_relays", noRelaysMsg(relaysFlag))
val filter = Filter(kinds = listOf(PersonaEvent.KIND), authors = listOf(me))
val personas =
@@ -600,8 +587,35 @@ object BuzzCommands {
}
}
/** Message for the `no_relays` error: neither `--relays` nor the account's outbox had one. */
private const val NO_RELAYS_MSG = "no relays: pass --relays ws://…"
/**
* The relay's `dm_created` content carries a `participants` array our [SystemMessageEvent]
* payload model drops, so read it off the raw content.
*
* Decoded as JSON, not string-munged: anything that isn't a flat array of non-blank strings is
* dropped rather than stringified into a plausible-looking pubkey. A pubkey is hex so it can't
* itself contain a comma, but a nested object or a non-array `participants` used to come back
* as garbage entries the caller had no way to tell from real ones.
*/
internal fun participantsOf(content: String): List<String> =
runCatching {
jsonParser
.parseToJsonElement(content)
.jsonObject["participants"]
?.jsonArray
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.takeIf(String::isNotBlank) }
}.getOrNull().orEmpty()
/**
* The `no_relays` detail. Passing `--relays` and having every entry rejected also lands here
* (the flag wins, so the outbox is never consulted), and telling that user to pass the flag
* they just passed is useless name the real problem instead.
*/
private fun noRelaysMsg(relaysFlag: String?) =
if (relaysFlag == null) {
"no relays: pass --relays ws://…"
} else {
"no usable relays in --relays: expected comma-separated ws:// or wss:// urls, got '$relaysFlag'"
}
/** The `--relays` set if given, else the account's outbox relays. */
private suspend fun relaysFor(
@@ -25,12 +25,19 @@ die() { printf 'error: %s\n' "$*" >&2; exit 1; }
cd "$BUZZ_WORKTREE" || die "cannot cd into worktree $BUZZ_WORKTREE"
# The PR base = the repo's default branch. Never operate on it directly.
base_branch="$(gh repo view --json defaultBranchRef -q .defaultBranchRef.name 2>/dev/null || echo main)"
# `gh` can also succeed while printing nothing (or a literal "null") for a repo it can't
# resolve, so fall back on the value, not just on the exit status.
base_branch="$(gh repo view --json defaultBranchRef -q .defaultBranchRef.name 2>/dev/null || true)"
[[ -n "$base_branch" && "$base_branch" != "null" ]] || base_branch="main"
case "$BUZZ_BRANCH" in
"$base_branch" | main | master) die "refusing to operate on the default branch ($BUZZ_BRANCH)" ;;
# Anything else is a per-run branch — the only thing this script is allowed to push.
*) : ;;
esac
title="$(git log -1 --format='%s' 2>/dev/null | cut -c1-72)"
# `|| true` keeps `set -e` from killing the script when the worktree has no commits yet —
# without it the fallback below is unreachable and the run fails with an empty stderr.
title="$(git log -1 --format='%s' 2>/dev/null | cut -c1-72)" || true
[[ -n "$title" ]] || title="Buzz run ${BUZZ_RUN:-}"
log "[workflow-ship] pushing $BUZZ_BRANCH"
@@ -0,0 +1,68 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* The gated-runner wrappers exist twice: `cli/src/main/resources/buzz-agent/` is what
* `amy buzz agent up` extracts into `~/.amy/buzz-agent`, and `tools/buzz-agent/` is the
* readable reference `tools/buzz-agent/README.md` tells people to point `--exec` at.
*
* Nothing in the build copies one to the other, so a fix applied to only one half ships a
* runner that behaves differently from its own documentation. This pins them together.
*/
class BuzzAgentWrapperSyncTest {
@Test
fun bundledWrappersMatchTheToolsReference() {
val bundled = repoRoot.resolve("cli/src/main/resources/buzz-agent")
val reference = repoRoot.resolve("tools/buzz-agent")
val scripts =
bundled
.listFiles()
?.filter { it.isFile }
.orEmpty()
.sortedBy { it.name }
assertTrue(scripts.isNotEmpty(), "no bundled wrappers found in $bundled")
scripts.forEach { script ->
val twin = reference.resolve(script.name)
assertTrue(twin.isFile, "${script.name} has no counterpart in tools/buzz-agent — add one")
assertEquals(
script.readText(),
twin.readText(),
"${script.name} drifted between cli/src/main/resources/buzz-agent and tools/buzz-agent — " +
"apply the change to both copies",
)
}
}
/** Walks up from the test's working directory to the checkout root (the dir holding both trees). */
private val repoRoot: File
get() =
generateSequence(File(".").absoluteFile) { it.parentFile }
.firstOrNull { File(it, "tools/buzz-agent").isDirectory && File(it, "cli/src/main/resources/buzz-agent").isDirectory }
?: error("could not locate the repo root from ${File(".").absolutePath}")
}
@@ -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.cli
import com.vitorpamplona.amethyst.cli.commands.BuzzCommands
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* `buzz dm list` reads `participants` off the raw `dm_created` content. It used to do that by
* stringifying the element and splitting on commas, which turned every non-string shape into
* plausible-looking-but-fake pubkeys. These cases pin the decoded behaviour.
*/
class BuzzParticipantsTest {
private val a = "a".repeat(64)
private val b = "b".repeat(64)
@Test
fun readsAFlatArrayOfStrings() {
assertEquals(listOf(a, b), BuzzCommands.participantsOf("""{"participants":["$a","$b"]}"""))
}
@Test
fun emptyWhenAbsentOrEmptyOrUnparseable() {
assertEquals(emptyList(), BuzzCommands.participantsOf("""{"type":"dm_created"}"""))
assertEquals(emptyList(), BuzzCommands.participantsOf("""{"participants":[]}"""))
assertEquals(emptyList(), BuzzCommands.participantsOf("not json at all"))
assertEquals(emptyList(), BuzzCommands.participantsOf(""))
}
@Test
fun rejectsNonArrayShapesInsteadOfStringifyingThem() {
// The old split-on-comma parse yielded ["nope"] and ["x":1}] respectively.
assertEquals(emptyList(), BuzzCommands.participantsOf("""{"participants":"nope"}"""))
assertEquals(emptyList(), BuzzCommands.participantsOf("""{"participants":{"x":1}}"""))
}
@Test
fun skipsNonStringElementsButKeepsTheRest() {
assertEquals(listOf(b), BuzzCommands.participantsOf("""{"participants":[{"nested":"$a"},"$b"]}"""))
assertEquals(listOf(a), BuzzCommands.participantsOf("""{"participants":["$a",null,""," "]}"""))
}
@Test
fun doesNotSplitAStringThatContainsACommaIntoTwoEntries() {
// The regression the old `arr.toString().split(",")` parse produced.
assertEquals(listOf("$a,$b"), BuzzCommands.participantsOf("""{"participants":["$a,$b"]}"""))
}
}
@@ -1,17 +1,95 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Login & Auth -->
<string name="login_title">Welkom bij Amethyst</string>
<string name="login_subtitle">Meld je aan bij je Nostr-account</string>
<string name="login_subtitle_desktop">Een Nostr-client voor desktop</string>
<string name="login_card_title">Inloggen met je Nostr-sleutel</string>
<string name="login_card_subtitle">nsec voor volledige toegang, bunker:// voor remote signer, of npub voor alleen-lezen</string>
<string name="login_with_key">Inloggen met sleutel</string>
<string name="login_button">Inloggen</string>
<string name="login_generate_new">Nieuwe sleutel genereren</string>
<string name="login_generate_button">Nieuwe genereren</string>
<string name="login_key_hint">Voer je privésleutel (nsec) of openbare sleutel (npub) in</string>
<string name="login_key_label">nsec, bunker:// of npub</string>
<string name="login_key_placeholder">nsec1… / bunker://… / npub1…</string>
<string name="login_show_key">Sleutel tonen</string>
<string name="login_hide_key">Sleutel verbergen</string>
<!-- New Key Warning -->
<string name="new_key_warning_title">BELANGRIJK: Bewaar je sleutels!</string>
<string name="new_key_warning_message">Je geheime sleutel (nsec) is de ENIGE manier om toegang te krijgen tot je account. Als je hem kwijtraakt, is je account voorgoed weg. Bewaar hem op een veilige plek!</string>
<string name="new_key_public_label">Openbare sleutel (deelbaar):</string>
<string name="new_key_secret_label">Geheime sleutel (deel dit NOOIT!):</string>
<string name="new_key_continue_button">Ik heb mijn sleutels bewaard, doorgaan</string>
<!-- Common Actions -->
<string name="action_copy">Kopiëren</string>
<string name="action_paste">Plakken</string>
<string name="action_cancel">Annuleren</string>
<string name="action_ok">OK</string>
<string name="action_save">Opslaan</string>
<string name="action_delete">Verwijderen</string>
<string name="action_share">Delen</string>
<!-- Errors -->
<string name="error_invalid_key">Ongeldig sleutelformaat. Controleer het en probeer opnieuw.</string>
<string name="error_network">Netwerkfout. Controleer je verbinding.</string>
<string name="error_generic">Er is een fout opgetreden. Probeer het opnieuw.</string>
<!-- Loading & Empty States -->
<string name="action_refresh">Vernieuwen</string>
<string name="action_try_again">Opnieuw proberen</string>
<string name="feed_empty">Feed is empty</string>
<string name="error_loading_feed">Error loading feed: %s</string>
<!-- Placeholder Screens -->
<string name="screen_search_title">Zoeken</string>
<string name="screen_search_description">Zoek naar gebruikers, notes en hashtags.</string>
<string name="screen_messages_title">Berichten</string>
<string name="screen_messages_description">Je versleutelde directe berichten verschijnen hier.</string>
<string name="screen_notifications_title">Meldingen</string>
<string name="screen_notifications_description">Vermeldingen, antwoorden en reacties verschijnen hier.</string>
<!-- Accessibility -->
<string name="accessibility_user_avatar">Avatar van gebruiker</string>
<string name="accessibility_navigate">Navigeren</string>
<!-- Relay history paging (shared feed markers + status card) -->
<string name="chats_history_loading_label">Laden:</string>
<string name="chats_history_fully_loaded_label">Volledig geladen:</string>
<string name="chats_history_fully_loaded">(volledig geladen)</string>
<string name="chats_history_by_relay">Geschiedenis per relay</string>
<string name="chats_history_stalled_retry">Wordt opnieuw geprobeerd als je dit scherm opnieuw opent</string>
<string name="chats_history_older">Oudere %1$s berichten</string>
<string name="chats_history_all_caught_up">Helemaal bij</string>
<string name="chats_history_reached_start">Begin van je %1$s berichten bereikt</string>
<string name="chats_history_subtitle">%1$s · %2$s · geladen sinds %3$s</string>
<string name="chats_history_subtitle_no_date">%1$s · %2$s</string>
<string name="chats_history_waiting">waiting on %1$s</string>
<string name="chats_history_incomplete">Sommige relays reageerden niet</string>
<string name="chats_history_incomplete_sub">%1$s onbereikbaar · tik om te zien welke</string>
<string name="chats_history_relays_title">%1$s · geschiedenis per relay</string>
<string name="chats_history_relay_since">sinds %1$s</string>
<string name="action_dismiss">Negeren</string>
<plurals name="chats_history_relays">
<item quantity="one">%1$d relay</item>
<item quantity="other">%1$d relays</item>
</plurals>
<!-- Notes & Replies -->
<string name="replying_to">replying to </string>
<!-- Static sites (NIP-5A) & napplets (NIP-5D) feed card -->
<string name="nsite_title">nSite: %1$s</string>
<string name="napplet_card_title">nApplet: %1$s</string>
<string name="napplet_card_kind">nApplet</string>
<string name="nsite_website_kind">nSite</string>
<string name="napplet_card_permissions">Waar het toegang toe heeft</string>
<string name="nsite_root_site">Root Site</string>
<string name="nsite_source">Bron:</string>
<string name="nsite_servers">Servers:</string>
<string name="nsite_open">Openen</string>
<!-- Custom emoji suggestions (NIP-30) -->
<string name="use_direct_url">Directe URL gebruiken</string>
<!-- Nicknames (NIP-85 contact cards) -->
<string name="nickname_dialog_title">Bijnaam</string>
<string name="nickname_dialog_explainer">Wordt aan jou getoond in plaats van de naam van deze gebruiker, overal in de app. Het wordt versleuteld opgeslagen in je contactkaart: alleen jij kunt het lezen. Typ : om je eigen emoji\'s te gebruiken.</string>
<string name="nickname_label">Bijnaam</string>
<string name="nickname_summary_label">Privénote over deze gebruiker</string>
<string name="nickname_save">Opslaan</string>
<string name="nickname_cancel">Annuleren</string>
<string name="git_status_open">Openen</string>
<string name="git_status_merged">Samengevoegd</string>
<string name="git_status_closed">Gesloten</string>
@@ -53,11 +131,16 @@
<string name="road_event_traffic_jam">File</string>
<string name="road_event_unknown">Weggebeurtenis</string>
<string name="podcast_value_zap_split_hint">Zaps hiervoor worden verdeeld over:</string>
<string name="podcast_value_split_percent">%1$d%%</string>
<string name="podcast_value_for_value">Value-for-Value</string>
<string name="relay_monitor_rtt_open">Openen</string>
<string name="relay_monitor_rtt_read">Lezen</string>
<string name="relay_monitor_rtt_write">Schrijven</string>
<string name="relay_monitor_network">Netwerk</string>
<string name="relay_monitor_relay_type">Type</string>
<string name="relay_monitor_requirements">Vereisten</string>
<string name="relay_monitor_supported_nips">Ondersteunde NIPs</string>
<string name="relay_monitor_ms">%1$d ms</string>
<string name="relay_discovery_accepted_kinds">Geaccepteerde kinds</string>
<string name="relay_discovery_geohash">Locatie</string>
<string name="calendar_rsvp_going">Ga</string>

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