Compare commits

...
Author SHA1 Message Date
Vitor PamplonaandGitHub 7cece7eafb Merge pull request #3825 from vitorpamplona/claude/intent-receivers-feed-sharing-rkwiv1
Add media share targets for Pictures, Shorts, and Video feeds
2026-07-30 12:22:44 -04:00
Claude dcf2807e75 fix: address audit findings on the media share targets
- Correct the KDoc on the Shorts and Longs composers. They claimed
  everything posted from them lands in that feed; VideoPostKind only
  governs videos, and the gallery picker takes images with no mime
  filter, so a picked JPEG still posts as a kind-20 picture that
  neither feed reads. Say so instead of asserting a false invariant.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Adds the missing spine, ten INFO lines a boot:

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

Reads on device as:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
2026-07-29 16:22:34 +00:00
Claude 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
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
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 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 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
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 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
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
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
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
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 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
100 changed files with 4582 additions and 781 deletions
+69
View File
@@ -295,6 +295,75 @@
</intent-filter>
</activity-alias>
<!-- "New Picture" share target: a gallery (or camera) shares an image and it goes straight
into the picture feed as a NIP-68 kind-20 post instead of a text note with a link. The
android:name simple class ("ShareAsPictureAlias") is matched at runtime by
ShareIntentRouting.SHARE_AS_PICTURE_ALIAS_SIMPLE_NAME; keep the two in sync. -->
<activity-alias
android:name=".ui.ShareAsPictureAlias"
android:exported="true"
android:label="@string/share_target_as_picture"
android:targetActivity=".ui.MainActivity">
<intent-filter android:label="@string/share_target_as_picture">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<intent-filter android:label="@string/share_target_as_picture">
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity-alias>
<!-- "New Short" share target: a video shared here always becomes a NIP-71 kind-22 short so
it lands in the Shorts feed, whatever its orientation. Video-only — a short is a video.
The android:name simple class ("ShareAsShortVideoAlias") is matched at runtime by
ShareIntentRouting.SHARE_AS_SHORT_VIDEO_ALIAS_SIMPLE_NAME; keep the two in sync. -->
<activity-alias
android:name=".ui.ShareAsShortVideoAlias"
android:exported="true"
android:label="@string/share_target_as_short_video"
android:targetActivity=".ui.MainActivity">
<intent-filter android:label="@string/share_target_as_short_video">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter android:label="@string/share_target_as_short_video">
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
</activity-alias>
<!-- "New Video" share target: opens the Video feed's media composer, which publishes a
NIP-71 video event (kind 21 or 22 by orientation) instead of a text note. The
android:name simple class ("ShareAsVideoAlias") is matched at runtime by
ShareIntentRouting.SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME; keep the two in sync. -->
<activity-alias
android:name=".ui.ShareAsVideoAlias"
android:exported="true"
android:label="@string/share_target_as_video"
android:targetActivity=".ui.MainActivity">
<intent-filter android:label="@string/share_target_as_video">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter android:label="@string/share_target_as_video">
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
</activity-alias>
<!-- "New Highlight" share target: a browser (or reader) shares a selected passage of
text and it opens the NIP-84 highlight composer. Text-only — a highlight is a text
passage, so no image/video filters here. The android:name simple class
@@ -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))
@@ -4122,6 +4145,7 @@ class Account(
alt: String?,
contentWarningReason: String?,
originalHash: String? = null,
videoKind: VideoPostKind = VideoPostKind.AUTO,
) {
if (!isWriteable()) return
@@ -4165,7 +4189,10 @@ class Account(
thumbhash = headerInfo.thumbHash?.thumbhash,
)
if (headerInfo.dim.height > headerInfo.dim.width) {
// The composer forces the kind when it was opened from a feed that only reads one of
// them (Shorts, Longs) or from that feed's share target, so the post lands where the
// user asked for it. Everywhere else the orientation decides.
if (videoKind.isShort(headerInfo.dim)) {
VideoShortEvent.build(videoMeta, alt ?: "") {
contentWarningReason?.let { contentWarning(contentWarningReason) }
}
@@ -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)
@@ -41,6 +41,7 @@ import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtIdHexComparator
import com.vitorpamplona.amethyst.commons.model.observables.EventListMatchingFilter
@@ -114,6 +115,7 @@ import com.vitorpamplona.quartz.buzz.stream.StreamMessageScheduledEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.buzz.stream.StreamReminderEvent
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
import com.vitorpamplona.quartz.buzz.stream.SystemMessagePayload
import com.vitorpamplona.quartz.buzz.stream.sidecars.ChannelSummaryEvent
import com.vitorpamplona.quartz.buzz.stream.sidecars.PresenceSnapshotEvent
import com.vitorpamplona.quartz.buzz.teams.TeamEvent
@@ -2223,6 +2225,30 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
attachToRelayGroupIfScoped(event, relay)
}
/**
* A Buzz kind-40099 system message. It renders as a narration row in the channel feed (via
* [consumeBuzzTimelineEvent]), but a `channel_deleted` one is also the **authoritative signal that
* a channel is gone**: the relay soft-deletes the channel and its 39000/39001/39002 discovery
* events but emits no member-removed notification and never retracts the kind-44100 that seeds the
* browse list — so without this the deleted channel keeps re-appearing (a stale 44100 re-announced
* every restart, its metadata now blank so it shows optimistically). Recording the delete in
* [RelayGroupDeletions] filters it out of every list and persists it across restarts.
*
* Gated on [isRelaySignedGroupEvent] (the 40099 is signed by the relay keypair) so a spoofed
* system message from a stray author can't hide a channel. Cross-device by construction: the relay
* replays this on subscribe, so a channel deleted on Buzz web/desktop is honored here too.
*/
private fun consume(
event: SystemMessageEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
consumeBuzzTimelineEvent(event, relay, wasVerified).also {
if (relay != null && isRelaySignedGroupEvent(event, relay) && event.payload()?.type == SystemMessagePayload.CHANNEL_DELETED) {
event.channel()?.let { channelId -> RelayGroupDeletions.markDeleted(GroupId(channelId, relay)) }
}
}
/** Store-only consume for Buzz kinds that carry no channel timeline row. */
private fun consumeBuzzRegularEvent(
event: Event,
@@ -4798,7 +4824,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
is StreamMessageV2Event -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is StreamMessageEditEvent -> consume(event, relay, wasVerified)
is StreamMessageDiffEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is SystemMessageEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is SystemMessageEvent -> consume(event, relay, wasVerified)
is CanvasEvent -> consume(event, relay, wasVerified)
// Forum root (45001) is a thread, not a chat row → Threads collection. Comments (45003)
// and votes (45002) are store-only: the forum-thread detail loads them on demand by root.
@@ -0,0 +1,53 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
/**
* Which NIP-71 kind a video upload is published as. Only videos are affected — whether an upload
* is a picture (NIP-68 kind 20) or a video is decided by the file's mime type and can't be
* overridden.
*
* The composer picks this from the feed it was opened on, so a post always lands in the feed the
* user was standing in (or shared to): the Shorts feed reads kind 22, the Longs feed reads kind 21
* and the Video feed reads both.
*/
enum class VideoPostKind {
/** Derive from the video's dimensions: portrait -> kind 22, landscape -> kind 21. */
AUTO,
/** Always NIP-71 kind 22 (short-form video), regardless of orientation. */
SHORT,
/** Always NIP-71 kind 21 (normal video), regardless of orientation. */
NORMAL,
;
/** True when a video of [dim] should be published as a NIP-71 kind 22 short. */
fun isShort(dim: DimensionTag): Boolean =
when (this) {
SHORT -> true
NORMAL -> false
AUTO -> dim.height > dim.width
}
}
@@ -0,0 +1,77 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.preferences
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
/**
* Device-global persistence for the set of deleted NIP-29 relay-group channels ([RelayGroupDeletions]),
* so a channel the user deleted (kind-9008) stays gone across a restart — even if the host relay keeps
* re-announcing a stale kind-44100 for it. Mirrors [BuzzChannelStarPreferences]: app-wide (not
* per-account), loads the saved keys into the singleton on construction, then writes every later change
* back. Construct once, eagerly.
*/
@Stable
class RelayGroupDeletionPreferences(
private val context: Context,
private val scope: CoroutineScope,
) {
init {
scope.launch {
restoreFromDisk()
// drop(1) skips the value present at collection start, which restoreFromDisk already wrote.
RelayGroupDeletions.flow.drop(1).collect { persist(it) }
}
}
private suspend fun restoreFromDisk() {
try {
val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return
if (raw.isNotEmpty()) RelayGroupDeletions.restore(raw)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("RelayGroupDeletionPrefs") { "Error reading deleted channels: ${e.message}" }
}
}
private suspend fun persist(keys: Set<String>) {
try {
context.sharedPreferencesDataStore.edit { prefs -> prefs[KEY] = keys }
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("RelayGroupDeletionPrefs") { "Error writing deleted channels: ${e.message}" }
}
}
companion object {
private val KEY = stringSetPreferencesKey("nip29.deletedChannels")
}
}
@@ -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()
}
@@ -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()
@@ -31,6 +31,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.VideoPostKind
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
@@ -73,15 +74,21 @@ open class NewMediaModel : ViewModel() {
// Strip location and sensitive metadata from files before upload
var stripMetadata by mutableStateOf(true)
// Which NIP-71 kind videos in this batch are published as. Set by the composer's host feed.
var videoKind: VideoPostKind = VideoPostKind.AUTO
open fun load(
account: Account,
uris: ImmutableList<SelectedMedia>,
videoKind: VideoPostKind = VideoPostKind.AUTO,
caption: String = "",
) {
this.caption = ""
this.caption = caption
this.account = account
this.multiOrchestrator = MultiOrchestrator(uris)
this.selectedServer = defaultServer()
this.stripMetadata = account.settings.stripLocationOnUpload
this.videoKind = videoKind
}
fun isImage(
@@ -180,6 +187,7 @@ open class NewMediaModel : ViewModel() {
alt = caption,
contentWarningReason = if (sensitiveContent) "" else null,
originalHash = it.uploadedHash,
videoKind = videoKind,
)
}
}
@@ -54,6 +54,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.VideoPostKind
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery
@@ -79,14 +80,16 @@ fun NewMediaView(
postViewModel: NewMediaModel,
accountViewModel: AccountViewModel,
nav: INav,
videoKind: VideoPostKind = VideoPostKind.AUTO,
initialCaption: String = "",
) {
val account = accountViewModel.account
val context = LocalContext.current
val scrollState = rememberScrollState()
LaunchedEffect(uris) {
postViewModel.load(account, uris)
LaunchedEffect(uris, videoKind, initialCaption) {
postViewModel.load(account, uris, videoKind, initialCaption)
}
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
@@ -22,6 +22,9 @@ package com.vitorpamplona.amethyst.ui.actions.uploads
import android.content.Context
import androidx.core.net.toUri
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -40,3 +43,20 @@ suspend fun resolveSharedMedia(
SelectedMedia(uri, context.contentResolver.getType(uri))
}
}
/**
* Batch form for SEND_MULTIPLE shares: resolves every URI in one trip to the IO dispatcher
* instead of one context switch per file. Blank entries are dropped, so an empty list in means
* an empty list out and the composer stays closed.
*/
suspend fun resolveSharedMedia(
context: Context,
uriStrings: List<String>,
): ImmutableList<SelectedMedia> {
val uris = uriStrings.mapNotNull { it.ifBlank { null }?.toUri() }
if (uris.isEmpty()) return persistentListOf()
return withContext(Dispatchers.IO) {
uris.map { SelectedMedia(it, context.contentResolver.getType(it)) }.toImmutableList()
}
}
@@ -52,13 +52,10 @@ fun LoadUrlPreview(
}
@Composable
fun LoadUrlPreviewDirect(
fun rememberUrlPreviewState(
url: String,
urlText: String,
callbackUri: String? = null,
accountViewModel: AccountViewModel,
nav: INav? = null,
) {
): UrlPreviewState {
@Suppress("ProduceStateDoesNotAssignValue")
val urlPreviewState by
produceState(
@@ -69,6 +66,18 @@ fun LoadUrlPreviewDirect(
accountViewModel.urlPreview(url) { value = it }
}
}
return urlPreviewState
}
@Composable
fun LoadUrlPreviewDirect(
url: String,
urlText: String,
callbackUri: String? = null,
accountViewModel: AccountViewModel,
nav: INav? = null,
) {
val urlPreviewState = rememberUrlPreviewState(url, accountViewModel)
CrossfadeIfEnabled(
targetState = urlPreviewState,
@@ -23,13 +23,17 @@ package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboard
@@ -37,12 +41,14 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextOverflow
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.preview.UrlInfoItem
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
import com.vitorpamplona.amethyst.ui.theme.Size14Modifier
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
import com.vitorpamplona.amethyst.ui.theme.previewCardImageModifier
import kotlinx.coroutines.launch
@@ -53,6 +59,7 @@ fun UrlPreviewCard(
url: String,
previewInfo: UrlInfoItem,
onUrlComments: (() -> Unit)? = null,
onCardClick: (() -> Unit)? = null,
) {
val uri = LocalUriHandler.current
val popupExpanded =
@@ -95,7 +102,11 @@ fun UrlPreviewCard(
MaterialTheme.colorScheme.innerPostModifier
.combinedClickable(
onClick = {
runCatching { uri.openUri(url) }
if (onCardClick != null) {
onCardClick()
} else {
runCatching { uri.openUri(url) }
}
},
onLongClick = {
popupExpanded.value = true
@@ -109,14 +120,34 @@ fun UrlPreviewCard(
modifier = previewCardImageModifier,
)
Text(
text = previewInfo.verifiedUrl?.host ?: previewInfo.url,
style = MaterialTheme.typography.bodySmall,
Row(
modifier = MaxWidthWithHorzPadding,
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = previewInfo.verifiedUrl?.host ?: previewInfo.url,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f, fill = false),
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
// Only meaningful when the card's own tap does something else (e.g. opening the
// comment thread); otherwise it would duplicate the card's open-in-browser tap.
if (onCardClick != null) {
IconButton(
onClick = { runCatching { uri.openUri(url) } },
) {
Icon(
symbol = MaterialSymbols.AutoMirrored.OpenInNew,
contentDescription = stringRes(R.string.url_preview_open_in_browser),
modifier = Size14Modifier,
tint = Color.Gray,
)
}
}
}
Text(
text = previewInfo.title,
@@ -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)
@@ -72,6 +72,7 @@ import com.vitorpamplona.amethyst.ui.navigation.bottombars.TabReselectCoordinato
import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.navs.rememberNav
import com.vitorpamplona.amethyst.ui.navigation.routes.MediaFeedRoute
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments
import com.vitorpamplona.amethyst.ui.navigation.routes.isBaseRoute
@@ -428,7 +429,7 @@ fun BuildNavigation(
) {
composableCapped<Route.Home> { HomeScreen(accountViewModel, nav) }
composable<Route.Message> { MessagesScreen(accountViewModel, nav) }
composableCapped<Route.Video> { VideoScreen(accountViewModel, nav) }
composableArgs<Route.Video> { VideoScreen(accountViewModel, nav, it.attachments, it.message) }
composableArgs<Route.Discover> { DiscoverScreen(it.initialTab, accountViewModel, nav) }
composableArgs<Route.Notification> { NotificationScreen(it.scrollToEventId, accountViewModel, nav) }
composableFromEnd<Route.Polls> { PollsScreen(accountViewModel, nav) }
@@ -439,7 +440,7 @@ fun BuildNavigation(
composableFromEnd<Route.ProfileBadges> { ProfileBadgesScreen(accountViewModel, nav) }
composableFromEnd<Route.ProfileAppRecommendations> { ProfileAppRecommendationsScreen(accountViewModel, nav) }
composableFromBottomArgs<Route.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) }
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
composableFromEndArgs<Route.Pictures> { PicturesScreen(accountViewModel, nav, it.attachments, it.message) }
composableFromEnd<Route.Workouts> { WorkoutsScreen(accountViewModel, nav) }
composableFromEnd<Route.GitRepositories> { GitRepositoriesScreen(accountViewModel, nav) }
@@ -469,7 +470,7 @@ fun BuildNavigation(
}
composableFromBottomArgs<Route.NewCalendarCollection> { NewCalendarCollectionScreen(nav, accountViewModel, it.dTag) }
composableFromEnd<Route.Products> { ProductsScreen(accountViewModel, nav) }
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.Shorts> { ShortsScreen(accountViewModel, nav, it.attachments, it.message) }
composableFromEnd<Route.PublicChats> { PublicChatsScreen(accountViewModel, nav) }
composableFromEnd<Route.RelayGroups> { RelayGroupDiscoveryScreen(accountViewModel, nav) }
composableFromEndArgs<Route.BuzzDmList> { BuzzDmListScreen(it.relayUrl, accountViewModel, nav) }
@@ -790,6 +791,7 @@ fun BuildNavigation(
composableFromEndArgs<Route.RelayGroupCreate> {
RelayGroupCreateScreen(
relayUrl = it.relayUrl,
isForum = it.isForum,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -970,6 +972,42 @@ fun BuildNavigation(
}
}
/** True for both share flavors: a single file/text (SEND) and a multi-file selection (SEND_MULTIPLE). */
private fun Intent.isShareAction(): Boolean = action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE
/** The text Android sent with the share — a caption, a URL, or the whole payload of a text-only share. */
private fun Intent.sharedText(): String? = getStringExtra(Intent.EXTRA_TEXT)?.ifBlank { null }
/**
* Every content URI the share carries, in the order the sending app listed them. SEND_MULTIPLE
* puts them in a parcelable ArrayList; plain SEND has at most one. Only the media targets declare
* SEND_MULTIPLE, so every other caller can just take the first.
*/
private fun Intent.sharedStreamUris(): List<String> =
if (action == Intent.ACTION_SEND_MULTIPLE) {
IntentCompat
.getParcelableArrayListExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
?.map { it.toString() }
.orEmpty()
} else {
listOfNotNull(IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)?.toString())
}
/**
* Opens a media feed on a share. Sharing again while an earlier share is still on screen replaces
* it instead of stacking a second copy of the feed — but only when the entry on top is itself a
* share (it carries attachments). A feed the user opened from the bottom bar carries none, so it
* stays put underneath and keeps its tab-root marker.
*/
private inline fun <reified T> Nav.navToSharedFeed(route: T) where T : Route, T : MediaFeedRoute {
val current = getRouteWithArguments(T::class, controller)
if (current is MediaFeedRoute && current.attachments.isNotEmpty()) {
popUpTo(route, T::class)
} else {
newStack(route)
}
}
@Composable
private fun NavigateIfIntentRequested(
nav: Nav,
@@ -986,49 +1024,43 @@ private fun NavigateIfIntentRequested(
val activity = LocalContext.current.getActivity()
if (activity.intent.action == Intent.ACTION_SEND) {
val isShareAsDm = ShareIntentRouting.isShareAsDm(activity.intent.component?.className)
val isShareAsHighlight = ShareIntentRouting.isShareAsHighlight(activity.intent.component?.className)
if (activity.intent.isShareAction()) {
val target = ShareIntentRouting.targetOf(activity.intent.component?.className)
// avoids restarting the destination screen when the intent is for the screen.
// Microsoft's swift key sends Gifs as new actions
if (isShareAsHighlight) {
if (isBaseRoute<Route.NewHighlight>(nav.controller)) return
} else if (isShareAsDm) {
if (isBaseRoute<Route.ShareToDM>(nav.controller)) return
} else {
if (isBaseRoute<Route.NewShortNote>(nav.controller)) return
// Microsoft's swift key sends Gifs as new actions.
// The media targets land on a feed the user may well be standing on already, so they can't
// guard on the destination — they rely on the intent being consumed below instead.
when (target) {
ShareTarget.HIGHLIGHT -> if (isBaseRoute<Route.NewHighlight>(nav.controller)) return
ShareTarget.DIRECT_MESSAGE -> if (isBaseRoute<Route.ShareToDM>(nav.controller)) return
ShareTarget.NEW_POST -> if (isBaseRoute<Route.NewShortNote>(nav.controller)) return
ShareTarget.PICTURE, ShareTarget.SHORT_VIDEO, ShareTarget.VIDEO -> Unit
}
// saves the intent to avoid processing again
var message by remember {
mutableStateOf(
activity.intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
it.ifBlank { null }
},
)
}
val message = remember { activity.intent.sharedText() }
val attachments = remember { activity.intent.sharedStreamUris() }
var media by remember {
mutableStateOf(
IntentCompat.getParcelableExtra(activity.intent, Intent.EXTRA_STREAM, Uri::class.java),
)
}
if (isShareAsHighlight) {
val parsed = message?.let { SharedHighlightParser.parse(it) }
nav.newStack(
Route.NewHighlight(
quote = parsed?.quote,
url = parsed?.url,
prefix = parsed?.prefix,
suffix = parsed?.suffix,
),
)
} else if (isShareAsDm) {
nav.newStack(Route.ShareToDM(message = message, attachment = media?.toString()))
} else {
nav.newStack(Route.NewShortNote(message = message, attachment = media.toString()))
when (target) {
ShareTarget.HIGHLIGHT -> {
val parsed = message?.let { SharedHighlightParser.parse(it) }
nav.newStack(
Route.NewHighlight(
quote = parsed?.quote,
url = parsed?.url,
prefix = parsed?.prefix,
suffix = parsed?.suffix,
),
)
}
// The single-file composers take the first URI: only the media targets declare
// SEND_MULTIPLE, so anything else can only be carrying one.
ShareTarget.DIRECT_MESSAGE -> nav.newStack(Route.ShareToDM(message = message, attachment = attachments.firstOrNull()))
ShareTarget.PICTURE -> nav.navToSharedFeed(Route.Pictures(attachments = attachments, message = message))
ShareTarget.SHORT_VIDEO -> nav.navToSharedFeed(Route.Shorts(attachments = attachments, message = message))
ShareTarget.VIDEO -> nav.navToSharedFeed(Route.Video(attachments = attachments, message = message))
ShareTarget.NEW_POST -> nav.newStack(Route.NewShortNote(message = message, attachment = attachments.firstOrNull()))
}
// Consume the launch intent so a later recomposition can't re-fire
@@ -1095,38 +1127,44 @@ private fun NavigateIfIntentRequested(
DisposableEffect(nav, activity) {
val consumer =
Consumer<Intent> { intent ->
if (intent.action == Intent.ACTION_SEND) {
val isShareAsDm = ShareIntentRouting.isShareAsDm(intent.component?.className)
val isShareAsHighlight = ShareIntentRouting.isShareAsHighlight(intent.component?.className)
// avoids restarting the destination screen when the intent is for the screen.
// Microsoft's swift key sends Gifs as new actions
if (isShareAsHighlight) {
if (!isBaseRoute<Route.NewHighlight>(nav.controller)) {
val parsed = intent.getStringExtra(Intent.EXTRA_TEXT)?.ifBlank { null }?.let { SharedHighlightParser.parse(it) }
nav.newStack(
Route.NewHighlight(
quote = parsed?.quote,
url = parsed?.url,
prefix = parsed?.prefix,
suffix = parsed?.suffix,
),
)
}
} else if (isShareAsDm) {
if (!isBaseRoute<Route.ShareToDM>(nav.controller)) {
val message = intent.getStringExtra(Intent.EXTRA_TEXT)?.ifBlank { null }
val attachment =
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.toString()
nav.newStack(Route.ShareToDM(message = message, attachment = attachment))
}
} else if (!isBaseRoute<Route.NewShortNote>(nav.controller)) {
intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
nav.newStack(Route.NewShortNote(message = it))
}
if (intent.isShareAction()) {
val target = ShareIntentRouting.targetOf(intent.component?.className)
val message = intent.sharedText()
val attachments = intent.sharedStreamUris()
val attachment = attachments.firstOrNull()
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.let {
nav.newStack(Route.NewShortNote(attachment = it.toString()))
}
// avoids restarting the destination screen when the intent is for the screen.
// Microsoft's swift key sends Gifs as new actions.
// The media targets land on a feed the user may well be standing on already, so
// they always navigate: the route carries the attachment, so the composer opens
// on the newly shared file even when the feed itself is already on screen.
when (target) {
ShareTarget.HIGHLIGHT ->
if (!isBaseRoute<Route.NewHighlight>(nav.controller)) {
val parsed = message?.let { SharedHighlightParser.parse(it) }
nav.newStack(
Route.NewHighlight(
quote = parsed?.quote,
url = parsed?.url,
prefix = parsed?.prefix,
suffix = parsed?.suffix,
),
)
}
ShareTarget.DIRECT_MESSAGE ->
if (!isBaseRoute<Route.ShareToDM>(nav.controller)) {
nav.newStack(Route.ShareToDM(message = message, attachment = attachment))
}
ShareTarget.PICTURE -> nav.navToSharedFeed(Route.Pictures(attachments = attachments, message = message))
ShareTarget.SHORT_VIDEO -> nav.navToSharedFeed(Route.Shorts(attachments = attachments, message = message))
ShareTarget.VIDEO -> nav.navToSharedFeed(Route.Video(attachments = attachments, message = message))
ShareTarget.NEW_POST ->
if (!isBaseRoute<Route.NewShortNote>(nav.controller) && (message != null || attachment != null)) {
nav.newStack(Route.NewShortNote(message = message, attachment = attachment))
}
}
} else {
val uri = intent.data?.toString()
@@ -20,10 +20,31 @@
*/
package com.vitorpamplona.amethyst.ui.navigation
/** Which entry of Android's share sheet the user picked. */
enum class ShareTarget {
/** The default "New Post" target: a kind-1 note with the shared text/media. */
NEW_POST,
/** "Send as DM": a NIP-17 private message. */
DIRECT_MESSAGE,
/** "New Highlight": a NIP-84 highlight of the shared passage. */
HIGHLIGHT,
/** "New Picture": a NIP-68 picture post, straight into the picture feed. */
PICTURE,
/** "New Short": a NIP-71 kind 22 video, straight into the Shorts feed. */
SHORT_VIDEO,
/** "New Video": a NIP-71 video, straight into the Video feed. */
VIDEO,
}
/**
* Distinguishes the extra share targets ("Send as DM", "New Highlight") from the default
* "New Post" share target. Every SEND intent-filter resolves to MainActivity; they are told
* apart by the component class name of the launching intent (the activity-alias name).
* Tells the share targets apart. Every SEND intent-filter resolves to MainActivity; which target
* the user picked is only visible in the component class name of the launching intent (the
* `<activity-alias>` name), so each alias below maps to the composer it opens.
*/
object ShareIntentRouting {
/**
@@ -41,7 +62,35 @@ object ShareIntentRouting {
*/
const val SHARE_AS_HIGHLIGHT_ALIAS_SIMPLE_NAME = "ShareAsHighlightAlias"
fun isShareAsDm(componentClassName: String?): Boolean = componentClassName?.endsWith(".$SHARE_AS_DM_ALIAS_SIMPLE_NAME") == true
/** See the caveat on [SHARE_AS_DM_ALIAS_SIMPLE_NAME]. */
const val SHARE_AS_PICTURE_ALIAS_SIMPLE_NAME = "ShareAsPictureAlias"
fun isShareAsHighlight(componentClassName: String?): Boolean = componentClassName?.endsWith(".$SHARE_AS_HIGHLIGHT_ALIAS_SIMPLE_NAME") == true
/** See the caveat on [SHARE_AS_DM_ALIAS_SIMPLE_NAME]. */
const val SHARE_AS_SHORT_VIDEO_ALIAS_SIMPLE_NAME = "ShareAsShortVideoAlias"
/** See the caveat on [SHARE_AS_DM_ALIAS_SIMPLE_NAME]. */
const val SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME = "ShareAsVideoAlias"
private val TARGET_BY_ALIAS =
mapOf(
SHARE_AS_DM_ALIAS_SIMPLE_NAME to ShareTarget.DIRECT_MESSAGE,
SHARE_AS_HIGHLIGHT_ALIAS_SIMPLE_NAME to ShareTarget.HIGHLIGHT,
SHARE_AS_PICTURE_ALIAS_SIMPLE_NAME to ShareTarget.PICTURE,
SHARE_AS_SHORT_VIDEO_ALIAS_SIMPLE_NAME to ShareTarget.SHORT_VIDEO,
SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME to ShareTarget.VIDEO,
)
/**
* Resolves the launching component to its target. Flavors can change the resolved package
* prefix, so the match is on the simple name; anything that isn't one of our aliases (chiefly
* MainActivity itself) is the default New Post target.
*/
fun targetOf(componentClassName: String?): ShareTarget {
if (componentClassName == null) return ShareTarget.NEW_POST
return TARGET_BY_ALIAS.entries
.firstOrNull { componentClassName.endsWith(".${it.key}") }
?.value
?: ShareTarget.NEW_POST
}
}
@@ -112,7 +112,7 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
id = NavBarItem.VIDEO,
labelRes = R.string.route_video,
icon = MaterialSymbols.Subscriptions,
resolveRoute = { Route.Video },
resolveRoute = { Route.Video() },
),
NavBarItem.DISCOVER to
NavBarItemDef(
@@ -231,7 +231,7 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
id = NavBarItem.PICTURES,
labelRes = R.string.pictures,
icon = MaterialSymbols.Photo,
resolveRoute = { Route.Pictures },
resolveRoute = { Route.Pictures() },
),
NavBarItem.WORKOUTS to
NavBarItemDef(
@@ -308,7 +308,7 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
id = NavBarItem.SHORTS,
labelRes = R.string.shorts,
icon = MaterialSymbols.PlayCircle,
resolveRoute = { Route.Shorts },
resolveRoute = { Route.Shorts() },
),
NavBarItem.MUSIC_TRACKS to
NavBarItemDef(
@@ -30,12 +30,32 @@ import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import kotlinx.serialization.Serializable
import kotlin.reflect.KClass
/**
* A feed whose composer can be pre-loaded from an Android share. [attachments] holds the shared
* content URIs and [message] the text sent alongside them; both are empty/null for every other way
* of reaching the feed, which is how the share flow tells its own entries apart from an ordinary
* bottom-bar visit.
*/
sealed interface MediaFeedRoute {
val attachments: List<String>
val message: String?
}
sealed class Route {
@Serializable object Home : Route()
@Serializable object Message : Route()
@Serializable object Video : Route()
/**
* The mixed media feed. [attachments] and [message] are set by the "New Video" share target: the content
* URIs of the shared files and the text Android sent alongside them, which pre-load the media
* composer and its caption. Empty for every other way of reaching the feed.
*/
@Serializable data class Video(
override val attachments: List<String> = emptyList(),
override val message: String? = null,
) : Route(),
MediaFeedRoute
@Serializable data class Discover(
val initialTab: DiscoverTab? = null,
@@ -81,7 +101,16 @@ sealed class Route {
)
}
@Serializable object Pictures : Route()
/**
* The picture feed. [attachments] and [message] are set by the "New Picture" share target: the content
* URIs of the shared files and the text Android sent alongside them, which pre-load the media
* composer and its caption. Empty for every other way of reaching the feed.
*/
@Serializable data class Pictures(
override val attachments: List<String> = emptyList(),
override val message: String? = null,
) : Route(),
MediaFeedRoute
@Serializable object Workouts : Route()
@@ -175,7 +204,16 @@ sealed class Route {
@Serializable object Products : Route()
@Serializable object Shorts : Route()
/**
* The short-video feed. [attachments] and [message] are set by the "New Short" share target: the content
* URIs of the shared files and the text Android sent alongside them, which pre-load the media
* composer and its caption. Empty for every other way of reaching the feed.
*/
@Serializable data class Shorts(
override val attachments: List<String> = emptyList(),
override val message: String? = null,
) : Route(),
MediaFeedRoute
@Serializable object PublicChats : Route()
@@ -741,6 +779,9 @@ sealed class Route {
@Serializable data class RelayGroupCreate(
val relayUrl: String,
// Buzz only: start the create flow on a `forum` channel (threaded posts) instead of a
// `stream` (chat) one — set by the community screen's per-section "+" buttons.
val isForum: Boolean = false,
) : Route()
@Serializable data class RelayGroupEdit(
@@ -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)
}
@@ -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
@@ -46,7 +46,6 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -74,6 +73,7 @@ import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelPreviewLoader
@@ -223,7 +223,7 @@ fun ConcordChannelListScreen(
Scaffold(
topBar = {
TopAppBar(
ShorterTopAppBar(
title = { Text(communityName, maxLines = 1) },
navigationIcon = {
// Back arrow only when pushed from elsewhere; as a bottom-nav tab the bar takes its place.
@@ -403,8 +403,6 @@ private fun ConcordChannelListRow(
remember(communityId, channelKey) { concordChannelUnreadCountFlow(account, communityId, channelKey) }
.collectAsStateWithLifecycle(0)
val hasUnread = unread > 0
// The recent posters' faces — recomputed as the channel's notes change (keyed on channelState).
val faceAuthors = remember(channelState) { channel.recentAuthorHexes(FACEPILE_MAX) }
Row(
Modifier
@@ -421,7 +419,7 @@ private fun ConcordChannelListRow(
tint = if (hasUnread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
// Line 1: channel name + the recent-posters facepile pushed to the right.
// Line 1: channel name + the last-message time pushed to the right.
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
channelName,
@@ -431,13 +429,6 @@ private fun ConcordChannelListRow(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
ConcordAuthorFacepile(faceAuthors, accountViewModel)
}
// Line 2: the last-message preview (or a live "typing…"), then the time + unread badge.
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(Modifier.weight(1f)) {
ConcordChannelPreviewLine(lastNote, isVoice, typingAuthors, accountViewModel)
}
lastNote?.createdAt()?.let { ts ->
Text(
timeAgo(ts, LocalContext.current, prefix = ""),
@@ -446,6 +437,12 @@ private fun ConcordChannelListRow(
maxLines = 1,
)
}
}
// Line 2: the last-message preview (or a live "typing…"), then the unread-message badge.
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(Modifier.weight(1f)) {
ConcordChannelPreviewLine(lastNote, isVoice, typingAuthors, accountViewModel)
}
ConcordUnreadBadge(unread)
}
}
@@ -458,9 +455,6 @@ private fun ConcordChannelListRow(
}
}
/** How many recent-poster avatars a channel row's facepile shows at most. */
private const val FACEPILE_MAX = 4
/**
* Bottom room the list leaves for the floating action button: a 56dp FAB + the Scaffold's 16dp margin
* + slack, so the last row's overflow menu stays tappable instead of sitting under the FAB. Matches
@@ -1,60 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* A horizontal strip of the recent posters in a channel — the "who's here" cue that makes a busy
* channel feel alive. Each poster is drawn with the app's standard profile avatar
* ([ClickableUserPicture]), so it carries the same following badge (top-right) and trust-score tag
* (bottom-centre) shown everywhere else a user appears, instead of a bare cropped image. Laid out
* with a small gap rather than an overlapping stack so those badges stay readable; the newest poster
* is leftmost. Renders nothing for an empty [authorHexes], so callers can drop it in unconditionally.
*/
@Composable
fun ConcordAuthorFacepile(
authorHexes: List<HexKey>,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
avatarSize: Dp = 24.dp,
maxShown: Int = 4,
) {
if (authorHexes.isEmpty()) return
val shown = authorHexes.take(maxShown)
Row(modifier, horizontalArrangement = Arrangement.spacedBy(2.dp)) {
shown.forEach { hex ->
ClickableUserPicture(
baseUserHex = hex,
size = avatarSize,
accountViewModel = accountViewModel,
)
}
}
}
@@ -39,7 +39,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -68,6 +67,7 @@ import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
@@ -115,7 +115,7 @@ fun ConcordHomeScreen(
Scaffold(
topBar = {
TopAppBar(
ShorterTopAppBar(
title = { Text(stringRes(R.string.concord_home_title)) },
navigationIcon = {
// Back arrow only when this is a pushed screen (from the drawer / a deep link);
@@ -26,7 +26,6 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
@@ -135,22 +134,3 @@ private fun ConcordChannel.newMessagesSince(
notes.count { _, note ->
(note.createdAt() ?: 0L) > sinceSecs && isConcordTimelineMessage(note, account)
}
/**
* The pubkeys of the [limit] most-recent distinct posters in this channel, newest first — the
* facepile shown on a channel row. One O(notes) pass keeps each author's latest post time, so a
* chatty author counts once (at their newest message) rather than crowding out quieter voices.
*/
fun ConcordChannel.recentAuthorHexes(limit: Int): List<HexKey> {
val latestByAuthor = HashMap<HexKey, Long>()
for (note in notes.values()) {
val author = note.author?.pubkeyHex ?: continue
val at = note.createdAt() ?: continue
val prev = latestByAuthor[author]
if (prev == null || at > prev) latestByAuthor[author] = at
}
return latestByAuthor.entries
.sortedByDescending { it.value }
.take(limit)
.map { it.key }
}
@@ -0,0 +1,104 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
import androidx.compose.foundation.layout.size
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
/**
* Pin/Unpin a Buzz channel (a device-local favorite — [BuzzChannelStars]). Moved off the per-channel
* list row into the opened channel's/forum's top-bar overflow, so the list row stays a clean
* tap-to-open target. Reads the live starred set so the label + icon reflect the current state.
*/
@Composable
fun BuzzPinDropdownItem(
groupId: GroupId,
closeMenu: () -> Unit,
) {
val starred by BuzzChannelStars.flow.collectAsStateWithLifecycle()
val isStarred = groupId.id in starred
DropdownMenuItem(
leadingIcon = {
Icon(
symbol = MaterialSymbols.PushPin,
contentDescription = null,
tint = if (isStarred) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
},
text = { Text(stringRes(if (isStarred) R.string.buzz_unpin else R.string.buzz_pin)) },
onClick = {
closeMenu()
BuzzChannelStars.toggle(groupId.id)
},
)
}
/**
* Add/Remove this relay-group [channel] from my kind-10009 list (whether it shows in Messages). A
* reversible toggle that never touches my relay membership — same split as "Leave" — so it stays
* readable either way. Reads the live kind-10009 list so it flips as the change lands. Moved off the
* per-channel list row into the opened screen's top-bar overflow.
*/
@Composable
fun RelayGroupMessagesDropdownItem(
channel: RelayGroupChannel,
accountViewModel: AccountViewModel,
closeMenu: () -> Unit,
) {
val joinedGroupIds by accountViewModel.account.relayGroupList.liveRelayGroupIds
.collectAsStateWithLifecycle()
val onMyList = channel.groupId in joinedGroupIds
DropdownMenuItem(
leadingIcon = {
Icon(
symbol = if (onMyList) MaterialSymbols.VisibilityOff else MaterialSymbols.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
},
text = { Text(stringRes(if (onMyList) R.string.remove_from_messages else R.string.add_to_messages)) },
onClick = {
closeMenu()
if (onMyList) {
accountViewModel.removeRelayGroupFromMessages(channel)
} else {
accountViewModel.addRelayGroupToMessages(channel)
}
},
)
}
@@ -36,8 +36,6 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
@@ -47,6 +45,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
@@ -73,7 +72,7 @@ import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.tor.TorType
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions
import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup
@@ -100,6 +99,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelaySubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.warningColor
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_DM
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_FORUM
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -114,9 +114,37 @@ import kotlinx.coroutines.launch
/** A first screen's worth of recent messages to prefetch per visible group card, ahead of a tap. */
private const val CHANNEL_LIST_WARMUP_LIMIT = 10
/** Grace period before offering the Tor→clearnet escape hatch, so a slow-but-working relay isn't nagged. */
/**
* How long this relay's socket must stay down before the Tor→clearnet escape hatch is offered, so a
* slow first connect (or a reconnect) isn't mistaken for a relay that blocks Tor exits.
*/
private const val TOR_CLEARNET_HINT_DELAY_MS = 6_000L
/**
* Whether to offer the Tor→clearnet escape hatch for this relay ([TorClearnetBanner]).
*
* The banner accuses *this relay* of blocking Tor exits and offers a privacy downgrade to work
* around it, so it has to rule out every other explanation — and be able to deliver:
*
* - [usesTor]: this relay is *actually* routed over Tor right now (`TorRelayEvaluation.useTor`, the
* same predicate the pool dials with). Tor being enabled globally says nothing about one relay —
* onion, localhost and the per-role presets (trusted / DM / new) each decide independently.
* - [torIsUp]: the SOCKS proxy is Active. While Tor is still bootstrapping *nothing* Tor-routed
* connects, which is Tor's problem (and its own dialog's), not this relay's.
* - [trustingMovesToClearnet]: the action on offer — adding the relay to the kind-10089 Trusted list —
* would actually change its routing. Under a preset that keeps trusted relays on Tor it wouldn't,
* and the button would be inert.
* - [disconnectedLongEnough]: the socket has been continuously down for [TOR_CLEARNET_HINT_DELAY_MS].
* A relay that answers is reachable by definition, and one that merely reconnects is not a relay
* that blocks Tor — only a sustained silence is.
*/
internal fun shouldOfferTorClearnetFallback(
usesTor: Boolean,
torIsUp: Boolean,
trustingMovesToClearnet: Boolean,
disconnectedLongEnough: Boolean,
): Boolean = usesTor && torIsUp && trustingMovesToClearnet && disconnectedLongEnough
/**
* Bottom room the list leaves for the floating action button: a 56dp FAB + the Scaffold's 16dp margin
* + slack, so the last row's overflow menu stays tappable instead of sitting under the FAB. Matches
@@ -172,6 +200,11 @@ fun RelayGroupChannelListScreen(
}
}
// Channels this device has deleted (kind-9008). A delete is terminal — the relay drops the group —
// but our cached 39000 (and a Buzz relay's stale re-announced 44100) would keep it in the list, so
// filter them out everywhere below. Collected as a StateFlow so a delete removes the row live.
val deletedChannels by RelayGroupDeletions.flow.collectAsStateWithLifecycle()
// Prefer the relay's own genuine, relay-signed groups (39000 author == the NIP-11 `self`).
// Recomputes as the NIP-11 doc resolves so real groups fill in and fakes stay hidden. But if
// NIP-11 is unreachable (e.g. a Cloudflare-fronted relay that resets the plain HTTP GET while
@@ -179,20 +212,22 @@ fun RelayGroupChannelListScreen(
// its de-facto signer — so its relay-signed groups still show while a stray user-published 39000
// (a different author) stays filtered.
val channels =
remember(allChannels, relayInfo) {
remember(allChannels, relayInfo, deletedChannels) {
val nip11Known = relayInfo.self != null || relayInfo.supported_nips != null
if (nip11Known) {
allChannels.filter { isRelaySignedRelayGroup(it, relayInfo) }
} else {
val dominantSigner =
allChannels
.mapNotNull { it.event?.pubKey }
.groupingBy { it }
.eachCount()
.maxByOrNull { it.value }
?.key
if (dominantSigner != null) allChannels.filter { it.event?.pubKey == dominantSigner } else allChannels
}
val signed =
if (nip11Known) {
allChannels.filter { isRelaySignedRelayGroup(it, relayInfo) }
} else {
val dominantSigner =
allChannels
.mapNotNull { it.event?.pubKey }
.groupingBy { it }
.eachCount()
.maxByOrNull { it.value }
?.key
if (dominantSigner != null) allChannels.filter { it.event?.pubKey == dominantSigner } else allChannels
}
signed.filterNot { it.groupId.toKey() in deletedChannels }
}
// Buzz relays expose no public group directory (membership is server-side), so `channels` above
@@ -200,6 +235,15 @@ fun RelayGroupChannelListScreen(
// (kind-44100) so browsing the relay lists the channels you already belong to — each addable to
// your kind-10009 list (so it then shows in Messages / Relay Groups). Same screen, one Browse.
val isBuzz = BuzzRelayDialect.isBuzz(relay) || relayInfo.software?.contains("buzz", ignoreCase = true) == true
// A Buzz relay lets any community member create a channel/forum (the creator becomes its owner),
// and the relay rejects a non-member's kind-9007. We don't hard-gate the "+" on membership: the
// NIP-43 roster (kind 13534) isn't fetched on this screen, so gating on it hid the "+" even from
// admins. Instead we offer it on any Buzz community and let the relay enforce — the same approach
// as the workspace overflow menu (Add people / Invite), whose own doc notes "any member sees them,
// the relay only serves the owner/admin ones."
val myPubkey = accountViewModel.account.signer.pubKey
val buzzVm: BuzzRelayImportViewModel = viewModel(key = "BuzzImport-${relay.url}")
LaunchedEffect(relay, isBuzz) { if (isBuzz) buzzVm.bind(accountViewModel.account, relay.url) }
val buzzChannels by buzzVm.channels.collectAsStateWithLifecycle()
@@ -226,9 +270,13 @@ fun RelayGroupChannelListScreen(
// ids so nothing the old flat list showed disappears.
val channelsById = remember(allChannels) { allChannels.associateBy { it.groupId.id } }
val buzzGroupIds =
remember(buzzChannels, channels) {
remember(buzzChannels, channels, deletedChannels) {
val seen = LinkedHashSet<String>()
(buzzChannels + channels.map { it.groupId }).filter { seen.add(it.id) }
// `channels` is already delete-filtered; also drop deleted ids from the membership-scoped
// `buzzChannels` (kind-44100), which the relay can keep re-announcing after a delete.
(buzzChannels + channels.map { it.groupId })
.filterNot { it.toKey() in deletedChannels }
.filter { seen.add(it.id) }
}
fun buzzTypeOf(groupId: GroupId): String? = channelsById[groupId.id]?.event?.buzzChannelType()
@@ -245,21 +293,34 @@ fun RelayGroupChannelListScreen(
fun buzzSortKey(groupId: GroupId): String = channelsById[groupId.id]?.toBestDisplayName()?.lowercase() ?: groupId.id
// Archived channels (relay-signed `archived` tag on the 39000) drop out of their normal section and
// gather in a collapsed "Archived" tail — the same hide-from-the-sidebar behavior the Buzz client
// has. They stay reachable there so an admin can open one and Unarchive it from the top bar.
fun isArchived(groupId: GroupId): Boolean = channelsById[groupId.id]?.isArchived() == true
val buzzChatChannels =
remember(buzzGroupIds, channelsById, starred) {
buzzGroupIds
.filter { buzzTypeOf(it).let { t -> t != BUZZ_CHANNEL_TYPE_FORUM && t != BUZZ_CHANNEL_TYPE_DM } }
.filter { buzzTypeOf(it).let { t -> t != BUZZ_CHANNEL_TYPE_FORUM && t != BUZZ_CHANNEL_TYPE_DM } && !isArchived(it) }
.sortedWith(compareByDescending<GroupId> { it.id in starred }.thenBy { buzzSortKey(it) })
}
val buzzForumChannels =
remember(buzzGroupIds, channelsById, starred) {
buzzGroupIds
.filter { buzzTypeOf(it) == BUZZ_CHANNEL_TYPE_FORUM }
.filter { buzzTypeOf(it) == BUZZ_CHANNEL_TYPE_FORUM && !isArchived(it) }
.sortedWith(compareByDescending<GroupId> { it.id in starred }.thenBy { buzzSortKey(it) })
}
// Every archived non-DM channel (chat + forum together), newest section at the bottom.
val buzzArchivedChannels =
remember(buzzGroupIds, channelsById) {
buzzGroupIds
.filter { buzzTypeOf(it) != BUZZ_CHANNEL_TYPE_DM && isArchived(it) }
.sortedBy { buzzSortKey(it) }
}
// Which sections the user has collapsed (session-scoped). Keyed by section id below.
var collapsedSections by remember { mutableStateOf(emptySet<String>()) }
// Which sections the user has collapsed (session-scoped). Keyed by section id below. Archived
// starts collapsed — it's the out-of-the-way tail, expanded only when someone goes looking.
var collapsedSections by remember { mutableStateOf(setOf("archived")) }
fun toggleSection(key: String) {
collapsedSections = if (key in collapsedSections) collapsedSections - key else collapsedSections + key
@@ -271,19 +332,54 @@ fun RelayGroupChannelListScreen(
var showAddPeople by remember { mutableStateOf(false) }
// Tor-failure escape hatch: a Cloudflare-fronted (or otherwise Tor-hostile) relay times out over
// Tor. When Tor is on, the relay isn't an onion, it isn't already trusted, and nothing has loaded
// after a grace period, offer to reach it over clearnet — which adds it to the kind-10089 Trusted
// Relay List (connected over clearnet even while Tor stays on for everything else).
val torType by Amethyst.instance.torPrefs.torType
.collectAsStateWithLifecycle(TorType.OFF)
val isOnion = remember(relay) { relay.url.contains(".onion") }
var connectTimedOut by remember(relay) { mutableStateOf(false) }
LaunchedEffect(relay) {
delay(TOR_CLEARNET_HINT_DELAY_MS)
connectTimedOut = true
// Tor. Offer to reach it over clearnet — which adds it to the kind-10089 Trusted Relay List
// (connected over clearnet even while Tor stays on for everything else).
//
// Every input below is a live signal, because a grace timer on its own says nothing about the
// relay: the banner used to fire on `torType != OFF && !onion && !trusted` plus a 6s delay, so on
// a working, answering relay it appeared after six seconds and never went away — the socket state
// was never consulted, and neither was whether this relay is Tor-routed at all (Tor being *on*
// doesn't mean this relay goes through it; the per-role presets decide).
val torEvaluation =
Amethyst.instance.torEvaluatorFlow.flow
.collectAsStateWithLifecycle()
// The same predicate the relay pool itself dials with, so the banner can't claim Tor for a relay
// the app is reaching over clearnet (onion / localhost / trusted-off-Tor are all folded in here).
val usesTor by remember(relay) { derivedStateOf { torEvaluation.value.useTor(relay) } }
// While Tor is bootstrapping every Tor-routed relay is silent — that's Tor's own failure (and its
// own dialog), so don't let it read as "this relay blocks Tor exits".
val torStatus by Amethyst.instance.torManager.status
.collectAsStateWithLifecycle()
val torIsUp = torStatus is TorServiceStatus.Active
// The offer adds the relay to the kind-10089 Trusted list, which only moves it to clearnet while
// trusted relays are *off* Tor. Under the Small-Payloads / Full-Privacy presets they are on Tor,
// so the button would add an entry and change no routing at all — don't offer what can't help.
val trustingMovesToClearnet by remember { derivedStateOf { !torEvaluation.value.torSettings.trustedRelaysViaTor } }
// Global flow (any relay's connect/disconnect re-emits), so derive this relay's boolean.
val connectedRelays =
accountViewModel.account.client
.connectedRelaysFlow()
.collectAsStateWithLifecycle()
val isConnected by remember(relay) { derivedStateOf { relay in connectedRelays.value } }
// Debounced on the *connection* rather than armed once per screen: the countdown restarts every
// time the socket drops and clears the moment it comes back, so a reconnect can't flash the
// banner, and circuits that die mid-session still surface it. Keying the wait on cached content
// instead would have hidden the offer exactly when a working relay went dark with a full screen.
var disconnectedLongEnough by remember(relay) { mutableStateOf(false) }
LaunchedEffect(relay, isConnected) {
if (isConnected) {
disconnectedLongEnough = false
} else {
delay(TOR_CLEARNET_HINT_DELAY_MS)
disconnectedLongEnough = true
}
}
val scope = rememberCoroutineScope()
val showTorHint = torType != TorType.OFF && !isOnion && relay !in trustedRelays && connectTimedOut
val showTorHint = shouldOfferTorClearnetFallback(usesTor, torIsUp, trustingMovesToClearnet, disconnectedLongEnough)
// A pinned relay works both as a pushed detail (from the drawer or another screen) and as a
// bottom-nav tab. Read once here (it is @Composable): the back arrow shows only when pushed;
@@ -326,17 +422,20 @@ fun RelayGroupChannelListScreen(
}
},
floatingActionButton = {
FloatingActionButton(onClick = { nav.nav(Route.RelayGroupCreate(relay.url)) }, shape = CircleShape) {
Icon(
symbol = MaterialSymbols.Add,
contentDescription =
stringRes(if (isBuzz) R.string.buzz_channel_create_title else R.string.relay_group_create_title),
modifier = Modifier.size(24.dp),
)
// A Buzz community creates channels/forums from the per-section "+" in their labels (like
// Direct Messages), so no FAB there. A vanilla NIP-29 relay is a flat directory with no
// sections, so it keeps the FAB to create a group.
if (!isBuzz) {
FloatingActionButton(onClick = { nav.nav(Route.RelayGroupCreate(relay.url)) }, shape = CircleShape) {
Icon(
symbol = MaterialSymbols.Add,
contentDescription = stringRes(R.string.relay_group_create_title),
modifier = Modifier.size(24.dp),
)
}
}
},
) { padding ->
val myPubkey = accountViewModel.userProfile().pubkeyHex
// A Buzz relay is a community, so always render its sectioned list (Channels, Forums, Direct
// Messages, Agent Console) even before anything loads, rather than the generic "empty" text.
if (channels.isEmpty() && !isBuzz) {
@@ -366,9 +465,11 @@ fun RelayGroupChannelListScreen(
// overlays content by design, so clearing it is the list's job. As contentPadding (not a
// modifier) so rows scroll *through* that strip and only come to rest clear of it; the
// modifier form would shrink the viewport and leave the FAB floating over dead space.
// Only the vanilla NIP-29 path has a FAB now; a Buzz community creates from its section
// headers, so it needs no bottom clearance.
LazyColumn(
modifier = Modifier.padding(padding),
contentPadding = PaddingValues(bottom = FAB_CLEARANCE),
contentPadding = PaddingValues(bottom = if (isBuzz) 0.dp else FAB_CLEARANCE),
) {
if (showTorHint) {
item(key = "tor-hint") {
@@ -382,16 +483,15 @@ fun RelayGroupChannelListScreen(
}
if (isBuzz) {
// While the membership fetch is still running and nothing has loaded, show a
// "Loading…" line. The old "you're not a member — accept the invite in the browser"
// empty text is gone: the section labels below now each carry a "+" to create a
// channel/forum, so an empty community is a starting point, not a dead end.
val noChannelsYet = buzzChatChannels.isEmpty() && buzzForumChannels.isEmpty()
if (noChannelsYet) {
item(key = "buzz-no-channels") {
if (noChannelsYet && buzzStatus is BuzzRelayImportViewModel.Status.Loading) {
item(key = "buzz-loading") {
Text(
text =
if (buzzStatus is BuzzRelayImportViewModel.Status.Loading) {
stringRes(R.string.buzz_import_loading)
} else {
stringRes(R.string.buzz_import_empty_body)
},
text = stringRes(R.string.buzz_import_loading),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth().padding(24.dp),
@@ -399,57 +499,61 @@ fun RelayGroupChannelListScreen(
}
}
// -- CHANNELS -- (Add-all now lives in the community's top-bar overflow menu)
if (buzzChatChannels.isNotEmpty()) {
// -- CHANNELS -- The label carries a "+" to create a channel (the community's FAB
// moved here, like Direct Messages). Add-all lives in the top-bar overflow menu.
// The header always shows so the "+" is available even before any channel loads;
// the collapse toggle is offered only when there's something to collapse.
run {
val channelsCollapsed = "channels" in collapsedSections
item(key = "sec-channels") {
RelayGroupSectionHeader(
title = stringRes(R.string.relay_group_section_channels),
collapsed = channelsCollapsed,
onToggle = { toggleSection("channels") },
)
onToggle = if (buzzChatChannels.isNotEmpty()) ({ toggleSection("channels") }) else null,
) {
SectionAddButton(stringRes(R.string.buzz_channel_create_title)) {
nav.nav(Route.RelayGroupCreate(relay.url))
}
}
}
if (!channelsCollapsed) {
if (buzzChatChannels.isNotEmpty() && !channelsCollapsed) {
itemsIndexed(buzzChatChannels, key = { _, it -> "chat-${it.id}" }) { index, groupId ->
RowHairline(index)
BuzzImportRow(
groupId = groupId,
isAdded = groupId.id in buzzAdded,
onAdd = { buzzVm.add(groupId) },
onRemove = { buzzVm.remove(groupId) },
accountViewModel = accountViewModel,
onOpen = { nav.nav(Route.RelayGroup(groupId.id, relay.url)) },
isStarred = groupId.id in starred,
onToggleStar = { BuzzChannelStars.toggle(groupId.id) },
)
}
}
}
// -- FORUMS --
if (buzzForumChannels.isNotEmpty()) {
// -- FORUMS -- Same treatment: an always-visible label with a "+" that starts the
// create flow on a forum channel (threaded posts) instead of a chat one.
run {
val forumsCollapsed = "forums" in collapsedSections
item(key = "sec-forums") {
RelayGroupSectionHeader(
title = stringRes(R.string.relay_group_section_forums),
collapsed = forumsCollapsed,
onToggle = { toggleSection("forums") },
)
onToggle = if (buzzForumChannels.isNotEmpty()) ({ toggleSection("forums") }) else null,
) {
SectionAddButton(stringRes(R.string.buzz_forum_create_title)) {
nav.nav(Route.RelayGroupCreate(relay.url, isForum = true))
}
}
}
if (!forumsCollapsed) {
if (buzzForumChannels.isNotEmpty() && !forumsCollapsed) {
itemsIndexed(buzzForumChannels, key = { _, it -> "forum-${it.id}" }) { index, groupId ->
RowHairline(index)
BuzzImportRow(
groupId = groupId,
isAdded = groupId.id in buzzAdded,
onAdd = { buzzVm.add(groupId) },
onRemove = { buzzVm.remove(groupId) },
accountViewModel = accountViewModel,
// A forum channel's primary content is its threads (kind-45001 posts), not a
// kind-9 chat, so open the forum/threads view directly instead of the chat.
onOpen = { nav.nav(Route.RelayGroupThreads(groupId.id, relay.url)) },
isStarred = groupId.id in starred,
onToggleStar = { BuzzChannelStars.toggle(groupId.id) },
// Forum posts live in a separate thread store, not the chat notes the
// activity preview reads — so don't warm a kind-9 sub that returns nothing.
showActivityPreview = false,
@@ -458,6 +562,38 @@ fun RelayGroupChannelListScreen(
}
}
// -- ARCHIVED -- Channels the relay has archived (chat + forum), tucked into a
// collapsed tail. Opening one and using the top-bar Unarchive brings it back.
if (buzzArchivedChannels.isNotEmpty()) {
val archivedCollapsed = "archived" in collapsedSections
item(key = "sec-archived") {
RelayGroupSectionHeader(
title = stringRes(R.string.relay_group_section_archived),
collapsed = archivedCollapsed,
onToggle = { toggleSection("archived") },
)
}
if (!archivedCollapsed) {
itemsIndexed(buzzArchivedChannels, key = { _, it -> "archived-${it.id}" }) { index, groupId ->
RowHairline(index)
val isForum = buzzTypeOf(groupId) == BUZZ_CHANNEL_TYPE_FORUM
BuzzImportRow(
groupId = groupId,
accountViewModel = accountViewModel,
onOpen = {
if (isForum) {
nav.nav(Route.RelayGroupThreads(groupId.id, relay.url))
} else {
nav.nav(Route.RelayGroup(groupId.id, relay.url))
}
},
isStarred = groupId.id in starred,
showActivityPreview = !isForum,
)
}
}
}
// -- DIRECT MESSAGES -- (this community's private conversations, most recent first)
item(key = "sec-dms") {
RelayGroupSectionHeader(title = stringRes(R.string.buzz_dm_title)) {
@@ -488,7 +624,6 @@ fun RelayGroupChannelListScreen(
row = row,
myPubkey = myPubkey,
isHidden = false,
onToggleMessages = { dmVm.removeFromMessages(row) },
accountViewModel = accountViewModel,
nav = nav,
) {
@@ -520,7 +655,6 @@ fun RelayGroupChannelListScreen(
row = row,
myPubkey = myPubkey,
isHidden = true,
onToggleMessages = { dmVm.addToMessages(row) },
accountViewModel = accountViewModel,
nav = nav,
) {
@@ -640,27 +774,44 @@ private fun RelayGroupSectionHeader(
}
}
/**
* The trailing "+" for a section label (Channels / Forums), matching the Direct Messages header's
* New-message icon: a primary-tinted Add glyph that creates a new item of that section's type.
*/
@Composable
private fun SectionAddButton(
contentDescription: String,
onClick: () -> Unit,
) {
IconButton(onClick = onClick) {
Icon(
symbol = MaterialSymbols.Add,
contentDescription = contentDescription,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
}
}
/**
* One inline Direct-Message conversation row inside the community view: the counterpart's avatar +
* name (or a "+N" cluster label for a group DM), a preview of the last message, a compact
* last-activity time, and an overflow holding the Add/Remove-from-Messages toggle. The channel's
* recent content is warmed while the row is visible so the preview fills in ahead of a tap. Tapping
* opens the DM as its relay-group chat.
* name (or a "+N" cluster label for a group DM), a preview of the last message, and a compact
* last-activity time. The channel's recent content is warmed while the row is visible so the preview
* fills in ahead of a tap. Tapping opens the DM as its relay-group chat; the Add/Remove-from-Messages
* (hide/unhide) action lives in that chat screen's top-bar overflow, not on this row.
*
* [isHidden] renders the row faded and flips the overflow to "Add to Messages" — a hidden DM is a
* live conversation the viewer merely parked, so it stays openable and reversible.
* [isHidden] renders the row faded — a hidden DM is a live conversation the viewer merely parked, so
* it stays openable and reversible from the opened conversation.
*/
@Composable
private fun BuzzDmInlineRow(
row: BuzzDmListViewModel.DmRow,
myPubkey: HexKey,
isHidden: Boolean,
onToggleMessages: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
onClick: () -> Unit,
) {
var menuOpen by remember { mutableStateOf(false) }
val others = row.others.ifEmpty { listOf(myPubkey) }
val leadHex = others.first()
val leadUser = remember(leadHex) { LocalCache.getOrCreateUser(leadHex) }
@@ -689,7 +840,7 @@ private fun BuzzDmInlineRow(
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(start = 16.dp, end = 4.dp, top = 10.dp, bottom = 10.dp)
.padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 10.dp)
.alpha(if (isHidden) 0.55f else 1f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
@@ -718,33 +869,6 @@ private fun BuzzDmInlineRow(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Box {
IconButton(onClick = { menuOpen = true }) {
Icon(
symbol = MaterialSymbols.MoreVert,
contentDescription = stringRes(R.string.more_options),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
leadingIcon = {
Icon(
symbol = if (isHidden) MaterialSymbols.Add else MaterialSymbols.VisibilityOff,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
},
text = { Text(stringRes(if (isHidden) R.string.add_to_messages else R.string.remove_from_messages)) },
onClick = {
menuOpen = false
onToggleMessages()
},
)
}
}
}
}
@@ -97,10 +97,15 @@ fun RelayGroupCreateScreen(
relayUrl: String,
accountViewModel: AccountViewModel,
nav: INav,
// Buzz only: pre-select the `forum` channel type (the Forums section's "+" routes here with true).
isForum: Boolean = false,
) {
val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return
val viewModel: RelayGroupMetadataViewModel = viewModel(key = "RelayGroupCreate:$relayUrl")
LaunchedEffect(relay) { viewModel.initCreate(accountViewModel, relay) }
LaunchedEffect(relay) {
viewModel.initCreate(accountViewModel, relay)
if (isForum) viewModel.isForum = true
}
// A group only works if the relay actually runs NIP-29 (otherwise it stores our 9007/9002 as
// ordinary events, never emits metadata/roster, and the "group" is a dead hex id). Gate creation
@@ -197,8 +202,15 @@ private fun RelayGroupMetadataScaffold(
Scaffold(
topBar = {
if (viewModel.isNewGroup) {
// The type is fixed by the caller (the community's per-section "+"), so the title names
// it — "New forum" vs "New channel" on Buzz — rather than offering a toggle to change it.
CreatingTopBar(
titleRes = if (viewModel.isBuzzRelay) R.string.buzz_channel_create_title else R.string.relay_group_create_title,
titleRes =
when {
!viewModel.isBuzzRelay -> R.string.relay_group_create_title
viewModel.isForum -> R.string.buzz_forum_create_title
else -> R.string.buzz_channel_create_title
},
isActive = { viewModel.canPost && (nip29Support == true || viewModel.isBuzzRelay) },
onCancel = nav::popBack,
onPost = onSubmit,
@@ -410,21 +422,11 @@ private fun GroupMetadataFields(viewModel: RelayGroupMetadataViewModel) {
viewModel.markTouched()
}
if (viewModel.isBuzzRelay) {
// Buzz's `channel_type`. Only offered on create: the relay takes it on the 9007 and its
// 9002 handler has no `channel_type` key, so an existing channel cannot be converted.
if (viewModel.isNewGroup) {
LabeledSwitchRow(
label = stringRes(R.string.buzz_channel_flag_forum),
description = stringRes(R.string.buzz_channel_flag_forum_desc),
checked = viewModel.isForum,
) {
viewModel.isForum = it
viewModel.markTouched()
}
}
return
}
// A Buzz channel's type (chat vs forum) is fixed by the caller — the community's per-section "+" —
// and isn't editable (the relay's 9002 has no `channel_type` key), so there's no toggle here. The
// remaining vanilla NIP-29 flags (invite-only / restricted below) don't apply to Buzz either, so
// stop here for a Buzz relay.
if (viewModel.isBuzzRelay) return
LabeledSwitchRow(
label = stringRes(R.string.relay_group_flag_invite_only),
@@ -34,15 +34,20 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -57,6 +62,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplyCount
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -166,6 +172,37 @@ private fun RelayGroupThreads(
)
}
},
actions = {
// The forum's per-item actions, moved off the community-list row into this screen's
// top-bar overflow: Pin/Unpin and the Add/Remove-from-Messages toggle, plus the
// admin-only Archive/Unarchive (so an archived forum can be brought back from here).
// Buzz-only, which every forum channel is.
if (isBuzz) {
var menuOpen by remember { mutableStateOf(false) }
val isAdmin = channel.membershipOf(accountViewModel.userProfile().pubkeyHex) == RelayGroupMembership.ADMIN
IconButton(onClick = { menuOpen = true }) {
Icon(
symbol = MaterialSymbols.MoreVert,
contentDescription = stringRes(R.string.more_options),
modifier = Modifier.size(22.dp),
)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
BuzzPinDropdownItem(channel.groupId) { menuOpen = false }
RelayGroupMessagesDropdownItem(channel, accountViewModel) { menuOpen = false }
if (isAdmin) {
val archived = channel.isArchived()
DropdownMenuItem(
text = { Text(stringRes(if (archived) R.string.buzz_channel_unarchive else R.string.buzz_channel_archive)) },
onClick = {
menuOpen = false
accountViewModel.archiveRelayGroup(channel, !archived)
},
)
}
}
}
},
popBack = nav::popBack,
)
},
@@ -53,6 +53,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
@@ -117,6 +118,9 @@ fun RelayGroupTopBar(
// My kind-10009 list, live: drives the Add/Remove-from-Messages toggle in the overflow below.
val joinedGroupIds by accountViewModel.account.relayGroupList.liveRelayGroupIds
.collectAsStateWithLifecycle()
// A Buzz DM is hidden via its own per-viewer 30622 snapshot, not the kind-10009 list, so the
// Messages toggle branches on this for DMs.
val hiddenDms by BuzzDmRegistry.hidden.collectAsStateWithLifecycle()
// Read once here (nav.canPop() is @Composable) so the post-action navigation can pop from a menu
// callback — leaving a group shouldn't strand the user on the screen of a group they left.
val canPop = nav.canPop()
@@ -237,7 +241,9 @@ fun RelayGroupTopBar(
// Buzz `t=stream` channel it is always empty (forum posts live in `t=forum` channels, which
// the relay's channel list already surfaces in their own section), so it read as a broken
// feature on every chat. Demoted to the overflow, where the frequency of use actually is.
if (!isDm || naddr != null || showMembershipActions) {
// A Buzz DM always gets the overflow too — its hide/unhide (below) is the DM row's old
// action, and it must be reachable even where the membership actions aren't offered.
if (!isDm || naddr != null || showMembershipActions || (isDm && isBuzzRelay)) {
IconButton(onClick = { menuOpen = true }) {
Icon(
symbol = MaterialSymbols.MoreVert,
@@ -246,6 +252,33 @@ fun RelayGroupTopBar(
)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
// Pin/Unpin moved here off the community-list row. A local favorite, so it's offered
// for any Buzz channel/forum regardless of membership; DMs are never pinned.
if (isBuzzRelay && !isDm) {
BuzzPinDropdownItem(channel.groupId) { menuOpen = false }
}
// A DM's Add/Remove-from-Messages, moved off the DM list row. It rides the per-viewer
// 30622 hide snapshot (kind-41012 hide / re-open), not the kind-10009 list, and is
// shown regardless of the membership gate below.
if (isBuzzRelay && isDm) {
val dmHidden = channel.groupId.id in (hiddenDms[myPubkey] ?: emptySet())
DropdownMenuItem(
text = { Text(stringRes(if (dmHidden) R.string.add_to_messages else R.string.remove_from_messages)) },
onClick = {
menuOpen = false
if (dmHidden) {
val participants =
channel.event
?.buzzParticipants()
?.filter { it != myPubkey }
.orEmpty()
accountViewModel.unhideBuzzDm(channel.groupId.relayUrl, participants.ifEmpty { listOf(myPubkey) })
} else {
accountViewModel.hideBuzzDm(channel)
}
},
)
}
if (!isDm) {
DropdownMenuItem(
text = { Text(stringRes(R.string.relay_group_threads_title)) },
@@ -308,6 +341,8 @@ fun RelayGroupTopBar(
// Two distinct actions, never conflated: the Messages toggle adds/drops the group
// on my kind-10009 list but keeps my relay membership either way; "Leave" sends
// the kind-9022 that actually removes me. Same split as the channel-invite card.
// (A DM's Messages toggle is the hide/unhide item above — it rides a different
// mechanism and must show even when these membership actions don't.)
//
// Reads the live kind-10009 list rather than assuming the group is on it: this
// bar also opens for channels reached from the workspace browse (a Buzz relay
@@ -335,6 +370,19 @@ fun RelayGroupTopBar(
if (canPop) nav.popBack()
},
)
// Archive/Unarchive (kind-9002 `archived` tag) — a reversible hide-from-the-sidebar,
// Buzz-only and admin-gated like Delete but NOT destructive, so no confirm dialog.
// A DM is never archived (it has its own hide), so this is channels/forums only.
if (isBuzzRelay && !isDm && displayMembership == RelayGroupMembership.ADMIN) {
val archived = channel.isArchived()
DropdownMenuItem(
text = { Text(stringRes(if (archived) R.string.buzz_channel_unarchive else R.string.buzz_channel_archive)) },
onClick = {
menuOpen = false
accountViewModel.archiveRelayGroup(channel, !archived)
},
)
}
// Deleting the whole channel/group (kind-9008) is destructive for everyone, so it's
// shown ONLY to an admin/owner — the same authorization gate as Edit above — and
// routed through a confirmation dialog rather than firing on tap.
@@ -26,7 +26,6 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
@@ -112,29 +111,6 @@ fun RelayGroupChannel.newestTimelineNote(account: Account): Note? =
.sortedByDefaultFeedOrder()
.firstOrNull()
/**
* The pubkeys of the [limit] most-recent distinct posters in this group, newest first — the facepile
* shown on a channel row. One O(notes) pass keeps each author's latest post time, so a chatty author
* counts once (at their newest message) rather than crowding out quieter voices.
*/
fun RelayGroupChannel.recentAuthorHexes(
account: Account,
limit: Int,
): List<HexKey> {
val latestByAuthor = HashMap<HexKey, Long>()
for (note in notes.values()) {
if (!isRelayGroupTimelineMessage(note, account)) continue
val author = note.author?.pubkeyHex ?: continue
val at = note.createdAt() ?: continue
val prev = latestByAuthor[author]
if (prev == null || at > prev) latestByAuthor[author] = at
}
return latestByAuthor.entries
.sortedByDescending { it.value }
.take(limit)
.map { it.key }
}
/** Whether this group's message store holds any acceptable timeline message created after [sinceSecs]. */
private fun RelayGroupChannel.hasChatNewerThan(
account: Account,
@@ -0,0 +1,85 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.theme.ChatLabelMaxWidth
/**
* A tappable chip naming the relay a NIP-29 / Buzz group lives on. A group id is only unique within
* its host relay, so the relay is part of the room's identity — the same `#general` can exist on two
* Buzz servers and only this chip tells them apart.
*
* Wears the same highlighted wash as [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCommunityPill]
* so every "which server / community does this room belong to" chip reads the same way. Unlike the
* muted note-header [com.vitorpamplona.amethyst.commons.ui.note.HeaderPill] (PoW/OTS/location
* markers), this one is a first-class navigation entry point, so it keeps the stronger
* `secondaryContainer` highlight. Shared by the Messages row and the Notifications feed so a Buzz
* message reads the same wherever it surfaces; the width is capped at [ChatLabelMaxWidth] with a
* middle ellipsis so a long host is truncated instead of crowding the channel name out.
*/
@Composable
fun RelayNameChip(
label: String,
onClick: () -> Unit,
) {
Surface(
shape = RoundedCornerShape(6.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.widthIn(max = ChatLabelMaxWidth).clickable(onClick = onClick),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(3.dp),
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
) {
Icon(
symbol = MaterialSymbols.Dns,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(11.dp),
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
)
}
}
}
@@ -20,22 +20,16 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -98,6 +92,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordCommunityHasUnreadFlow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.rememberConcordImageModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.LoadEphemeralChatChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayNameChip
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupChannelLastReadRoute
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupServerHasUnreadFlow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ConcordServerRoomNote
@@ -746,43 +741,6 @@ private fun ConcordServerRoomCompose(
)
}
/**
* A tappable chip naming the server/community a Messages row belongs to. Unlike the muted
* note-header [HeaderPill] (PoW/OTS/location markers), this one is a first-class navigation entry
* point, so it keeps the stronger `secondaryContainer` highlight.
*/
@Composable
private fun RelayNameChip(
label: String,
onClick: () -> Unit,
) {
Surface(
shape = RoundedCornerShape(6.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.widthIn(max = ChatLabelMaxWidth).clickable(onClick = onClick),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(3.dp),
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
) {
Icon(
symbol = MaterialSymbols.Dns,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(11.dp),
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
)
}
}
}
/**
* Renders a Messages row title as the channel name followed by a muted [HeaderPill] naming the room
* type (Public Chat, Marmot Group, ...). The pill mirrors the Concord community chip so every group
@@ -139,7 +139,7 @@ fun MessagesPager(
// same state holder, so the two surfaces cannot disagree.
headerContent =
if (tabs[page].resource == R.string.new_requests) {
{ ChannelInvitesSection(accountViewModel) }
{ ChannelInvitesSection(accountViewModel, nav) }
} else {
null
},
@@ -45,6 +45,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.VideoPostKind
import com.vitorpamplona.amethyst.ui.actions.NewMediaModel
import com.vitorpamplona.amethyst.ui.actions.NewMediaView
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect
@@ -63,6 +64,12 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* The Longs feed's composer. Every *video* posted from here is a NIP-71 kind-21 video — recorded or
* picked alike — so portrait footage lands in the Longs feed too instead of being routed to kind 22
* by its orientation. Images are unaffected: the gallery picker accepts them and they still post as
* NIP-68 kind-20 pictures, which the Longs feed does not read.
*/
@Composable
fun NewLongVideoButton(
accountViewModel: AccountViewModel,
@@ -106,6 +113,7 @@ fun NewLongVideoButton(
postViewModel = postViewModel,
accountViewModel = accountViewModel,
nav = nav,
videoKind = VideoPostKind.NORMAL,
)
}
@@ -21,29 +21,47 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.ui.layouts.NoteComposeLayout
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.DisplayBlankAuthor
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.UserNameRowHeight
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
@@ -60,6 +78,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
@Composable
fun ChannelInvitesSection(
accountViewModel: AccountViewModel,
nav: INav,
modifier: Modifier = Modifier,
) {
val invites by accountViewModel.feedStates.channelInvites.flow
@@ -69,48 +88,110 @@ fun ChannelInvitesSection(
Column(modifier) {
invites.forEach { invite ->
ChannelInviteCard(invite, accountViewModel)
// Keyed by channel: the list is sorted newest-first, so an arriving invite shifts every row
// below it. Without a key Compose matches children by position and each shifted row would
// recompose against a different invite — re-resolving the actor and reloading their avatar.
key(invite.channelId) {
ChannelInviteCard(invite, accountViewModel, nav)
HorizontalDivider(thickness = DividerThickness)
}
}
}
}
/**
* One pending add, drawn as a feed row instead of a floating Material card: the actor is the row's
* author — picture, name and time in the usual note header — and "added you to X" is the row's content,
* so the prompt reads like the reply/mention notifications it sits next to. The three choices take the
* reactions slot, which spans the full width and therefore fits "Add to Messages" without wrapping.
*/
@Composable
fun ChannelInviteCard(
invite: BuzzChannelInvite,
accountViewModel: AccountViewModel,
nav: INav,
) {
val channel = remember(invite.channelId, invite.relay) { LocalCache.getOrCreateRelayGroupChannel(GroupId(invite.channelId, invite.relay)) }
val baseChannel =
remember(invite.channelId, invite.relay) {
LocalCache.getOrCreateRelayGroupChannel(GroupId(invite.channelId, invite.relay))
}
// The channel's own metadata flow, collected directly rather than through `observeChannel`. That
// helper also registers a ChannelFinder query, and every assembler under it is gated on
// `is PublicChatChannel` / `is LiveActivitiesChannel` — a RelayGroupChannel yields no filter at all,
// so the registration buys nothing and only churns the app-wide key set on mount/unmount. The flow
// still fills the name in when the group's kind-39000 lands from the directory subscription, and
// nothing here opens the channel's *message* subscription — holding that back until the viewer
// answers is the whole point of the prompt.
val channelState by
remember(baseChannel) { baseChannel.flow().metadata.stateFlow }
.collectAsStateWithLifecycle()
val channel = channelState.channel as? RelayGroupChannel ?: baseChannel
val actorUser = remember(invite.actor) { invite.actor?.let { LocalCache.getOrCreateUser(it) } }
val actorName = actorUser?.let { observeUserName(it, accountViewModel).value }
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 5.dp),
) {
Column(Modifier.padding(12.dp)) {
Text(
text = stringRes(R.string.channel_invite_title, channel.toBestDisplayName()),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
)
Text(
// A pending invite is by definition unanswered, so it always carries the new-item wash rather than
// fading with a last-read marker: it is a standing question, not a dated event.
val backgroundColor =
MaterialTheme.colorScheme.newItemBackgroundColor
.compositeOver(MaterialTheme.colorScheme.background)
NoteComposeLayout(
modifier =
remember(backgroundColor) {
Modifier.drawBehind { drawRect(backgroundColor) }.fillMaxWidth()
},
authorPicture = {
Box(Size55Modifier, contentAlignment = Alignment.BottomEnd) {
if (actorUser != null) {
UserPicture(actorUser, Size55dp, accountViewModel = accountViewModel, nav = nav)
} else {
DisplayBlankAuthor(Size55dp, accountViewModel = accountViewModel)
}
}
},
firstRow = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Size5dp),
modifier = UserNameRowHeight,
) {
// Who did it matters: the relay reports a self-join with the same event, so naming the
// actor is what tells "I joined this" apart from "a stranger put me here".
text =
stringRes(
R.string.channel_invite_body,
actorName ?: stringRes(R.string.channel_invite_unknown_actor),
invite.relay.displayUrl(),
),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp),
)
if (actorUser != null) {
UsernameDisplay(actorUser, Modifier.weight(1f), accountViewModel = accountViewModel)
} else {
Text(
text = stringRes(R.string.channel_invite_unknown_actor),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
// DottedTight, not Dotted: the row's `spacedBy` already supplies the gap, so the
// dotted variant's own leading space would double it. Same choice the note header makes.
TimeAgo(invite.createdAt, style = TimeAgoStyle.DottedTight)
}
},
secondRow = {},
noteContent = {
Text(text = stringRes(R.string.channel_invite_title, channel.toBestDisplayName()))
Text(
text = invite.relay.displayUrl(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
reactionsRow = {
Row(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = Size10dp),
) {
// Leave is separate from Ignore on purpose: Ignore is a local display choice that leaves
// you in the roster, Leave is the kind-9022 that actually removes you from the channel.
@@ -120,10 +201,12 @@ fun ChannelInviteCard(
TextButton(onClick = { accountViewModel.dismissChannelInvite(invite.channelId) }) {
Text(stringRes(R.string.channel_invite_ignore))
}
// Accepting *is* `addRelayGroupToMessages`, the same call behind the channel top bar's
// "Add to Messages", so it carries that label rather than a second word for one action.
TextButton(onClick = { accountViewModel.acceptChannelInvite(channel) }) {
Text(stringRes(R.string.channel_invite_accept), fontWeight = FontWeight.Bold)
Text(stringRes(R.string.add_to_messages), fontWeight = FontWeight.Bold)
}
}
}
}
},
)
}
@@ -246,7 +246,7 @@ internal fun SingleNotificationsBody(
ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav)
// "X added you to #channel" prompts sit above the feed rather than inside it: they are a
// standing decision, not a dated event, so they must not scroll away into history.
ChannelInvitesSection(accountViewModel)
ChannelInvitesSection(accountViewModel, nav)
},
)
}
@@ -34,12 +34,14 @@ import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
@@ -50,6 +52,7 @@ import com.vitorpamplona.amethyst.ui.actions.NewMediaView
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePicture
import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -63,11 +66,23 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* The picture feed's composer. Images post as NIP-68 kind-20 pictures and land in this feed; a
* video picked from the gallery still posts as a NIP-71 video and shows up in the video feeds
* instead, since the file's mime type — not the host feed — decides picture vs. video.
*
* @param sharedAttachments content URIs handed over by the "New Picture" share target. When
* non-empty the composer opens on them right away, so the share lands as a picture post instead
* of a text note with a link.
* @param sharedCaption text Android sent alongside the files; pre-fills the caption field.
*/
@Composable
fun NewPictureButton(
accountViewModel: AccountViewModel,
nav: INav,
navScrollToTop: () -> Unit,
sharedAttachments: List<String> = emptyList(),
sharedCaption: String? = null,
) {
var isOpen by remember { mutableStateOf(false) }
var wantsToPostFromCamera by remember { mutableStateOf(false) }
@@ -83,6 +98,11 @@ fun NewPictureButton(
}
}
val context = LocalContext.current
LaunchedEffect(sharedAttachments) {
resolveSharedMedia(context, sharedAttachments).takeIf { it.isNotEmpty() }?.let { pickedURIs = it }
}
if (wantsToPostFromCamera) {
TakePicture { uri ->
wantsToPostFromCamera = false
@@ -106,6 +126,7 @@ fun NewPictureButton(
postViewModel = postViewModel,
accountViewModel = accountViewModel,
nav = nav,
initialCaption = sharedCaption.orEmpty(),
)
}
@@ -42,11 +42,15 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.Picture
fun PicturesScreen(
accountViewModel: AccountViewModel,
nav: INav,
attachments: List<String> = emptyList(),
message: String? = null,
) {
PicturesScreen(
picturesFeedContentState = accountViewModel.feedStates.picturesFeed,
accountViewModel = accountViewModel,
nav = nav,
attachments = attachments,
message = message,
)
}
@@ -55,6 +59,8 @@ fun PicturesScreen(
picturesFeedContentState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
attachments: List<String> = emptyList(),
message: String? = null,
) {
WatchLifecycleAndUpdateModel(picturesFeedContentState)
WatchAccountForPicturesScreen(picturesFeedContentState = picturesFeedContentState, accountViewModel = accountViewModel)
@@ -66,8 +72,8 @@ fun PicturesScreen(
PicturesTopBar(accountViewModel, nav)
},
bottomBar = {
AppBottomBar(Route.Pictures, nav, accountViewModel) { route ->
if (route == Route.Pictures) {
AppBottomBar(Route.Pictures(), nav, accountViewModel) { route ->
if (route is Route.Pictures) {
picturesFeedContentState.sendToTop()
} else {
nav.navBottomBar(route)
@@ -76,7 +82,7 @@ fun PicturesScreen(
},
floatingButton = {
FabBottomBarPadded(nav) {
NewPictureButton(accountViewModel, nav, picturesFeedContentState::sendToTop)
NewPictureButton(accountViewModel, nav, picturesFeedContentState::sendToTop, attachments, message)
}
},
accountViewModel = accountViewModel,
@@ -34,22 +34,26 @@ import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.VideoPostKind
import com.vitorpamplona.amethyst.ui.actions.NewMediaModel
import com.vitorpamplona.amethyst.ui.actions.NewMediaView
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideo
import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -63,11 +67,23 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* The Shorts feed's composer. Every *video* posted from here is a NIP-71 kind-22 short — recorded,
* picked or shared alike — so landscape footage lands in the Shorts feed too instead of being
* routed to kind 21 by its orientation. Images are unaffected: the gallery picker accepts them and
* they still post as NIP-68 kind-20 pictures, which the Shorts feed does not read.
*
* @param sharedAttachments content URIs handed over by the "New Short" share target. When non-empty
* the composer opens on them right away.
* @param sharedCaption text Android sent alongside the files; pre-fills the caption field.
*/
@Composable
fun NewShortVideoButton(
accountViewModel: AccountViewModel,
nav: INav,
navScrollToTop: () -> Unit,
sharedAttachments: List<String> = emptyList(),
sharedCaption: String? = null,
) {
var isOpen by remember { mutableStateOf(false) }
var wantsToRecordVideo by remember { mutableStateOf(false) }
@@ -83,6 +99,11 @@ fun NewShortVideoButton(
}
}
val context = LocalContext.current
LaunchedEffect(sharedAttachments) {
resolveSharedMedia(context, sharedAttachments).takeIf { it.isNotEmpty() }?.let { pickedURIs = it }
}
if (wantsToRecordVideo) {
TakeVideo { uri ->
wantsToRecordVideo = false
@@ -106,6 +127,8 @@ fun NewShortVideoButton(
postViewModel = postViewModel,
accountViewModel = accountViewModel,
nav = nav,
videoKind = VideoPostKind.SHORT,
initialCaption = sharedCaption.orEmpty(),
)
}
@@ -42,11 +42,15 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.ShortsFil
fun ShortsScreen(
accountViewModel: AccountViewModel,
nav: INav,
attachments: List<String> = emptyList(),
message: String? = null,
) {
ShortsScreen(
shortsFeedContentState = accountViewModel.feedStates.shortsFeed,
accountViewModel = accountViewModel,
nav = nav,
attachments = attachments,
message = message,
)
}
@@ -55,6 +59,8 @@ fun ShortsScreen(
shortsFeedContentState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
attachments: List<String> = emptyList(),
message: String? = null,
) {
WatchLifecycleAndUpdateModel(shortsFeedContentState)
WatchAccountForShortsScreen(videoFeedState = shortsFeedContentState, accountViewModel = accountViewModel)
@@ -66,8 +72,8 @@ fun ShortsScreen(
ShortsTopBar(accountViewModel, nav)
},
bottomBar = {
AppBottomBar(Route.Shorts, nav, accountViewModel) { route ->
if (route == Route.Shorts) {
AppBottomBar(Route.Shorts(), nav, accountViewModel) { route ->
if (route is Route.Shorts) {
shortsFeedContentState.sendToTop()
} else {
nav.navBottomBar(route)
@@ -76,7 +82,7 @@ fun ShortsScreen(
},
floatingButton = {
FabBottomBarPadded(nav) {
NewShortVideoButton(accountViewModel, nav, shortsFeedContentState::sendToTop)
NewShortVideoButton(accountViewModel, nav, shortsFeedContentState::sendToTop, attachments, message)
}
},
accountViewModel = accountViewModel,
@@ -133,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
@@ -281,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
@@ -331,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
@@ -1133,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)
}
}
}
@@ -34,12 +34,14 @@ import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
@@ -51,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePicture
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideo
import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -64,11 +67,23 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* The Video feed's composer. That feed renders every media kind, so videos keep the automatic kind
* choice (portrait -> NIP-71 kind 22, landscape -> kind 21) and pictures post as NIP-68 kind 20
* all three show up here either way, which is why this is the one media composer that pins nothing.
*
* @param sharedAttachments content URIs handed over by the "New Video" share target. When non-empty
* the composer opens on them right away, so the share lands as a NIP-71 video event instead of a
* text note with a link.
* @param sharedCaption text Android sent alongside the files; pre-fills the caption field.
*/
@Composable
fun NewImageButton(
fun NewVideoFeedButton(
accountViewModel: AccountViewModel,
nav: INav,
navScrollToTop: () -> Unit,
sharedAttachments: List<String> = emptyList(),
sharedCaption: String? = null,
) {
var isOpen by remember { mutableStateOf(false) }
@@ -90,6 +105,11 @@ fun NewImageButton(
}
}
val context = LocalContext.current
LaunchedEffect(sharedAttachments) {
resolveSharedMedia(context, sharedAttachments).takeIf { it.isNotEmpty() }?.let { pickedURIs = it }
}
if (wantsToPostFromCamera) {
TakePicture { uri ->
wantsToPostFromCamera = false
@@ -120,6 +140,7 @@ fun NewImageButton(
postViewModel = postViewModel,
accountViewModel = accountViewModel,
nav = nav,
initialCaption = sharedCaption.orEmpty(),
)
}
@@ -59,11 +59,15 @@ import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
fun VideoScreen(
accountViewModel: AccountViewModel,
nav: INav,
attachments: List<String> = emptyList(),
message: String? = null,
) {
VideoScreen(
accountViewModel.feedStates.videoFeed,
accountViewModel,
nav,
attachments,
message,
)
}
@@ -72,6 +76,8 @@ fun VideoScreen(
videoFeedContentState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
attachments: List<String> = emptyList(),
message: String? = null,
) {
WatchLifecycleAndUpdateModel(videoFeedContentState)
WatchAccountForVideoScreen(videoFeedContentState = videoFeedContentState, accountViewModel = accountViewModel)
@@ -83,8 +89,8 @@ fun VideoScreen(
StoriesTopBar(accountViewModel, nav)
},
bottomBar = {
AppBottomBar(Route.Video, nav, accountViewModel) { route ->
if (route == Route.Video) {
AppBottomBar(Route.Video(), nav, accountViewModel) { route ->
if (route is Route.Video) {
videoFeedContentState.sendToTop()
} else {
nav.navBottomBar(route)
@@ -93,7 +99,7 @@ fun VideoScreen(
},
floatingButton = {
FabBottomBarPadded(nav) {
NewImageButton(accountViewModel, nav, videoFeedContentState::sendToTop)
NewVideoFeedButton(accountViewModel, nav, videoFeedContentState::sendToTop, attachments, message)
}
},
accountViewModel = accountViewModel,
@@ -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>
@@ -2473,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>
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>
+9 -2
View File
@@ -2426,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>
@@ -2574,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>
@@ -2616,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>
@@ -2697,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>
@@ -2769,6 +2772,9 @@
<string name="share_to_dm_title">Send to…</string>
<string name="share_to_dm_start_new">New message</string>
<string name="share_target_as_highlight">New Highlight</string>
<string name="share_target_as_picture">New Picture</string>
<string name="share_target_as_short_video">New Short</string>
<string name="share_target_as_video">New Video</string>
<string name="new_highlight_title">New Highlight</string>
<string name="new_highlight_passage_label">Highlighted text</string>
<string name="new_highlight_passage_placeholder">What stood out to you?</string>
@@ -3607,6 +3613,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>
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class VideoPostKindTest {
private val portrait = DimensionTag(1080, 1920)
private val landscape = DimensionTag(1920, 1080)
private val square = DimensionTag(1080, 1080)
@Test
fun autoFollowsTheOrientation() {
assertTrue(VideoPostKind.AUTO.isShort(portrait))
assertFalse(VideoPostKind.AUTO.isShort(landscape))
// A square video is not taller than it is wide, so it stays a normal video.
assertFalse(VideoPostKind.AUTO.isShort(square))
}
@Test
fun shortStaysShortEvenWhenTheFootageIsLandscape() {
// The Shorts feed only reads kind 22. A landscape video shared to the "New Short" target
// (or recorded from the Shorts composer) has to be a short or it never shows up there.
assertTrue(VideoPostKind.SHORT.isShort(landscape))
assertTrue(VideoPostKind.SHORT.isShort(portrait))
assertTrue(VideoPostKind.SHORT.isShort(square))
}
@Test
fun normalStaysNormalEvenWhenTheFootageIsPortrait() {
assertFalse(VideoPostKind.NORMAL.isShort(portrait))
assertFalse(VideoPostKind.NORMAL.isShort(landscape))
assertFalse(VideoPostKind.NORMAL.isShort(square))
}
}
@@ -21,34 +21,52 @@
package com.vitorpamplona.amethyst.navigation
import com.vitorpamplona.amethyst.ui.navigation.ShareIntentRouting
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import com.vitorpamplona.amethyst.ui.navigation.ShareTarget
import org.junit.Assert.assertEquals
import org.junit.Test
class ShareIntentRoutingTest {
@Test
fun detectsAliasByExactClassName() {
assertTrue(ShareIntentRouting.isShareAsDm("com.vitorpamplona.amethyst.ui.ShareAsDMAlias"))
assertEquals(ShareTarget.DIRECT_MESSAGE, ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.ShareAsDMAlias"))
}
@Test
fun detectsAliasRegardlessOfPackagePrefix() {
// Flavors can change the resolved package prefix; match on the simple name.
assertTrue(ShareIntentRouting.isShareAsDm("com.example.fork.ui.ShareAsDMAlias"))
assertEquals(ShareTarget.DIRECT_MESSAGE, ShareIntentRouting.targetOf("com.example.fork.ui.ShareAsDMAlias"))
}
@Test
fun rejectsMainActivity() {
assertFalse(ShareIntentRouting.isShareAsDm("com.vitorpamplona.amethyst.ui.MainActivity"))
fun detectsEveryAlias() {
assertEquals(ShareTarget.HIGHLIGHT, ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.ShareAsHighlightAlias"))
assertEquals(ShareTarget.PICTURE, ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.ShareAsPictureAlias"))
assertEquals(ShareTarget.SHORT_VIDEO, ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.ShareAsShortVideoAlias"))
assertEquals(ShareTarget.VIDEO, ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.ShareAsVideoAlias"))
}
@Test
fun rejectsNull() {
assertFalse(ShareIntentRouting.isShareAsDm(null))
fun mainActivityIsTheDefaultNewPostTarget() {
assertEquals(ShareTarget.NEW_POST, ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.MainActivity"))
}
@Test
fun nullIsTheDefaultNewPostTarget() {
assertEquals(ShareTarget.NEW_POST, ShareIntentRouting.targetOf(null))
}
@Test
fun rejectsSuffixThatIsNotASimpleName() {
assertFalse(ShareIntentRouting.isShareAsDm("com.evil.XShareAsDMAlias"))
assertEquals(ShareTarget.NEW_POST, ShareIntentRouting.targetOf("com.evil.XShareAsDMAlias"))
assertEquals(ShareTarget.NEW_POST, ShareIntentRouting.targetOf("com.evil.XShareAsVideoAlias"))
}
/**
* "ShareAsShortVideoAlias" also ends with "VideoAlias", so a sloppier match would route every
* short to the plain video composer (kind 21 instead of 22 — the wrong feed).
*/
@Test
fun shortVideoAliasDoesNotCollideWithTheVideoAlias() {
assertEquals(ShareTarget.SHORT_VIDEO, ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.ShareAsShortVideoAlias"))
}
}
@@ -0,0 +1,135 @@
/*
* 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.navigation
import com.vitorpamplona.amethyst.ui.navigation.ShareIntentRouting
import com.vitorpamplona.amethyst.ui.navigation.ShareTarget
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
/**
* Guards the one link in the share-target chain the compiler cannot see: the `<activity-alias>`
* names in AndroidManifest.xml against the `SHARE_AS_*_ALIAS_SIMPLE_NAME` constants that
* [ShareIntentRouting] matches them by. Renaming an alias on one side only still builds and still
* runs — the share just silently opens the wrong composer (a picture share falls through to the
* default "New Post" target and becomes a text note).
*
* Checked in both directions, so neither a renamed constant nor a new alias without a constant
* slips through.
*/
class ShareTargetManifestTest {
private val aliasPattern = Regex("""<activity-alias[^>]*android:name="\.ui\.(\w+)"""", RegexOption.DOT_MATCHES_ALL)
private fun manifest(): String {
// Gradle runs unit tests with the module directory as the working directory; fall back to
// the repo root so the test also passes when run from an IDE configured that way.
val candidates =
listOf(
File("src/main/AndroidManifest.xml"),
File("amethyst/src/main/AndroidManifest.xml"),
)
val found = candidates.firstOrNull { it.isFile }
assertTrue(
"Could not locate AndroidManifest.xml from ${File(".").absolutePath}",
found != null,
)
return found!!.readText()
}
private fun declaredAliases(): Set<String> =
aliasPattern
.findAll(manifest())
.map { it.groupValues[1] }
.toSet()
@Test
fun everyRoutedAliasIsDeclaredInTheManifest() {
val declared = declaredAliases()
val expected =
listOf(
ShareIntentRouting.SHARE_AS_DM_ALIAS_SIMPLE_NAME,
ShareIntentRouting.SHARE_AS_HIGHLIGHT_ALIAS_SIMPLE_NAME,
ShareIntentRouting.SHARE_AS_PICTURE_ALIAS_SIMPLE_NAME,
ShareIntentRouting.SHARE_AS_SHORT_VIDEO_ALIAS_SIMPLE_NAME,
ShareIntentRouting.SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME,
)
expected.forEach {
assertTrue(
"ShareIntentRouting routes \"$it\" but AndroidManifest.xml declares no such " +
"<activity-alias android:name=\".ui.$it\">. Declared: $declared",
it in declared,
)
}
}
@Test
fun everyDeclaredAliasRoutesToItsOwnTarget() {
declaredAliases().forEach { alias ->
val target = ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.$alias")
assertTrue(
"AndroidManifest.xml declares <activity-alias android:name=\".ui.$alias\"> but " +
"ShareIntentRouting has no constant for it, so shares to it fall through to " +
"the default New Post composer.",
target != ShareTarget.NEW_POST,
)
}
}
@Test
fun aliasesResolveToDistinctTargets() {
val aliases = declaredAliases()
val targets = aliases.map { ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.$it") }
assertEquals(
"Two aliases resolve to the same ShareTarget — one of them is shadowed by a " +
"suffix match. Aliases: $aliases, targets: $targets",
targets.size,
targets.toSet().size,
)
}
/** The media targets are the only ones that accept a multi-file selection. */
@Test
fun mediaAliasesAcceptSendMultiple() {
val text = manifest()
listOf(
ShareIntentRouting.SHARE_AS_PICTURE_ALIAS_SIMPLE_NAME,
ShareIntentRouting.SHARE_AS_SHORT_VIDEO_ALIAS_SIMPLE_NAME,
ShareIntentRouting.SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME,
).forEach { alias ->
val block =
text
.substringAfter("android:name=\".ui.$alias\"")
.substringBefore("</activity-alias>")
assertTrue(
"The \".ui.$alias\" share target has no SEND_MULTIPLE intent-filter, so sharing " +
"several files at once will not offer it.",
block.contains("android.intent.action.SEND_MULTIPLE"),
)
}
}
}
@@ -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)
}
}
@@ -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>
@@ -178,6 +178,9 @@ class RelayGroupChannel(
fun isPrivate(): Boolean = event?.isPrivate() ?: false
/** Buzz-only: the relay has archived this channel (hidden from the sidebar, but not deleted). */
fun isArchived(): Boolean = event?.isArchived() ?: false
fun isRestricted(): Boolean = event?.isRestricted() ?: false
fun isClosed(): Boolean = event?.isClosed() ?: false
@@ -0,0 +1,75 @@
/*
* 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.commons.model.nip29RelayGroups
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* The set of NIP-29 relay-group channels this device has **deleted** (kind-9008), keyed by
* [GroupId.toKey] (`id@relay`, so a group is scoped to its host relay — group ids are only
* unique per relay).
*
* A delete is terminal and destroys the group for everyone: the relay drops it and stops serving
* its kind-39000 metadata. But the client already holds that metadata in `LocalCache`, and a Buzz
* relay may keep re-announcing a stale kind-44100 member-added notification that would re-surface
* the channel in the community's browse list — after a restart too, since it's re-fetched from the
* relay. So the deletion has to be remembered client-side and the channel filtered out everywhere
* the list is built.
*
* Like [BuzzChannelStars], there is no personal Nostr event for "I deleted this from my view", so
* this is a process-wide singleton mirrored to a device-global store by the platform
* ([com.vitorpamplona.amethyst] `RelayGroupDeletionPreferences`) and restored at startup. Deleting
* is authoritative and terminal, so an entry is only ever added, never removed.
*/
object RelayGroupDeletions {
private val deleted = MutableStateFlow<Set<String>>(emptySet())
/** The deleted group keys ([GroupId.toKey]); the community view collects this to hide them. */
val flow: StateFlow<Set<String>> = deleted
fun isDeleted(groupKey: String): Boolean = groupKey in deleted.value
fun isDeleted(groupId: GroupId): Boolean = isDeleted(groupId.toKey())
/** Record [groupId] as deleted (idempotent). */
fun markDeleted(groupId: GroupId) = markDeleted(groupId.toKey())
/** Record [groupKey] ([GroupId.toKey]) as deleted (idempotent). */
fun markDeleted(groupKey: String) {
while (true) {
val current = deleted.value
if (groupKey in current) return
if (deleted.compareAndSet(current, current + groupKey)) return
}
}
/** Replaces the whole set — used to restore from disk at startup. */
fun restore(keys: Set<String>) {
deleted.value = keys
}
/** Test-only: clears the set so unit tests don't leak state into each other. */
fun clearForTesting() {
deleted.value = emptySet()
}
}
@@ -0,0 +1,87 @@
/*
* 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.commons.model.nip29RelayGroups
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* The device-global "deleted channels" bookkeeping: a delete is relay-scoped (keyed by
* [GroupId.toKey]) and terminal (only ever added), so the same id on a different relay stays visible.
*/
class RelayGroupDeletionsTest {
private val relayA = RelayUrlNormalizer.normalize("wss://a.example.com")
private val relayB = RelayUrlNormalizer.normalize("wss://b.example.com")
private val gid = "0123456789abcdef"
@BeforeTest
fun reset() = RelayGroupDeletions.clearForTesting()
@AfterTest
fun tearDown() = RelayGroupDeletions.clearForTesting()
@Test
fun marksAChannelDeletedAndReflectsInTheFlow() {
val group = GroupId(gid, relayA)
assertFalse(RelayGroupDeletions.isDeleted(group))
RelayGroupDeletions.markDeleted(group)
assertTrue(RelayGroupDeletions.isDeleted(group))
assertTrue(RelayGroupDeletions.isDeleted(group.toKey()))
assertEquals(setOf(group.toKey()), RelayGroupDeletions.flow.value)
}
@Test
fun deletionIsRelayScoped() {
RelayGroupDeletions.markDeleted(GroupId(gid, relayA))
// The same group id on a different host relay is a different group, so it stays visible.
assertTrue(RelayGroupDeletions.isDeleted(GroupId(gid, relayA)))
assertFalse(RelayGroupDeletions.isDeleted(GroupId(gid, relayB)))
}
@Test
fun markingIsIdempotent() {
val group = GroupId(gid, relayA)
RelayGroupDeletions.markDeleted(group)
RelayGroupDeletions.markDeleted(group)
assertEquals(1, RelayGroupDeletions.flow.value.size)
}
@Test
fun restoreReplacesTheWholeSet() {
RelayGroupDeletions.markDeleted(GroupId(gid, relayA))
val restored = setOf(GroupId("aaaa", relayB).toKey(), GroupId("bbbb", relayB).toKey())
RelayGroupDeletions.restore(restored)
assertEquals(restored, RelayGroupDeletions.flow.value)
assertFalse(RelayGroupDeletions.isDeleted(GroupId(gid, relayA)))
}
}
@@ -31,6 +31,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.After
@@ -38,6 +39,9 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
/**
* Regression tests for PR #3483 review findings on FeedMetadataCoordinator:
@@ -73,34 +77,78 @@ class FeedMetadataCoordinatorTest {
private fun pubkey(seed: Int): HexKey = seed.toString(16).padStart(64, '0')
/**
* Suspends until every coroutine the coordinator launched into [scope] has finished.
*
* This is what makes the assertions deterministic. The coordinator does its work in
* `scope.launch { subscribe(); gate.awaitAll(timeout); unsubscribe(); promote-or-roll-back }`,
* so "has call 1 finished?" is a question about the job tree, not about the clock. The tests
* used to answer it with `delay(timeoutMs + margin)` and assert straight after — which holds
* only while the dispatcher is free to start that coroutine promptly. On a loaded runner
* (macOS CI, 1431 tests in the same module) the launch itself can be queued past the margin,
* the roll-back lands late, the next call short-circuits, and the assertion fails on a
* perfectly healthy coordinator. Waiting on the jobs removes the margin entirely.
*/
private suspend fun awaitCoordinatorIdle(timeoutMs: Long = 30_000) {
val deadline = System.currentTimeMillis() + timeoutMs
while (scope.coroutineContext.job.children
.any { it.isActive }
) {
check(System.currentTimeMillis() < deadline) { "coordinator work did not settle within ${timeoutMs}ms" }
delay(2)
}
}
/** Polls [predicate] to a deadline. For preconditions we cannot express as job completion. */
private suspend fun waitUntil(
message: String,
timeoutMs: Long = 30_000,
predicate: () -> Boolean,
) {
val deadline = System.currentTimeMillis() + timeoutMs
while (!predicate()) {
check(System.currentTimeMillis() < deadline) { "timed out waiting for: $message" }
delay(2)
}
}
/**
* Fake client that captures subscribe/unsubscribe and lets the test
* drive EOSE notifications on any dispatcher we choose.
*
* Every collection here is written from the coordinator's coroutines (`Dispatchers.Default`,
* and `Dispatchers.IO` for the concurrent-EOSE test) and read from the test thread, so plain
* `mutableMapOf`/`mutableListOf` were two more races: 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 here, so the fake must not be the weak link.
*/
private class ControllableClient(
private val delegate: INostrClient = EmptyNostrClient(),
) : INostrClient by delegate {
val subscriptions = mutableMapOf<String, SubscriptionListener?>()
val subscribeCalls = mutableListOf<Map<NormalizedRelayUrl, List<Filter>>>()
var unsubscribeCallCount = 0
private set
val subscriptions = ConcurrentHashMap<String, SubscriptionListener>()
val subscribeCalls: MutableList<Map<NormalizedRelayUrl, List<Filter>>> =
Collections.synchronizedList(mutableListOf())
private val unsubscribes = AtomicInteger(0)
val unsubscribeCallCount: Int get() = unsubscribes.get()
override fun subscribe(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: SubscriptionListener?,
) {
subscriptions[subId] = listener
listener?.let { subscriptions[subId] = it }
subscribeCalls.add(filters)
}
override fun unsubscribe(subId: String) {
subscriptions.remove(subId)
unsubscribeCallCount++
unsubscribes.incrementAndGet()
}
fun fireEose(relay: NormalizedRelayUrl) {
subscriptions.values.filterNotNull().forEach { it.onEose(relay, forFilters = null) }
// Snapshot: a coordinator coroutine may unsubscribe concurrently.
subscriptions.values.toList().forEach { it.onEose(relay, forFilters = null) }
}
}
@@ -119,13 +167,13 @@ class FeedMetadataCoordinatorTest {
// Call 1 — no relay EOSEs; must time out.
coordinator.loadKind3Batched(pubkeys, timeoutMs = 200)
delay(350) // exceed the timeout
awaitCoordinatorIdle() // call 1 timed out and rolled back
// Call 2 — the same pubkeys must be re-subscribed since call 1
// never got a successful EOSE. The old code would silently
// short-circuit here.
coordinator.loadKind3Batched(pubkeys, timeoutMs = 200)
delay(50) // let the launcher run
awaitCoordinatorIdle()
assertEquals(
"Zero-EOSE timeout must not permanently dedup pubkeys",
@@ -158,13 +206,13 @@ class FeedMetadataCoordinatorTest {
val pubkeys = listOf(pubkey(1), pubkey(2))
coordinator.loadKind3Batched(pubkeys, timeoutMs = 1_000)
// Give the launcher time to register the listener before we fire.
delay(50)
// The listener must be registered before we fire, or the EOSEs go nowhere.
waitUntil("subscription registered") { client.subscriptions.isNotEmpty() }
indexRelays.forEach(client::fireEose)
delay(200) // let the coordinator finish + promote to queued
awaitCoordinatorIdle() // coordinator finished + promoted to queued
coordinator.loadKind3Batched(pubkeys, timeoutMs = 200)
delay(50)
awaitCoordinatorIdle()
assertEquals(
"Successful call must dedup subsequent identical calls",
@@ -187,13 +235,13 @@ class FeedMetadataCoordinatorTest {
val pubkeys = listOf(pubkey(1))
coordinator.loadKind3Batched(pubkeys, timeoutMs = 300)
delay(30)
waitUntil("subscription registered") { client.subscriptions.isNotEmpty() }
// Only 1 of 3 EOSEs — timeout still fires but we made progress.
client.fireEose(relay1)
delay(400)
awaitCoordinatorIdle()
coordinator.loadKind3Batched(pubkeys, timeoutMs = 200)
delay(50)
awaitCoordinatorIdle()
assertEquals(
"≥1 EOSE = progress = promote to queued (avoid re-asking)",
@@ -222,7 +270,7 @@ class FeedMetadataCoordinatorTest {
)
coordinator.loadKind3Batched(listOf(pubkey(1)), timeoutMs = 2_000)
delay(50) // wait for subscription
waitUntil("subscription registered") { client.subscriptions.isNotEmpty() }
// Fire EOSEs concurrently from many dispatchers.
val jobs =
@@ -235,9 +283,9 @@ class FeedMetadataCoordinatorTest {
// The 2nd call must short-circuit — every relay EOSE'd, so
// pubkey(1) is now in queuedKind3Pubkeys.
delay(100)
awaitCoordinatorIdle()
coordinator.loadKind3Batched(listOf(pubkey(1)), timeoutMs = 200)
delay(50)
awaitCoordinatorIdle()
assertEquals(
"Under concurrent EOSE from all relays, aggregator must reach target",
@@ -261,10 +309,10 @@ class FeedMetadataCoordinatorTest {
// Call 1 — zero EOSE, timeout.
coordinator.loadMetadataBatched(pubkeys, timeoutMs = 200)
delay(350)
awaitCoordinatorIdle()
// Call 2 — must re-subscribe.
coordinator.loadMetadataBatched(pubkeys, timeoutMs = 200)
delay(50)
awaitCoordinatorIdle()
assertTrue(
"Metadata batch also retries on zero-EOSE timeout",
@@ -284,13 +332,13 @@ class FeedMetadataCoordinatorTest {
)
coordinator.loadKind3Batched(listOf(pubkey(1)), timeoutMs = 200)
delay(50)
waitUntil("subscription registered") { client.subscriptions.isNotEmpty() }
// clear() must drop the in-flight tracker even mid-request.
coordinator.clear()
delay(300) // let call 1 finish + roll back
awaitCoordinatorIdle() // call 1 finished + rolled back
coordinator.loadKind3Batched(listOf(pubkey(1)), timeoutMs = 200)
delay(50)
awaitCoordinatorIdle()
assertTrue(client.subscribeCalls.size >= 2)
}
+12 -8
View File
@@ -78,6 +78,18 @@
"tag": "v1.13.1",
"since": "2026-07-28T23:17:02-04:00",
"translators": [
{
"user": "Bardesss",
"languages": [
"Dutch"
]
},
{
"user": "maxblake2015",
"languages": [
"Polish"
]
},
{
"user": "vitorpamplona",
"languages": [
@@ -125,10 +137,6 @@
"user": "vazw",
"languages": []
},
{
"user": "Bardesss",
"languages": []
},
{
"user": "anthony-robin",
"languages": []
@@ -281,10 +289,6 @@
"user": "hypnotichemionus4",
"languages": []
},
{
"user": "maxblake2015",
"languages": []
},
{
"user": "eiie7",
"languages": []
@@ -89,7 +89,13 @@ data class ConcordCommunityState(
ownerPubKey: String,
floors: Map<String, EntityFloor> = emptyMap(),
): Map<String, EntityFloor> {
val pool = EditionFold.admissible(editions, floors)
// This call folds exactly one epoch's editions, so they ARE the snapshot: an entity
// appearing here has been re-wrapped into this epoch and must anchor on version, not
// on a `prev` that necessarily dangles back into the epoch the floor came from. The
// known heads `admissible` re-seats are not in the set, so an entity this epoch never
// mentioned correctly keeps chain-walk semantics.
val snapshot = editions.mapTo(HashSet(editions.size)) { it.rumorId }
val pool = EditionFold.admissible(editions, floors, snapshot = snapshot)
val authority = AuthorityResolver.resolve(pool, ownerPubKey)
val out = HashMap<String, EntityFloor>(floors)
for ((kind, list) in pool.groupBy { it.entityKind }) {
@@ -97,7 +103,7 @@ data class ConcordCommunityState(
// Gate the CANDIDATES, don't pre-filter the chain: a rejected edition mid-chain must
// stay inert instead of orphaning the authorized editions above it (EditionFold.candidates).
val heads =
EditionFold.foldGated(list, floors) {
EditionFold.foldGated(list, floors, snapshot = snapshot) {
authority.isOwner(it.author) || (bit != null && authority.hasPermission(it.author, bit))
}
for ((entity, head) in heads) {
@@ -119,10 +125,15 @@ data class ConcordCommunityState(
// authority chains, the per-kind gated folds), so the anti-rollback floor is applied
// once, up front, on the shared pool: a rolled-back edition is never seen by any of
// them, and the head we already folded is re-seated so the entity keeps its state.
@Suppress("NAME_SHADOWING")
val editions = EditionFold.admissible(editions, floors)
// The editions handed in are one epoch's — the current one — so they are the
// snapshot that selects the compaction arm. Captured BEFORE `admissible` re-seats
// known heads from older epochs, which must not be mistaken for this epoch's.
val snapshot = editions.mapTo(HashSet(editions.size)) { it.rumorId }
val heads = EditionFold.fold(editions, floors).values
@Suppress("NAME_SHADOWING")
val editions = EditionFold.admissible(editions, floors, snapshot = snapshot)
val heads = EditionFold.fold(editions, floors, snapshot = snapshot).values
// Resolve authority from the FULL edition set (not the structural heads): the resolver
// folds each role/grant chain through authorized editions only, so a rogue higher-version
// edition can't supersede a legit one before authority is even judged.
@@ -142,7 +153,7 @@ data class ConcordCommunityState(
kind: ControlEntityKind,
bit: Int,
): Map<String, ControlEdition> =
EditionFold.foldGated(editions.filter { it.entityKind == kind }, floors) {
EditionFold.foldGated(editions.filter { it.entityKind == kind }, floors, snapshot = snapshot) {
authority.isOwner(it.author) || authority.hasPermission(it.author, bit)
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.concord.cord04Roles
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.concurrent.ConcurrentSet
/**
* The anti-rollback floor for one Control Plane entity: the [version] and
@@ -97,16 +98,61 @@ typealias GapReporter = (entityIdHex: String, floorVersion: Long, offeredVersion
object EditionFold {
private const val TAG = "ConcordEditionFold"
/**
* Rollback refusals already reported by [LOG_GAP], keyed by the exact refusal
* (entity + floor + offered version) rather than by entity, so a *new* rollback
* attempt against the same entity is still reported.
*
* Bounded in practice: a key is only ever added when an entity is offered a chain
* it already refused at a version pair it has not seen, which happens once per real
* rotation, not once per fold. Deliberately never cleared and deliberately
* fail-open — see [LOG_GAP].
*/
private val reportedGaps = ConcurrentSet<String>()
/** Above this many distinct refusals, stop deduplicating and just warn. See [LOG_GAP]. */
private const val MAX_TRACKED_GAPS = 4096
/**
* The default [GapReporter]: a rollback refusal is security-relevant (a rotator
* tried to revert an entity), so it is warned, never swallowed.
*
* Warned **once per distinct refusal**, though. Amethyst re-folds the whole buffer
* from scratch on every control-plane change, so an entity stuck refusing a rollback
* re-reports the identical refusal on every refold — 22 byte-identical warnings in an
* 80s cold start, which reads as 22 attacks rather than one unchanged state. The
* dedup is on the message's own contents, so nothing a reader could act on is lost.
*
* Fails **open**: past [MAX_TRACKED_GAPS] distinct refusals it reverts to warning
* every time. A flood of distinct rollback attempts is exactly when the warnings
* matter most, so the failure mode is a noisy log, never a silenced one.
*/
val LOG_GAP: GapReporter = { entityIdHex, floorVersion, offeredVersion ->
Log.w(TAG) {
"Control-plane rollback refused for entity $entityIdHex: already folded v$floorVersion, offered chain tops out at v$offeredVersion and does not connect to it"
val firstReport =
reportedGaps.size() >= MAX_TRACKED_GAPS ||
reportedGaps.add("$entityIdHex@$floorVersion<-$offeredVersion")
if (firstReport) {
Log.w(TAG) {
"Control-plane rollback refused for entity $entityIdHex: already folded v$floorVersion, offered chain tops out at v$offeredVersion and does not connect to it"
}
}
}
/**
* The compaction-era head: the highest-version edition at or above [floorVersion], ties
* broken by the lower rumor id — no `prev`, no hash, no contiguity. See the compaction arm
* in [foldEntity] for why version is the right (and only sound) anchor behind a Refounding.
* Null when nothing at or above the floor was offered, which is the only gap this arm has.
*/
private fun bootstrapHead(
editions: List<ControlEdition>,
floorVersion: Long,
): ControlEdition? =
editions
.filter { it.version >= floorVersion }
.minWithOrNull(compareByDescending<ControlEdition> { it.version }.thenBy { it.rumorId })
/**
* Groups mixed [editions] by entity id and folds each to its head, honoring the
* per-entity anti-rollback [floors] (keyed by [ControlEdition.entityIdHex]).
@@ -119,12 +165,13 @@ object EditionFold {
fun fold(
editions: Collection<ControlEdition>,
floors: Map<String, EntityFloor> = emptyMap(),
snapshot: Set<String>? = null,
onGap: GapReporter = LOG_GAP,
): Map<String, ControlEdition> {
val byEntity = editions.groupBy { it.entityIdHex }
val out = HashMap<String, ControlEdition>(byEntity.size)
for ((entity, list) in byEntity) {
foldEntity(list, floors[entity], onGap)?.let { out[entity] = it }
foldEntity(list, floors[entity], snapshot, onGap)?.let { out[entity] = it }
}
return out
}
@@ -134,18 +181,44 @@ object EditionFold {
*
* With no [floor] this is the fresh-joiner fold: genesis-anchored, falling back
* to the lowest-version edition present (the compaction bootstrap). With a
* [floor] the walk is anchored at the exact edition already folded; if that
* edition is not among [editions] the chain is **gapped** and nothing above the
* floor is adopted — [EntityFloor.known] is kept instead (or null when we no
* longer hold it). A head is therefore never below the floor version.
* [floor] the walk is anchored at the edition already folded **or** at its
* immediate successor when that successor cites the floor's hash; failing both
* the chain is **gapped** and nothing above the floor is adopted —
* [EntityFloor.known] is kept instead (or null when we no longer hold it). A head
* is therefore never below the floor version.
*
* [snapshot] is the set of [ControlEdition.rumorId]s belonging to the epoch being
* folded, supplied once a community has Refounded. An entity present in it takes
* the version-anchored compaction arm instead of the chain walk — see the arm
* itself for why. Null (the default) keeps the pure chain walk, which is right for
* a single-epoch fold and for every caller that has no epoch to speak of.
*/
fun foldEntity(
editions: List<ControlEdition>,
floor: EntityFloor? = null,
snapshot: Set<String>? = null,
onGap: GapReporter = LOG_GAP,
): ControlEdition? {
if (editions.isEmpty()) return floor?.known
// Compaction arm (CORD-06 §3). Once this entity has been re-wrapped into the epoch
// being folded, `prev` chaining across the epoch boundary is meaningless: a Refounding
// trims history and re-wraps the head alone, so the predecessor our floor names stays
// behind on the older epoch and every offered `prev` dangles by design. Anchor on
// VERSION instead — a re-wrap preserves the original author's signature but cannot
// raise the version inside the signed seal, so a re-served stale edition always loses
// to the compacted head. Presence of the entity in the snapshot selects the ARM;
// version selects the HEAD, over every edition we hold and not just the subset.
if (floor != null && snapshot != null && editions.any { it.rumorId in snapshot }) {
return bootstrapHead(editions, floor.version)
?: run {
// Nothing at or above the floor was served: the head we already accepted
// vanished from the offered set — withheld, so fail closed.
onGap(editions[0].entityIdHex, floor.version, editions.maxOf { it.version })
floor.known
}
}
// Index editions by version, keeping the tie-break winner where several
// share a version (lower rumor id wins).
val byVersion = HashMap<Long, MutableList<ControlEdition>>()
@@ -153,12 +226,25 @@ object EditionFold {
var head =
if (floor != null) {
// Anchored at what we already folded: the offered set MUST contain that exact
// edition (same version AND same hash — a same-version sibling is a fork, not
// our chain). Failing that, refuse to move at all rather than accept an
// unverifiable jump; walking up from the floor also makes a head below the
// floor version structurally impossible.
editions.firstOrNull { it.version == floor.version && it.hashHex == floor.hashHex }
// Anchored at what we already folded. Below the floor is history we absorbed, so
// the anchor is the lowest offered version at or above it, and only two shapes
// connect: that edition IS the floor (same version AND hash — a same-version
// sibling is a fork, not our chain), or it is the floor's immediate successor and
// cites the floor's hash. The latter is the ordinary cross-epoch shape and is a
// STRONGER proof of connection than mere presence. Anything else is a jump we
// refuse; walking up from the anchor also makes a head below the floor version
// structurally impossible.
val lowest = byVersion.keys.filter { it >= floor.version }.minOrNull()
val winner = lowest?.let { v -> byVersion[v]?.minByOrNull { it.rumorId } }
val anchor =
when {
winner == null -> null
lowest == floor.version -> winner.takeIf { it.hashHex == floor.hashHex }
lowest == floor.version + 1 ->
winner.takeIf { it.prevHash != null && it.prevHash.toHexKey() == floor.hashHex }
else -> null
}
anchor
?: run {
onGap(editions[0].entityIdHex, floor.version, editions.maxOf { it.version })
return floor.known
@@ -222,12 +308,21 @@ object EditionFold {
fun candidates(
editions: List<ControlEdition>,
floor: EntityFloor? = null,
snapshot: Set<String>? = null,
onGap: GapReporter = LOG_GAP,
): List<ControlEdition> {
val head = foldEntity(editions, floor, onGap) ?: return emptyList()
// Ask the fold whether it gapped rather than re-deriving the condition here: with the
// compaction arm and the successor anchor there are three ways to connect, and a second
// copy of that test is a bug waiting to drift out of sync with the first.
var gapped = false
val head =
foldEntity(editions, floor, snapshot) { e, f, o ->
gapped = true
onGap(e, f, o)
} ?: return emptyList()
// A gap re-seated the known head: nothing from the offered set is admissible above
// the floor, so the known edition is the only candidate.
if (floor != null && editions.none { it.version == floor.version && it.hashHex == floor.hashHex }) {
if (floor != null && gapped) {
return listOf(head)
}
val out = ArrayList<ControlEdition>(editions.size)
@@ -247,9 +342,10 @@ object EditionFold {
fun foldEntityGated(
editions: List<ControlEdition>,
floor: EntityFloor? = null,
snapshot: Set<String>? = null,
onGap: GapReporter = LOG_GAP,
gate: (ControlEdition) -> Boolean,
): ControlEdition? = candidates(editions, floor, onGap).firstOrNull(gate)
): ControlEdition? = candidates(editions, floor, snapshot, onGap).firstOrNull(gate)
/**
* Groups mixed [editions] by entity id and folds each to the highest-priority
@@ -258,13 +354,14 @@ object EditionFold {
fun foldGated(
editions: Collection<ControlEdition>,
floors: Map<String, EntityFloor> = emptyMap(),
snapshot: Set<String>? = null,
onGap: GapReporter = LOG_GAP,
gate: (ControlEdition) -> Boolean,
): Map<String, ControlEdition> {
val byEntity = editions.groupBy { it.entityIdHex }
val out = HashMap<String, ControlEdition>(byEntity.size)
for ((entity, list) in byEntity) {
foldEntityGated(list, floors[entity], onGap, gate)?.let { out[entity] = it }
foldEntityGated(list, floors[entity], snapshot, onGap, gate)?.let { out[entity] = it }
}
return out
}
@@ -285,6 +382,7 @@ object EditionFold {
fun admissible(
editions: Collection<ControlEdition>,
floors: Map<String, EntityFloor>,
snapshot: Set<String>? = null,
onGap: GapReporter = LOG_GAP,
): List<ControlEdition> {
if (floors.isEmpty()) return editions.toList()
@@ -298,7 +396,12 @@ object EditionFold {
out.addAll(list)
continue
}
if (list.any { it.version == floor.version && it.hashHex == floor.hashHex }) {
// Same three-way connection test as the fold, asked of the fold itself so the two
// can't drift apart: present at the floor, a successor citing it, or the compaction
// arm's version anchor.
var gapped = false
foldEntity(list, floor, snapshot) { _, _, _ -> gapped = true }
if (!gapped) {
out.addAll(list)
continue
}
@@ -88,27 +88,41 @@ class MetadataEvent(
} ?: ""
fun contactMetadataJson() =
try {
Json.parseToJsonElement(content) as JsonObject
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("MetadataEvent") { "Content Parse Error: ${toNostrUri()} ${e.message}" }
null
if (content.isBlank()) {
// An empty kind-0 is a valid, empty profile — not a parse failure.
EMPTY_METADATA_JSON
} else {
try {
Json.parseToJsonElement(content) as JsonObject
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("MetadataEvent") { "Content Parse Error: ${toNostrUri()} ${e.message}" }
null
}
}
fun contactMetaData() =
try {
JsonMapper.fromJson<UserMetadata>(content)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("MetadataEvent") { "Content Parse Error: ${toNostrUri()} ${e.message}" }
null
if (content.isBlank()) {
// Users do publish kind 0s with an empty content to wipe their
// profile. That must replace the previous metadata with a blank
// one, not be discarded as an error.
UserMetadata()
} else {
try {
JsonMapper.fromJson<UserMetadata>(content)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("MetadataEvent") { "Content Parse Error: ${toNostrUri()} ${e.message}" }
null
}
}
companion object {
const val KIND = 0
const val FIXED_D_TAG = ""
private val EMPTY_METADATA_JSON = JsonObject(emptyMap())
fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG)
fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, FIXED_D_TAG, null)
@@ -29,6 +29,7 @@ import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.nullable
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
@@ -44,8 +45,10 @@ import kotlinx.serialization.json.booleanOrNull
* profile (name, picture, about…).
*
* This serializer accepts any JSON primitive (matching the pre-existing lenient
* behavior, where a bare number or boolean decodes into a string field) and
* treats objects, arrays, and JSON null as absent rather than failing.
* behavior, where a bare number or boolean decodes into a string field), unwraps
* a single-element array wrapping a primitive (seen in the wild as
* `"nip05":["name@domain"]`, where the intended value is unambiguous), and treats
* objects, longer arrays, and JSON null as absent rather than failing.
*
* Same rationale as [BirthdayTolerantSerializer].
*/
@@ -60,6 +63,17 @@ object TolerantStringSerializer : KSerializer<String?> {
return when {
element is JsonNull -> null
element is JsonPrimitive -> element.content
// A one-element array around a primitive has only one possible reading,
// so recover the value instead of dropping the field.
element is JsonArray -> {
val single = element.singleOrNull()
if (single is JsonPrimitive && single !is JsonNull) {
single.content
} else {
Log.w("TolerantStringSerializer") { "Ignoring array string field with ${element.size} elements" }
null
}
}
else -> {
// Log the JSON kind only, not the raw (untrusted, network-sourced) value.
Log.w("TolerantStringSerializer") { "Ignoring non-primitive string field (${element::class.simpleName})" }
@@ -101,12 +101,20 @@ class RelayLogger(
Log.d(logTag(relay.url), "Disconnected")
}
/**
* Debug, not error: under the outbox model a client dials hundreds of relays per boot,
* and a large share of them are dead, unreachable, or refused by the local Tor proxy.
* One line per failed socket is a few hundred lines that say the same four things
* ("SOCKS: TTL expired", "SSLHandshakeException", "Host unreachable", …) and cannot be
* acted on individually. The actionable form is the aggregate — failures bucketed by
* cause, which the consumer's boot census already reports.
*/
override fun onCannotConnect(
relay: IRelayClient,
errorMessage: String,
) {
super.onCannotConnect(relay, errorMessage)
Log.e(logTag(relay.url), errorMessage)
Log.d(logTag(relay.url), errorMessage)
}
}
@@ -57,6 +57,14 @@ class GroupMetadataEvent(
fun picture() = tags.firstTagValue("picture")
/**
* Buzz-only: whether the relay has marked this channel **archived** — a hide-from-the-sidebar
* state, distinct from a delete (the channel and its history live on). The Buzz relay stamps an
* `["archived","true"]` tag onto the 39000 for an archived channel and clients hide it; a plain
* NIP-29 relay has no such concept, so this is false there.
*/
fun isArchived() = tags.firstTagValue("archived") == "true"
/**
* Topic hashtags (`t` tags) the relay advertises for this group, used by the
* discovery feed's hashtag filter. NIP-29 doesn't define these; a group only
@@ -82,6 +82,12 @@ class EditMetadataEvent(
parent: String? = null,
children: List<String> = emptyList(),
previousEvents: List<String> = emptyList(),
// Buzz-only channel-settings tags (a Buzz relay reads these on 9002; a plain NIP-29 relay
// ignores them). [visibility] is "open"/"private" — Buzz's own vocabulary, which it reads
// instead of the NIP-29 `private` status flag, so editing visibility on a Buzz channel needs
// this tag to take. [archived] toggles the channel's archived state ("true"/"false").
visibility: String? = null,
archived: Boolean? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<EditMetadataEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
@@ -90,6 +96,8 @@ class EditMetadataEvent(
about?.let { add(arrayOf("about", it)) }
picture?.let { add(arrayOf("picture", it)) }
status.forEach { add(arrayOf(it.code)) }
visibility?.let { add(arrayOf("visibility", it)) }
archived?.let { add(arrayOf("archived", if (it) "true" else "false")) }
addAll(HashtagTag.assemble(hashtags))
// Mip-map each geohash into every prefix so a coarser followed geohash still matches.
geohashes.forEach { addAll(GeoHashTag.assemble(it).toList()) }
@@ -110,6 +110,84 @@ class EditionFoldFloorTest {
assertEquals(v3.rumorId, head?.rumorId)
}
/**
* The cross-epoch case, observed on device: a CORD-06 Refounding re-wraps ONE edition per
* entity, so an entity edited *after* the refounding has its successor alone on the new epoch
* while the floor edition stays behind on the old one. The offered set therefore never contains
* the floor edition — but v3 cites its hash, which is a *stronger* proof the chain connects than
* mere presence. Refusing here pins the entity at the floor forever and silently discards the
* newer state (a role edit, a channel rename, a banlist entry) on every refold.
*
* Regression guard: reproduced on device 2026-07-29 (entity e83ee182 pinned at v1 across 21
* consecutive refolds while epoch 1 offered v2 citing v1's hash). Mirrors Armada's third
* anchor branch, `versions[0] === floor + 1n → bytesEq(lo.prevHash, floorHash)`.
*/
@Test
fun advancesWhenOnlyTheConnectingSuccessorIsOffered() {
val gaps = mutableListOf<Triple<String, Long, Long>>()
val head = EditionFold.foldEntity(listOf(v3), floorAtV2) { e, f, o -> gaps += Triple(e, f, o) }
assertEquals(v3.rumorId, head?.rumorId, "v3.prev == floor.hash, so the chain connects")
assertTrue(gaps.isEmpty(), "a successor citing the floor is not a rollback")
}
/** But a successor that cites something else is still a fork, and still refused. */
@Test
fun stillRefusesASuccessorThatDoesNotCiteTheFloor() {
val forked = edition(3, ByteArray(32) { 0x11 }, """{"member":"a","role_ids":["owner-ish"]}""", rumorId = "id-forked")
val gaps = mutableListOf<Triple<String, Long, Long>>()
val head = EditionFold.foldEntity(listOf(forked), floorAtV2) { e, f, o -> gaps += Triple(e, f, o) }
assertEquals(v2.rumorId, head?.rumorId, "an unconnected successor must not be adopted")
assertEquals(listOf(Triple(v2.entityIdHex, 2L, 3L)), gaps, "and the refusal must still be reported")
}
// ---- the compaction arm (CORD-06 §3) --------------------------------------
/**
* Behind a Refounding the predecessor is trimmed, so a `prev` that reaches back to the floor
* is NOT guaranteed — the compacted head can sit any distance above it. Once the entity is in
* the epoch's snapshot the anchor is the version alone, so v3 is adopted over a floor of v1
* even though nothing links them. This is the case part 1 alone cannot handle.
*/
@Test
fun compactionArmAdoptsTheHighestVersionWithoutAChain() {
val gaps = mutableListOf<Triple<String, Long, Long>>()
val head =
EditionFold.foldEntity(
listOf(v3),
v1.asFloor(),
snapshot = setOf(v3.rumorId),
) { e, f, o -> gaps += Triple(e, f, o) }
assertEquals(v3.rumorId, head?.rumorId, "version anchors the compaction arm; prev is ignored")
assertTrue(gaps.isEmpty(), "a compacted head above the floor is not a gap")
}
/** The floor still holds in the compaction arm: a re-served stale edition cannot downgrade us. */
@Test
fun compactionArmStillRefusesEverythingBelowTheFloor() {
val gaps = mutableListOf<Triple<String, Long, Long>>()
val head =
EditionFold.foldEntity(
listOf(v0, v1),
floorAtV2,
snapshot = setOf(v0.rumorId, v1.rumorId),
) { e, f, o -> gaps += Triple(e, f, o) }
assertEquals(v2.rumorId, head?.rumorId, "nothing at or above the floor: keep what we knew")
assertEquals(listOf(Triple(v2.entityIdHex, 2L, 1L)), gaps, "and report the refusal")
}
/** An entity this epoch never re-wrapped is not in the snapshot, so it keeps chain-walk semantics. */
@Test
fun entityAbsentFromTheSnapshotStillChainWalks() {
val forked = edition(3, ByteArray(32) { 0x11 }, """{"member":"a","role_ids":["owner-ish"]}""", rumorId = "id-forked")
val head = EditionFold.foldEntity(listOf(forked), floorAtV2, snapshot = setOf("some-other-rumor")) { _, _, _ -> }
assertEquals(v2.rumorId, head?.rumorId, "no snapshot membership -> the chain walk still refuses the fork")
}
/** A floor whose known edition we no longer hold still refuses to move down — it adopts nothing. */
@Test
fun refusesRollbackWithNoKnownEditionToFallBackOn() {
@@ -66,7 +66,7 @@ class TolerantPrimitiveSerializersTest {
fun otherNonStringValuesAreIgnoredFieldByField() {
val meta =
metaWith(
"""{"name":{"first":"A"},"display_name":["A"],"picture":null,"about":"bio","website":{}}""",
"""{"name":{"first":"A"},"display_name":["A","B"],"picture":null,"about":"bio","website":{}}""",
).contactMetaData()
assertIs<UserMetadata>(meta)
@@ -77,6 +77,37 @@ class TolerantPrimitiveSerializersTest {
assertEquals("bio", meta.about, "well-formed fields must survive the malformed ones")
}
/**
* Regression for profiles that wrap a string field in a one-element array
* (seen in the wild as `"nip05":["shoreline@decentnewsroom.com"]`). There is
* only one possible reading, so the value must be recovered rather than
* silently dropped along with the user's NIP-05 verification.
*/
@Test
fun singleElementArrayIsUnwrapped() {
val meta = metaWith("""{"name":"A","nip05":["a@b.com"],"lud16":[123]}""").contactMetaData()
assertIs<UserMetadata>(meta)
assertEquals("a@b.com", meta.nip05)
assertEquals("123", meta.lud16)
}
@Test
fun arraysThatAreNotASinglePrimitiveAreStillIgnored() {
listOf(
"""{"name":"A","nip05":[]}""",
"""{"name":"A","nip05":["a@b.com","c@d.com"]}""",
"""{"name":"A","nip05":[{}]}""",
"""{"name":"A","nip05":[["a@b.com"]]}""",
"""{"name":"A","nip05":[null]}""",
).forEach { json ->
val meta = metaWith(json).contactMetaData()
assertIs<UserMetadata>(meta, "profile must survive nip05 in $json")
assertEquals("A", meta.name)
assertNull(meta.nip05, "ambiguous array nip05 must be dropped in $json")
}
}
/** Pre-existing lenient behavior: bare primitives still decode into string fields. */
@Test
fun numberAndBooleanPrimitivesStillDecodeIntoStringFields() {
@@ -117,7 +148,26 @@ class TolerantPrimitiveSerializersTest {
@Test
fun nonJsonContentReturnsNull() {
assertNull(metaWith("Relay initialized").contactMetaData())
assertNull(metaWith("").contactMetaData())
}
/**
* An empty content is a valid, empty profile — a user wiping their metadata —
* not a parse error. It must decode to a blank [UserMetadata] so the new
* (replaceable) event actually replaces the stale profile downstream.
*/
@Test
fun blankContentIsAnEmptyProfileNotAnError() {
listOf("", " ", "\n", "\t ").forEach { blank ->
val meta = metaWith(blank).contactMetaData()
assertIs<UserMetadata>(meta, "blank content must decode into an empty profile, not null")
assertNull(meta.name)
assertNull(meta.displayName)
assertNull(meta.picture)
assertNull(meta.about)
assertNull(meta.nip05)
assertEquals(emptySet(), metaWith(blank).contactMetadataJson()?.keys, "blank content must decode into an empty json object")
}
}
/** Dropped fields must not leak back into the serialized profile as nulls. */
@@ -0,0 +1,79 @@
/*
* 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.quartz.nip29RelayGroups
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
import com.vitorpamplona.quartz.utils.EventFactory
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The Buzz-only channel-settings tags on kind-9002 edit-metadata (`visibility`, `archived`) and the
* `archived` reflection on the relay-signed kind-39000. These ride the 9002 as tags — Buzz reads its
* own `visibility` vocabulary rather than the NIP-29 `private` status flag, and stamps `archived` onto
* the 39000 for an archived channel.
*/
class ChannelSettingsTagTest {
private val relaySelf = "aa".repeat(32)
private val sig = "bb".repeat(64)
private val id = "00".repeat(32)
private val gid = "0123456789abcdef"
private fun tagValue(
tags: Array<Array<String>>,
name: String,
): String? = tags.firstOrNull { it.isNotEmpty() && it[0] == name }?.getOrNull(1)
@Test
fun editEmitsVisibilityTagOnlyWhenSet() {
val priv = EditMetadataEvent.build(gid, visibility = "private")
assertEquals("private", tagValue(priv.tags, "visibility"))
val open = EditMetadataEvent.build(gid, visibility = "open")
assertEquals("open", tagValue(open.tags, "visibility"))
// Absent by default so an ordinary metadata edit doesn't reclassify visibility.
assertNull(tagValue(EditMetadataEvent.build(gid, name = "x").tags, "visibility"))
}
@Test
fun editEmitsArchivedTagAsTrueFalse() {
assertEquals("true", tagValue(EditMetadataEvent.build(gid, archived = true).tags, "archived"))
assertEquals("false", tagValue(EditMetadataEvent.build(gid, archived = false).tags, "archived"))
// Null archived means "don't touch it" — no tag emitted.
assertNull(tagValue(EditMetadataEvent.build(gid, name = "x").tags, "archived"))
}
@Test
fun metadataReflectsArchivedFlag() {
val archivedTemplate = GroupMetadataEvent.build(gid, name = gid) { add(arrayOf("archived", "true")) }
val archived = EventFactory.create(id, relaySelf, archivedTemplate.createdAt, GroupMetadataEvent.KIND, archivedTemplate.tags, "", sig) as GroupMetadataEvent
assertTrue(archived.isArchived())
val plainTemplate = GroupMetadataEvent.build(gid, name = gid)
val plain = EventFactory.create(id, relaySelf, plainTemplate.createdAt, GroupMetadataEvent.KIND, plainTemplate.tags, "", sig) as GroupMetadataEvent
assertFalse(plain.isArchived())
}
}
@@ -0,0 +1,154 @@
/*
* 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.quartz.nip01Core.metadata
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.KotlinSerializationMapper
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
/**
* Real kind-0 events collected from relays that used to lose all or part of the
* profile. Each is checked through **both** wire parsers — Jackson (the
* JVM/Android [com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper]
* actual) and kotlinx serialization (the native actual) — because a profile that
* renders on one platform and not the other is the harder bug to spot.
*/
class MetadataEventWildProfilesTest {
/** Runs [assertions] against the event as parsed by each of the two mappers. */
private fun bothMappers(
json: String,
assertions: (label: String, event: MetadataEvent) -> Unit,
) {
assertions("jackson", JacksonMapper.fromJson(json) as MetadataEvent)
assertions("kotlinx", KotlinSerializationMapper.fromJson(json) as MetadataEvent)
}
/**
* Empty content is a valid, empty profile — someone wiping their metadata —
* so it must decode into a blank [UserMetadata]. Returning null made this a
* parse "error" that `LocalCache.consume` dropped, leaving the stale profile
* in place forever even though a newer replaceable event had arrived.
*/
@Test
fun emptyContentIsAnEmptyProfile() {
val json =
"""
{
"id": "3d6f3be9e56aaf0f6e7f013d886f123ff72863df6cc103f2a9d2398221a46a73",
"pubkey": "bed07edf2bb9690324f7898213a7431586f12088e727c2b722ab7c5b2d80368f",
"created_at": 1785356310,
"kind": 0,
"tags": [],
"content": "",
"sig": "2bddad095b8a7dc087cf1c030bf48f93280274d4cb01eb352d1540c0f2d2f4dad84fbd52d8a031c148df735a22e706e29c79d2c956b5e5d9edca49346bb2ba5a"
}
""".trimIndent()
bothMappers(json) { label, event ->
val meta = event.contactMetaData()
assertIs<UserMetadata>(meta, "$label: empty content must not be an error")
assertNull(meta.name, label)
assertNull(meta.displayName, label)
assertNull(meta.picture, label)
assertNull(meta.about, label)
assertNull(meta.nip05, label)
assertEquals(emptySet(), event.contactMetadataJson()?.keys, "$label: empty content is an empty json object")
}
}
/**
* `"nip05":["shoreline@decentnewsroom.com"]` — a string field wrapped in a
* one-element array. The intended value is unambiguous, so it is recovered
* instead of dropping the user's NIP-05 verification.
*/
@Test
fun singleElementArrayNip05IsRecovered() {
val json =
"""
{"id":"642168789c75b50a7f67d0dab7bf4723433a70a7694c63c77eeca03a74af636c","pubkey":"689c44a2c362229489eb3fc5273920469ae3fa6ce01467e07f1c3383097a26b9","created_at":1782219566,"kind":0,"tags":[["display_name","Shoreline Poetry"],["name","shoreline"],["about","Poems and fragments. Inner weather. Ceasing, breaking, flowing, and becoming something new."],["picture","https://image.nostr.build/e98b3341636054b33ccf414390d75d02b721511d5e331ffcac63bacfa56fa1c4.jpg"],["banner","https://images.pexels.com/photos/18558371/pexels-photo-18558371.jpeg"],["lud16","purepug2@primal.net"],["website","https://shoreline.decentnewsroom.com"],["nip05","shoreline@decentnewsroom.com"]],"content":"{\"display_name\":\"Shoreline Poetry\",\"name\":\"shoreline\",\"about\":\"Poems and fragments. Inner weather. Ceasing, breaking, flowing, and becoming something new.\",\"banner\":\"https://images.pexels.com/photos/18558371/pexels-photo-18558371.jpeg\",\"website\":\"https://shoreline.decentnewsroom.com\",\"nip05\":[\"shoreline@decentnewsroom.com\"],\"lud16\":\"purepug2@primal.net\",\"picture\":\"https://image.nostr.build/e98b3341636054b33ccf414390d75d02b721511d5e331ffcac63bacfa56fa1c4.jpg\"}","sig":"fe2a682f4cc407e6de07ff7e68309f757b3a63ba41851c21199ea71067aafd0a27dc874fc575104654d908cb65aba25dd060086c31723f496efb9271ee256560"}
""".trimIndent()
bothMappers(json) { label, event ->
val meta = event.contactMetaData()
assertIs<UserMetadata>(meta, label)
assertEquals("shoreline", meta.name, label)
assertEquals("Shoreline Poetry", meta.displayName, label)
assertEquals("shoreline@decentnewsroom.com", meta.nip05, "$label: the one-element array must be unwrapped")
assertEquals("purepug2@primal.net", meta.lud16, label)
assertEquals("https://shoreline.decentnewsroom.com", meta.website, label)
}
}
/**
* A profile dumped straight out of some client's internal state: `"nip05":{}`
* and `"custom_data":{}` plus keys that are not NIP-24 fields at all. The
* malformed nip05 is dropped, everything else survives.
*/
@Test
fun objectNip05AndForeignKeysDoNotDropTheProfile() {
val json =
"""
{"id":"dde6cd9661490879de9d5c1db12e1025ef6e429e7f6efb1021f1fda7fa1bc99d","pubkey":"85faee02e65460a46a5e8dbb47d46f0b1c02030aba3da437257e5759a00e0885","created_at":1710229789,"kind":0,"tags":[],"content":"{\"name\":\"krit\",\"about\":\"\",\"display_name\":\"krit\",\"picture\":\"\",\"pubkey\":\"npub1shawuqhx23s2g6j73ka504r0pvwqyqc2hg76gde90et4ngqwpzzsua9kwr\",\"pubkeyHex\":\"85faee02e65460a46a5e8dbb47d46f0b1c02030aba3da437257e5759a00e0885\",\"lastUpdatedAt\":0,\"nip05\":{},\"custom_data\":{},\"isLogin\":false}","sig":"df4ef440b80843e53b9b99e0ebbf1d5f22fbeb4cc8ce8993b3df42a16d96e06b74036f10f3cf62348a4f5124b91afc2ff7d82482289879994b30bb73138a92d9"}
""".trimIndent()
bothMappers(json) { label, event ->
val meta = event.contactMetaData()
assertIs<UserMetadata>(meta, label)
assertEquals("krit", meta.name, label)
assertEquals("krit", meta.displayName, label)
assertNull(meta.nip05, "$label: an object nip05 has no readable value")
// The empty strings the client wrote are normalized away on consumption.
meta.cleanBlankNames()
assertNull(meta.picture, label)
}
}
/**
* A Ditto profile: `"birthday":"10-24"` (NIP-24 defines an object), a `fields`
* array of arrays, and assorted client-private keys. Everything the app reads
* must survive; the birthday stays dropped because a bare `"10-24"` is
* ambiguous (MM-DD vs DD-MM) — see [BirthdayTolerantSerializer].
*/
@Test
fun stringBirthdayAndExtraFieldsDoNotDropTheProfile() {
val json =
"""
{"id":"ed269c23907649461da4b0fe109eed689ed1a562d33873b97ed01496dd02b87c","pubkey":"932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d","created_at":1779416127,"kind":0,"tags":[["client","Ditto","31990:781a1527055f74c1f70230f10384609b34548f8ab6a0a6caa74025827f9fdae5:ditto"],["published_at","1779044138"]],"content":"{\"about\":\"Team Soapbox. Freedom advocate.\",\"banner\":\"https://blossom.ditto.pub/ee0d.jpeg\",\"bot\":false,\"display_name\":\"MK Fain\",\"lud16\":\"mkfain@cash.app\",\"name\":\"MK Fain\",\"nip05\":\"mk@ditto.pub\",\"picture\":\"https://blossom.ditto.pub/cf2b.jpeg\",\"displayName\":\"MK Fain\",\"pubkey\":\"932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d\",\"npub\":\"npub1jvnpg4c6ljadf5t6ry0w9q0rnm4mksde87kglkrc993z46c39axsgq89sc\",\"created_at\":1718637715,\"fields\":[[\"Soapbox\",\"https://soapbox.pub\"],[\"Ditto FAQ\",\"https://ditto.pub/help\"]],\"birthday\":\"10-24\",\"showBirthday\":true,\"client\":\"divine.video\",\"shape\":\"x\"}","sig":"a78ed7b638eb240dbc81ea6565d5de4fb67d970fa391ea69b9757e1c6dfb448c54ea7d41f16802027135c1b6bd729e3889eb27065b432d42660cf1fb186fc8a0"}
""".trimIndent()
bothMappers(json) { label, event ->
val meta = event.contactMetaData()
assertIs<UserMetadata>(meta, label)
assertEquals("MK Fain", meta.name, label)
assertEquals("MK Fain", meta.displayName, label)
assertEquals("mk@ditto.pub", meta.nip05, label)
assertEquals("mkfain@cash.app", meta.lud16, label)
assertEquals(false, meta.bot, label)
assertEquals("Team Soapbox. Freedom advocate.", meta.about, label)
assertNull(meta.birthday, "$label: an ambiguous string birthday is dropped, not fatal")
}
}
}