Compare commits

...

109 Commits

Author SHA1 Message Date
Claude
12aa9fe954 feat(cli): first-class 'amy buzz' commands for block/buzz workspaces
Thin assembly over quartz + commons (no new protocol in cli/):
- buzz post RELAY GID <text>  — publish a kind-40002 stream message (h-scoped)
- buzz read RELAY GID         — drain the recent human-visible timeline (9/40002/40099)
- buzz attest AGENT           — sign a NIP-OA OwnerAttestation offline, print the auth tag
- buzz console [--relays]     — drain kind-44200 turn metrics (#p=me), NIP-44-decrypt,
                                and aggregate via the shared commons AgentFleetAggregator
- buzz personas [--relays]    — list my kind-30175 personas (newest per slug)

Join/leave/create reuse 'amy relaygroup' (Buzz workspaces are NIP-29 groups). Wired
into Main dispatch + usage; README command table + ROADMAP updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 05:01:25 +00:00
Claude
54364731a6 feat(buzz): 'Workspaces' bottom-nav tab + hub screen
Gives Buzz a first-class navigation identity instead of hiding inside the generic
Relay Groups list. NavBarItem.BUZZ (Route.BuzzWorkspaces) is a pinnable bottom-nav
destination (added to NavBarCatalog + BottomBarCategories + the preloader when).

BuzzWorkspacesScreen is a modern hub: an Agent-Console hero card up top, then the
user's joined groups filtered to Buzz-dialect relays as cards with a colored
monogram avatar, live name/host/member-count and an unread dot, plus an inviting
empty state that routes to Browse groups. It reuses the always-on relay-group state
subscription (no per-screen fetch) and the same RelayGroupChannel the chat opens.

Also fixes the open-chat-tail test for the added 20002 typing kind
(RELAY_GROUP_OPEN_TAIL_KINDS), and documents typing + the hub in the buzz README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 04:27:59 +00:00
Claude
a0d50c69f6 feat(buzz): live typing indicators (kind 20002)
Brings Buzz to typing-indicator parity with Concord, verified against buzz-core
kind.rs (KIND_TYPING_INDICATOR=20002, requires_h_channel_scope=false).

- commons BuzzTypingState: process-wide, lock-guarded channel->typist->heartbeat
  registry with stale pruning + future-clamp (6 unit tests).
- LocalCache records 20002 heartbeats into it (still no feed row; own typing
  filtered in the UI).
- RELAY_GROUP_OPEN_TAIL_KINDS requests 20002 on the open channel's live tail only
  (ephemeral, scoped to the room on screen, never the joined fleet).
- Account.sendBuzzTyping fires a throttled heartbeat to the host relay; the
  composer sends it on text change (gated to Buzz relays).
- BuzzTypingIndicator: an animated three-dot '… is typing' row above the composer
  that slides in/out and ages typists out on a timer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 04:15:50 +00:00
Claude
5d9efc800a docs(buzz): correct reaction/delete claim — Buzz ALL_KINDS accepts 7/5/9005/1984/emoji
Earlier text wrongly said Buzz has no reaction kind. buzz-core/src/kind.rs ALL_KINDS
includes KIND_REACTION(7), KIND_DELETION(5), KIND_NIP29_DELETE_EVENT(9005)/GROUP(9008),
KIND_REPORT(1984), and emoji list/set (10030/30030); requires_h_channel_scope(7) is
false. So the shared chat action sheet's react/delete/report already work against Buzz.
Only zaps (no 9734/9735) aren't relay-stored (payment still works). Typing/presence are
accepted by Buzz but not yet rendered by Amethyst.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 03:57:51 +00:00
Claude
f869834e9d docs(buzz): sync README with canvas viewer + edit composer; note no reaction kind
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 02:26:43 +00:00
Claude
fb7bdf3057 feat(buzz): edit composer for stream messages (kind 40003)
Closes the edit loop: Amethyst already renders 40003 edit overlays but could
not create them. ChannelNewMessageViewModel gains a Buzz edit mode
(editBuzzMessage/clearBuzzEdit) — the next send publishes a kind-40003 edit
targeting the original instead of a new 40002, kept minimal to mirror Buzz's
build_edit (h + e + content). An 'Edit' action on the message sheet is gated to
my own 40002 messages (the kind alone marks it Buzz) and threads to the composer
via an optional onWantsToEditBuzz callback through the shared chat feed
(default-null, so no other chat surface is affected). EditFieldRow shows an
editing banner with a cancel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 02:20:34 +00:00
Claude
f31e7a4c0b feat(buzz): workspace canvas viewer (kind 40100)
Adds a read surface for the Buzz canvas, which was consumed into overlay state
but had no UI. BuzzWorkspaceState gains a canvasUpdates flow; BuzzCanvasScreen
(Route.BuzzCanvas) renders the newest 40100 markdown for a channel and recomposes
when a newer revision lands. Entry point is a Dashboard icon in the relay-group
top bar, shown only on a Buzz-dialect relay once a canvas has arrived.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 02:12:17 +00:00
Claude
4a4baf6a23 feat(buzz): NIP-OA agent auth at connect (hold + inject auth tag)
Tier 1 connect path. BuzzHeldAttestations (commons) stores the OwnerAttestations
this device received, keyed by the agent pubkey each authorizes (verify-passing
only). AuthCoordinator.buzzAugmented appends the owner-signed auth tag to an
account's NIP-42 AUTH event when that account's key has a held attestation and
the relay speaks the Buzz dialect — and only that account's AUTH, never the
Concord stream-key AUTHs sharing the template, and never on non-Buzz relays.
So an un-enrolled agent key gets virtual membership while its owner stays a member.

AgentAttestationScreen gains a 'Hold an attestation' section (paste the auth tag
JSON, verified against the current account, stored/removed) alongside the existing
owner-side issuance. Store is in-memory for now — persisting per-account is a
follow-up. 4 store tests added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 01:34:56 +00:00
Claude
bce544e8c3 feat(buzz): persona create/edit from the agent console
Tier 3 write flow. AgentPersonaEditScreen + AgentPersonaEditViewModel publish
a Buzz Agent Persona (NIP-AP kind:30175) to the workspace's Buzz-dialect relays
(falling back to the owner outbox). The Personas tab gains a 'New persona' row
and each card is tappable to edit.

On edit the existing PersonaEvent is loaded from LocalCache so fields the form
does not expose (name pool, respond-to allowlist, parallelism) are preserved
rather than dropped on republish; the slug (d tag) is locked once chosen and
validated against the relay's grammar (^[a-z0-9][a-z0-9_-]{0,63}$).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 01:28:30 +00:00
Claude
0ea5d0e876 feat(buzz): widen group REQ to richer kinds + kind-aware renderers
Read-path interop. Two changes:

1. RelayGroupFilterBuilders: BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS now also
   requests canvas (40100), forum posts/votes/comments (45001-45003), agent
   jobs (43001-43006), and huddle lifecycle (48100-48103) — all h-scoped, so
   the same #h group REQ returns them. Previously only 40002/40003/40008/40099
   were requested, so LocalCache could consume these kinds but they never
   arrived for a group feed.

2. RenderBuzzNotes: kind-aware rows dispatched from ChatMessageCompose —
   RenderBuzzDiff (40008: monospace, horizontally-scrollable diff with a
   repo/commit/file header instead of prose-wrapped gutters), RenderBuzzActivityRow
   (job + huddle lifecycle as centered system narration — huddles MUST be caught
   here since their content is JSON), and RenderBuzzForumVote (45002). Forum
   posts stay as plain chat bubbles (their content is already plain text).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 01:23:26 +00:00
Claude
5137b92046 feat(buzz): NIP-OA attestation issuance in the agent console
Adds AgentAttestationScreen, reached from an "Attest" FAB on the console.
The owner enters an agent pubkey (npub/hex) and optional AttestationConditions
(kind, created_at before/after), and OwnerAttestation.sign() produces the
signed auth tag, displayed for copy to hand to the agent operator out-of-band.

Entirely offline (nothing is published) and gated on a raw private key: the
NIP-OA signature covers a hashed commitment rather than a Nostr event, so
NIP-46 bunker / NIP-55 external-signer accounts get an explanation instead of
the form. Inputs are validated (kind 0-65535, unix bounds 0-u32, npub/hex key,
no self-attestation) with human-readable errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 00:36:15 +00:00
Claude
2124409025 docs(buzz): document the agent-owner console in the buzz README
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 23:35:42 +00:00
Claude
1b76dd250e feat(buzz): live observer telemetry tab in agent console
Adds a third Observer tab streaming ephemeral agent telemetry frames
(ObserverFrameEvent, kind:24200, p=owner) live from every Buzz-dialect
relay via subscribeAsFlow. Frames are decrypted (frame:telemetry only),
deduped across relays, sorted newest-first, and bounded to a 200-row ring.
The live REQ is opened only while the Observer tab is on screen (started
in a DisposableEffect, cancelled on dispose and in onCleared) because
observer frames are never persisted by relays or LocalCache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 23:34:29 +00:00
Claude
201aafb5bf feat(buzz): agent-owner console with cost + personas tabs
Read-only owner dashboard over the AI-agent fleet. AgentConsoleViewModel
fetches turn metrics (kind:44200, p=owner) and personas (kind:30175,
authored by owner) from the Buzz-dialect relays plus the owner outbox set,
decrypts the NIP-44 metric payloads with the owner signer (cached by event
id), and derives fleet + per-agent totals via the pure AgentFleetAggregator.

AgentConsoleScreen renders a Costs tab (fleet total, per-agent spend/tokens/
turns/sessions, estimated-total warning) and a Personas tab (display name,
model, runtime, provider, system prompt). Wired as Route.AgentConsole with a
settings-catalog entry under App settings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 23:26:58 +00:00
Claude
81fa81612b feat(commons): agent fleet cost aggregator for the owner console
Pure aggregation of decrypted NIP-AM turn metrics (kind:44200) into per-agent
and fleet token/cost totals — the data core of the agent-owner console.

Correctness that makes the numbers trustworthy: turns group by (agent,
sessionId); within a session `cumulative` is the monotonic running total, so the
max cumulative per field is the authoritative session total (robust to dropped
turns), falling back to summing per-turn deltas only where no cumulative exists —
per field, so a cumulative that omits costUsd still gets cost from deltas.
Delta-sums that include a deltaReliable=false turn flag the fleet estimate.

10 tests cover cumulative-not-summed, missing-turn recovery, delta fallback,
per-field mixing, multi-session/multi-agent rollup, sessionless singletons, and
the unreliable-estimate flag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 23:14:09 +00:00
Claude
c79b795477 fix(buzz): address adversarial review findings (channel-swap, verified marks, engram tombstone)
Three-reviewer pass over the branch. Fixes, most-severe first:

HIGH — channel-instance swap froze open screens. The subclass-upgrade design
replaced a group's RelayGroupChannel with a Buzz-typed instance on dialect
discovery, but screens/feeds/composers capture the instance for life, so all of
them kept rendering the orphan (frozen feed; composer stuck on kind 9). Removed
BuzzWorkspaceChannel entirely; RelayGroupChannel is a single stable type again.
Buzz-only overlay state (edit + canvas) now lives in BuzzWorkspaceStates, a
registry keyed by the channel UUID — dialect discovery no longer touches object
identity. The swap also silently dropped all relay-signed state (name, members,
admin status, pins, threads) and demoted the confirmed host; gone with the swap.

HIGH — wrong thread-root got messages relay-rejected AFTER the draft was
destroyed. A reply to a direct reply derived root=parent (buzzThreadRoot null on
a collapsed reply), which Buzz's ancestry validator rejects. Fallback is now
buzzThreadRoot() ?: buzzThreadReply() ?: parent.id. Also: minichat (kind 1111)
replies in Buzz channels are relay-rejected, so they're gated off for Buzz relays.

HIGH — pre-create defeated stray-redirect and marked the dialect off unverified
input. consumeBuzzTimelineEvent no longer pre-creates the channel; attachment
goes through the shared NIP-29 path (with its stray protection), and the dialect
is marked only off a VERIFIED event (markBuzzIfVerified) — a hostile relay can no
longer flip what the composer sends.

MEDIUM — engram tombstone divergence: a memory body missing `value` decoded as a
null tombstone (a deletion) where Buzz rejects it. `value` is now required (no
default); added NIP-AE slug-grammar validation. Pinned by test.

MEDIUM — unbounded edit overlay: pruneOldMessagesChannel now prunes overlay
entries for reaped messages. Overlay is keyed by channel id so own offline edits
(null relay) apply too.

Also: 40002 inbound reply linkage in computeReplyTo (we emit thread markers we
couldn't read); registered-but-unhandled 40901/40902/48001 now consumed;
unconditional Buzz-kind widening (kills the history-cursor-skip); stream mention
dedup; persona empty-list omission for content-hash parity; thread-marker
positional guard; edit-note-loading blank-row fallback.

Full amethyst unit suite + affected quartz suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 23:00:52 +00:00
Claude
6bc16314e8 test(amethyst): update open-tail kind expectation for the Buzz bootstrap
The open-channel tail deliberately carries the Buzz timeline kinds now (the
dialect-detection bootstrap); pin the new contract in the existing builder test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 22:24:27 +00:00
Claude
c85f51c40a refactor(buzz): typed thread/p tags in composer + dialect bootstrap + live join/leave proof
Review feedback: the 40002 composer branch hand-rolled raw arrayOf tags. Now:

- New shared `buzz/threading` package: `buzzThread(root, parent)` builder verb
  emitting Buzz's exact thread_tags wire form (["e",root,"","root"] +
  ["e",parent,"","reply"], collapsing when parent==root; the empty relay slot
  is deliberate — MarkedETag.assemble's arrayOfNotNull would slide the marker
  into the relay slot) and `buzzThreadRoot()/buzzThreadReply()` positional
  readers. The forum verbs now delegate to it (streams and forum comments share
  thread_tags in buzz-sdk), and the composer uses buzzThread + the typed
  pTag(PTag(...)) verb instead of raw arrays.

- Dialect bootstrap fix: the single-group open-channel REQ now always includes
  the Buzz timeline kinds. Without this, an undiscovered Buzz relay was a
  chicken-and-egg: fleet subs only widen after BuzzRelayDialect marks the
  relay, but the mark comes from consuming a Buzz kind no filter asked for.
  Opening a channel is explicit one-group intent, so the wider ask is cheap and
  matches nothing on vanilla relays; fleet-wide subs stay dialect-gated
  (both behaviors pinned in BuzzTimelineKindsTest). The live relay's NIP-11
  ("Buzz Relay", supported_extensions=[nip-er,nip-pl]) is a future
  connect-time marker.

- Live proof of the full workspace lifecycle against the running Buzz relay
  (BuzzRelayLiveInteropTest, 3/3 green): discover the channel via its
  relay-signed 39000 (queryable by #d), join with NIP-29 kind-9021 from a
  second member, post a 40002 after joining, and leave with kind-9022 — the
  exact Quartz events Amethyst's group UI sends.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 22:16:39 +00:00
Claude
54ecae50af feat(amethyst): render Buzz channels natively + compose kind-40002 messages
Completes the visible half of Buzz workspace support:

- Composer: BuzzWorkspaceChannel branch (checked before its RelayGroupChannel
  parent) sends the native kind-40002 stream message with the group's h tag,
  Buzz thread markers on replies (["e",root,"","root"] + ["e",parent,"","reply"],
  collapsing when parent is the root — mirrors thread_tags in buzz-sdk) and a
  p notify to the parent author, with the same hashtag/url/quote/emoji/imeta
  enrichment as every other channel type.
- Rendering: kind-40099 system messages render as centered system lines (like
  NIP-28 admin rows) from the relay-signed payload; kind-40003 edits render as
  an overlay — the newest edit's content replaces the stale original with an
  "(edited)" marker, recomposing via the channel's editUpdates flow.
- LocalCache correction pinned by test: edits are overlays, never timeline rows
  (Buzz's own CHANNEL_TIMELINE_CONTENT_KINDS excludes 40003) — a 40003 is
  stored and recorded in the channel's edit map but no longer attached to the
  timeline, so it can't render as a duplicate message.

All Buzz unit tests green; app compiles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 21:21:38 +00:00
Claude
7903834efd feat(commons): Buzz workspaces as NIP-29 groups in the Buzz dialect
Surfaces block/buzz workspace channels inside the existing NIP-29 relay-group
experience — one group model, dialect-aware rendering, zero impact on vanilla
groups (kind-filtered feeds can never receive Buzz kinds by accident).

- BuzzRelayDialect (commons): per-relay capability registry. Event-shape
  detection — the first Buzz-only kind consumed from a relay marks it; vanilla
  relays never serve those kinds, so no false positives. NIP-11 marking can be
  layered on later.
- BuzzWorkspaceChannel (commons): sibling of RelayGroupChannel (now `open`)
  holding Buzz-only channel state: the kind-40003 edit overlay (never render
  superseded text as current) and the newest kind-40100 canvas. Kind-9 chat and
  kind-40002 stream messages share ONE timeline per group so mixed-dialect
  conversations stay whole.
- LocalCache: consumes every registered Buzz kind (previously all fell into the
  "Event Not Supported" branch). Timeline kinds attach to the group's channel,
  materialized dialect-aware with an in-place upgrade (note migration) when the
  dialect is discovered after the channel was first created as plain NIP-29.
  Addressables store replaceably; the rest store as queryable regular events;
  ephemeral signals (typing 20002, observer 24200, huddle reaction 24810,
  pairing 24134) mark the dialect but are deliberately not persisted.
- RelayGroupFilterBuilders: group-chat REQs widen their timeline kind set with
  40002/40003/40008/40099 only for marked relays; vanilla NIP-29 REQs unchanged.

Tests cover dialect detection + materialization, the plain-channel upgrade with
timeline migration, newest-edit-wins overlay ordering, and that filter builders
extend kinds only on marked relays. Full amethyst unit suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 20:58:11 +00:00
Claude
d7125b8b08 test(quartz): live interoperability test against a running Buzz relay
Adds an env-gated jvmTest (BUZZ_RELAY_WS / BUZZ_MEMBER_SK / BUZZ_OWNER_SK; CI
skips it) that drives the real block/buzz relay booted from its own compose +
cargo build, with members enrolled via buzz-admin. Verified green against the
running relay:

- NIP-42 member auth, and NIP-OA/NIP-AA agent auth: a brand-new un-enrolled
  agent key authenticates using only our owner-signed `auth` tag — the relay's
  NIP-OA membership fallback accepts the Quartz-produced attestation.
- Channel lifecycle: kind:9007 create via the existing NIP-29 CreateGroupEvent
  (Buzz channels are NIP-29 groups), kind:40002 publish, REQ round-trip with a
  byte-identical echo (same id), EOSE.

Interop lessons encoded in the test + README: tenancy is host-bound (connect
with the bound Host or the WS upgrade 404s), `h` values must parse as UUIDs,
and the channel row must exist before channel-scoped kinds are accepted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 20:17:58 +00:00
Claude
f5d22ce720 feat(quartz): implement the full Buzz-custom protocol surface (~77 event kinds)
Adds Quartz-native models for the entire block/buzz custom kind space, each as
an idiomatic per-NIP package (Event + KIND companion + tags/ + write-DSL +
read accessors, mirroring nip88Polls) and registered in EventFactory. Standard
NIP-29/34/51/42/43 kinds Buzz reuses are left to their existing Quartz classes.

Coverage:
- Agent identity: Persona 30175, Team 30176, Managed Agent 30177, Agent Profile 10100
- Agent telemetry (encrypted): Observer 24200, Engram 30174 (HMAC d-tag derivation
  pinned to Buzz reference vectors), Turn Metric 44200 (prior commit)
- Workspace overlays: Reminders 30300, Push Lease 30350, DM Visibility 30622,
  Workspace Profile 9033, Identity Archival 9035/9036/8002/8003/13535,
  Channel Window 39005/39006, relay admin 9030-9032, moderation 9040-9044, 42000
- Messaging/collab: stream 40002-40100 (+sidecars), DMs 41001/41010-41012,
  jobs 43001-43006, forum 45001-45003, workflow 30620/46001-46031,
  notifications 44100/44101, presence 20001/20002, huddles 24810/48100-48106,
  pairing 24134, audit 48001, media 49001, read-state (NIP-RS helpers on 30078)

All schemas confirmed against the authoritative Rust in a local block/buzz
checkout (buzz-core / buzz-sdk / buzz-relay), not the outdated prose NIPs.
Kinds only reserved-but-unbuilt in Buzz (jobs, workflow lifecycle, some
stream/sidecar/audit kinds) are modeled tolerantly and flagged in KDoc + README.

Kind conflicts with existing Amethyst classes are implemented but deliberately
NOT registered in EventFactory (incumbent keeps dispatch): 9041 (GoalEvent),
20001 (GeohashPresenceEvent), 39005 (GroupPinnedEvent), plus 49001 (Buzz marks
it non-wire) and 30078 read-state (reuses AppSpecificDataEvent).

156 Buzz tests pass; full quartz jvmTest green (no regressions).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 19:54:05 +00:00
Claude
1757ef4513 feat(quartz): restructure Buzz to per-event layout + Agent Turn Metric (NIP-AM)
Addresses review feedback on the initial Buzz commit:

- Drop the central `BuzzKinds` registry (un-idiomatic). Each event now
  declares its own `const val KIND` on its companion and is wired into
  `EventFactory`, matching the nip88Polls-style per-NIP layout used across
  Quartz (Event + tags/ + TagArrayBuilderExt write-DSL + TagArrayExt readers).

- Implement Agent Turn Metric (NIP-AM, kind:44200): an encrypted per-turn
  token-usage/cost record published by an agent to its owner. content is a
  NIP-44 v2 ciphertext of AgentTurnMetricPayload (camelCase), between the
  agent (author + `agent` tag) and owner (`p` tag); either party decrypts.

- Verify against a vector generated by Buzz's OWN code rather than a
  transcribed schema: a small generator on the real buzz-core emits a signed
  44200 event with deterministic keys; AgentTurnMetricVectorTest dispatches it
  through EventFactory, NIP-44-decrypts it, and asserts the payload — proving
  end-to-end interop. Fixture + generator committed under jvmTest resources.

Schemas confirmed against buzz-core/src/agent_turn_metric.rs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 19:04:15 +00:00
Claude
a54c808763 feat(quartz): add Buzz protocol kind registry + Owner Attestation (NIP-OA)
Introduces a top-level `buzz` package modelling the block/buzz protocol in
Quartz. Buzz is a NIP-29-family relay-group workspace (relay is the source of
truth, plaintext content, server-side membership) that layers an agent +
workspace vocabulary on top, so this package models only the Buzz-custom
extensions and reuses the existing NIP-29/34/51/42/43 classes for the standard
kinds.

- BuzzKinds: the full kind registry mirroring buzz-core `kind.rs`, annotated
  with where each standard kind already lives in Quartz.
- Owner Attestation (NIP-OA): the owner-signed `auth` tag that lets agents act
  as first-class members. Commitment = SHA-256("nostr:agent-auth:" + agent +
  ":" + conditions), BIP-340 Schnorr-signed by the owner; conditions grammar
  (kind=/created_at</created_at>, canonical decimals, u16/u32 bounds) matches
  buzz-sdk `nip_oa.rs` exactly.
- Tests pin Buzz's own published known-answer vector as a cross-implementation
  compliance check: our preimage hash equals Buzz's SHA-256 and our verifier
  accepts Buzz's reference signature.

Confirmed against the authoritative Rust (buzz-core/buzz-sdk), not the prose
NIP drafts, which lag the implementation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 18:47:12 +00:00
David Kaspar
a960ceb6a8 Merge pull request #3657 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-21 16:25:36 +01:00
davotoula
3fdb976aa9 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-21 15:24:41 +00:00
davotoula
a6b40cfc0f docs(skill): prevent duplicate-key mistake in find-missing-translations 2026-07-21 16:19:39 +01:00
davotoula
b616f69c1d update cz, se, pt, de 2026-07-21 16:19:31 +01:00
Vitor Pamplona
b6bf3b8a70 Merge pull request #3656 from davotoula/fix/bidi-control-literals
Replace raw bidi/invisible Unicode characters in source with \u escapes
2026-07-21 10:53:49 -04:00
David Kaspar
73a58e042d Merge pull request #3655 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-21 15:34:00 +01:00
davotoula
e3dae26483 docs: add coding standard banning raw invisible/bidi characters in source 2026-07-21 15:04:57 +01:00
davotoula
86c8d0b12c fix: replace raw bidi/invisible control characters in source with \u escapes
the files are now pure-ASCII and visually unambiguous:

- BlossomPaymentSafetyTest: raw U+202E/U+202C test payload -> escapes
- BlossomPaymentRequired: BIDI_OVERRIDES char array -> escapes
- Sanitizer: RTL_OVERRIDES and ZERO_WIDTH regex classes -> escapes

Verified by mutation: with BIDI_OVERRIDES stripping disabled,
reasonBidiOverridesAreRemoved fails, proving the escaped payload still
carries a real U+202E. Emoji ZWJ sequences in RichTextParserTest are
intentionally untouched (functional joiners, not bidi controls).
2026-07-21 15:04:47 +01:00
vitorpamplona
db0ff71432 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-21 14:00:35 +00:00
Vitor Pamplona
72e27a3530 Merge pull request #3654 from davotoula/feat/share-note-as-qr
Share a note (link) as a QR code
2026-07-21 09:57:06 -04:00
davotoula
556ef9f880 fix(share-qr): stop the mode toggle labels clipping 2026-07-21 14:30:04 +01:00
davotoula
52c863a9c6 feat(share): add QR payload selection for notes
test(share): pin naddr encoding for addressable notes in QR payloads
feat(share): add strings for the QR share screen
feat(share): add fixed-height note card for the QR screen
feat(share): add display-only QR screen for notes
feat(share): register the ShareNoteAsQr destination
feat(share): add Share as QR to the note share sheet
fix(share): gate NSFW thumbnail with ContentWarningGate, prefer article title
fix(share): size the QR from available width, add a11y description
fix(share): add QR a11y strings, document QR row in ShareActionRows KDoc
fix(share): compact permanently-covered NSFW thumbnail, fix inert QR width cap
fix(share-qr): close sensitivity gate leaks and text/thumbnail bugs on the share-as-QR card
fix(share-qr): fix stale payload, unreachable controls, missing back button, and screen-wake handling
fix(share-qr): show image thumbnail for image-only notes, hide raw media URL
2026-07-21 14:29:49 +01:00
Vitor Pamplona
5e5ecdab8b Merge PR: NIP-34 git parity for amy + quartz interop fixes
Merges nostr proposal 2ca4f8ae into main:
- feat(cli): full NIP-34 git collaboration parity for `amy git`
- feat(cli): `amy git grasp list|set` (NIP-34 GRASP server list, kind 10317)
- feat(cli): `amy git browse|cat|log` — read git objects over smart-HTTP
- feat(cli): `amy git init` — bootstrap a repo from the local git checkout
- feat(cli): `amy git label` (NIP-32) and `amy git apply` (patch -> working tree)
- fix(quartz): NIP-34 wire-format interop with ngit (clone/web, issue p, plain r)
- test(cli): live interop check against the real ngit-published amethyst repo
- fix(cli): shallow-clone euc, read truncation, process deadlock
- fix(cli): read routing, status perf, publish-ack + robustness
- fix: complete PR clone multi-value (kinds 1618/1620) + CLI robustness
- revert(amethyst): drop GitStatusIndex auth change; defer to separate proposal

clone/web now serialize as single multi-value tags per NIP-34 (ngit drops
repeated ones) for kinds 30617, 1618 and 1620; readers stay tolerant of the
legacy repeated form.

The GitStatusIndex status-authority guard was reverted before merge: it
authorized against the status event's own `a` tag, which the author controls,
so a forged ["a","30617:<attacker>:x"] defeated it — and unverifiable statuses
were dropped permanently (no re-reduce when the 30617 announcement arrives),
rendering closed issues as open. Refiling as its own proposal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 23:54:49 -04:00
Claude
062ab0f012 revert(amethyst): drop GitStatusIndex auth change; defer to separate proposal
The isAuthoritative guard added to GitStatusIndex read the repository owner
from the status event's own `a` tag (GitStatusEvent.repositoryAddress() ->
first a tag, no kind filter, no cross-check against the target's repo). That
value is attacker-controlled: a forged kind:1632 carrying
`["a", "30617:<attacker-pubkey>:anything"]` makes `status.pubKey ==
repoAddress.pubKeyHex` pass, so the spoof it meant to block still succeeds.

It also regressed reads: reduceLatestByTarget only re-runs on a new
kind 1630-1633, so a status dropped while the 30617 was uncached stayed
dropped, leaving genuinely-closed items in the Open tab with wrong counts.

Keep this series focused on the quartz + cli NIP-34 parity work. The Android
status-authorization hardening (resolve the repo from the target item, load
the cached 30617, authorize against repo.pubKey + maintainers() + the target
author, and re-reduce when a 30617 arrives) will land as its own proposal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:38:20 +00:00
Claude
5f5356c0fe fix: complete PR clone multi-value, git-status auth spoofing, CLI robustness
Addresses review findings from a merge-time audit.

- **Complete the headline multi-value `clone` fix for PRs** (was applied only to
  kind:30617). GitPullRequestEvent (1618) and GitPullRequestUpdateEvent (1619)
  carry `clone` with the same spec shape but still emitted repeated single-value
  tags and read only the first value — so the exact interop bug this branch set
  out to kill was still live for PRs, both directions (ngit keeps only the last
  repeated tag; we lost every URL after the first from ngit's multi-value tag).
  Now both emit one multi-value `["clone", …]` tag and read both forms. Verified
  on the wire + GitNip34InteropTest + CLI harness (40 checks).

- **Android git-status spoofing (GitStatusIndex)**: newest-status-wins with no
  author check meant anyone could publish a kind-1632 and make someone else's
  issue render closed. Now filter statuses to the repository owner (from the
  status's own `a` tag), declared maintainers (from the cached announcement), or
  the target item's author — matching NIP-34 and the CLI's derivation. Pre-existing
  on main; this branch made the CLI/Android divergence visible.

- **CLI robustness**: `git comment`/`git patch` no longer block forever reading
  stdin on an interactive TTY (amy is non-interactive — error instead). The local
  `git` subprocesses in `git init`/`git apply` now drain stdout on a side thread
  under a bounded `waitFor` + `destroyForcibly`, so a wedged git can't hang the
  CLI.

Left as a follow-up (cosmetic): GitBrowseCommands.candidateUrls duplicates
GitRepositoryBrowserViewModel's — worth lifting to shared code, not worth the
cross-module coupling here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
4e0ac212ee fix(cli): second audit pass — read routing, status perf, publish-ack + robustness
Findings from a second review round (two independent reviewers), with fixes:

Read path (git issues/patches/prs/thread):
- **Reads ignored the repo's own relays** (correctness). They queried only the
  account outbox/bootstrap (general relays); NIP-34 events live on the repo
  announcement's advertised relays (often GRASP/git-specific), which general
  relays don't mirror — so `amy git issues <repo>` with no --relay could return
  empty. Now fetch the announcement once and read from queryTargets ∪ its
  advertised `relays`. Verified live: `git issues`/`git prs` on the amethyst
  repo now return real events (and derive `closed`) with NO --relay.
- **O(items × statuses) status rescan** with un-memoized `rootEventId()` reparse
  → pre-group statuses by root id once (O(1) lookup per item).
- **Status query could truncate / exceed relay caps**: statuses are now paged
  (`drainAllPages`) and the `#e` id set is chunked to 50 (under the common
  ~100-value relay filter cap).
- **Latency regression**: capped the list `drainAllPages` idle timeout to 12s
  (was the 30s default; `drain` had been 8s).
- **Nondeterministic status on same-second ties** → deterministic id tie-break.
- Reuse the fetched repo for the maintainer set (removes a redundant round-trip).

Write path:
- **`git init` silently reported success when the 30618 state publish failed**
  — its ack was dropped. Now surfaced as `state_published_to`/`state_rejected_by`
  with a stderr warning on total rejection.
- **`git apply`** feeds stdin as UTF-8 (was JVM default charset — corrupted
  non-ASCII patches) and joins the stdin thread in `finally` (no leak on error).
- **`normalizeCloneUrl`** drops the port from `ssh://git@host:port/…` (it was
  carried into the https URL, making it unreachable).
- **Delivery fallback** to the account outbox (repo unresolved / no advertised
  relays) now warns to stderr instead of reporting silent success.

Known limitation (documented, not fixed): patch-revision-chain status derivation
follows only the root item, and `git thread` shows first-level replies only
(nested trees and 1619 PR-updates are out of scope). 38/38 harness green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
63d01c9287 fix(cli): audit fixes — shallow-clone euc, read truncation, process deadlock
Findings from a review pass over the git-parity branch, with fixes:

- **`git init` announced a WRONG earliest-unique-commit on shallow clones**
  (interop-critical). `git rev-list --max-parents=0 HEAD` returns the shallow
  boundary commits, not the true root, so the repo would be announced under a
  different cross-fork identity than ngit computes. Now: detect shallow clones
  and omit the euc with a warning to pass `--earliest-commit`; on full clones
  derive the deterministic `--first-parent` mainline root instead of an
  arbitrary `tail -1`.

- **`git issues|patches|prs` silently truncated and mis-derived status** on
  active repos: one single-page `drain` pulled items AND status events under a
  shared cap, so status events (newer, more numerous) could crowd items out of
  the window and the close-status that determines an item's state could fall
  outside it → a closed item read as open. Now paginate the items
  (`drainAllPages`) and fetch exactly the statuses that `e`-reference them.
  Verified on the live amethyst repo: 51 PRs paginated, 19 correctly closed.

- **Pipe-buffer deadlocks** (latent): `GitInitCommand.git()` discards stderr to
  the OS (a chatty command can no longer fill its stderr pipe and hang the
  stdout read); `GitApplyCommand.runGit()` writes stdin on a background thread
  while draining stdout, so a patch larger than the pipe buffer can't deadlock.

- Minor: `git cat` binary detection uses an index loop instead of boxing 8000
  bytes; `GitRepositoryEvent.clones()/webs()` dedupe.

The harness `git init` test now runs against a fresh full checkout (this repo's
CI checkout is shallow) and adds a shallow-clone case asserting the euc is
omitted. 38/38.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
fa32ea50a3 test(cli): live interop check against the real ngit-published amethyst repo
Adds a `--live` block that reads the actual amethyst repository ngit publishes
to relay.ngit.dev and asserts our reader parses ngit's real multi-value `clone`
tag (currently 4 URLs) plus its published issues. This is the real-world proof
of the multi-value interop fix: the pre-fix reader would have surfaced only the
first clone URL. Opt-in (needs network + the live relay), skipped by default.

Verified manually end-to-end against the live repo: repo announcement (4 clone
URLs), issues (1621), patches (1617), pull requests (1618, with a real `closed`
status derived from ngit's status event), and a NIP-22 comment via `git thread`
all read correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
1a77c95531 fix(quartz): NIP-34 wire-format interop with ngit (clone/web, issue p, plain r)
Verified Amethyst's NIP-34 events byte-for-byte against the ngit reference
implementation (DanConwayDev/ngit-cli) and the spec, and fixed three real
interop divergences in quartz — so ngit/gitworkshop and Amethyst read each
other's git repos, issues, patches, and PRs without losing data.

- Repository announcement `clone`/`web` were emitted as REPEATED single-value
  tags (`["clone", a]`, `["clone", b]`). The spec and ngit use ONE multi-value
  tag (`["clone", a, b]`), and ngit's parser keeps only the LAST of repeated
  known tags — so multi-URL repos silently lost every URL but one in both
  directions. Now emitted as a single multi-value tag; `clones()`/`webs()` read
  BOTH the spec form and the legacy repeated form, so old events still parse.
  (`relays`/`maintainers` were already correct multi-value tags.)

- Issues (kind 1621) were missing the `["p", <repo-owner>]` tag that patches and
  PRs already include — a maintainer watching `#p` wouldn't see them. The
  builder now adds it (fixes both the CLI and the Android issue-creation path,
  which both passed an empty notify list).

- Patch / PR / PR-update `r` tags carried the `"euc"` marker
  (`["r", commit, "euc"]`). Per the spec and ngit that marker belongs only on
  the kind-30617 announcement; other `r` tags are plain `["r", commit]`. A `#r`
  filter matches either shape, so this is a spec-compliance/byte-parity fix.

`alt` (NIP-31) tags are intentionally still omitted — quartz treats the generic
alt client-hint as deprecated, and ngit/gitworkshop parse the structured tags,
so it isn't required for interop.

Adds `GitNip34InteropTest` (5 cases: multi-value write, tolerant read of both
forms, issue p-tag, plain patch r-tag) and 4 wire-format assertions to the CLI
git harness (37 offline). No regressions in the nip34 or Search suites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
d3e208322c feat(cli): add amy git label (NIP-32) and amy git apply (patch → working tree)
Close two more ngit/nak parity gaps:

- `git label TARGET LABEL[,LABEL]` — attach NIP-32 kind:1985 labels to an
  issue/patch/PR (the `ngit pr label` / `issue label` surface), over quartz's
  existing `LabelEvent`. Namespace defaults to `ugc`; `--namespace` overrides.
- `git apply PATCH_ID` — fetch a kind:1617 patch and apply it to the local
  working tree via `git am` (the `nak git patch apply` / `ngit pr apply`
  surface); `--check` dry-runs `git apply --check`, `--print` emits the patch.
  Shells out to `git` like `git init`, since it operates on the local checkout.

Verified end-to-end: a patch published to a relay, fetched, and `git am`'d as a
real commit into a scratch repo; labels land as kind 1985. The harness gains 5
assertions (label + a full publish→apply round-trip), now 33 offline.

Remaining out-of-scope items are documented: git-packfile push (needs a git
write layer quartz lacks) and NIP-34 cover notes (kind 1624, no quartz builder).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
b06184ac49 feat(cli): add amy git init — bootstrap a repo from the local git checkout
Match `ngit init` / `nak git init`: read the local git repository and publish a
NIP-34 repository announcement, deriving the fields instead of making the user
type them. Shells out to `git` to determine the name (top-level dir), clone URL
(origin remote, ssh→https normalized), earliest-unique-commit (root commit),
and — for the accompanying kind:30618 state — the branch/tag tips and HEAD.
Publishes the 30617 announcement and (unless `--no-state`) the 30618 state in
one shot. Every derived value is overridable with a flag; outside a git repo
the derivation is skipped and `--name`/`--clone` are supplied manually.

This is the one `amy git` verb that shells out to `git`, since it is inherently
about the local working tree — exactly like the tools it mirrors.

Verified against the amethyst checkout itself (derives name=amethyst, the origin
clone URL, the root commit as EUC, and a 30618 with the live branches + HEAD).
The harness gains 4 assertions driving `git init` against its own checkout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
45d33c016e feat(cli): add amy git browse|cat|log — read git objects over smart-HTTP
Give `amy git` the git-object read side of `nak git download` / a shallow
clone. `browse` lists a repo's tree, `cat` prints (or `--out` writes) a file at
a ref, and `log` shows recent commit history — all over the git smart-HTTP v2
protocol via quartz's `GitHttpClient` (the same shallow-clone path the Android
repo browser uses). REPO may be a NIP-34 coordinate/naddr (whose announcement
supplies the clone URL) or a raw http(s) clone URL; `--clone` and `--ref`
override the URL and branch/tag.

Read-only: pushing git objects back to clone/GRASP servers stays out of scope.

Verified live against a public repo (octocat/Hello-World) — browse/cat/log all
return correct trees, blobs, and history. The harness gains a `--live` block
(28 assertions with `--live`, 24 in the default offline run) exercising these
against `$LIVE_REPO`, skipped by default since it needs a reachable git host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:57 +00:00
Claude
9922eef9f7 feat(cli): add amy git grasp list|set (NIP-34 GRASP server list, kind 10317)
Declare/read a user's preferred GRASP (Git-over-Nostr hosting) servers in
preference order — the NIP-65-style list `ngit`/`nak git` consult to decide
where PR tip branches (`refs/nostr/<pr-id>`) get pushed. `set` publishes a
kind:10317 to the outbox; `list` reads it back cache-first (anonymous-capable).
Thin assembly over quartz `UserGraspListEvent`. The git push itself stays out
of scope, as with the rest of the packfile transport.

Extends the git NIP-34 harness with a grasp round-trip (24 assertions) and
updates the README/ROADMAP/help tables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:57 +00:00
Claude
ad66e6c224 feat(cli): full NIP-34 git collaboration parity for amy git
Extend `amy git` from repo announce/list/show/issue to the complete
pure-Nostr surface of `ngit` and `nak git`, so every NIP-34 collaboration
flow is scriptable without a GUI.

New sub-verbs (all thin assembly over quartz's `nip34Git` builders):

- `git state`   — kind:30618 repository state (branch/tag tips + HEAD)
- `git patch`   — kind:1617 patch from `git format-patch` (--file or stdin),
                  with --root/--root-revision, --commit, --parent-commit,
                  and --in-reply-to for revision chains
- `git pr` / `git pr-update` — kind:1618 pull request + kind:1619 tip update
- `git comment` — NIP-22 kind:1111 reply on an issue/patch/PR/repo (the
                  modern replacement for the deprecated kind:1622 git reply)
- `git open|applied|close|draft` — kind:1630/1631/1632/1633 status events
                  (aliases `merged`/`resolved` for applied); applied carries
                  --merge-commit / --commit / --patch
- `git issues|patches|prs` — list a repo's items with status derived from the
                  newest authoritative (owner/maintainer/author) status event,
                  with --open/--applied/--closed/--draft/--status filters
- `git thread`  — one item plus its status timeline and comments

Shared parsing/fetch/routing glue lives in `GitSupport`; the existing
announce/list/show/issue verbs now reuse it. The git *packfile* transport
(clone/fetch/push of real objects to clone/GRASP servers) stays out of
scope — it needs a git plumbing layer, not an event builder — and is
documented as such.

Adds `cli/tests/git/git-nip34-headless.sh` (21 assertions, drives the whole
flow against `amy serve` and checks the status-deriving reads) and updates
the README/ROADMAP command tables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:57 +00:00
Vitor Pamplona
da451145af Merge pull request #3653 from vitorpamplona/claude/notifications-pagination-n0qahe
Add infinite-scroll backward pagination for notification history
2026-07-20 20:10:05 -04:00
Claude
a5ac06ad6c Merge branch 'main' into claude/notifications-pagination-n0qahe
Bring the notifications-pagination feature up to date with main.

Conflict resolution — the two live-notification managers:
main independently fixed the "notifications capped at a week" bug by a
different route: it dropped the oneWeekAgo() floor and now runs an all-time
`#p`+`limit` query gated by the lastNoteCreatedAtIfFilled() paging boundary
(kept together with its lastNoteCreatedAtWhenFullyLoaded collector job). That
updateFilter + newSub pair is one self-consistent unit, so this merge takes
main's complete version of AccountNotificationsEoseFrom{Inbox,Random}
RelaysManager and keeps the branch's dedicated `until`+`limit` history pager as
an additive layer on top (Account.notificationHistory, the history manager,
NotificationHistoryPaging.kt, the markers/retry UI, filter builders, tests).

Net: the feed gets main's all-time live query plus the branch's unbounded
backward pager. Note the two now overlap for users under the relay limit — the
pager's remaining unique value is scrolling past that limit; worth a review
pass, not a merge blocker.

Verified: :amethyst:compilePlayDebugKotlin, spotlessApply (clean), and the
notification unit tests (FilterNotificationsHistoryTest,
NotificationKindsContractTest) all green on the merged tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-21 00:00:28 +00:00
Claude
77908b2be0 refactor(notifications): extract history paging out of CardFeedView
CardFeedView had grown a ~90-line paging block plus an auto-retry loop,
constants and helpers that aren't about rendering cards. Move all of it into
a dedicated NotificationHistoryPaging.kt:

- rememberNotificationHistoryPaging(): the look-ahead buffer driver (with the
  per-burst cap), the stalled-relay auto-retry loop, cursor building, and the
  per-relay sentinels — returns the List<RelayReachCursor> the feed draws.
- BootstrapNotificationHistoryWhenEmpty(): the empty-feed hunt.
- The five tuning constants and the reachState / relayShortName helpers.

CardFeedView.FeedLoaded now just fetches the pager, calls the helper for the
cursors, and renders the detail dialog; the in-gap RelayReachMarkers stay
inline (they're per-row). No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-20 21:15:06 +00:00
Claude
05426d8c66 fix(notifications): bound eager fill + only the active feed drives paging
Address the two audit findings on the infinite-scroll driver.

- Bound the eager fill (#1): the buffer targets 100 rows but pages are pulled
  in events and notifications collapse heavily into cards, so on a dense
  account a fill could keep pulling until it downloaded the whole history to
  reach the row target. Cap consecutive pages pulled WITHOUT scrolling
  (NOTIFICATION_MAX_PAGES_PER_BURST); scrolling resets the budget, so paging
  resumes as the buffer is consumed. Appended older cards don't move
  firstVisibleItemIndex, so the from-top preload still fills the full
  look-ahead on open — only a dense whale is bounded.
- Only the active feed drives (#2): add drivesPaging (default true); the split
  screen passes page == pagerState.currentPage so an off-screen tab composed
  during a swipe no longer drives the shared account pager, and its buffer
  driver / auto-retry loop / sentinels stay idle. Single screen and side panel
  keep driving.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-20 21:07:30 +00:00
Claude
c8f43a1bd4 feat(notifications): auto-retry faulty relays + actionable relay detail
Improve the notifications history UX around slow/unreachable relays, keeping
the per-relay markers (they let users notice their own bad relays) but making
recovery automatic and the tap-through actionable.

- Auto-retry stalled relays with backoff (~3s→30s): once the buffer driver
  stops (every relay done-or-stalled) but some are merely stalled, keep
  re-advancing them so recovery no longer depends on the user scrolling to the
  marker or reopening the screen. One non-restarting effect so the backoff
  survives the transient in-flight blips each retry causes; cancels on leave.
- Add a "Try Again" action to RelayReachDetailDialog (shared): when a caller
  passes onRetry and a relay is stalled, the tapped marker's detail popup
  offers an active retry and drops the now-inaccurate "retries on reopen" hint.
  Notifications wire it to advanceAll; DM callers pass nothing (unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-20 20:36:43 +00:00
Claude
cc94ef9103 fix(notifications): retry stalled relays via per-relay sentinels
The buffer-only driver stopped paging once every relay was done-or-stalled
(exhausted), so a transient all-relays blip halted history until re-navigation
— bad for faulty relays with different datasets, exactly when we'd miss data.

Restore the per-relay RelayReachSentinels alongside the look-ahead buffer:
- the buffer driver (advanceAll) keeps the runway full from healthy relays;
- the sentinels retry an individual relay when its frontier marker scrolls
  into view — the recovery path once the buffer can't keep the frontier ahead
  (relays stalled/exhausted), naturally rate-limited by scrolling.

The buffer keeps the frontier ~a screen below the fold, so the sentinels stay
quiet during normal scrolling and only fire on stall/end. This also makes the
kept per-relay markers functional again (they drive the retry) instead of
purely decorative.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-20 20:14:30 +00:00
Claude
ad75344c3e feat(notifications): infinite-scroll paging via a look-ahead buffer
Switch the notifications feed from marker-visibility paging (load only when
the bottom marker is on screen) to infinite scroll: keep ~100 already-loaded
rows below the viewport so the user practically never reaches the end.

- Replace RelayReachSentinels with a buffer-depth driver: when fewer than
  NOTIFICATION_LOOKAHEAD_BUFFER (100) rows remain ahead of the last visible
  one, step every not-done relay one older page (advanceAll). It re-fires as
  each page settles until the buffer refills or all relays run dry — the
  wallet's lastVisibleIndex >= totalItems - N pattern with a large N.
- Keep the per-relay BackwardRelayPager engine, cursors and filters unchanged.
- Keep the in-feed per-relay progress markers + tap-through detail dialog;
  they are now purely visual (loading is driven by the buffer, not by them).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-20 19:48:27 +00:00
Vitor Pamplona
ef9a891974 Merge pull request #3652 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-20 15:17:43 -04:00
vitorpamplona
b2cfb5e7ee chore: sync Crowdin translations and seed translator npub placeholders 2026-07-20 19:14:54 +00:00
Vitor Pamplona
0601cfafbc Merge pull request #3651 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-20 15:12:11 -04:00
vitorpamplona
0214382d0f chore: sync Crowdin translations and seed translator npub placeholders 2026-07-20 18:19:06 +00:00
Vitor Pamplona
a0ab3ec66d Merge PR: feat(compose): resolve NIP-05 (incl. Namecoin .bit) in the @-mention popover
Merges nostr proposal b07eb505 into main:
- feat(nip05): add Nip05Id.parseLenient for mention/text rendering
- feat(compose): wire NIP-05 popover mentions to nostr:nprofile1…

Also closes duplicate proposal 4b90b41f, which pointed at the same commits.

Beyond the feature, this replaces the unvalidated `Nip05Id("_", prefix)` raw
constructor in UserSuggestionState with `Nip05Id.parseLenient(prefix)`, closing
a hole where a typed mention such as `evil.com#x.bit` produced a GET to an
arbitrary host via `toUserUrl()`'s bare interpolation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 14:13:17 -04:00
Vitor Pamplona
15ce992b61 Merge pull request #3650 from vitorpamplona/fix/napplet-account-isolation-and-consent
fix: v1.13.0 pre-release QA — napplet account isolation, Concord authority, and 30 other fixes
2026-07-20 13:28:41 -04:00
Vitor Pamplona
8913af7a79 fix(concord)!: enforce CORD-04 rank gating on the Banlist fold
Closes the privilege escalation: any BAN holder could ban the authorities above
them — including the owner — because the Banlist gate checked only the BAN bit.
Once banned, a member loses all authority (`hasPermission` is `!isBanned && ..`)
and honest clients drop their events, so a single edition from the most junior
moderator permanently silenced every admin above them.

CORD-04 §3 requires the rank half: "One hard rule binds every action: the actor
must hold the required bit and strictly outrank its target — equal cannot act on
equal (an admin cannot ban a peer admin)", restated as §5 step 3. Only §4, which
defines the Banlist, states the bit half alone — which is why both this client
and Armada shipped the same rank-blind gate.

§3 is stated per TARGET while the Banlist is one whole-list document, so it is
enforced as a DELTA rule: an edition may only add or remove npubs its signer
strictly outranks, judged against the roster settled behind it; the owner is
never a valid target (position 0 is "supreme and unremovable"); and entries the
signer may not act on are IGNORED rather than rejecting the edition, so one bad
entry cannot discard the bulk-ban §4 recommends as the collision remedy, and a
rogue cannot grief the list by forcing rejections.

ConcordModeration.currentBanned now reads the honored banlist through the
resolver instead of decoding the raw head. Besides picking up the fork healing
it was missing, this closes a laundering path: our own next ban/unban would
otherwise re-publish an entry our fold refuses, under our signature.

BREAKING (consensus): Armada has not shipped this rule, so banlists can differ
between clients until it does — we now ignore a ban Armada honors whenever the
signer did not outrank the target. Shipping the spec-conformant behaviour was
judged better than continuing to honor an escalation. Write-up to send upstream
is docs/concord-banlist-rank-conformance.md.

The three tests added in 0ae6bc6698 as @Ignore-d documentation now pass and are
un-ignored; two companions (a moderator still bans a plain member, the owner
still bans anyone) passed throughout and pin what the fix had to preserve.
Full :quartz:jvmTest and :commons:jvmTest suites green.

Still open and documented, not addressed here: a banned BAN holder can lift
their own ban (a fixpoint-ordering question that needs a spec ruling), and a
forked ban survives an unban that does not chain onto it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 12:53:11 -04:00
Vitor Pamplona
ea6762b137 docs(concord): Banlist rank gap is a conformance bug, not a spec gap
Correcting the previous commit's reasoning. It concluded from the two
implementations that CORD-04 does not rank-gate the Banlist and that enforcing
it would be a unilateral divergence. Reading the actual spec
(github.com/concord-protocol/concord, not in the Armada repo) shows the
opposite: §3 is normative and binds "every action" — "the actor must hold the
required bit and strictly outrank its target — equal cannot act on equal (an
admin cannot ban a peer admin)" — and §5 step 3 restates it. Banning is the
example the rule itself picks.

Only §4, the section defining the Banlist, states the bit half alone. Both
independent implementations read §4 in isolation and made the same mistake,
which is evidence about the section rather than about the readers.

So the fold fix is spec-mandated. It remains consensus-affecting (we would
ignore bans Armada honors until they ship), so it wants coordination rather
than a race, and the fold is still unchanged here.

Adds docs/concord-banlist-rank-conformance.md to share upstream: verbatim spec
citations, both implementations' gates, a delta-based rule that makes the
per-target requirement expressible against a whole-list entity, and two further
reproduced findings — a banned BAN-holder can lift their own ban (so bans do
not stick against any BAN holder), and a forked ban survives an unban that does
not chain onto it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 12:05:21 -04:00
Vitor Pamplona
0ae6bc6698 fix(concord): rank-gate the Ban and Remove affordances
Ban/Remove were offered to any BAN holder against any non-owner, ignoring rank
— unlike the role picker, which routes through `canActOn`. Both the Members
roster and the message-level path (`Account.concordBanTarget`, the chokepoint
for the quick-action menu, the note dropdown, the note action sections and the
chat action sheet) now require `canActOn(me, target, BAN)`.

This is NOT the no-op it first looked like. The premise that the fold would
drop such a ban is wrong, and a test proves it: BANLIST is a single whole-list
entity, so `authorizedHeads`/`banGate` gate on the author's BAN bit alone and
never rank-check the list's *contents*. A rank-5 moderator's ban of a rank-1
admin is therefore ACCEPTED by every client, and the admin then loses every
permission, since `hasPermission` is `!isBanned && ..`. It is privilege
escalation, not a silent no-op.

The fold is deliberately left alone. Armada has the identical gap — its
`banlistGate` calls the rank-blind `isAuthorized(.., Permissions.BAN)` while
its role path uses the rank-aware `canActOnPosition` — so rank-gating our fold
would make us ignore bans every other client honors, splitting the banlist
across clients. Closing it needs a spec change, like CORD-05. Refusing to
AUTHOR such a ban restricts only what we write, never what we accept, so it
cannot diverge consensus.

Three `@Ignore`-d tests in AuthorityResolverTest state the fold-level invariant
and currently fail by design; two companions assert the gate does not
over-correct (a moderator still bans a plain member; the owner still bans
anyone). Un-ignore the first three when the spec closes the gap.

The owner short-circuits the check rather than going through `canActOn`, which
begins at `hasPermission` and is false while banned — since a rogue BAN holder
*can* currently banlist the owner, routing them through it would let them be
locked out of moderating their own community.

Device-verified on Amethyst QA Concord as Dr. Edo (QA Lead, rank 2): Bob
(Admin, rank 1) now offers only the disabled "Roles… / You don't outrank this
member" where Ban and Remove used to be enabled, while the Helper (rank 5)
still offers Roles…, Ban and Remove.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:54:28 -04:00
Claude
ba8de9ba21 feat(notifications): paginate history by time with in-feed load markers
Notifications were pinned to the recent week with no way to scroll further
back. Mirror the NIP-04 / gift-wrap DM approach: a backward, per-relay
until+limit pager driven by in-feed window-limit markers that pull the next
older page only while visible.

- Add Account.notificationHistory (RelayLoadingCursors) and
  AccountNotificationsHistoryEoseManager, a BackwardRelayPager over the
  inbox + NIP-29 group-host relays, registered always-on in
  AccountFilterAssembler. It parks until a marker advances a relay.
- Add filterNotificationsHistoryToPubkey / filterGroupNotificationsHistoryToPubkey
  and AllNotificationKinds: one combined-kinds filter per relay so the single
  per-relay cursor stays gap-proof (empty page + EOSE = nothing older).
- Wire RelayReachMarkers + RelayReachSentinels into the notifications card
  feed (CardFeedView), with an empty-feed bootstrap, so scrolling a relay's
  marker into view loads more.
- Fix the two live notification loaders to a fixed one-week tail (drop the
  fullness-driven `since` drift), letting the marker-driven pager cleanly own
  everything older — the same live-tail/history split DMs use.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-20 15:53:32 +00:00
Vitor Pamplona
c15774e4e1 docs(qa): record the notifications deadlock and close two open findings
Adds the Notifications and Concord role-grant rows to the coverage table,
retires the "grantConcordRole is unreachable" finding, and records two new
ones: the Members roster offering Ban/Remove on members the viewer doesn't
outrank (silently dropped on fold), and notification cards whose target note
isn't cached rendering as placeholders.

Also adds the pattern worth carrying forward: a paging boundary gated on a
full page deadlocks against a narrow query window, and the empty state is
self-sustaining.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:36:39 -04:00
Vitor Pamplona
4e7242a295 feat(concord): assign CORD-04 roles from the Members roster
`Account.grantConcordRole` had been implemented with zero callers, so role
grants were unreachable from the app while the changelog claimed they ship.
This adds the missing surface: a "Roles…" item beside "Make admin" opening a
multi-select over the roles the viewer may hand out.

Both rank rules are enforced by delegating to `AuthorityResolver` rather than
reimplementing them:

- assignable roles are `roles().filter { myRank < it.position }` — the fold
  drops a grant whose granter does not strictly outrank every assigned role, so
  offering one at or above our own position would publish an edition that every
  client then silently discards;
- reachable members are `authority.canActOn(me, target, MANAGE_ROLES)`, which
  already folds the whole rule (hold the bit, not banned, target isn't the
  owner, strictly outrank) and makes self-promotion fall out for free.

Out-of-reach members show the item disabled *with a reason* instead of omitting
it, so there is no silently no-op control.

The grant REPLACES a member's role set rather than merging into it, so the
dialog preselects their current roles. That preselection is provably complete:
a member's rank is the lowest position they hold, and the dialog only opens
when we strictly outrank that rank, so every role they hold sits strictly below
us and is therefore rendered — no held role can be silently stripped.

`amy concord roles` also gained a `grants:` section reading the post-fixpoint
`authority.roleHolders()`. It previously printed role *definitions* but never
the *grants*, which made the fold outcome unverifiable from the CLI; a
rank-violating grant now shows up as visibly absent rather than as if it landed.

Device-verified on a test community (Admin/QA Lead/Helper/Greeter): the picker
hides roles above the viewer, disables on members who outrank them, preselects
correctly, and a saved grant survived the fold and a fresh relay drain read
back from a second client.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:35:48 -04:00
Vitor Pamplona
97b861dd5e fix(notifications): remove the 7-day since floor that emptied the tab
The notifications query pinned `since` to `oneWeekAgo()` whenever it had no
EOSE timestamp. Combined with two other facts that produced a deadlock:

- the EOSE `since` map is in-memory only (`SincePerRelayMap = MutableMap<..>`),
  so EVERY cold start re-pinned the window to 7 days; and
- the backward-paging escape hatch (`lastNoteCreatedAtIfFilled()`) only arms
  once the feed holds a FULL page (`notes.size >= localFilter.limit()`).

The feed could not fill a page because the query only asked for a week, and the
query could not widen because the feed never filled. Any account whose last
inbound mention was older than 7 days saw a near-empty Notifications tab
forever — including a fresh install of a long-established account.

Worse, the floor was applied to `filterSummaryNotificationsToPubkey` (kinds
1/7/6/9735 — the overwhelming bulk of notifications), which was not even wired
to the paging fallback that the secondary per-key kinds received.

Home is the precedent and does not do this: `filterHomePostsByAllFollows`
passes `since ?: boundary`, i.e. plain null on a cold start, relying on the
relay-side `limit` to bound the response. Notifications was the outlier. These
filters are `#p`-scoped to the user's own key and carry a `limit`
(2000/500/200/20), so an all-time query is one index scan returning at most
`limit` events newest-first — same cost, strictly more useful.

Measured on the test account before the fix: 163 events p-tagging it exist on
relays and are fetchable (100 kind-1 and 100 kind-7, both hitting the query
cap), 77 of which clear the follow gate — yet the tab rendered ~3, because the
account's most recent inbound activity was a month old. After the fix the tab
scrolls back through Feb 2025.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:34:07 -04:00
Vitor Pamplona
4ee0416a90 docs(qa): record v1.13.0 test coverage, open findings and recipes
The 30 fixes from this session each carry their reasoning in their own
commit message, and the regression tests are in the repo. What none of
that captures is the shape of the testing itself: what was actually
exercised, what was not, what we knowingly left broken, and how to
reproduce the setups. That lived only in one conversation.

Records four things:

- **Coverage**, honestly split three ways: exercised on device; fixed but
  only unit-verified (roughly half the session's work, including the
  entire Concord authority chain); and never opened at all (Nests,
  Marmot, the whole Desktop app, payments, notifications). Plus the
  platform gaps — one tablet, one flavour, one ABI, Android 14 only while
  targetSdk is 37, and no real `release` build.

- **Open findings** we chose not to fix, so they are decisions rather
  than oversights: the unbumped versionCode, the Firebase scheduling
  failure that may cost Crashlytics, Tor settings needing a restart, an
  unreachable relay reported as empty, WebView profiles surviving logout,
  control-plane edits still dropping unknown keys, and the CORD-05
  root-binding gap that Armada shares and therefore needs a spec
  conversation.

- **Setup recipes** that took real time to work out: isolating `amy` by
  `$HOME`, which relays actually accept a NIP-29 create, deep-linking
  past a 1000-entry directory, and proving "no network before consent" by
  running a local relay over `adb reverse` and counting events either
  side of the tap.

- **Patterns** that recurred often enough to be process problems: tests
  that assert the bug, implemented-but-unreachable capabilities, Gradle
  serving a stale up-to-date test run and reporting success for a
  deliberately broken build, and four confident diagnoses that
  measurement overturned.

Written for whoever picks this up next, including the parts that reflect
badly on the testing rather than only the parts that reflect well.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:56:21 -04:00
Vitor Pamplona
a040aa522c fix(perf): keep favicon and settings disk reads off the main thread
Two StrictMode disk violations on startup and on IPC, fixed differently
because they are different problems.

**Favicon storage was writing on the IPC handler thread**, i.e. the main
looper: `BrowserIconRegistry.record` did `File.writeBytes` inline, and
`init` scanned the directory with `listFiles`. Both now run on an IO
scope. The directory is still published synchronously so `iconModelFor`
and `record` work immediately — only the scan and the write are deferred.
`keys` updates after the bytes are actually on disk, so a reader is never
told an icon exists before the file backing it does, and until the scan
lands an icon renders its placeholder for a frame and then recomposes,
which is what the StateFlow is for.

**The settings read was left synchronous on purpose, and stays that
way.** `notificationServiceEnabled` reads SharedPreferences inside a lazy
initialiser; the surrounding comments record why it is not hydrated
asynchronously — an async hydrate reopens a window where a late disk read
clobbers a user's toggle. Converting it would have looked like a
StrictMode cleanup while resurrecting a settings-corruption bug. Instead
the prefs file is warmed on an IO thread at startup, so the first
synchronous read hits SharedPreferences' in-memory cache. Best-effort by
design: if a main-thread reader wins the race it pays the disk hit once,
exactly as before, and correctness is unchanged either way.

Verified on device: `notificationServiceEnabled` StrictMode hits went
from 9 to 0 on a cold start, no icon storage failures, 884 ms cold start,
no crashes — and both pinned web-app icons (ditto.pub and Brainstorm)
still render, which is the thing making the write async could plausibly
have broken.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:52:05 -04:00
Vitor Pamplona
4255ba3345 fix(browser): capture icons from sites that declare only an SVG favicon
A pinned web app showed a generic placeholder instead of its icon —
ditto.pub failed while brainstorm.nosfabrica.com worked. The
discriminator is what each site declares:

    brainstorm:  favicon.svg  AND  favicon.png   <- the PNG is why it works
    ditto:       logo.svg only  (+ an apple-touch-icon nobody read)

Icons were captured solely through `WebChromeClient.onReceivedIcon`,
which hands back a rasterized Bitmap. Android WebView does not decode SVG
favicons into that callback, so a site offering a raster alternative gets
picked up and an SVG-only site never fires it at all: nothing is
recorded, `iconModelFor()` returns null forever, and it fails silently —
no error, no log line, just a permanently generic icon. SVG-only
declarations are increasingly the norm, so this was set to affect more
apps over time.

Adds a sniffer that, after a main-frame load, ranks the page's declared
icons (raster `rel="icon"` → SVG `rel="icon"` → apple-touch-icon →
/favicon.ico) and fetches the best one IN PAGE CONTEXT, falling through
on failure. The bytes are size-bounded and magic-byte validated before
being relayed down the existing record path; anything unrecognised is
dropped.

Fetching in page context is the point, not an implementation detail. The
registry captures from the WebView deliberately — "an alternative to the
main app fetching host/favicon.ico itself, which would bypass Tor and
leak the request". Ditto's /favicon.ico exists and returns a valid icon,
so fetching it from the app would have been the easy fix and the wrong
one. Every byte still comes through the sandbox WebView's own network
path.

No rasterisation needed: coil3's SVG decoder is registered on the
singleton loader and sniffs by content rather than extension, so stored
SVG bytes decode even under the registry's .png filename. Only a leading
BOM/whitespace trim was required so byte 0 is the '<' the sniffer wants.

Also fixes a second gap found while diagnosing: `NappletBrowserService` —
the EMBEDDED path backing pinned bottom-nav tabs — had no icon capture at
all, so a pinned app's icon depended on having once opened it full
screen. It now gets both the missing raster callback and the sniff.
`NappletHostService` deliberately left alone: it serves verified blobs
over a synthetic internal host, and applet icons already come from the
manifest, so capture there would be dead code.

The sniff runs after a delay and skips when the raster callback already
claimed the host, so sites that worked before are untouched.

Verified on device: ditto.pub now shows its real icon on the favorite
card, the pinned sidebar tab and the recents row, via both the embedded
and full-screen paths; Brainstorm is unchanged; example.com (no usable
icon) degrades to the placeholder with zero record calls and no hang; no
crashes.

Pre-existing, not fixed: `BrowserIconRegistry.record` writes the file on
the IPC handler thread, tripping StrictMode. It did so before this change
— which now simply gives it more occasions. Moving that write off-main is
a cheap follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:09:24 -04:00
Vitor Pamplona
08bd5d9b4a fix(napplet): tightening an app's trust level drops its live grants
Changing an app to PARANOID did not stop it signing. `sessionAllows` is
consulted BEFORE `signerLedger.decide()`, so a live "allow for this
session" grant short-circuits exactly the check the stricter policy would
have failed. The user picks "I'm a bit paranoid", the UI updates, and the
app carries on signing on the strength of a grant made under the old
policy — until every applet surface closes.

The trust level is a decision about how an app is treated from now on, so
the policy change now drops what that app is currently holding, the same
way revoking and forgetting already do. It fires on any change rather
than only on tightening: loosening is the user's call too, and a stale
grant surviving a deliberate re-decision is surprising in either
direction.

Completes the revocation work — the three paths that change what an app
may do (forget, per-op revoke, trust level) now all clear its live
session grants.

Not automatically tested: this is a Compose click handler and `amethyst`
has no Robolectric. The underlying `revokeSessionGrants` is covered by
`revokingAnAppDropsItsLiveSessionSignerGrants`, which was verified to
fail before its namespacing fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 09:28:34 -04:00
Vitor Pamplona
b0668baeec fix(napplet): make revoking an app actually drop its live grants
Three defects, each of which made revocation look like it worked.

**`revokeSessionGrants` had no callers.** It was added with the KDoc "so
revoking an app takes effect immediately instead of lingering until this
broker instance dies" and then never wired, so revoking an app in
Connected Apps left its in-memory session grants active. The user
revokes; the app keeps signing.

**And it was broken as written.** `sessionAllows` keys are the
account-namespaced `napplet:<signer>:<coordinate>|<op>`, but every revoke
call site holds the BARE coordinate, so the prefix match found nothing.
Wiring it naively would have looked correct and silently done nothing. It
now namespaces before matching, and also clears the post-Cancel re-prompt
cooldown so a revoked app prompts on next use instead of being quietly
dropped.

**Worse: there were three ledgers.** `NappletBrokerService`,
`ConnectedAppsScreen` and `ConnectedAppDetailScreen` each constructed
their own `NappletPermissionLedger`, while ALLOW_SESSION grants are
per-instance in-memory state. So "Forget" cleared the screen's own
always-empty session map while the grants the broker actually consults
lived on. The KDoc described a process-wide singleton; it wasn't one.
Promoted to a real singleton in AppModules alongside the existing
permission store, and shared by all three.

The screens are plain composables with no binder to the broker service,
so rather than invent an IPC path the cached broker moved to the
service's companion under a lock — matching the sibling main-process
registries in that package. Both revoke paths call it: the Forget button
and the per-op revoke.

Also gives `NappletPermissionLedger.endSession()` its first caller, which
promoting the ledger made necessary: it used to die with the service, so
session grants had a natural bound. Now that it outlives the service,
`onDestroy` restores exactly the lifetime ALLOW_SESSION already implied.
The boundary is safe — the service is bind-only and is destroyed only
once every applet and browser surface has unbound, so switching between
two open applets never drops grants mid-use. Deliberately NOT wired to
account switch (already handled by account-keying) or to backgrounding
(would re-prompt mid-use).

Test notes, kept honest: the revoke test was verified to fail before the
namespacing fix. The `endSession` test PASSES without the change —
`endSession` itself was always correct, the bug was that nobody called
it — so it is characterization for the new lifetime contract, not a
regression test. The `onDestroy` wiring and the composable click handlers
have no automated coverage; `amethyst` has no Robolectric and no harness
was invented for them.

Known gap, left alone deliberately: changing an app's trust level to
PARANOID does not drop its live session grants, because `sessionAllows`
is consulted before the signer ledger. That is a revoke-shaped action and
belongs in the same fix, but it is a behaviour change and was out of
scope tonight.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 09:13:37 -04:00
Vitor Pamplona
62440748a7 fix(concord): stop a rejected edition orphaning the honest ones after it
An unauthorized control edition in the middle of an entity's chain
permanently froze that entity. Observed on device for a member's GRANT:

    v0 owner    (grant mods)
    v1 owner    (grant admins)     <- fold stopped here, forever
    v2 MIDTIER  (escalation, correctly rejected)
    v3,v4,v5 owner                    orphaned, unreachable

`AuthorityResolver` filtered unauthorized editions out BEFORE calling
`EditionFold.foldEntity`, and the walk only advances when the next
version cites the current head's hash. Removing v2 severed the chain, so
every honest edition above it was lost. Any member could permanently
freeze any member's role assignment — including the owner's ability to
change it — with a single event, recoverable only by a Refounding. It
predates the recent rank gates (verified with a zero-role identity); the
gates only widen which editions can poison.

Armada does not have this bug, and its approach settles the design.
Reading its control-plane fold (read for semantics only — Armada is
AGPLv3, Amethyst is MIT, no code taken): the chain walk runs over the
UNFILTERED set, producing an ordered candidate list — chain-verified head
first, then every remaining edition version-descending — and authority is
applied AFTERWARDS, per candidate, picking the first admissible one. A
rejected edition is skipped during the ascending admissibility walk
without truncating it. For the chain above, Armada picks v5.

So the fix is not to filter later but to gate later: `EditionFold` gains
candidate-based gated folding, and the resolver and community state now
gate per candidate instead of pre-filtering the pool. Authority checks
themselves are unchanged — only WHEN they run moved. Applied to ROLE,
GRANT, BANLIST, CHANNEL, METADATA and the authorized-head map.

The writer had to be fixed too, for a sharper reason than expected. With
an ungated `headOf`, a rogue banlist edition at the tip is read as
current state, so the owner's next ban REPUBLISHES THE ROGUE'S CONTENT
UNDER THE OWNER'S SIGNATURE — an unauthorized empty banlist laundered
into an owner-signed one the moment the owner bans anyone else. Tolerant
reading cannot heal that, because the resulting edition is genuinely
authorized. `ConcordModeration.headOf` now folds the authority-gated
heads, and `owner` is a REQUIRED parameter rather than defaulted, since a
silently-wrong default here is a consensus footgun.

Banlist healing is preserved with one necessary change: the ancestry walk
now runs over the full pool rather than the authorized subset. Ancestry is
structural — walking only authorized editions stops at the rejected one
and misreads genuine ancestors as concurrent forks, resurrecting bans an
unban had cleared.

Six regression tests, each verified to fail without the fix. Two process
notes worth recording: the first "without the fix" run reported BUILD
SUCCESSFUL because Gradle served a stale up-to-date `jvmTest` — trusting
it would have meant concluding the tests were worthless. And the
forged-edition test initially passed both ways because the forgery's
content coincided with the honest outcome; it was rewritten so the
mid-chain arm genuinely discriminates.

The rank-gate, rogue-higher-version, floor and rollback tests all pass
unchanged.

Known gap: `headOf` gates through the per-kind permission map, which is
coarser than the resolver's rank gates, so the writer can still pick a
head the reader rejects when an in-permission but out-of-rank edition
sits at the tip. Tolerant reading makes that benign, but it is not an
exact reader/writer match; tightening it needs the resolver to expose
per-entity heads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 09:06:13 -04:00
davotoula
2d29f7ccda no empty methods
use constants instead of repeating strings
2026-07-20 13:58:06 +01:00
Vitor Pamplona
5f712b3a8a feat(concord): let a member leave a community
`Account.leaveConcordCommunity` has existed since the feature landed and
had ZERO callers anywhere in the repo — so joining a Concord community
was one-way. It stayed in the account's kind-13302 list and in Messages
permanently, with no affordance on the community screen, the Members
screen, or Messages.

Found because a test account joined a probe community whose only relay
then went away: stuck in the list, channels unrecoverable, nothing to
tap. Third capability found this session that is fully implemented and
unreachable, after `ConcordInviteBundle.isExpired` (called only from a
test, so invite expiry was decorative) and
`NappletPermissionLedger.endSession` (never called, so session grants
outlived every revoke). Each made a feature look complete to anyone
reading the model.

Adds "Leave community" to the community screen's top-bar overflow,
mirroring how NIP-29 relay groups already place membership-destroying
actions, behind a confirmation. It renders whether or not the Control
Plane ever folded, which is the case that matters — a dead-relay
community never folds.

The copy is deliberately narrow about what leaving does: it removes the
community from THIS account's list and stops syncing, it does NOT notify
the community or remove anyone from a roster, and returning needs a new
invite.

Owner leaving is allowed, with an extra warning. Blocking it would make
the actual stuck case unfixable, since the motivating community was one
the account created; and it is the user's own private list to edit.
But it is irreversible in a way worth stating: `ownerSalt` lives only in
that entry, so discarding it retires the community rather than
transferring it. Ownership is read from the stored entry rather than the
folded authority, because a dead-relay community has no folded authority.

Works offline by construction: the underlying call rewrites the local
list (falling back to the on-disk backup when nothing folded) and
publishes fire-and-forget to the user's OWN outbox — never the
community's relays — so the UI does not wait on a relay that cannot
answer. Both paths that could resurrect a left entry were checked:
stranded recovery iterates live communities only, and the list import
takes the newest 13302, which is ours.

Tests cover the real logic behind the button — `unfollow` was previously
untested — driven through the offline-backup path with no cached relay
event: drops only the named community, preserves other memberships'
secrets, empties cleanly on the last one, no publish for a community
never joined, and the rewritten list stays self-encrypted.

Not verified on device: building an APK would have replaced the build a
concurrent Concord authority test was running against. The composable
itself has no automated coverage — `amethyst` has no Robolectric.

Two follow-ups noted, not fixed: leaving does not unpin a community from
the bottom bar, so a pinned one leaves a dead tab; and
`grantConcordRole` is another zero-caller capability — the general
CORD-04 role-grant path is unreachable, with only the narrower
make/remove-admin wired up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 08:57:52 -04:00
Vitor Pamplona
ab2f4d2da8 fix(nip29): split group-state REQs so relays stop rejecting them
A joined NIP-29 group showed its raw hex id instead of its name — in the
header, the Messages list and the browse directory — offered "Join" to an
account the relay already listed as an ADMIN, and hid the entire admin
surface. Members, Edit group, Invite people, the Threads "+" FAB,
pin/unpin, share and the member count all sit behind the same
`isMember()` gate, so none of them were reachable. Posting was blocked
too.

The cause is not membership modelling, warmup skipping, or the local
kind-10009 list. **The relay rejected the subscription outright.** The app
asked for all five group-state kinds in one filter, captured on the wire:

    SEND ["REQ",…,{"kinds":[39000,39001,39002,39003,39005],"#d":[…]}]
    RECV ["CLOSED",…,"blocked: it's not allowed to mix metadata kinds
                      with others"]

The whole REQ is dropped, so zero 39000/39001/39002 reach LocalCache.
Membership is derived from those events, so it fell back to NONE and
every gated control disappeared. Chat messages arrived on a separate `#h`
REQ, which is why the group looked half-alive rather than broken.

relay29/khatru29 evaluates that rule PER FILTER, confirmed by probe:

    39000-39003 + 39005 in one filter   -> CLOSED, 0 events
    the same kinds as two filters       -> 4 events, EOSE
    39000-39003 alone                   -> 4 events

So the fix is to split the kinds into two filters in the same REQ — no
extra subscription, no new assembler. This repairs every joined group on
any relay29-family relay, not just the one that surfaced it: the always-on
joined-state subscription was hitting the identical rejection everywhere.

Two hypotheses were tested and disproved rather than assumed. The
join-tap/warmup theory was wrong — instrumentation showed the filter was
built correctly with the right scope and only failed on the wire. The
`since`-on-replaceable theory was also wrong here: `since` logged null on
every call, since the map is per-subscription and reset on disconnect
rather than a persisted floor. Both left unchanged.

Regression tests fail on the old single-filter behaviour and pass on the
new one, verified by reverting the behaviour while keeping the constants
so the tests genuinely run rather than fail to compile.

Verified on device: header now reads "Amethyst QA 1.13" with an Admin
badge and member count 2, composer enabled, overflow menu showing
Members / Edit group / Invite people / Leave — all consistent with the
relay's roster.

Not fixed here: a stale "Requested" state is client-side only and is
never reconciled against an arriving roster. With this fix a genuine
pending→member transition now resolves, but a rejected 9021 still shows
"Requested" until restart. Separate change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:18:13 -04:00
Vitor Pamplona
a87049e451 fix(chats): stop crashing when a relay's group directory is sorted
Browsing a relay's groups (Relay Groups → Find groups → pick a relay)
crashed the app to the launcher:

    java.lang.IllegalArgumentException: Comparison method violates its
    general contract!
      at java.util.TimSort.mergeLo
      ...
      at RelayGroupChannelListScreen$allChannels$3$1$1.emit

`sortedBy { it.toBestDisplayName().lowercase() }` re-evaluates its key on
every comparison, and the key comes from mutable shared state — the
channel's display name, which a kind-39000 directory event can change
while the sort is running. TimSort detects the inconsistency and throws.

The failure scales with directory size and needs no user action: a relay
hosting 1237 groups tripped it on the first browse, while a relay with 18
never did — which is why an earlier sweep of this same screen missed it.
v1.12.6 already fixed this class elsewhere ("snapshot live-stream status
order before sorting"); these sites were not covered.

Adds `sortedBySnapshot`, which computes each key ONCE before any
comparison runs, and applies it to all four sites that sorted live
objects by their display name: the relay group directory (both the
initial value and the observer), the parent-group picker, and the
name-ordered search results. The picker's site also had to materialise
its Sequence first, since sorting lazily would have re-introduced the
same window.

Verified on device: the exact tap that crashed now loads the screen with
zero FATAL EXCEPTIONs and the app stays in the foreground.

Found while setting up a NIP-29 group to test the admin surface, which is
also how the directory got large enough to expose it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:43:40 -04:00
Vitor Pamplona
894dc7d99b fix(location): don't self-select a location the user never picked
The map picker opened blank, showed "Tessalit, ML" — the middle of the
Sahara — as if it were chosen, and had Confirm/Teleport ENABLED, so a user
could join a geohash cell they never selected. Three of those are one bug
and the fourth follows from it. Notably there is no tile, network or Tor
problem: tiles were downloading and rendering the whole time.

**The defect.** `onCenterChanged` treated any map movement beyond
`SELECT_MOVE_EPS` (0.0005°) as the user's first pick. osmdroid emits a
scroll when the MapView is first laid out, reporting a pixel-quantized
centre — and at the opening zoom one pixel is about 0.4°, roughly 800x the
epsilon. The phantom scroll therefore always cleared the threshold and the
picker selected a location before the user touched anything. No epsilon
can fix that: the quantization error is unbounded at low zoom. Replaced
with a `mapTouched` gate, so only finger-driven movement can become a
selection.

**Why it looked blank.** That phantom selection made the level effect zoom
to 12 over empty desert, and the night tile filter darkened featureless
Sahara tiles to near-black. Tiles were present; there was nothing to see.
Tapping "Region" re-zoomed and a detailed map appeared instantly, which is
what ruled out a loading failure.

**Why Tessalit.** The self-selection encoded the default centre, and
`WORLD_CENTER_LAT/LON = 20.0/0.0` is inland Mali — not "mid-Atlantic" as
the comment claimed. Moved to -30.0 longitude, genuinely unnamed ocean and
off the prime meridian; two nearby hardcoded 20.0/0.0 literals now use the
constants.

**The label mismatch was mostly a misread, with a real bug behind it.**
20N/0E sits exactly on the prime meridian, so a sub-kilometre pan flips the
geohash between the `e…` and `s…` halves of the world — both cells really
were Tessalit, so the name was right. The genuine bug is narrower: the name
came from a 450 ms-debounced cell, so mid-pan the previous cell's name
rendered beside the new geohash. The name now renders only when the
debounced cell matches the live one, and an unresolvable cell shows no
label rather than echoing its geohash.

Side benefit: the seeded composer path no longer has its exact seed
coordinates overwritten by osmdroid's quantized initial scroll.

Verified on device before and after, on both the Teleport screen and the
composer dialog: tiles on open, no bogus place, no selection until a real
touch, Confirm disabled until then; then pan → cell with no label at sea,
pan → "Oriximiná, BR", search → "Lisboa, PT", composer pick → confirmed.

Pre-existing, not fixed: in the modal dialog only, the picker header is
overdrawn by the MapView and invisible. Proven to predate this change by
A/B against an unmodified build. Back-dismiss works; cosmetic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:22:24 -04:00
Vitor Pamplona
717461f4d0 fix(chats): draw day headers above the message they introduce
Day separators in group chat did not match the messages under them: a
"Jul 1, 2025" header sat directly above a Sep 23 bubble, and a "Sep 23"
header above a Sep 25 one — while LATER separators in the same
conversation were correct.

The obvious explanation — that a bubble group spanning midnight takes its
header from the group's first message — is wrong. `ChatGroupPosition`
already refuses to group two messages whose formatted dates differ, so a
group cannot cross a day boundary at all.

The real cause is an off-by-one from a false assumption about
`reverseLayout`, stated in the comment that used to sit here: that later
content "draws just above the message". `reverseLayout` reverses the
order of the LIST'S ITEMS, not the content inside one item — each item's
Column still lays out top to bottom. Verified with a throwaway headless
Compose layout probe rather than by argument: with `reverseLayout = true`
and two items each holding a message and a divisor, item 1 (older) sits
above item 0 (newer), but within each item the divisor sits BELOW its own
message.

So the divisor for item i was labelled with date(i) and gated on
date(i+1) != date(i) — the arithmetic was right — but drawn between item
i and item i-1, heading the NEXT, newer message while showing the
previous one's date. That also explains the detail the grouping theory
could not: whenever two consecutive messages fall on the same day the
mislabelled header happens to read correctly, which is why only some
separators looked wrong.

Moving the divisor to the top of the item Column fixes it. Its arguments
were already correct, so nothing else changed — no formatting change, no
grouping change, and one fewer list lookup since it now reuses the
already-computed older note. Subject headers had the same inversion and
are fixed with it.

One call site serves every chat surface — NIP-29 relay groups, NIP-28
public chats, ephemeral chats, live-activity chats, Concord channels,
private DMs, geohash chats and Marmot groups — so all were wrong and all
are fixed.

`markersInGap` immediately below had the identical bug from the identical
assumption: it is handed the bounds for the gap toward the OLDER message
but, composed last, rendered in the gap toward the newer one. Moved for
the same reason.

Not unit-tested, deliberately: the date arithmetic was always correct, so
a pure-function test passes both before and after. Catching this needs a
Compose layout assertion, and `:amethyst` has no Robolectric or JVM
Compose harness — only instrumented tests. The layout probe above is what
established the behaviour; it was deleted afterwards.

Not verified on-device: the visual result is inferred from that probe
plus the reported symptoms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:14:00 -04:00
Vitor Pamplona
f2e42105f7 fix(podcast): stop rendering an episode description twice
The episode renderer shows two blocks: the `description` tag via
`episodeDescription()`, and the event content as markdown. Most feeds put
the SAME text in both, and the thread view renders both (`makeItShort` is
false there), so the whole description appeared twice inside one card —
one author header, one player, two identical bodies, each with its own
"Show More" pill.

The existing guard only suppressed the markdown block when the content
was blank. It now also suppresses it when the content merely repeats the
description, compared on collapsed whitespace so a copy differing only in
wrapping still counts as a duplicate. Feeds were unaffected because they
pass `makeItShort = true` and never rendered the second block, which is
why this only showed on the episode screen.

Found by a device sweep of the untested feature areas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:07:37 -04:00
Vitor Pamplona
c6a6068486 fix(chats): stop showing ciphertext in DM previews; fix stuck npub names
Two bugs found by a device sweep of the Messages list.

**Raw NIP-04 ciphertext rendered as the message preview.** Rows in New
Requests showed base64 blobs like `0tyoSVovKSK9uDKLUVMs137TD0b+vz…`.
Cause: every decryption branch in `Account.cachedDecryptContent` and
`decryptContent` is gated on `isWriteable()`, and the non-writeable path
fell through to `event.content` verbatim — which for a kind:4 IS the
NIP-04 blob. A read-only (npub-only) login therefore hit this on every
legacy DM room.

Both functions now return null instead of the ciphertext, which closes
the leak on every surface reading them — including the open chatroom
body, which had the identical fallthrough. A new pure classifier backs
the preview and never reads `event.content` for an encrypted kind, so a
future raw fallback cannot resurface there.

Pending and undecryptable are now distinguished on facts the UI actually
has, rather than collapsed into one message: no key at all, or a kind:4
between two other people, is "could not decrypt"; encrypted with our key
a party but plaintext not yet back is "Decrypting…", which resolves
itself when the signer answers. The old code showed the not-found string
for the pending case.

**Group DM titles stuck on npubs while the facepile beside them showed
real names** — and this one is not a display bug at all. Both already
observe metadata through the same flow; the fault is in `User`:

    fun metadata() = metadata ?: UserMetadataCache().also { metadata = it }

Non-atomic lazy init on a plain field, called from BOTH the Compose main
thread (every `observeUserInfo` composition) and the relay/IO threads
(`updateUserInfo`). Two threads can each read null, each allocate, and
one instance is orphaned. A composable collecting the ORPHANED cache
never receives the metadata, so it sits on its pubkey fallback forever
while a sibling that got the surviving instance renders the name — which
is exactly "npubs in the title, names in the facepile, same row", and
why it never recovers.

All six per-user lazy caches are now `@Volatile` with double-checked
locking under one process-wide lock, held only for the allocation. The
store holds tens of thousands of users, so a lock per user would be
worse than the bug.

This likely explains a broader class of "some names resolve and others
never do" symptoms, not just the row that surfaced it.

Not fixed: for a read-only account the open chatroom body now renders
nothing for a kind:4 rather than ciphertext — better, but it deserves the
same "could not decrypt" placeholder the preview row got.

Unverified: that the account which showed the ciphertext was in fact
read-only. Every other route to ciphertext was traced and returns null,
so the non-writeable fallthrough is the only reachable source, but the
device state itself was not captured.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:01:19 -04:00
Vitor Pamplona
73d59a29bf fix(nip46): gate identity reads on pairing; make decrypt consent informed
Two problems in the remote signer, both about a client getting something
without the user meaningfully agreeing to it.

**`get_public_key` and `get_relays` answered anyone.** Every other method
runs through `ifAuthorized`; these did not, and nothing required a prior
successful `connect`. The service decrypts and dispatches any well-formed
kind-24133 envelope, so anyone holding the `bunker://` URI — pasted into a
malicious app, posted for support, leaked in a screenshot — could ask it
which account it belongs to, without the secret and without connecting.
`get_relays` additionally handed over the inbox relay set. That defeated
the transport/identity split, which otherwise works: the relay-visible
traffic really is anonymous, since the p-tag and author are a transport
key and the payload is NIP-44.

Both now require the client to be paired. The authorizer interface gains
`isPaired` with NO default, so a future authorizer has to state its own
rule rather than silently inheriting "everyone is paired".

`ping` is deliberately left open. It reveals nothing the caller does not
already have — a signer is alive at a pubkey they hold — and first-party
behaviour could be confirmed but third-party clients that ping before
connecting could not be ruled out. Breaking a legitimate handshake to
close a minor oracle is a bad trade. The choice is pinned by a test that
also asserts the pairing check is never consulted, so it stays deliberate
rather than drifting back by accident.

**Decrypt consent showed nothing at all.** The bridge populated the
content preview and raw data only for signing requests, so a decrypt
request produced an empty preview block — no ciphertext, no counterparty,
not even the "Show event" toggle — leaving "AppName wants to read your
private messages" with *Allow always* as the primary button. Meanwhile
the coordinator documented the opposite: "Amethyst decrypts first, then
asks permission to expose." That was never implemented.

Now:
- The counterparty is resolved and shown, so the prompt reads "…read your
  private messages **with Alice**". It never degrades to nothing —
  cached name, else a shortened npub. Knowing *whose* messages is a
  categorically different decision.
- The message is decrypted BEFORE prompting and the plaintext is the
  preview, as documented. It is a local operation and nothing is exposed
  until approval. Failure, blank and hang all collapse to an explanatory
  string under a timeout, so the dialog is never empty and cannot stall.
- A narrower grant is offered ALONGSIDE the broad one, not instead of it:
  `DecryptFrom(counterparty)` keyed `decrypt:<hex>` next to `Decrypt`.
  The dialog's primary button becomes "Always allow for Alice" with the
  broad option demoted. Because the ledger stores an opaque op key, no
  persisted decision migrates and the storage format is untouched.

  Scoping decrypt per counterparty *instead* would have been worse than
  the bug: a DM client would prompt once per conversation, training users
  to approve everything. A narrow option beside the broad one gives
  granularity without the prompt explosion.

Also fixes a latent bug found on the way: `AllowForSession` recorded the
*requested* op rather than the *granted* one, which would have widened a
narrow session grant back to broad.

Verified by three sabotage passes; the tests that stayed green under them
are the intended negative guards. One existing test asserted the buggy
behaviour outright ("public reads are never gated") and was rewritten.

Not done: the batched consent sheet still records the broad op for
"remember" — offering the narrow choice per row there is a UX design
question, not a mechanical change.

Needs a device check before release: the decrypt preview runs the account
signer before consent. That is free for a local key, but an account backed
by an external NIP-55 signer (Amber) may show Amber's own prompt ahead of
Amethyst's.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:28:05 -04:00
Vitor Pamplona
7792c42f1d fix(blossom): bound what a paid server can extract on a 402
Four defects in the pay-to-upload path, all exploitable by a hostile or
merely broken media server.

**No amount cap, and the shown amount was not the paid amount.**
`amountSats()` was display-only — `pay()` never read it, so a server
asking 10,000,000 sats was handled exactly like one asking 10. `pay()`
now takes the amount the dialog showed, re-derives it from the invoice,
and refuses on over-cap, on mismatch with the dialog, and on an
amountless invoice (a payee-chosen amount must never be paid
unattended). The cap is 10,000 sats: real BUD-07 per-blob fees are
single-digit to low-hundreds, so this is one to two orders of magnitude
above legitimate use and caps one prompt's damage without blocking
anyone.

**Unbounded re-prompting.** The post-payment retry caught generic
`Exception` — including a second `BlossomPaymentException` — marked the
target missing, then re-entered the mirror path, so a server that
pocketed the preimage and replied 402 again got an indefinite pay-prompt
cycle. A new prompt ledger allows one prompt per (blob, target) per
user-initiated action; the user's own tap resets it, the automatic
continuation does not.

**Double-spend window.** `withTimeoutOrNull(90_000)` abandoned the wait
without cancelling the in-flight NWC request, so a payment settling at
second 91 was reported as failure and could be paid again. NIP-47
`sendZapPaymentRequestFor` is fire-and-forget with no cancellation and
does not return a request id, so cancelling is not reachable. Instead the
invoice is claimed before sending and released only on a definitive
wallet answer — the timeout path deliberately does not release it, so it
can never be sent twice. Known limit: the claim is process-lifetime, so a
timed-out invoice becomes payable again after a restart; persisting it
needs a real store.

**Unsanitised server text.** The server-controlled `X-Reason` header was
rendered verbatim directly above the pay button, letting a hostile server
assert its own amount ("Pay 1 sat") in what looked like Amethyst's voice.
It is now stripped of control characters and Unicode bidi overrides,
whitespace-collapsed, clamped to 200 chars, and rendered in a distinct
style as `<host> says: "…"` so it cannot be mistaken for our wording.

The two policies are small standalone classes so they are testable
without an Account graph. Verified by disabling all four checks: 12 of 15
tests fail, covering every defect; the 3 that stay green are the intended
negative controls.

Not covered: the background mirror sweep does not go through this handler
and cannot prompt, so it was never exposed to the cap or re-prompt
defects, and it inherits the invoice claim and sanitisation for free.
`pay()` itself is still not end-to-end tested — that needs Account
mocking well beyond this fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:03:20 -04:00
Vitor Pamplona
6045e28830 fix(cashu): validate a token's mint URL before contacting it
`MintHttpClient` only trimmed trailing slashes — no scheme check, no host
check — and `token.mint` comes verbatim from any pasted or posted Cashu
token. Tapping Redeem on a token in someone's note therefore made the
device issue HTTP requests to an arbitrary URL: `http://127.0.0.1:<port>`,
LAN addresses, `169.254.169.254` (cloud metadata), any scheme at all —
plus it disclosed the user's IP to whoever controlled the URL.

Validation now runs in the constructor, so no caller can issue a request
before it. The rule: `https://` to a public host, or `http://` to a
`.onion` host, and nothing else. Onion mints matter — a blanket
"https only" rule would have silently broken every Tor mint.

Rejected hosts cover the private/loopback/link-local/unique-local ranges
plus CGNAT, multicast, reserved and `0/8`: none is a public unicast host,
so allowing them buys nothing and leaks reachability.

The bypasses are what make this worth care, and each has a test:
IPv4-mapped and IPv4-compatible IPv6 (`::ffff:127.0.0.1`, `::127.0.0.1`),
the full `inet_aton` spellings (`2130706433`, `0177.0.0.1`, `0x7f000001`,
`127.1`), trailing-dot hosts, and userinfo disguise
(`https://mint.example.com@127.0.0.1/`) — handled by splitting on the
LAST `@`. The host parse is hand-rolled rather than delegated to
`java.net.URI`/`HttpUrl` precisely because those normalise these forms
inconsistently.

A mint the user added to their own wallet is exempt from the host and
https rules — a self-hosted mint on a LAN is a legitimate setup, and the
threat here is a *pasted, untrusted* token pointing inward, not a mint
the user chose. The exemption never relaxes the scheme check. It is
threaded properly rather than TODO'd: the melt path passes the wallet's
known mints and marks the mint user-configured only on a match; the
wallet ops and CLI pass it directly, since those URLs are the user's own.

Refusal gets its own message rather than reusing the mint-error string,
whose wording would have misattributed our own refusal to the mint.

DNS rebinding is out of scope and noted in a comment — the check runs
pre-resolution and cannot defend against a host that resolves differently
on the second lookup.

Verified by disabling the scheme and host checks: 11 of 20 tests fail,
every rejection case among them, and every allow case still passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:02:59 -04:00
Vitor Pamplona
c84acddc0c fix(concord): renaming a private channel no longer makes it public
`renameConcordChannel` built `ChannelEntity(name = ...)` from scratch
without reading the standing definition. `private`, `voice` and every
other flag default to false, so the published edition declared the
channel PUBLIC and TEXT. Renaming a private channel silently reclassified
it; renaming a voice channel silently converted it.

The messages themselves stay encrypted — a private channel is
independently keyed and a rename does not rotate that key — but the
Control Plane, which is what every client reads to decide how to present
and gate a channel, now says the wrong thing about it. A moderator
fixing a typo in a channel name should not change who a client believes
may read it.

`deleteConcordChannel` had the same shape: its tombstone also reset the
flags, so retiring a channel reclassified it on the way out.

Both now carry the standing definition forward and change only what they
mean to change.

Found while auditing which write paths re-serialize a decoded entity
rather than re-wrapping it — the same audit that produced the community
list fix. This one is worse than the unknown-key loss it was found
beside, because it discards KNOWN fields with a privacy meaning rather
than fields we merely failed to model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:59:44 -04:00
Vitor Pamplona
5c23f8490d fix(concord): stop destroying other clients' data in the community list
The private community list (kind 13302) is documented as wire-compatible
with Armada's `communityList.ts`, whose entry type ends in
`[k: string]: unknown` — unknown keys are part of the contract, and
`ConcordJson`'s own KDoc says shapes are "deliberately client-extensible
(CORD-03/04)". But `ignoreUnknownKeys = true` plus closed `@Serializable`
DTOs meant decode dropped every unmodelled key and encode never restored
it, so **every Amethyst write of a user's list silently stripped fields
another client had written**, across every community in it.

Already proven, not hypothetical: `JoinMaterialWire` declared a
`refounder` field that nothing in the repo reads, so it was parsed and
destroyed on the first write. We only avoided destroying Armada's
`invite_ref`/`excluded_at_epoch` because those were modelled hours ago,
for stranded recovery — the anchor recovery depends on would otherwise
have been deleted on every write.

Each wire DTO's compiler-generated serializer is now wrapped in a shared
`JsonTransformingSerializer` that lifts unknown keys into a bag on
decode and merges them back on encode, with declared fields winning on
conflict. The known-key set is read from the descriptor rather than
hand-listed, so it cannot drift from the DTO. Preserved at the document
root, each entry, the `current` join material, each channel, each
held_root, each tombstone, and everything nested inside `seed`.
`refounder`'s typed field is removed so it round-trips generically.

Two further data-loss bugs surfaced while doing it, both fixed here:

- **`seed` was overwritten with `current` on every write**, destroying
  the immutable join anchor. It is now kept and re-emitted verbatim as a
  raw JsonObject — we never hydrate from it while `current` exists, so we
  have no business rewriting it, and keeping it raw preserves everything
  nested inside for free.
- **`tombstones` were re-encoded as an empty list**, which did not just
  lose their unknown keys: it RESURRECTED communities another client had
  deliberately removed. They are now carried verbatim.

Verified by four separate sabotage passes (no-op the transform,
re-mint `seed`, restore the empty-tombstone write, flip the merge order);
each new test fails under at least one, and every mechanism is covered.

Control-plane re-serialization was audited too and is NOT fixed here:
`compactControlPlane` is safe (it re-wraps the original seal verbatim),
but the user-facing *edit* paths — `editConcordMetadata`, `grant`, and
the channel edits — construct fresh typed entities and re-encode, so they
drop extensions the same way. Fixing those means merging into the head
edition's raw JsonObject on each edit path, which is a larger change than
this should carry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:59:19 -04:00
Vitor Pamplona
f8d8a2b135 feat(concord): recover a membership stranded by a Refounding
A rotation carries only (newRoot, newEpoch, rotator) — no recipient list
— so a member simply left out of the recipient set receives nothing and
is stranded on the dead epoch forever while everyone else moves on. It
applies to any member, the owner included, and cannot be prevented on the
receive side: there is nothing to check.

Armada does not prevent it either; it recovers, and this follows the same
approach. The invite link a membership was joined through is stored as
the anchor, and when that link later resolves to a HIGHER epoch, the
membership merges forward.

Uses Armada's exact wire names so the kind-13302 list stays compatible in
both directions: `invite_ref` (the link in bare `<naddr>#<fragment>`
form, host-stripped so a link minted by a different front end reduces to
the same anchor) and `excluded_at_epoch`, both at the entry level.

Details that decide whether this works at all:

- `merge()` lets a higher-epoch winner inherit the loser's `invite_ref`
  when it has none. Without it a two-device merge silently discards the
  only anchor recovery has, disarming it permanently.
- `adoptConcordRoot` carries `invite_ref`/`excluded_at_epoch` through a
  rotation; it rebuilt the entry field-by-field and would have dropped
  them at exactly the moment they matter.
- Merging forward preserves `heldRoots`, so prior-epoch history the
  member legitimately holds is not lost by recovering.
- Recovery requires a strictly higher epoch and a matching community id,
  so it is monotonic and cannot be steered by an unrelated bundle.

Hooked onto the existing Concord revision tick immediately after
`drainConcordRekeys`, because the two are halves of one problem: a
rotation you were included in arrives as a rekey to drain, one you were
excluded from produces no message at all and can only be found by polling
the link. Rate-limited to 15 minutes per community; an idle tick costs a
map lookup. Only a Live bundle recovers — an expired or revoked link is
not a missed rotation.

Verified by mutating the production code eight ways (dropping the anchor,
dropping heldRoots, dropping the epoch comparison, renaming the wire
keys, removing the merge inheritance, breaking bare-form parsing) and
confirming each produced exactly the expected failures.

Two things this surfaced, both left for their own change:

- Our parser does NOT round-trip unknown JSON keys — `ignoreUnknownKeys`
  plus closed DTOs — while Armada's format ends in `[k: string]:
  unknown`. So every Amethyst write of the community list silently strips
  fields Armada added that we do not model; it already discards the
  `refounder` field we parse but never re-emit. That is live interop data
  loss, caused by us, independent of this work.
- `mergeForward` keeps the entry's existing private-channel grants rather
  than adopting the bundle's, matching what `joinConcordViaInvite`
  already does. If a recovered member should pick up new-epoch grants,
  both paths need it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:36:52 -04:00
Vitor Pamplona
fac1bf5b5d feat(concord): refuse Control-Plane rollbacks with a version floor
`ConcordRefounding.compactControlPlane` re-wraps one edition per entity
when a community rotates epoch, and the ROTATOR chooses which one
survives. The receiving side had no memory: `refold()` folds only the
wraps at the current epoch's Control-Plane address and discards the prior
epoch's buffer, and `EditionFold` accepts whatever it is handed (its
no-genesis fallback anchors at the lowest version present).

So a rotator could publish only version 1 of a chain and omit version 2 —
restoring a revoked role, clearing a banlist, reverting metadata. Every
signature is genuine; this is rollback by omission, not forgery.

Adds a per-entity floor: the version AND hash last successfully folded.

- **No floor (fresh joiner)** — unchanged: genesis anchor, else the
  lowest-version edition as the legitimate compaction bootstrap.
- **With a floor** — the walk is anchored AT the floor: the offered set
  must contain the exact edition already folded (version and hash; a
  same-version sibling is a fork, not our chain), then walks up. A head
  below the floor is structurally unreachable.
- **Gap** (the floor edition is absent) — refuse, and keep the known
  head. Refusing by *retaining* matters here: this fold is recomputed
  from scratch each time, so letting an entity vanish would itself be a
  rollback — a dropped banlist is an unban.

The floor needs no new persistence. It is derived from `heldRoots`, the
rotated-out access roots already persisted in the NIP-44 self-encrypted
kind-13302 list: the session derives each prior epoch's Control-Plane
address from them, folds oldest-first, and takes the resulting heads as
the floor. That survives both a process restart and the session rebuild
`ConcordSessionRegistry.sync` performs at exactly the moment of a
Refounding — which would have destroyed any in-session floor. If the old
planes are not served, there is no floor and behaviour is as before.

Floors are built from AUTHORITY-GATED heads, not raw ones. Without that,
any ex-member still holding a rotated-out root could mint a high-version
edition on the old plane and freeze the entity for every honest client —
a denial of service this change would otherwise have introduced. Covered
by a test.

Verified by disabling both enforcement points: 7 of 12 quartz tests and
the end-to-end commons test fail, and the ones that still pass are
exactly the non-regression cases (fresh joiner, honest compaction,
pass-through without floors).

Known limit: `AuthorityResolver.resolve` folds authorized SUBSETS of the
edition pool and does not carry floors itself; gating happens at the pool
level before the resolver sees anything. Sound, but connectivity checked
on the full set is a weaker precondition than on each subset — passing
floors into the resolver's three folds is worth a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:36:25 -04:00
Vitor Pamplona
30f4638954 fix(concord): re-key observed members on a Refounding
The Refounding recipient set was `Guestbook joins ∪ roster ∪ owner ∪ self`
— it never consulted `observedAuthors`, the members seen publishing to a
channel. `ConcordCommunitySession.allMembers()` already unions them in
(and already subtracts the banned); the rotation path just didn't use it.

Amethyst announces a Guestbook Join, but nothing in the protocol requires
one, so in practice most members of a cross-client community have never
sent one. Every member who had only ever *posted* — no role, no Join —
therefore received no rekey blob and was silently expelled by the next
rotation. Removing a single spammer would quietly strand most of the
community, and it fell hardest on Armada members, who are the bulk of the
roster in the communities we interop with.

Now uses `allMembers()`. Still a floor rather than a census, as its KDoc
says: a member who joined silently, holds no role, and has never posted
leaves no trace to re-key, and nothing here can find them. Stranded
recovery is what brings those back.

Also corrects an overclaim on `ConcordInviteBundle.validate`. Its KDoc
said self-certification stops a bundle smuggling "a false owner or a fake
key for a real community". Only the first half is true: `community_id`
commits to (owner, salt) — both public in any invite — and NOT to
`community_root`, so an attacker can mint a bundle carrying a real
community's id, owner and salt beside a root of their own. A joiner
adopts that root, believes they are in the real community, and posts into
planes the attacker can read.

That is a CORD-05 limitation, not an implementation gap: Armada's
`validateBundle` checks exactly the same thing and also leaves the root
unbound, so a stricter unilateral rule would break interop while
protecting nobody. Verifying the adopted root's Control Plane does not
close it either — sealed editions carry the owner's own signature, so an
attacker can re-wrap genuine owner editions into their plane, which is
what a legitimate compaction does. Closing it needs a spec change
(commit the root into the self-certifying id, or require the bundle to be
signed by a roster-authorized member); the KDoc now says so instead of
claiming a guarantee the code does not provide.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:35:50 -04:00
Vitor Pamplona
0f53eb09a2 fix(concord): gate revokes on target rank; drop the owner rotation refusal
Reviewed the previous commit's authority changes against Armada
(gitlab.com/soapbox-pub/armada), the reference Concord client we interop
with. Read for its rules only — Armada is AGPLv3 and Amethyst is MIT, so
no code was taken from it.

It confirmed the role rank gate (Armada checks both directions too: the
author must outrank the position being minted AND the standing position
being replaced) and the banlist check on rotation. It also showed one of
our rules was wrong and one gate was missing.

**Removes the owner's refusal of foreign rotations.** The previous commit
made the owner ignore any rotation it did not author, reasoning that a
BAN-holder could otherwise carry the owner onto a root of their choosing.
Armada does the opposite on purpose — "authority is the roster, never key
possession" — and it is right: an admin legitimately rotating to remove a
spammer would leave the owner alone on the dead epoch, self-inflicting
the strand the rule was meant to prevent, and diverging from the
reference implementation forks communities across clients. The threat is
better answered by the rank gate: with role editions gated, nobody can
escalate themselves to BAN, so BAN-holders are people the owner
deliberately trusted.

**Adds the missing rank gate on grants.** A grant was authorized if the
granter outranked every role it handed out — but a REVOKE carries no role
ids, and `all {}` over an empty list is vacuously true. So any
MANAGE_ROLES holder could strip anyone's roles, the owner's admins
included: promotion was gated, demotion was free. Armada treats a grant
as an action ON the member and requires outranking the target's standing
rank; this now does the same.

Both new tests were verified to fail with the corresponding check
disabled, and each has a companion asserting the legitimate case still
works (an admin can still revoke a moderator beneath it).

Also records what Armada does about exclusion, since we cannot prevent it
receiver-side: it does not try to. A rotation carries no recipient list
there either, so a stranded member instead re-resolves the invite link
they joined through and merges forward to the higher epoch ("stranded
recovery"). Amethyst has no equivalent, so a stranded member stays
stranded — noted at the call site as follow-up work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:10:36 -04:00
Vitor Pamplona
c9c91a8c98 fix(concord): rank-gate role editions and harden rotation authority
Two authority holes, plus the limit that remains.

**Role editions had no rank gate.** Grant editions are correctly gated —
a granter must hold MANAGE_ROLES *and* strictly outrank every role it
hands out. Role editions checked only that the author held MANAGE_ROLES.
Nothing stopped an authorized signer editing a role at or above its own
rank, including the role it holds itself.

So a moderator at position 5 with MANAGE_ROLES could publish one edition
on their own role's chain claiming position 1 and every permission bit,
then a second demoting the real admins beneath them — reaching full
authority over everyone but the owner in two editions. The
rogue-higher-version defence added earlier does not cover this: it drops
editions from *unauthorized* signers, and this signer is authorized.

Role editions are now gated in both directions: an author may not claim a
position at or above its own rank, may not touch a role that already sits
at or above it, and may not hand a role permission bits it does not hold
itself. Deleting keeps only the second rule, so retiring a role beneath
you still works. The owner is unaffected.

**A banned moderator could still rotate the community.** The rekey
receive path authorized the rotator with `effectivePermissions(...)`,
which ignores the banlist, rather than `hasPermission(...)`, which
excludes banned members. Now uses the latter.

**The owner no longer adopts a root someone else minted.** A rotation
replaces the community root, so a rotator who *includes* the owner as a
recipient hands themselves the keys to the owner's own community — the
owner would follow them onto an attacker-chosen epoch. The owner changes
epoch only by rotating themselves.

**Known limit, documented at the call site.** A rotation carries only
(newRoot, newEpoch, rotator) — no recipient list — so a receiver cannot
tell who was omitted. A BAN-holder can therefore still evict the owner by
leaving them out: everyone else adopts, the owner is stranded on the old
epoch. That is not fixable in the receive path; it needs a protocol
change (a recipient commitment the receiver can check, or owner
co-signing). Tracked for CORD-06.

The escalation test was verified to fail with the gate disabled, and a
companion test asserts an admin can still edit a role beneath it, so the
gate is not merely blocking everything.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:00:50 -04:00
Vitor Pamplona
996b800da5 docs(changelog): correct claims the code does not support
An audit of the v1.13.0 notes against the implementation found several
claims that are wrong or overstated. These are user-facing release notes,
so a false claim changes what people believe about their own security.

Corrected:

- **Privacy Lock.** The notes said it gates Messages "behind a password or
  biometric" on "Android and Desktop", with inactivity auto-lock and
  optional preview redaction. It does not exist on Android at all — no
  code, no settings entry — and there is no biometric implementation on
  any platform (`CredentialPrompter` has no implementations). Preview
  redaction persists a setting nothing reads, and the auto-lock is a fixed
  timer, since the idle-reset modifier is never applied. It is also a
  screen gate rather than encryption at rest: the account stays live and
  messages keep syncing while locked. Now described as what ships — a
  password gate on the Desktop Messages and Wallet columns.
- **"Every signature, payment, or data read needs your explicit
  approval."** Only payments require per-use consent; everything else can
  be granted once and reused, and the default trust level auto-signs notes,
  reactions and encryption after a single tap.
- **Web of Trust (GrapeRank)** was listed under app features, but crawling
  and scoring exist only in the `amy` CLI — no app module references it.
  The app consumes NIP-85 cards published by an operator, which is what the
  entry now says.
- **"One-tap trust for your follows' relays"** described bulk-granting
  relays used by people you follow. What exists is category rules evaluated
  per challenge.
- **Pinned web apps "show the app's own icon"** — they render a generic
  placeholder.
- **PoW** contradicted itself: "all cores" in one entry, "half the device's
  cores" in another. The latter matches `PoWPolicy.minerWorkers`.
- **Onion-Location "through every HTTP client"** — the Android app's
  clients only; the desktop, CLI, geode and sandbox blob clients don't
  install it.
- **Relay hardening** claimed REQ refusals stop immediately and failures
  evict "on the first strike"; both take repeated failures, and the
  first-strike eviction applies only to crawls.
- **`bunker://` links cannot be pasted in** — Amethyst only emits them.
- **Git code browser** needs a repository with an http(s) clone URL.
- **Geohash anonymous identity** — the per-area identity is unlinkable, but
  the optional nickname is one global handle, so setting it links your
  posts across areas.
- **Concord ban** is read-time enforcement for everyone else; the banned
  member keeps the keys until a Refounding.

Adds an Upgrading section for the user-visible effects of the per-account
isolation work: sites signed out once, permissions re-asked per account,
and relay logins now prompting under the default remote-signer policy.

Contributor and translator credits are left as they are — several entries
are unresolved npubs and Crowdin-generated usernames, but correcting
attribution is not a call to make from the code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 19:52:52 -04:00
Vitor Pamplona
2ccd837f30 fix(napplet): sign as the account a surface was launched as
Requests resolved their signer through `sessionManager.loggedInAccount()`
— whichever account is active *right now* — with no binding to the
surface that asked. A full-screen host is a separate activity that an
account switch does not tear down, so:

- Open a site full-screen as A and log in via NIP-07: the page shows A.
- Switch to B in the main app.
- Return to that still-open surface and request a signature: the broker
  handed it **B's** key.

Confirmed on device before the fix. Worse than a mismatched prompt: B's
session was then written into **A's** WebView storage jar, so afterwards
even the embedded tab — which rebuilds correctly and had been verified
correct — displayed the wrong account. Per-account isolation held only
until a full-screen surface wrote a foreign session into a jar. And it
happened silently, because the ledger is per-account+origin and B had
already granted "always allow" for that origin from an earlier session.

`NappletLaunchRegistry.Session` now carries the account that minted the
token, and the broker resolves *that* account out of the cache. This
needs no new machinery to satisfy both halves of the rule: embedded
surfaces are torn down and re-minted on a switch, so they follow the
active account, while a full-screen surface keeps the account it was
opened with. It also extends an argument the code already made — the
sandbox can only act as the napplet it was launched as, because it holds
only its own token; now the same is true of the account.

Fails closed: if the launch account is no longer loaded, the request is
refused rather than falling back to whoever is signed in now.

The same live-account resolution existed on two adjacent paths, fixed
here too:

- Relay subscriptions took the account from a global supplier, so a
  full-screen surface's REQs would target the newly-active account's
  relays while its signatures came from the old one. The account is now
  passed per-open from the launch token.
- `identity.changed` streamed the app's active account, so a page bound
  to A could be told it had become B while signatures still returned A —
  the same desync inverted. It is now bound to the surface's own account
  and reports only that account going away.

Verified on device: with B active, a fresh identity read from a
full-screen surface launched as A returns **A**; the embedded tab still
follows B; both surfaces ran simultaneously under different accounts with
no cross-writes between jars.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 19:38:52 -04:00
Vitor Pamplona
c8be65a02e fix(concord): require consent for invite links, enforce expiry, fold the head
Three fixes to the Concord invite and moderation paths.

**Invite deep links redeemed with zero consent.** `ConcordInviteScreen`
called `joinConcordViaInvite` from a `LaunchedEffect` on open, and the
manifest registers `https://amethyst.social/invite/` as BROWSABLE. So a
link on any web page — or a QR code, or a push — silently caused a
connection to up to three ATTACKER-CHOSEN relay URLs decoded from the URL
fragment (disclosing the user's IP to a third party), a Guestbook JOIN
signed by the user's identity published to those relays, and a write to
their private community list. No tap, no preview.

The screen now opens in an awaiting-consent state and only joins from an
explicit Join button. The preview is built entirely from the link itself
— base64url and NIP-19 decoding, both pure in-memory — and touches the
network for nothing: no relay connection, no signing, no publishing. It
shows the relays it would contact so the user can see whom they'd be
talking to. The community name lives inside a bundle only those relays
can serve, so it is honestly reported as unknown until joining rather
than fetched.

**Invite expiry was decorative.** `ConcordInviteBundle.isExpired` had no
production callers at all — the only ones were in a test — so an expired
invite redeemed forever. Expiry is now enforced at `classify`, the choke
point every redeem path funnels through, with its own result and message
so the user knows to ask for a fresh link.

**Moderation read the wrong edition.** `ConcordModeration` used
`firstOrNull` over `controlEditions()`, which is in wrap-ARRIVAL order,
not the folded head. Once an entity had two or more editions the next one
chained off a stale predecessor, forking the chain at an already-used
version, and `EditionFold` then resolved the fork by `minByOrNull` on the
rumor id — a coin flip. Bans were masked by a down-only healing union;
UNBANS and role revocations were not, so they could silently fail to
apply. Both call sites now fold to the true head.

Regression tests assert the fold-head behaviour under two arrival orders
— a single order accidentally puts the head first and passes against the
buggy code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:59:50 -04:00
Vitor Pamplona
153191e722 fix(nip46): stop auto-signing relay AUTH under the default policy
`REASONABLE` — the default policy on connect — auto-approved kind 22242
(NIP-42 relay auth). The in-code justification was that the event is
ephemeral and bound to one relay and challenge, so it cannot be replayed
elsewhere. That is true and beside the point: the requesting app supplies
the `relay` and `challenge` tags verbatim, so it never needs to replay —
it just asks for a FRESH signature naming any relay it likes.

A paired app could therefore, with no prompt, open its own socket to any
NIP-42 relay, take the challenge, get 22242 signed, and authenticate to
that relay AS THE USER. That yields read access to whatever the relay
gates behind AUTH — notably the kind-1059 giftwrap inbox and its full DM
metadata (who, when, how many) — and burns quota on paid relays, which
bill whoever authenticates.

Amethyst auto-signing AUTH for relays the USER configured is not the same
as letting a third party name the relay; the comment conflated them.

22242 now falls through to ASK. The existing test asserted the vulnerable
behaviour with the same flawed reasoning, so it is inverted here rather
than merely extended.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:59:33 -04:00
Vitor Pamplona
5fd8be64fd fix(podcast): clamp V4V fee splits so a feed cannot multiply payments
`computeShares` paid each fee recipient `totalMilliSats * split / 100`
with no upper bound on `split`, and `split` comes verbatim from a
kind-30054 episode event that anyone can publish (`ValueTag.parse` is a
bare `fromJson` with no validation). A single `fee:true, split:1000`
recipient was therefore paid TEN TIMES the amount the user chose. The
`remainder` clamp looked like a safety net but only zeroed the honest
recipients; it never touched the fee recipients themselves.

Proven by test before fixing: `split:1000` pays 10x, and two fee
recipients at 60% each pay 1,200,000 millisats for a 1,000,000 zap.

This was reachable in the worst possible place. Streaming V4V pays every
minute, automatically, so a modest multiplier stays under a typical NWC
budget and simply runs — and the on-screen running total tracks the
INTENDED amount, so it reads "100 sats" while 1,000 left the wallet,
while the streaming error handler suppresses the toast. The ordinary zap
button reroutes through the same path for any note carrying a value
block, so it was not limited to the streaming toggle.

Fees are a percentage off the top, so each is now clamped to 100% and the
cumulative total to the remaining budget: the payout can never exceed
what the user chose.

The existing test asserted the right invariant (`sum <= total`) but only
ever ran it on well-formed input, which is why this survived.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:59:18 -04:00
Vitor Pamplona
7f135a12ab fix(embed): rebuild pinned tabs when the account changes
A WebView's storage profile is fixed at construction, so per-account jars
mean each pinned tab has to be rebuilt on an account switch. Nothing did
that, and the result was a tab that went permanently black: the
logged-in subtree is wrapped in `key(account.pubKey)`, so a switch
disposes and rebuilds it, which closes each sandbox session — but
`EmbeddedTabHost` is process-scoped, so the warm controllers survived.
The rebuilt layer then re-attached fresh views to controllers whose
adapter had already been consumed, and a `SandboxedSdkView` with no
adapter paints only its background, forever.

Three fixes:

- Tabs are torn down and re-armed against the new account's profile when
  it changes, including tabs that were never opened, so none survives
  bound to the previous account's jar.
- `attachView` now recovers instead of silently doing nothing when a
  second view attaches after the adapter was spent, minting a FRESH
  session id — reusing the id let the disposed view's late `close()`
  reap the replacement.
- The re-warm kickoff moved off `LaunchedEffect`. It dispatches through
  the composition's scope, and an account switch floods the main thread:
  measured on a slow device, the sweep started 943-1157 ms after the
  teardown, and the account watcher itself fired 3-4 s late. Running it
  synchronously in the same apply phase drops that to 10-23 ms. The
  suspending work stays in a coroutine; only the kickoff is immediate.

Also gates the load overlay on the tab id rather than a live controller,
so a tab that is re-arming shows the existing spinner instead of a bare
surface — previously the one moment it most needed a cover was the one
moment it had none.

Verified on device across both switch directions, and with profile
detection force-disabled to confirm the recovery path alone prevents the
black.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:59:04 -04:00
Vitor Pamplona
095b156953 fix(napplet): give each account its own WebView storage jar
Embedded web content — browser tabs, napplets, nSites — runs in WebViews
in the `:napplet` process, and their cookies, localStorage, IndexedDB and
service workers were SHARED across every Nostr account on the device.
Nothing in the repo ever cleared them: no `CookieManager`, no
`WebStorage` call anywhere.

So a web app stayed logged in as the previous account after a switch, and
a Nostr web client's localStorage — which routinely holds decrypted DMs,
drafts and follow caches — was readable by whichever account came next.
For a user keeping a pseudonymous npub apart from a real one, the app
could correlate the two itself.

Uses the androidx.webkit multi-profile API (already a dependency) to give
each account its own profile: cookies, storage, geolocation grants and
service workers are all partitioned per `Profile`. Switching accounts
moves to that account's jar and switching back restores the session
intact — isolation rather than deletion, so nothing is lost.

The sandbox never learns which account it is serving. The main process
derives an opaque, domain-separated SHA-256 of the account pubkey,
truncated to 32 hex chars, and passes only that; `:napplet` validates the
shape before use, so a compromised sandbox cannot mint a name for another
account's jar. Both re-arm paths read the current profile at send time,
so a re-created session can never resurrect the previous account's jar.

Where MULTI_PROFILE is unsupported (older WebView), isolation degrades to
lossy-but-safe: cookies and web storage are wiped when the account behind
the WebViews changes, rather than silently shared.

Known gap, documented at the logout hook: a removed account's profile is
not deleted. It cannot be done from the main process — WebView profiles
live in the `:napplet` data directory, and booting WebView here would
collide on it — so it needs a broker message that has the sandbox call
`ProfileStore.deleteProfile`, and that must refuse a profile still bound
to a live WebView.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:58:47 -04:00
Vitor Pamplona
5bce87d76e fix(napplet): make the consent dialog say what a signature really does
The dialog rendered `kind` plus a 160-char content preview and nothing
else. For the kinds whose payload lives entirely in the TAGS, that is
technically true and practically useless — the user saw "publish an event
of kind 3" while approving a replacement of their whole social graph.
kind 10002 redirects every future read and write to attacker relays;
kind 5 deletes notes.

Now, for a replaceable list, it diffs the proposed tags against the copy
already cached on the account and reports what actually changes, rather
than a raw total that hides the dangerous case (a list that silently
drops 130 follows). A single-account edit — by far the common one — names
and pictures that account, so the user can recognize who it is at a
glance. Republishing an identical list says so plainly instead of raising
a false alarm, and a missing baseline falls back to the total and admits
it could not compare. Mute lists diff people only, so the string points
at "Show Event" for muted words and hashtags.

Adds a "Show Event" raw-event toggle mirroring the NIP-46 dialog, which
already had one; the napplet dialog had no way to inspect the full event.

Also fixes an amountless-invoice display bug in the same family:
`LnInvoiceUtil.getAmountInSats` returns ZERO (not null, not a throw) for
a BOLT11 with no amount, so both this dialog and the Blossom pay dialog
affirmatively rendered "0 sats" — telling the user a payment was free
when the amount is in fact unspecified and chosen by the payee.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:57:57 -04:00
Vitor Pamplona
389b460f7a fix(napplet): disclose what connecting to an app pre-grants
Accepting the "Connect to Nostr" dialog bulk-grants every declared
non-payment capability as ALLOW_ALWAYS — RELAY, IDENTITY, STORAGE,
RESOURCE, UPLOAD, NOTIFY, KEYS — unless the user picks PARANOID. The
dialog never showed that: `buildConnectInfo` did not receive `declared`
at all, so the user approved a set on the strength of a title and an
icon.

`SignerConnectInfo.requestedPermissions` already existed for exactly this
("shown so the user gives INFORMED consent before those ops are
pre-granted") and is populated by the NIP-46 nostrconnect path. The
napplet path simply never filled it in. This threads `declared` through
`NostrConnectPrompt` to `buildConnectInfo`, which lists the capabilities
that actually get pre-granted — SHELL/THEME never prompt and VALUE is
per-use, so listing those would overstate what accepting hands over.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:57:12 -04:00
Vitor Pamplona
dbf15a2146 fix(napplet): scope applet grants per app and per account
Every napplet/web-app grant was keyed by applet coordinate alone
(`<appAuthor>:<identifier>`), which carries no account. The stores and
ledgers are process-wide singletons shared by all accounts, so grants
leaked in two directions:

- **Across apps.** `NappletBroker.sessionAllows` held a bare `op.key`
  ("sign:1"), and the check ran *before* the per-app ledger lookup. One
  app's "Allow for this session" therefore authorized that op for every
  other applet and browser origin, silently, for the broker's lifetime.

- **Across accounts.** A grant made under one npub authorized the same
  applet under every other npub on the device. For a user keeping a
  pseudonymous account separate from a real one, an app authorized by
  one could sign as the other with no prompt — defeating the point of
  separate accounts.

NIP-46 already solved this shape correctly: `Nip46PermissionAuthorizer`
namespaces by account (`nip46:<signer>:<client>`) and keys session grants
by `(coordinate, op)`. Its comment even claims it "mirrors the napplet
broker's sessionAllows" — the mirror was backwards. This adopts the same
pattern on the napplet side:

- `sessionAllows` and `NappletPermissionLedger.session` are keyed by
  account + coordinate + op.
- Napplet storage and the capability store namespace keys by account,
  read at call time so a switch moves reads/writes with no rebuild.
- The signer ledger is deliberately NOT account-scoped at the store: it
  is shared with NIP-46, whose sessions run for a specific account rather
  than the active one, so scoping it there would break a background
  bunker. The napplet path namespaces its own coordinate instead.

Also here, found while scoping:

- `identity.watch` consulted only the manifest declaration and never the
  ledger, bypassing a standing DENY — it short-circuits before
  `NappletBroker.handle`, where the "a standing denial always wins" rule
  lives. It now applies that rule itself.
- `DataStoreNappletStorage.keys()` filtered on a space separator while
  keys are written with NUL, so it silently matched nothing and always
  returned an empty list.

Existing grants live under the old un-namespaced keys and are not
migrated: users are re-prompted once. Migrating would attribute grants
made under the broken model to whichever account is active, preserving
the bug.

Regression tests cover both leak directions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:56:58 -04:00
Vitor Pamplona
ee3b8786d4 Merge pull request #3648 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-19 15:15:02 -04:00
Vitor Pamplona
49b93db532 Merge pull request #3647 from vitorpamplona/claude/changelog-new-version-y1y331
docs(changelog): add v1.13.0 release notes
2026-07-19 15:13:29 -04:00
vitorpamplona
710c68d6aa chore: sync Crowdin translations and seed translator npub placeholders 2026-07-19 19:09:10 +00:00
Vitor Pamplona
824f9898de Merge pull request #3645 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-19 15:06:36 -04:00
vitorpamplona
fe761ab7f2 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-19 14:44:14 +00:00
m
a1b7cad85c feat(compose): wire NIP-05 popover mentions to nostr:nprofile1…
When the author types a NIP-05 mention (full `m@testls.bit` form or bare
`testls.bit` domain), the existing user-suggestion popover already resolves it
asynchronously on Dispatchers.IO via Nip05Client (Namecoin for .bit, regular
.well-known/nostr.json for everything else). On pick, however, the inserted
token was `@npub1…`, losing the relay hints already harvested during
resolution.

Insert `nostr:${user.toNProfile()} ` for NIP-05-shaped picks so:

  * the send-time NewMessageTagger parses the bech32 inline through its
    existing `nprofile1` branch — no extra main-thread I/O, no per-word
    network round-trips that could compound into 30s+ stalls on dead servers;
  * relay hints harvested by nip05ResolutionFlow ride along in the nprofile;
  * the email-vs-mention call is the author's: typing `vitor@vitorpamplona.com`
    surfaces the suggestion but only commits as a mention when the author taps
    the row. Just typing the address sends as plain text.

Non-NIP-05 picks (search by name, typed npub/nprofile, hex pubkey) keep the
existing `@npub1…` insertion form.

Also collapses the open-coded `Nip05Id("_", prefix.lowercase())`
bare-domain synthesis onto Nip05Id.parseLenient so there's one place doing
that conversion.

Addresses review feedback on #3165: avoids the extra parser pass and the
inline send-time resolve loop in NewMessageTagger by routing all NIP-05
mentions through the popover the author already uses.
2026-07-11 20:35:16 +10:00
m
6ac8e475df feat(nip05): add Nip05Id.parseLenient for mention/text rendering
Add a small companion helper that lets callers parse either:
  - a full NIP-05 identifier (`name@domain.tld`), same as Nip05Id.parse; or
  - a bare domain (`domain.tld`), synthesized as the wildcard form
    `_@domain.tld` per NIP-05.

This is the building block used by upcoming mention parsing in note
compose (NewMessageTagger) and note rendering (RichTextParser) so that
typing `@m@testls.bit` or `@testls.bit` in a note tags the user
correctly, and a received note that contains `m@testls.bit` becomes a
clickable user link instead of falling into the email/mailto bucket.

The helper is intentionally split out from the strict `parse`
(unchanged) so its lenient behaviour (bare-domain synthesis) cannot
sneak into NIP-05 verification flows that need the strict form.
2026-07-11 20:35:16 +10:00
524 changed files with 35432 additions and 942 deletions

View File

@@ -302,6 +302,21 @@ Do this before considering the task complete.
`forEach`/`map`/`filter`/`any` — the `fast*` variants allocate no iterator,
no intermediate list, and no lambda object. Don't "modernize" those into
stdlib collection calls; match the surrounding hot-path style.
- **Never put raw invisible/bidirectional Unicode characters in source files**
— write them as `\uXXXX` escapes instead (`'\u202E'`, `Regex("[\u200B-\u200D\uFEFF]")`).
This covers the bidi family Sonar's Trojan-Source rule (CVE-2021-42574)
flags — U+202AU+202E, the isolates U+2066U+2069, U+200E/U+200F, U+061C —
plus zero-width characters (U+200BU+200D, U+FEFF, U+2060). The escape
compiles to the identical codepoint, so behaviour is unchanged; the point is
that the file on disk stays visually unambiguous. Applies even when the
character is *intentional* (sanitizer strip-lists, adversarial test
payloads) — that's data, and escapes express it just as well. Exceptions:
U+200D as part of a real emoji ZWJ sequence in test data (👩‍👧 — functional,
not a bidi control), and LRM/RLM inside Crowdin-managed `strings.xml`
translations (legitimate RTL typography; don't touch those files by hand
anyway). Note the tooling trap: the Edit tool may normalise a typed
`\uXXXX` back into the raw character — if that happens, do the replacement
at byte level (`perl -CSD -pe 's/\x{202E}/\\u202E/g'`).
### Navigation Shell
- **Desktop**: Sidebar + main content area

View File

@@ -332,6 +332,35 @@ When adding translated strings to locale files:
- **Append new strings at the bottom** of the file, just before the closing `</resources>` tag.
- Do NOT try to insert them in alphabetical or matching order — a separate process handles ordering.
- **Insert into each locale ONLY the keys missing from *that* locale — never a shared "union" block.** Because Crowdin strips keys asymmetrically (Step 2), a key you translate may already exist in some target locales. If you compute one union set of missing keys, translate it, and paste the *same* block into every locale, you will create **duplicate keys** in whichever locales already had them. Drive the insertion off the **per-locale** diff, not the union:
```bash
# For each locale, insert only the keys comm -23 reports missing FOR THAT LOCALE.
for l in cs de-rDE sv-rSE pt-rBR; do
missing=$(comm -23 \
<(grep '<string name=' $base/values/strings.xml | grep -v 'translatable="false"' \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<string name=' $base/values-$l/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort))
# ... append ONLY the $missing keys' translations to values-$l/strings.xml ...
done
```
(This bit us on 2026-07-21: `ps1_save_block`, `podcast_value_for_value`, and `chats_history_relays` were each missing in only *some* commons locales, but the same 3-key block was pasted into all four — producing duplicates in the locales that already had them.)
- **After inserting, verify each edited file has no duplicate keys AND is well-formed XML — before you call the task done.** A duplicate key is not a warning: the `commons` tree's Compose-resources build task fails hard on it (`convertXmlValueResourcesForCommonMain: … Duplicated key '…'`), which breaks the build for everyone. Quick post-insertion gate over every file you touched:
```bash
for f in <every edited strings.xml>; do
dups=$(grep -oE '<(string|plurals) name="[^"]*"' "$f" \
| sed 's/.*name="\([^"]*\)"/\1/' | sort | uniq -d)
[ -n "$dups" ] && echo "DUP in $f: $dups"
python3 -c "import xml.dom.minidom; xml.dom.minidom.parse('$f')" \
|| echo "MALFORMED $f"
done
# For a commons change, also run the build task that enforces this:
# ./gradlew :commons:convertXmlValueResourcesForCommonMain
```
## Common Mistakes
@@ -344,6 +373,7 @@ When adding translated strings to locale files:
- **Trusting a git "sync-timestamp" heuristic to pre-filter the list** — this skill used to skip keys added before the last `New Crowdin translations` commit, on the theory that Crowdin had already "decided" them. It was dropped: a key added shortly before an export that translators hadn't reached yet is genuinely missing, so the heuristic silently dropped real work. Use the raw on-disk diff and reconcile against the Crowdin web UI's untranslated count instead.
- **Adding source-identical fallbacks locally** — they get overwritten on the next Crowdin sync. Android falls back to `values/strings.xml` at runtime anyway, so a key intentionally kept as English already renders correctly. Skip these by inspection (brand terms, loanwords, `v%1$s`-style strings); don't translate them to an identical value.
- **Skipping per-locale diffs when only diffing cs** — Crowdin can strip different keys in different locales (each translator's choice), so cs is not a reliable upper bound. Diff each target locale and union the results.
- **Pasting the union set of missing keys into every locale → duplicate keys** — the union is the right set to *translate*, but the wrong set to *insert*. A key missing in only some locales, inserted into all of them, duplicates in the ones that already had it. Drive each file's insertion off its own per-locale diff (see Step 6). In `commons`, a duplicate key is build-breaking: `convertXmlValueResourcesForCommonMain` fails with `Duplicated key '…'`. **Always run the post-insertion duplicate + XML-wellformedness gate in Step 6 before declaring done.** (Happened 2026-07-21 with `ps1_save_block` / `podcast_value_for_value` / `chats_history_relays`.)
- **Inserting strings in a specific position** — always append at the bottom; ordering is handled separately
- **Hardcoding `"1"` in a `<plurals>` `quantity="one"` item** — always use the count placeholder; otherwise non-English `one` categories produce wrong text
- **Copying English's `one`/`other` set into every locale** — each language must include all CLDR plural categories it uses (e.g. Czech needs `one`, `few`, `many`, `other`)

View File

@@ -567,6 +567,7 @@ dependencies {
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.secp256k1.kmp.jni.jvm)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.junit)

View File

@@ -0,0 +1,190 @@
# v1.13.0 release QA — coverage, open findings, and recipes
One extended testing session against v1.13.0 (~2011 commits since v1.12.6). Fixes landed on
`fix/napplet-account-isolation-and-consent` (30 commits); each commit message carries its own
root-cause reasoning and is the better reference for *why* a given change looks the way it does.
This document records what that session **could not** capture in commit messages: what was actually
exercised, what was not, what we chose to leave broken, and how to reproduce the setups.
**Device under test:** Samsung SM-T220 tablet, Android 14, `sw600dp`, `play`/`benchmark`, arm64.
Everything below is that one configuration unless stated.
---
## 1. Coverage
### Exercised on device
| Area | Notes |
|---|---|
| Upgrade path | install over a month-old build; migrations survived, ~880 ms cold start |
| Napplet / web app per-account isolation | leak found and fixed; each account now has its own jar |
| Embedded tab rebuild on account switch | blank-tab bug found and fixed; verified both directions + a forced-failure A/B |
| Launch-account signing binding | desync reproduced end to end, then verified fixed |
| NIP-46 remote signer | 13 checks, all passing (pairing gate, 22242 prompt, decrypt counterparty/plaintext/narrow grant/scoping) |
| Concord | invite consent gate, private/voice rename, role rank gate, revoke gate |
| NIP-29 relay groups | directory browse (crash found), naddr deep-link join, membership resolution |
| Breadth sweep | Messages, NIP-29, Git, Podcasts, Blossom list, Location, Theming |
| Location | map picker + teleport, before/after on 4 symptoms, composer path |
| Notifications | tab showed ~3 items; root-caused to a `since` deadlock and fixed — feed now scrolls back 16 months |
| Concord role grants | picker built and device-verified (rank gating, preselection, survives the fold) |
### Fixed but **only unit-verified** — never run on a device
Concord rollback floor · stranded recovery · member-set gap · invite expiry · moderation head ·
**chain-poisoning fix** · community-list unknown-key preservation · V4V fee clamp · Cashu SSRF
validator · Blossom 402 (cap / re-prompt / double-spend / `X-Reason`) · amountless-invoice display ·
**napplet consent diff dialog** (never seen rendered) · connect-dialog capability disclosure ·
`identity.watch` DENY · DM ciphertext previews and the `User` metadata race · chat date separators ·
podcast duplicate description · Concord leave affordance · the three revocation wirings.
### Never opened at all
Nests / audio rooms (`quic` + MoQ) · Marmot / MLS · **the entire Desktop app** (where Privacy Lock
actually ships) · Blossom "Sync all" (skipped deliberately — uploads to real servers) · podcast
chapters / transcripts / credits · Git branch switching · Messages live-typing and per-type toggles ·
real payments (zaps, V4V streaming, Cashu redeem) · push notifications · search · Calendar, Chess,
Polls, Marketplace, Workouts, Badges, Follow Packs, Emojis, HLS Upload, App Store, Live Streams.
NIP-29 admin: the menu is reachable and renders, but Edit metadata, invite creation, subgroups and
pinned messages were never exercised.
### Platform gaps
- **Android 14 only.** `targetSdk` is 37; Android 15+ forces edge-to-edge and that path is untested.
An emulator makes this cheap and it is the highest-value remaining gap.
- **Tablet only** — no phone layout. **`play` only** — no fdroid. **`benchmark` only** — the real
`release` (full R8) has never been built or run. **arm64 only.**
- **Amber / NIP-55** external signer never tested, including a known decrypt double-prompt risk.
- **Tor-on paths** — Tor was disabled for untrusted relays mid-session and not restored.
---
## 2. Open findings (known, deliberately not fixed)
**Release mechanics**
- `appCode` still `454` and `app` still `1.12.6` — Play hard-rejects a duplicate versionCode.
- Firebase `TransportRuntime` cannot schedule (`JobInfoSchedulerService` missing from the merged
manifest). If this reproduces in `release`, **Crashlytics delivery is broken** and the release
ships blind.
**Correctness / UX**
- Tor settings do not take effect until app restart, with no indication.
- An unreachable relay is reported as "No groups on this relay yet" — indistinguishable from empty.
- NIP-29: a stale "Requested" join state is never reconciled against an arriving 39002 roster.
- Concord: leaving does not unpin from the bottom bar, leaving a dead tab.
- Read-only accounts render nothing for a kind:4 chatroom body (better than ciphertext, still wrong).
- Modal geohash picker header is overdrawn by the MapView (pre-existing).
- `amy relaygroup create` reports success on relays that silently reject it — always verify with `info`.
- `amy login bunker://…` hangs and never delivers a `connect`.
**Security / protocol**
- NIP-46 "Generate a new address" claims to disconnect every app; it rotates the transport key and
revokes nothing.
- WebView storage profiles are never deleted on logout — a removed account's cookies persist.
Requires a broker message so `:napplet` can call `ProfileStore.deleteProfile`.
- Control-plane *edit* paths (`editConcordMetadata`, `grant`, channel edits) still drop unknown JSON
keys; only the community list was fixed.
- **CORD-05: `community_id` does not commit to `community_root`**, so a crafted invite can carry a
real community's identity with an attacker's root. **Armada has the identical gap** — this needs a
spec conversation, not a unilateral fix.
- **CORD-04: the BANLIST is not rank-gated, so any BAN holder can ban anyone — including the owner.**
Role/grant editions are rank-gated (`canActOn`), but a banlist edition is a single *whole-list*
entity, so no client rank-checks its contents; the gate is the author's BAN bit alone. A rank-5
moderator's ban of a rank-1 admin is therefore **accepted** by the fold, and the admin then loses
every permission (`hasPermission` is `!isBanned && …`). **Armada has the identical gap** — its
`banlistGate` calls the rank-blind `isAuthorized(.., Permissions.BAN)` while its role path uses
the rank-aware `canActOnPosition`.
**This is a conformance bug, NOT a spec gap** — an earlier note here said the opposite and was
wrong. CORD-04 §3 is explicit and normative: "One hard rule binds every action: the actor must
hold the required bit **and** *strictly* outrank its target — equal cannot act on equal (an admin
cannot ban a peer admin)", restated as step 3 of §5. Only §4, the section that defines the
Banlist, omits the rank half — and both independent implementations read §4 in isolation and made
the same mistake. Spec: <https://github.com/concord-protocol/concord> (`04.md`).
**FIXED and shipping**`AuthorityResolver` now enforces §3 as a *delta rule* (an edition may only
add/remove npubs its signer strictly outranks; the owner is never a valid target; unpermitted
entries are ignored rather than rejecting the edition, so a bulk-ban survives). The UI and the
ban/unban write path route through it too — `ConcordModeration.currentBanned` now reads the
*honored* banlist via the resolver instead of decoding the raw head, which also closes a
laundering path where our own next ban would re-publish an unauthorized entry under our signature.
**Known consequence: Armada has not shipped this, so banlists can differ between clients**
we ignore a ban Armada honors when the signer did not outrank the target. Deliberate.
Write-up to send upstream: `docs/concord-banlist-rank-conformance.md`.
Still open, both covered in the write-up: a banned member holding BAN can lift their own ban (the
gate reads role-derived permissions, so bans do not stick against any BAN holder — this one is a
genuine fixpoint-ordering question and needs a spec ruling), and a forked ban survives an unban
that does not chain onto it.
- Notification cards whose target note isn't in `LocalCache` render "Event is loading or can't be
found in your relay list" (seen on old zaps). `tagsAnEventByUser` needs the reacted-to note
loaded, so deep history stays partially unresolved. Cosmetic, pre-existing.
---
## 3. Setup recipes
**`amy` with an isolated identity** (never touch the maintainer's real one):
```
AMY=$(pwd)/cli/build/install/amy/bin/amy
H=/tmp/qa-home; mkdir -p $H
HOME=$H $AMY --account qa login <nsec> --secret-backend plaintext
```
`amy` scopes by `$HOME`, not a flag. `init` prompts for a passphrase and hangs without a TTY — use
`--secret-backend plaintext` for throwaway identities.
**NIP-29 test group.** `relaygroup create` on `communities.nos.social` and `relay.groups.nip29.com`
returned success but published nothing; `groups.0xchat.com` worked. Always confirm with
`relaygroup info`. To reach a group in a 1000+ entry directory, skip the UI and deep-link:
`adb shell am start -a android.intent.action.VIEW -d "nostr:<naddr>"`, encoded via
`amy encode naddr --pubkey <relay-nip11-pubkey> --kind 39000 --identifier <groupId> --relay <url>`.
To make a device account an admin, have it join first, read its pubkey from `relaygroup info`, then
`put-user … --role admin`.
**Proving "no network before consent."** Run a local relay (`amy serve`), expose it with
`adb reverse tcp:7777`, and make it the *only* relay in the artefact under test. Count events before
and after the user action — that turns "I didn't see traffic" into an actual measurement.
---
## 4. Patterns worth acting on
These recurred often enough to be process problems rather than individual bugs.
**Tests that assert the bug.** At least five encoded the buggy behaviour as intended — a NIP-46 test
named `getPublicKeyReturnsUserPubKeyWithoutAuthorization`, a V4V invariant only ever run on
well-formed input, a napplet session test that never crossed accounts. *Always verify a new
regression test fails without the fix* — and beware that **Gradle will serve a stale up-to-date
`jvmTest` and report BUILD SUCCESSFUL**, which makes that check silently lie. Use `--rerun-tasks`.
**Implemented-but-unreachable capabilities.** Five found: `leaveConcordCommunity`,
`ConcordInviteBundle.isExpired`, `NappletPermissionLedger.endSession`, `grantConcordRole`, and
`NappletBroker.revokeSessionGrants`. Each made a feature look complete to anyone reading the model
while being unreachable to users, and the first one actually invoked turned out to be **broken as
written**. A lint for "public capability with no caller outside its declaring file" would catch the
whole class cheaply.
**A narrow query window can deadlock against its own paging.** The Notifications tab asked relays
for 7 days, and its backward-paging fallback only armed once the feed held a *full page* — so a
quiet inbox could never fill a page, and therefore never widened the window. The EOSE `since` map
is in-memory, so every cold start re-pinned it. Look for this shape wherever a "load more" boundary
is gated on a full page: the empty state is self-sustaining. Note also that the relay-side `limit`
already bounds these queries, which is what makes dropping the time floor safe.
**Hypotheses need measurement, not plausibility.** Four confident diagnoses were wrong: the "npub in
title" bug was a `User` lazy-init data race, not a display bug; chat date separators were a
`reverseLayout` misconception, not bubble grouping; the map picker had no tile problem at all; and
NIP-29 membership was a *relay rejecting the REQ* (`blocked: it's not allowed to mix metadata kinds
with others`), not membership modelling. Instrument first.
**Check the reference implementation.** Reading Armada changed the answer three times out of three —
it corrected an owner-rotation rule that would have stranded owners, stopped an invite-binding "fix"
that was both interop-breaking and ineffective, and supplied the chain-poisoning design (gate *after*
folding, not before). Armada is **AGPLv3** and Amethyst is MIT: read for semantics, copy nothing.
**Comments encoding constraints are load-bearing.** The synchronous SharedPreferences read looks like
an obvious StrictMode fix; its comment records that an async hydrate reopens a settings-clobber race.
Removing it would have been a confident, review-passing regression.
**Beware concurrent agents and `git add -A`.** Two commits were contaminated, and one silently
committed another worker's temporary revert. Stage explicit paths, always.

View File

@@ -32,6 +32,9 @@ import com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabHost
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.LogLevel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
/**
@@ -95,6 +98,10 @@ class Amethyst : Application() {
// Index device-local captured favicons (main process only; decorates favorites + suggestions).
BrowserIconRegistry.init(this)
// Warm the global-settings prefs off-main so the first (deliberately synchronous) read of
// them does not hit disk on the main thread. See LocalPreferences.warmGlobalSettings.
CoroutineScope(Dispatchers.IO).launch { LocalPreferences.warmGlobalSettings() }
// Hydrate the per-web-client Tor routing preferences so a site opted out of Tor (some reject Tor
// exits) starts on the open web without first flashing a failed Tor load.
WebAppNetworkRegistry.init(this)

View File

@@ -27,6 +27,7 @@ import androidx.security.crypto.EncryptedSharedPreferences
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
@@ -694,8 +695,32 @@ class AppModules(
// Per-relay NIP-42 ALLOW/DENY overrides are now per-account (Account.relayAuthPermissions,
// backed by a file under accounts/<pubkey>/), so there is no app-wide store here anymore.
/**
* The account every napplet/web-app grant and byte of storage is scoped to. Read lazily on each
* call (never captured) so an account switch immediately moves embedded apps to the new account's
* namespace: an app authorized by one npub is never authorized under another.
*/
val nappletAccountScope: () -> String = { sessionManager.loggedInAccount()?.pubKey ?: "" }
// Singleton stores for napplet permissions — DataStore v1 enforces one instance per file.
val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appContext) }
val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appContext, nappletAccountScope) }
/**
* The one napplet permission ledger for the main process. Its persistent half is just the store
* above, but it also holds the in-memory ALLOW_SESSION grants — and *those* only work if every
* caller shares this instance. The broker service and the Connected Apps screens used to build
* a ledger each, so a "Forget"/revoke tapped in the UI cleared the screen's own (always empty)
* session map while the grants the broker was actually consulting lived on untouched.
*
* Session lifetime is bounded by [com.vitorpamplona.amethyst.napplet.NappletBrokerService]'s
* onDestroy (all applet/browser surfaces gone), which calls `endSession()`.
*/
val nappletPermissionLedger by lazy { NappletPermissionLedger(nappletPermissionStore, nappletAccountScope) }
// NOT account-scoped here on purpose: this store is shared with NIP-46, whose coordinates already
// carry their owning account (`nip46:<signer>:<client>`) and whose sessions run for a specific
// account rather than the active one. The napplet path namespaces its own coordinate the same way
// (see NappletBroker.signerCoordinateFor) instead.
val signerPermissionStore by lazy { DataStoreNostrSignerPermissionStore(appContext) }
// Display + relay info for connected NIP-46 remote-signer clients.

View File

@@ -236,6 +236,19 @@ object LocalPreferences {
// the source of truth, so there is no async hydrate that could clobber a user toggle.
private fun globalSettingsPrefs(): SharedPreferences = Amethyst.instance.appContext.getSharedPreferences("amethyst_global_settings", Context.MODE_PRIVATE)
/**
* Loads the global-settings prefs file into SharedPreferences' in-memory cache, off the main
* thread, so the first synchronous read below hits memory rather than disk.
*
* The read itself is deliberately synchronous — see [setNotificationServiceEnabled]: an async
* hydrate reintroduces a window where a late disk read clobbers a user's toggle. So this warms
* the cache instead of deferring the read. Best-effort: if a main-thread reader wins the race it
* simply pays the disk hit once, exactly as before.
*/
fun warmGlobalSettings() {
globalSettingsPrefs().getBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, true)
}
private val notificationServiceEnabled: MutableStateFlow<Boolean> by lazy {
MutableStateFlow(globalSettingsPrefs().getBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, true))
}

View File

@@ -193,6 +193,16 @@ private fun SignerConsentDialog(
if (info.accountName != null) {
ConnectedAccountRow(info.accountName, info.accountPicture, info.accountPubKey)
}
// For a decrypt request, WHOSE conversation is being read is the decision. Show
// that person as an avatar + name, never as nothing.
if (info.counterpartyName != null) {
Text(
stringResource(R.string.nip46_signer_messages_with),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
ConnectedAccountRow(info.counterpartyName, info.counterpartyPicture, info.counterpartyPubKey)
}
}
Spacer(Modifier.height(12.dp))
@@ -204,12 +214,31 @@ private fun SignerConsentDialog(
HorizontalDivider()
Spacer(Modifier.height(8.dp))
// Primary: always allow this op
Button(
onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_consent_allow_always))
// Primary: the NARROWEST "remember" available. For decrypt that is "always allow for
// Alice" — one broad decrypt grant would otherwise hand over every conversation
// forever, and scoping the op itself would mean a prompt per conversation.
val narrowOp = info.narrowOp
if (narrowOp != null && info.narrowOpLabel != null) {
Button(
onClick = { onGrant(SignerOpGrant.AllowForOp(narrowOp)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(info.narrowOpLabel)
}
// The broad grant stays available, but demoted below the scoped one.
OutlinedButton(
onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_consent_allow_always))
}
} else {
Button(
onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_consent_allow_always))
}
}
// Secondary: allow just once

View File

@@ -65,6 +65,23 @@ data class SignerConsentInfo(
* non-event ops.
*/
val previewTemplate: EventTemplate<Event>? = null,
/**
* The OTHER party of a decrypt request — whose conversation the app is asking to read — shown as
* an avatar + name. "X wants to read your messages with Alice" is a categorically different
* decision from "X wants to read your private messages", so this must reach the dialog.
* Null for every op that has no counterparty (signing, and the napplet/browser paths).
*/
val counterpartyName: String? = null,
val counterpartyPicture: String? = null,
val counterpartyPubKey: String? = null,
/**
* A NARROWER op the dialog may offer to remember instead of [op] — today only
* [com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp.DecryptFrom], i.e.
* "always allow, but only for this counterparty". Offered ALONGSIDE the broad "Always allow" so
* the user gets granularity without a prompt per conversation. [narrowOpLabel] is its button text.
*/
val narrowOp: NostrSignerOp? = null,
val narrowOpLabel: String? = null,
)
/** One pending per-operation consent request, as the batched sheet renders it. */

View File

@@ -22,10 +22,14 @@ package com.vitorpamplona.amethyst.favorites
import android.content.Context
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.File
/**
@@ -50,12 +54,28 @@ object BrowserIconRegistry {
@Volatile private var iconDir: File? = null
/** Binds the app context and indexes already-stored icons. Idempotent. */
// Disk work runs here, never on the caller's thread. Both entry points are reached from threads
// that must not block: init() from app startup and record() from the broker's IPC handler, which
// is the main looper — StrictMode flagged the write, and a slow filesystem would have stalled the
// UI while a favicon was saved.
private val io = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/**
* Binds the app context and indexes already-stored icons. Idempotent.
*
* [iconDir] is published synchronously so [iconModelFor] and [record] work immediately; only the
* directory scan is deferred. Until it lands [keys] is empty, so an icon simply renders its
* placeholder for one frame and then recomposes — [keys] is a StateFlow precisely so that arrival
* drives recomposition.
*/
fun init(context: Context) {
if (iconDir != null) return
val dir = File(context.applicationContext.filesDir, DIR).apply { mkdirs() }
val dir = File(context.applicationContext.filesDir, DIR)
iconDir = dir
_keys.value = dir.listFiles()?.mapNotNull { it.name.removeSuffix(PNG).takeIf { n -> n.isNotBlank() } }?.toSet() ?: emptySet()
io.launch {
dir.mkdirs()
_keys.value = dir.listFiles()?.mapNotNull { it.name.removeSuffix(PNG).takeIf { n -> n.isNotBlank() } }?.toSet() ?: emptySet()
}
}
/** Persists [bytes] as the favicon for [host] and marks it available. Called from the broker on IPC. */
@@ -66,11 +86,17 @@ object BrowserIconRegistry {
val dir = iconDir ?: return
if (host.isBlank() || bytes.isEmpty()) return
val key = sanitize(host)
try {
File(dir, key + PNG).writeBytes(bytes)
_keys.update { it + key }
} catch (e: Exception) {
Log.w("BrowserIconRegistry", "Failed to store favicon for $host", e)
// Fire-and-forget: a favicon is a decoration, and the IPC handler must not wait on disk.
// [keys] updates only after the bytes are actually on disk, so a reader can never be told an
// icon exists before the file backing it does.
io.launch {
try {
dir.mkdirs()
File(dir, key + PNG).writeBytes(bytes)
_keys.update { it + key }
} catch (e: Exception) {
Log.w("BrowserIconRegistry", "Failed to store favicon for $host", e)
}
}
}

View File

@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.napplet.NappletLauncher
import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
import com.vitorpamplona.amethyst.napplethost.HostProfile
import com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity
@@ -92,9 +93,20 @@ object FavoriteAppLauncher {
}
val isFavorite = FavoriteAppsRegistry.isFavorite("url:$url")
val intent =
NappletBrowserActivity.intent(context, url, proxyPort, useTor, theme = theme, isFavorite = isFavorite).apply {
if (context !is Activity) addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
NappletBrowserActivity
.intent(
context,
url,
proxyPort,
useTor,
theme = theme,
isFavorite = isFavorite,
// Opaque per-account storage partition, so a web app can't carry one npub's session
// into another. Derived here (the sandbox never sees the pubkey).
webViewProfile = NappletWebViewProfiles.current(),
).apply {
if (context !is Activity) addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}

View File

@@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.commons.model.nip72Communities.CommunityListDe
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.privateChats.hasEncryptedContent
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendError
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage
@@ -152,6 +153,7 @@ import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.Notify
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
@@ -208,6 +210,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
@@ -362,6 +365,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.math.BigDecimal
import java.util.concurrent.ConcurrentHashMap
import kotlin.coroutines.cancellation.CancellationException
import com.vitorpamplona.quartz.experimental.nip95.header.thumbhash as nip95thumbhash
import com.vitorpamplona.quartz.experimental.profileGallery.thumbhash as galleryThumbhash
@@ -371,6 +375,14 @@ private const val ONCHAIN_BACKEND_NOT_CONFIGURED = "Bitcoin chain backend is not
/** Name of the default Concord community Admin role minted by "Make admin". */
private const val CONCORD_ADMIN_ROLE = "Admin"
/**
* How often a joined Concord community's stored invite link is re-resolved to check whether
* we were left out of a Refounding (see `recoverStrandedConcordCommunities`). Stranding is
* rare and silent, so this trades detection latency for not turning the revision tick into a
* relay-fetch loop.
*/
private const val RECOVERY_CHECK_INTERVAL_MS = 15 * 60 * 1000L
@OptIn(DelicateCoroutinesApi::class)
@Stable
class Account(
@@ -650,6 +662,11 @@ class Account(
val dmRelays = DmInboxRelayState(dmRelayList, nip65RelayList, privateStorageRelayList, localRelayList, scope)
val notificationRelays = NotificationInboxRelayState(nip65RelayList, localRelayList, scope)
// Account-level notification history paging cursors (one scope per account): how far back each
// notification relay has been paged by until+limit. Held here so they share the account's lifetime;
// the history loader ([AccountNotificationsHistoryEoseManager]) binds its orchestrator to these.
val notificationHistory = RelayLoadingCursors()
val cashuWalletState =
com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState(
pubKey = signer.pubKey,
@@ -2090,6 +2107,16 @@ class Account(
* bundle we can't open (e.g. minted by a newer client) must not strand the user
* on a spinner that retries forever.
*
* A bundle whose `expires_at` has passed is rejected with
* [ConcordInviteResult.Expired]. Expiry is resolved inside
* [ConcordActions.classifyInvite], so it is enforced on every redeem path rather
* than being a field nobody reads.
*
* **This must only ever be called from an explicit user action.** It contacts
* relay URLs carried in the link (chosen by whoever minted it) and publishes a
* Guestbook JOIN signed by this account, so calling it on deep-link arrival would
* leak the user's IP and enroll them without consent — see `ConcordInviteScreen`.
*
* If the resolved community is already in the joined list, this returns
* [ConcordInviteResult.Joined] without re-following or re-announcing a Guestbook
* JOIN, so reopening an old invite for a community you're already in simply takes
@@ -2112,6 +2139,7 @@ class Account(
val bundle =
when (val status = ConcordActions.classifyInvite(wraps, parsed.fragment.token)) {
is InviteBundleStatus.Live -> status.invite
is InviteBundleStatus.Expired -> return ConcordInviteResult.Expired
InviteBundleStatus.Revoked -> return ConcordInviteResult.Revoked
InviteBundleStatus.Unreadable -> return ConcordInviteResult.Incompatible
InviteBundleStatus.Absent -> return ConcordInviteResult.NotReachable
@@ -2135,6 +2163,10 @@ class Account(
relays = bundle.relays,
name = bundle.name,
addedAt = TimeUtils.now() * 1000,
// Anchor for stranded recovery: keep the link we joined through, domain-agnostic, so a
// Refounding that leaves us out of the recipient set is recoverable later. See
// recoverStrandedConcordCommunities().
inviteRef = ConcordActions.bareInviteRef(url),
)
joinConcordCommunity(entry)
return ConcordInviteResult.Joined(bundle.communityId)
@@ -2343,7 +2375,7 @@ class Account(
): Boolean {
val session = concordSessions.sessionFor(communityId) ?: return false
if (!isWriteable()) return false
val wrap = ConcordModeration.grant(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, roleIds, session.controlEditions(), TimeUtils.now())
val wrap = ConcordModeration.grant(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, roleIds, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
}
@@ -2405,12 +2437,12 @@ class Account(
val roleIdHex =
existing?.key ?: run {
val roleId = RandomInstance.bytes(32)
val roleWrap = ConcordModeration.defineRole(signer, cp, roleId, concordAdminRole(), session.controlEditions(), TimeUtils.now())
val roleWrap = ConcordModeration.defineRole(signer, cp, roleId, concordAdminRole(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, roleWrap)
roleId.toHexKey()
}
val grantWrap = ConcordModeration.grant(signer, cp, communityId.hexToByteArray(), member, listOf(roleIdHex), session.controlEditions(), TimeUtils.now())
val grantWrap = ConcordModeration.grant(signer, cp, communityId.hexToByteArray(), member, listOf(roleIdHex), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, grantWrap)
return true
}
@@ -2422,17 +2454,26 @@ class Account(
): Boolean {
val session = concordSessions.sessionFor(communityId) ?: return false
if (!isWriteable()) return false
val grantWrap = ConcordModeration.grant(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now())
val grantWrap = ConcordModeration.grant(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, grantWrap)
return true
}
/**
* If [note] is a Concord channel message whose author this account is allowed to
* ban — the actor is the owner or holds the BAN permission, and the target is
* neither the owner nor the actor — returns `(communityId, memberHex)`. Null
* otherwise, so the UI shows the Ban action only when it would actually take
* effect on fold.
* ban — the actor outranks the target and holds the BAN permission, and the target
* is neither the owner nor the actor — returns `(communityId, memberHex)`. Null
* otherwise, so the UI offers Ban only where we are willing to act.
*
* The rank half is ours alone. CORD-04 rank-gates role grants (`canActOn`) but the
* BANLIST is a single whole-list entity, so neither this client's fold nor Armada's
* rank-checks the *contents* of a banlist edition — both gate only on the author's
* BAN bit (Armada: `banlistGate` → `isAuthorized(.., Permissions.BAN)`, while its
* role path uses the rank-aware `canActOnPosition`). A moderator's ban of an admin
* above them is therefore *accepted* by every client today. Since we cannot refuse
* such a ban without diverging from Armada, we at least refuse to author one — this
* restricts what we write, never what we accept, so it cannot split consensus.
* Enforcing it on the fold needs a spec change; see the QA plan's open findings.
*/
fun concordBanTarget(note: Note): Pair<String, HexKey>? {
val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return null
@@ -2446,7 +2487,11 @@ class Account(
?.value
?.authority ?: return null
if (authority.isOwner(author)) return null
val canBan = authority.isOwner(signer.pubKey) || authority.effectivePermissions(signer.pubKey).has(ConcordPermissions.BAN)
// The owner short-circuits rather than going through canActOn: canActOn starts at
// hasPermission, which is false while banned, and a rogue BAN holder *can* currently put
// the owner on the banlist (see the KDoc) — routing the owner through it would let them be
// locked out of moderating their own community.
val canBan = authority.isOwner(signer.pubKey) || authority.canActOn(signer.pubKey, author, ConcordPermissions.BAN)
return if (canBan) communityId to author else null
}
@@ -2457,7 +2502,7 @@ class Account(
): Boolean {
val session = concordSessions.sessionFor(communityId) ?: return false
if (!isWriteable()) return false
val wrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now())
val wrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
}
@@ -2469,7 +2514,7 @@ class Account(
): Boolean {
val session = concordSessions.sessionFor(communityId) ?: return false
if (!isWriteable()) return false
val wrap = ConcordModeration.unban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now())
val wrap = ConcordModeration.unban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
}
@@ -2483,7 +2528,7 @@ class Account(
/**
* Remove [removed] from the community absolutely (CORD-06 Refounding): ban them,
* roll the `community_root`, re-key every retained member (Guestbook membership
* the privileged roster self) via kind-3303 blobs, and republish the compacted
* observed authors the privileged roster self) via kind-3303 blobs, and republish the compacted
* Control Plane under the new root. A removed member keeps the prior root (so
* their history stays readable) but receives no blob, so they can never decrypt
* anything published after the rotation.
@@ -2508,14 +2553,23 @@ class Account(
// and thus the new epoch — carries the ban. publishConcordWrap folds it in locally
// first, so each subsequent edition chains onto the updated banlist head.
for (target in removedLower) {
val banWrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), target, session.controlEditions(), TimeUtils.now())
val banWrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), target, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, banWrap)
}
// 2. Recipient set: everyone we're keeping — Guestbook joins roster self, minus the
// removed and the already-banned.
// 2. Recipient set: everyone we're keeping, minus the removed and the already-banned.
// Uses allMembers() — Guestbook joins OBSERVED AUTHORS roster owner — not just the
// Guestbook set. Most members never send a Guestbook Join (Amethyst announces one, other
// clients need not), so building the set without observed authors silently expelled every
// member who had only ever posted: they hold no role, receive no blob, and the Refounding
// strands them. That mainly hit cross-client communities, where Armada members are the
// bulk of the roster.
//
// Still a floor, not a census (see allMembers): a member who joined without a Guestbook
// motion, holds no role, and has never posted leaves no trace to find, so a Refounding
// cannot re-key them. Stranded recovery is what gets those members back.
val recipients =
(session.members.value + authority.roleHolders() + state.ownerPubKey + signer.pubKey)
(session.allMembers() + signer.pubKey)
.mapTo(HashSet()) { it.lowercase() }
.apply {
removeAll(removedLower)
@@ -2583,6 +2637,13 @@ class Account(
relays = entry.relays,
name = entry.name,
addedAt = entry.addedAt,
// The invite_ref anchor must survive a rotation, or the *next* Refounding we're left
// out of would be unrecoverable.
inviteRef = entry.inviteRef,
excludedAtEpoch = entry.excludedAtEpoch,
// Unknown keys another client wrote (Armada's list is `[k: string]: unknown`)
// must survive our rotation write, or we delete their data on every rekey.
residue = entry.residue,
)
sendMyPublicAndPrivateOutbox(concordChannelList.follow(next))
announceConcordGuestbookJoin(next, inviteCreator = null, inviteLabel = null)
@@ -2591,10 +2652,23 @@ class Account(
/**
* Drain any buffered inbound base-rotation rekeys (CORD-06 receive path): for
* each joined community, look for our new root among the kind-3303 wraps seen at
* our next base-rekey address. If a role-authorized rotator (owner or a current
* BAN-holder) delivered us one, adopt it. Idempotent — once adopted, the session
* rebuilds at the new epoch and its next-rekey address moves on, so a stale wrap
* never re-triggers. Called on every Concord revision tick.
* our next base-rekey address. If a role-authorized rotator (owner or a current,
* non-banned BAN-holder) delivered us one, adopt it. Idempotent — once adopted, the
* session rebuilds at the new epoch and its next-rekey address moves on, so a stale
* wrap never re-triggers. Called on every Concord revision tick.
*
* Authority is the roster, never key possession: any non-banned BAN-holder may
* rotate, including for the owner. The owner deliberately does NOT refuse a root
* authored by someone else — refusing would strand the owner alone on the dead
* epoch whenever an admin legitimately rotates, and would diverge from Armada,
* which forks a community across clients. Self-escalation to BAN is prevented
* upstream by the role rank gate in AuthorityResolver.
*
* A rotation carries only (newRoot, newEpoch, rotator); there is no recipient list,
* so a receiver cannot tell who was left out, and a BAN-holder can evict anyone (the
* owner included) by omission — nothing on this receive path can prevent it. The
* cure is after the fact: see [recoverStrandedConcordCommunities], which re-resolves
* the invite link the membership was joined through and merges forward.
*/
private suspend fun drainConcordRekeys() {
if (!isWriteable()) return
@@ -2612,12 +2686,76 @@ class Account(
) ?: continue
if (received.newEpoch <= entry.rootEpoch) continue
val authority = session.state.value?.authority ?: continue
val authorized = authority.isOwner(received.rotator) || authority.effectivePermissions(received.rotator).has(ConcordPermissions.BAN)
// hasPermission, not effectivePermissions: the latter ignores the banlist, so a BAN-holder
// who has themselves been banned could still rotate the whole community.
val authorized = authority.isOwner(received.rotator) || authority.hasPermission(received.rotator, ConcordPermissions.BAN)
if (!authorized) continue
adoptConcordRoot(entry, received.newRoot, received.newEpoch)
}
}
// Last time we re-resolved each community's invite_ref, so the recovery sweep rides the
// Concord revision tick (which fires on every structural change) without turning it into a
// relay-fetch loop.
private val lastConcordRecoveryCheck = ConcurrentHashMap<String, Long>()
/**
* Stranded recovery (CORD-05/06 receive path). A Refounding carries only
* `(newRoot, newEpoch, rotator)` — **no recipient list** — so a member simply left
* out of the rekey recipient set receives nothing and sits on the dead epoch
* forever while everyone else moves on. This happens to any member, the owner
* included, and [drainConcordRekeys] cannot prevent it: there is no message to
* miss detecting.
*
* The way back is the invite link the membership was joined through
* ([ConcordCommunityListEntry.inviteRef], persisted by [joinConcordViaInvite] and
* carried through every rotation by [adoptConcordRoot]). The community keeps
* re-minting its bundle at that same addressable coordinate, so a bundle there at
* a **strictly higher** epoch than ours proves we were left behind — and carries
* the new root. Same or lower epoch is a no-op. Memberships with no link (direct
* invites, legacy entries) are inert here; that is expected, not an error.
*
* The merge itself ([ConcordActions.recoverStranded]) is epoch-monotonic and keeps
* both the `invite_ref` anchor (so the *next* exclusion is recoverable too) and the
* entry's [HeldRoot]s (so prior-epoch history the member legitimately holds stays
* derivable). We then re-announce the Guestbook at the new epoch, exactly as an
* ordinary rotation does, so the recovered member is visible to whoever refounds
* next instead of being silently dropped again.
*
* Called on the Concord revision tick, but rate-limited per community
* ([RECOVERY_CHECK_INTERVAL_MS]) — a tick with nothing to do costs a map lookup.
*/
private suspend fun recoverStrandedConcordCommunities() {
if (!isWriteable()) return
val now = TimeUtils.nowMillis()
for (entry in concordChannelList.liveCommunities.value) {
val inviteRef = entry.inviteRef ?: continue
val last = lastConcordRecoveryCheck[entry.id]
if (last != null && now - last < RECOVERY_CHECK_INTERVAL_MS) continue
lastConcordRecoveryCheck[entry.id] = now
val parsed = ConcordActions.parseInviteLink(inviteRef) ?: continue
val relays =
(
parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } +
entry.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
).toSet()
if (relays.isEmpty()) continue
val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }
val wraps = client.fetchAll(filters = filters)
// Only a live bundle recovers: an expired/revoked link is not a rotation we missed.
val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite ?: continue
val merged = ConcordActions.recoverStranded(entry, bundle) ?: continue
if (!adoptedConcordRotations.add("${entry.id}:${merged.rootEpoch}")) continue
Log.i("Concord", "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}")
sendMyPublicAndPrivateOutbox(concordChannelList.follow(merged))
announceConcordGuestbookJoin(merged, inviteCreator = null, inviteLabel = null)
}
}
/**
* Replace the community metadata (name / icon / description / relays) with a new
* Control-Plane edition. Honored on fold only when this account holds
@@ -2634,7 +2772,7 @@ class Account(
val session = concordSessions.sessionFor(communityId) ?: return false
if (!isWriteable()) return false
val metadata = MetadataEntity(name = name, icon = icon, banner = banner, description = description, relays = relays)
val wrap = ConcordModeration.editMetadata(signer, session.controlPlaneKey(), communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now())
val wrap = ConcordModeration.editMetadata(signer, session.controlPlaneKey(), communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
}
@@ -2652,7 +2790,7 @@ class Account(
if (!isWriteable()) return false
val channelId = RandomInstance.bytes(32)
val channel = ChannelEntity(name = name.trim())
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelId, channel, session.controlEditions(), TimeUtils.now())
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelId, channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
}
@@ -2665,8 +2803,16 @@ class Account(
): Boolean {
val session = concordSessions.sessionFor(communityId) ?: return false
if (!isWriteable()) return false
val channel = ChannelEntity(name = name.trim())
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now())
// Carry the standing definition forward and change only the name. A ChannelEntity built from
// scratch defaults `private` and `voice` to false, so renaming a private channel used to
// publish an edition declaring it PUBLIC — and a voice channel became a text channel.
val standing =
session.state.value
?.channels
?.get(channelIdHex)
?.definition
val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false)
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
}
@@ -2679,8 +2825,15 @@ class Account(
): Boolean {
val session = concordSessions.sessionFor(communityId) ?: return false
if (!isWriteable()) return false
val channel = ChannelEntity(name = name.trim(), deleted = true)
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now())
// Same as rename: preserve the standing flags so a tombstone does not also silently
// reclassify the channel it retires.
val standing =
session.state.value
?.channels
?.get(channelIdHex)
?.definition
val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false, deleted = true)
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
}
@@ -2751,6 +2904,17 @@ class Account(
follow(channel)
}
/**
* Fire a Buzz kind-20002 typing heartbeat for [channel] to its host relay. Ephemeral
* (never stored) and fire-and-forget — no delivery tracking, no local echo (we filter
* our own typing in the UI). Throttled by the composer to [BuzzTypingState.TYPING_HEARTBEAT_SECS].
*/
suspend fun sendBuzzTyping(channel: RelayGroupChannel) {
if (!isWriteable()) return
val signed = signer.sign(TypingIndicatorEvent.build(channel.groupId.id))
client.publish(signed, setOf(channel.groupId.relayUrl))
}
/** Send a kind 9022 leave request to the host relay and drop it from our list. */
suspend fun leaveRelayGroup(channel: RelayGroupChannel) {
val template = LeaveRequestEvent.build(channel.groupId.id)
@@ -4916,7 +5080,10 @@ class Account(
else -> event.content
}
} else {
event.content
// A read-only (npub-only) account holds no key, so nothing above can run. Returning
// `content` verbatim would push the raw NIP-04/NIP-44 base64 blob straight into the
// UI (chat bubbles, Messages previews, ...). Callers treat null as "not readable".
if (event.hasEncryptedContent()) null else event.content
}
}
@@ -4943,6 +5110,11 @@ class Account(
draftsDecryptionCache.cachedDraft(event)?.content
}
// Encrypted kinds that reached here did so because this account is not writeable
// (every branch above is gated on isWriteable). Their `content` is ciphertext —
// hand back null rather than let the blob render. See cachedDecryptContent.
event != null && event.hasEncryptedContent() -> null
else -> {
event?.content
}
@@ -5373,6 +5545,9 @@ class Account(
refreshConcordChannelIndex()
// A revision also bumps when a base-rotation rekey lands; adopt ours if present.
runCatching { drainConcordRekeys() }.onFailure { Log.w("Concord", "rekey drain failed", it) }
// A rotation we were *excluded* from produces no rekey to drain, so it can only be
// found by re-resolving the invite link we joined through. Rate-limited internally.
runCatching { recoverStrandedConcordCommunities() }.onFailure { Log.w("Concord", "stranded recovery failed", it) }
}
}

View File

@@ -47,6 +47,13 @@ sealed interface ConcordInviteResult {
*/
data object Revoked : ConcordInviteResult
/**
* The bundle opened fine, but its `expires_at` has passed. Retrying can't help —
* unlike [Revoked] the owner didn't retire the link, it simply timed out, so the
* user's next step is to ask for a fresh one.
*/
data object Expired : ConcordInviteResult
/**
* The bundle event was found but could not be opened with the link's token —
* typically because it was minted by a newer/incompatible Concord client whose

View File

@@ -28,6 +28,9 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.cashu.MintDirectoryIndex
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
@@ -50,6 +53,82 @@ import com.vitorpamplona.amethyst.model.nipBCOnchainZaps.OnchainZapResolver
import com.vitorpamplona.amethyst.service.BundledInsert
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.dateFormatter
import com.vitorpamplona.quartz.buzz.aeEngrams.EngramEvent
import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent
import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricEvent
import com.vitorpamplona.quartz.buzz.aoObserver.ObserverFrameEvent
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent
import com.vitorpamplona.quartz.buzz.audit.AuditEntryEvent
import com.vitorpamplona.quartz.buzz.cwChannelWindow.WindowBoundsEvent
import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent
import com.vitorpamplona.quartz.buzz.dm.DmCreatedEvent
import com.vitorpamplona.quartz.buzz.dm.DmHideEvent
import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent
import com.vitorpamplona.quartz.buzz.dvDmVisibility.DmVisibilityEvent
import com.vitorpamplona.quartz.buzz.erReminders.EventReminderEvent
import com.vitorpamplona.quartz.buzz.forum.ForumCommentEvent
import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent
import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleEndedEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleGuidelinesEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantJoinedEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantLeftEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleReactionEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleStartedEvent
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.ArchiveRequestEvent
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.ArchivedIdentitiesListEvent
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.ArchivedIdentityEvent
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.UnarchiveRequestEvent
import com.vitorpamplona.quartz.buzz.iaIdentityArchival.UnarchivedIdentityEvent
import com.vitorpamplona.quartz.buzz.jobs.JobAcceptedEvent
import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent
import com.vitorpamplona.quartz.buzz.jobs.JobErrorEvent
import com.vitorpamplona.quartz.buzz.jobs.JobProgressEvent
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent
import com.vitorpamplona.quartz.buzz.managedAgents.ManagedAgentEvent
import com.vitorpamplona.quartz.buzz.moderation.ModerationBanEvent
import com.vitorpamplona.quartz.buzz.moderation.ModerationResolveReportEvent
import com.vitorpamplona.quartz.buzz.moderation.ModerationTimeoutEvent
import com.vitorpamplona.quartz.buzz.moderation.ModerationUntimeoutEvent
import com.vitorpamplona.quartz.buzz.moderation.ProductFeedbackEvent
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
import com.vitorpamplona.quartz.buzz.notifications.MemberRemovedNotificationEvent
import com.vitorpamplona.quartz.buzz.pairing.PairingEvent
import com.vitorpamplona.quartz.buzz.plPushLease.PushLeaseEvent
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminAddMemberEvent
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminChangeRoleEvent
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminRemoveMemberEvent
import com.vitorpamplona.quartz.buzz.stream.CanvasEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageBookmarkedEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessagePinnedEvent
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.sidecars.ChannelSummaryEvent
import com.vitorpamplona.quartz.buzz.stream.sidecars.PresenceSnapshotEvent
import com.vitorpamplona.quartz.buzz.teams.TeamEvent
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot
import com.vitorpamplona.quartz.buzz.workflow.ApprovalDenyEvent
import com.vitorpamplona.quartz.buzz.workflow.ApprovalGrantEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowApprovalDeniedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowApprovalGrantedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowApprovalRequestedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowCancelledEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowCompletedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowDefEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowFailedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowStepCompletedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowStepFailedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowStepStartedEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggeredEvent
import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
@@ -1257,6 +1336,16 @@ object LocalCache : ILocalCache, ICacheProvider {
(eTagTargets + qTagTargets).mapNotNull { checkGetOrCreateNote(it) }
}
is StreamMessageV2Event -> {
// Buzz threads 40002s with marked root/reply e-tags (thread_tags in
// buzz-sdk). Link both so inbound replies render their quote bubble —
// we emit these markers on send, so we must also read them.
listOfNotNull(
event.tags.buzzThreadRoot(),
event.tags.buzzThreadReply(),
).distinct().mapNotNull { checkGetOrCreateNote(it) }
}
else -> {
emptyList()
}
@@ -1986,6 +2075,82 @@ object LocalCache : ILocalCache, ICacheProvider {
return new
}
/**
* Marks the serving relay as Buzz, but only off a VERIFIED event: the mark changes
* what the composer sends (40002 vs kind 9) and how new channels on the relay are
* treated, so an unverifiable frame from a buggy/hostile relay must not flip it.
* The note-has-event check is the same verification gate the attach path uses.
*/
private fun markBuzzIfVerified(
event: Event,
relay: NormalizedRelayUrl?,
) {
if (relay != null && getNoteIfExists(event.id)?.event != null) {
BuzzRelayDialect.mark(relay)
}
}
/**
* Consume + channel-attach for Buzz workspace timeline kinds (stream messages,
* diffs, system rows, forum posts, job cards, huddle lifecycle). These kinds only
* exist on `block/buzz` relays, so their (verified) arrival IS the dialect
* detection. Attachment goes through the SAME shared NIP-29 path kind-9 chat uses
* — including its stray-redirect protection — so mixed vanilla/Buzz conversations
* share one timeline and non-host strays never mint phantom channels.
*/
private fun consumeBuzzTimelineEvent(
event: Event,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
consumeRegularEvent(event, relay, wasVerified).also {
markBuzzIfVerified(event, relay)
attachToRelayGroupIfScoped(event, relay)
}
/** Store-only consume for Buzz kinds that carry no channel timeline row. */
private fun consumeBuzzRegularEvent(
event: Event,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
consumeRegularEvent(event, relay, wasVerified).also {
markBuzzIfVerified(event, relay)
}
private fun consume(
event: StreamMessageEditEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
// Buzz's own timeline set excludes 40003: an edit is an OVERLAY replacing an
// earlier message's content, never a row of its own. Store it and record the
// overlay (keyed by the channel's UUID, so own sends with no provenance relay
// land too), but do NOT attach it to the timeline.
consumeBuzzRegularEvent(event, relay, wasVerified).also {
val target = event.editedMessage() ?: return@also
val channelId = event.channel() ?: return@also
val editNote = getOrCreateNote(event.id)
if (editNote.event != null) {
BuzzWorkspaceStates.getOrCreate(channelId).addEdit(target, editNote)
}
}
private fun consume(
event: CanvasEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
// The canvas is the channel's single living document, not a chat row: track
// only the newest revision as overlay state, never attach it to the timeline.
consumeBuzzRegularEvent(event, relay, wasVerified).also {
val channelId = event.channel() ?: return@also
val note = getOrCreateNote(event.id)
if (note.event != null) {
BuzzWorkspaceStates.getOrCreate(channelId).updateCanvas(note)
}
}
/**
* Attach a group-scoped content event (a kind-9 chat, kind-1068 poll, …
* carrying an `h` tag) to its [RelayGroupChannel]. NIP-29 reuses the generic
@@ -3040,6 +3205,13 @@ object LocalCache : ILocalCache, ICacheProvider {
channel.pruneStalePresence(TimeUtils.now() - PRESENCE_PRUNE_AGE_SECONDS)
}
// A Buzz workspace's edit/canvas overlay is keyed off the channel id, outside
// `notes`, so the top-N reap never touches it. Drop overlay entries whose target
// message was just pruned, else they pin the edit note + author forever.
if (channel is RelayGroupChannel) {
BuzzWorkspaceStates.getIfExists(channel.groupId.id)?.pruneEdits(channel.notes.keys())
}
if (toBeRemoved.size > 100 || channel.notes.size() > 100) {
println(
"PRUNE: ${toBeRemoved.size} old messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept",
@@ -4407,6 +4579,107 @@ object LocalCache : ILocalCache, ICacheProvider {
}
}
// ------------------------------------------------------------------
// Buzz workspace kinds (block/buzz — the Buzz dialect of NIP-29).
// Timeline kinds attach into the group's BuzzWorkspaceChannel; the
// rest are stored for query/state. Kinds 9041/20001/39005/49001 are
// absent on purpose: their numbers belong to GoalEvent,
// GeohashPresenceEvent, GroupPinnedEvent and a non-wire audit kind.
// ------------------------------------------------------------------
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 CanvasEvent -> consume(event, relay, wasVerified)
is ForumPostEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is ForumCommentEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is ForumVoteEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is JobRequestEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is JobAcceptedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is JobProgressEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is JobResultEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is JobCancelEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is JobErrorEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is HuddleStartedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is HuddleParticipantJoinedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is HuddleParticipantLeftEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is HuddleEndedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
// Buzz addressable/replaceable state.
is PersonaEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is TeamEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is ManagedAgentEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is AgentProfileEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is EngramEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is WorkflowDefEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is EventReminderEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is PushLeaseEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is DmVisibilityEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is WindowBoundsEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is ArchivedIdentitiesListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
// Buzz store-only regular kinds (queryable state; no timeline row yet).
is StreamMessagePinnedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is StreamMessageBookmarkedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is StreamMessageScheduledEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is StreamReminderEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is DmCreatedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is DmOpenEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is DmAddMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is DmHideEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is MemberAddedNotificationEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is MemberRemovedNotificationEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is AgentTurnMetricEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ModerationBanEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ModerationTimeoutEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ModerationUntimeoutEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ModerationResolveReportEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ProductFeedbackEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is RelayAdminAddMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is RelayAdminRemoveMemberEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is RelayAdminChangeRoleEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is SetWorkspaceProfileEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ArchiveRequestEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is UnarchiveRequestEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ArchivedIdentityEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is UnarchivedIdentityEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is HuddleGuidelinesEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowTriggeredEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowStepStartedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowStepCompletedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowStepFailedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowCompletedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowFailedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowCancelledEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowApprovalRequestedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowApprovalGrantedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowApprovalDeniedEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is WorkflowTriggerEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ApprovalGrantEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ApprovalDenyEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
// Buzz relay-signed sidecars and audit projections: store-only, queryable.
is AuditEntryEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ChannelSummaryEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is PresenceSnapshotEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
// Buzz ephemeral signals: transient by definition (20000-29999) — do not
// pollute the note store, and do NOT mark the dialect from them (they are
// never stored, so the verified-mark gate has nothing to check).
is TypingIndicatorEvent -> {
// Live "who is typing" side-effect only — record the heartbeat into the
// process-wide typing state (the channel view collects it) and return
// false so it never becomes a feed row. Own-typing is filtered in the UI.
event.channelId()?.let { BuzzTypingState.record(it, event.pubKey, event.createdAt, TimeUtils.now()) }
false
}
is ObserverFrameEvent -> false
is HuddleReactionEvent -> false
// Pairing (24134) is deliberately dialect-neutral: it flows during device
// pairing before any workspace relationship is established.
is PairingEvent -> false
is PollEvent -> {
consumeRegularEvent(event, relay, wasVerified).also {
attachToRelayGroupIfScoped(event, relay)

View File

@@ -29,14 +29,14 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectCoordinator
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentCoordinator
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.napplet.label
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt
import kotlinx.coroutines.withTimeoutOrNull
/**
@@ -119,39 +119,43 @@ object Nip46ConsentBridge {
} ?: AppConnectResult.Cancelled
}
/** Per-operation consent: describe the request (op + event preview) and await the user's grant. */
/**
* Per-operation consent: describe the request and await the user's grant.
*
* For a decrypt request this DECRYPTS FIRST and shows the resulting plaintext, together with the
* counterparty the conversation is with. That is what makes the decision reviewable: without it
* the dialog said only "wants to read your private messages" with no way to tell one request from
* another. Decryption is local — [signer] runs on this device and nothing leaves it unless the
* user approves — and it is bounded by [Nip46ConsentInfoBuilder.DECRYPT_PREVIEW_TIMEOUT_MS] so a slow or failing signer
* degrades to an explanatory message instead of hanging or blanking the prompt.
*/
suspend fun requestOp(
coordinate: String,
clientPubKey: HexKey,
op: NostrSignerOp,
request: BunkerRequest,
signer: NostrSigner,
): SignerOpGrant {
val context = Amethyst.instance.appContext
val info = runCatching { Amethyst.instance.nip46ClientStore.load(coordinate) }.getOrNull()
val title = info?.name?.ifBlank { null } ?: context.getString(R.string.nip46_signer_remote_app)
val preview =
if (request is BunkerRequestSign) {
request.event.content
.take(160)
.trim()
} else {
""
}
val rawData = if (request is BunkerRequestSign) JacksonMapper.toJsonPretty(request.event) else ""
val face = accountFace(coordinate)
val consentInfo =
SignerConsentInfo(
appletTitle = title,
Nip46ConsentInfoBuilder.build(
coordinate = coordinate,
op = op,
operationSummary = op.label(context),
contentPreview = preview,
rawData = rawData,
title = title,
iconUrl = info?.image,
accountName = face.name,
accountPicture = face.picture,
accountPubKey = face.pubKey,
previewTemplate = (request as? BunkerRequestSign)?.event,
op = op,
request = request,
account = accountFace(coordinate),
faceOf = ::userFace,
strings =
Nip46ConsentStrings(
opLabel = { it.label(context) },
allowAlwaysFor = { context.getString(R.string.nip46_signer_allow_always_for, it) },
decryptFailed = context.getString(R.string.nip46_signer_decrypt_failed),
),
decrypt = { decryptWithAccountSigner(signer, it) },
)
// Fail closed if the prompt is never answered so a stuck dialog can't hold the signer hostage.
return withTimeoutOrNull(CONSENT_TIMEOUT_MS) {
@@ -159,16 +163,30 @@ object Nip46ConsentBridge {
} ?: SignerOpGrant.DenyOnce
}
/**
* Performs the local decryption behind the decrypt preview with the account's own signer. Errors
* and timeouts are handled by [Nip46ConsentInfoBuilder]; this only maps the request to a call.
*/
private suspend fun decryptWithAccountSigner(
signer: NostrSigner,
request: BunkerRequest,
): String? =
when (request) {
is BunkerRequestNip04Decrypt -> signer.nip04Decrypt(request.ciphertext, request.pubKey)
is BunkerRequestNip44Decrypt -> signer.nip44Decrypt(request.ciphertext, request.pubKey)
else -> null
}
/** The account being signed for (avatar + name), resolved from the coordinate's signer pubkey. */
private fun accountFace(coordinate: String): AccountFace {
private fun accountFace(coordinate: String): SignerFace {
val pubKey = Nip46PermissionAuthorizer.signerPubKeyOf(coordinate)
val user = pubKey?.let { LocalCache.getUserIfExists(it) }
return AccountFace(name = user?.toBestDisplayName(), picture = user?.profilePicture(), pubKey = pubKey)
return SignerFace(name = user?.toBestDisplayName(), picture = user?.profilePicture(), pubKey = pubKey)
}
private data class AccountFace(
val name: String?,
val picture: String?,
val pubKey: String?,
)
/** Cached profile for a counterparty; the builder supplies the shortened-npub fallback. */
private fun userFace(pubKey: HexKey): SignerFace {
val user = LocalCache.getUserIfExists(pubKey)
return SignerFace(name = user?.toBestDisplayName(), picture = user?.profilePicture(), pubKey = pubKey)
}
}

View File

@@ -0,0 +1,176 @@
/*
* 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.nip46Signer
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer.Companion.decryptCounterparty
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer.Companion.toNarrowSignerOp
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.withTimeoutOrNull
/** Avatar + display name for one pubkey, as the consent dialogs render it. */
data class SignerFace(
val name: String?,
val picture: String?,
val pubKey: String?,
)
/**
* The user-visible strings the builder needs, injected rather than read from `R.string` so the
* builder itself carries no Android dependency and can be unit-tested.
*/
class Nip46ConsentStrings(
/** Human-readable label for an op, e.g. "read your private messages with Alice". */
val opLabel: (NostrSignerOp) -> String,
/** Button text for the counterparty-scoped grant; the argument is the counterparty's name. */
val allowAlwaysFor: (String) -> String,
/** Shown as the preview when Amethyst itself could not decrypt the message. */
val decryptFailed: String,
)
/**
* Builds the [SignerConsentInfo] for one NIP-46 per-operation prompt.
*
* Split out of [Nip46ConsentBridge] (which owns the Android `Context`/`LocalCache` lookups) so the
* decisions that matter for safety are testable without an emulator:
* - a decrypt request is DECRYPTED FIRST and the plaintext becomes the preview, honouring the
* contract the dialog documented but never implemented;
* - a decrypt that cannot be decrypted still produces a populated dialog, never a blank one;
* - the counterparty label is never empty — it degrades to a shortened npub, never to nothing.
*/
object Nip46ConsentInfoBuilder {
/** Characters of plaintext/content shown inline before the "show more" toggle takes over. */
const val PREVIEW_MAX_CHARS = 160
/**
* Upper bound on the pre-consent decryption. Short on purpose: the preview is a nicety, the
* prompt is not, so a signer that stalls (e.g. an external NIP-55 app that is not responding)
* must not delay the dialog.
*/
const val DECRYPT_PREVIEW_TIMEOUT_MS = 8_000L
suspend fun build(
coordinate: String,
title: String,
iconUrl: String?,
op: NostrSignerOp,
request: BunkerRequest,
account: SignerFace,
/** Resolves a pubkey to a cached profile; the builder supplies its own npub fallback. */
faceOf: (HexKey) -> SignerFace,
strings: Nip46ConsentStrings,
/** Performs the local decryption. May fail, return null, or hang — all are handled. */
decrypt: suspend (BunkerRequest) -> String?,
): SignerConsentInfo {
val counterparty = request.decryptCounterparty()
val plaintext = if (counterparty != null) decryptPreview(request, decrypt, strings.decryptFailed) else null
val preview =
when {
request is BunkerRequestSign ->
request.event.content
.take(PREVIEW_MAX_CHARS)
.trim()
plaintext != null -> plaintext.take(PREVIEW_MAX_CHARS).trim()
else -> ""
}
val rawData =
when {
request is BunkerRequestSign -> JacksonMapper.toJsonPretty(request.event)
// Only worth a "show more" toggle when the preview actually truncated it.
plaintext != null && plaintext.length > PREVIEW_MAX_CHARS -> plaintext
else -> ""
}
// A decrypt grant can be scoped to one conversation: offer "always allow for Alice" next to
// the broad "always allow", instead of only the all-conversations-forever choice.
val narrowOp = request.toNarrowSignerOp()
val counterpartyFace = counterparty?.let { face(it, faceOf) }
return SignerConsentInfo(
appletTitle = title,
coordinate = coordinate,
op = op,
// For decrypt this names the counterparty ("read your private messages with Alice").
operationSummary = strings.opLabel(narrowOp ?: op),
contentPreview = preview,
rawData = rawData,
iconUrl = iconUrl,
accountName = account.name,
accountPicture = account.picture,
accountPubKey = account.pubKey,
previewTemplate = (request as? BunkerRequestSign)?.event,
counterpartyName = counterpartyFace?.name,
counterpartyPicture = counterpartyFace?.picture,
counterpartyPubKey = counterparty,
narrowOp = narrowOp,
narrowOpLabel = counterpartyFace?.name?.let { strings.allowAlwaysFor(it) },
)
}
/**
* Decrypts the message the app asked to read. Never throws and never hangs: a signer that fails,
* refuses, returns nothing, or takes too long yields [failureText], because a request whose
* ciphertext we cannot even read is itself worth showing — a blank dialog is not.
*/
private suspend fun decryptPreview(
request: BunkerRequest,
decrypt: suspend (BunkerRequest) -> String?,
failureText: String,
): String =
withTimeoutOrNull(DECRYPT_PREVIEW_TIMEOUT_MS) {
try {
decrypt(request)?.ifBlank { null }
} catch (e: CancellationException) {
// Includes this block's own timeout — must propagate so withTimeoutOrNull sees it.
throw e
} catch (e: Exception) {
Log.w("NIP46Signer") { "decrypt preview failed: ${e.message}" }
null
}
} ?: failureText
/** [faceOf], but with a guaranteed non-blank name (shortened npub when the user isn't cached). */
private fun face(
pubKey: HexKey,
faceOf: (HexKey) -> SignerFace,
): SignerFace {
val resolved = runCatching { faceOf(pubKey) }.getOrNull()
return SignerFace(
name = resolved?.name?.ifBlank { null } ?: shortIdentifier(pubKey),
picture = resolved?.picture,
pubKey = pubKey,
)
}
/** A shortened npub for an uncached pubkey; falls back to the hex prefix if it isn't valid hex. */
fun shortIdentifier(pubKey: HexKey): String {
val npub = runCatching { NPub.create(pubKey) }.getOrNull()
return if (!npub.isNullOrBlank()) npub.take(12) + "" else pubKey.take(12) + ""
}
}

View File

@@ -170,7 +170,12 @@ class Nip46SignerState(
// connect, and an allow/deny prompt whenever the ledger says ASK (dangerous kinds,
// decryption, DMs, or a PARANOID app). Same surface + ledger as napplet/browser signing.
connectConsent = Nip46ConsentBridge::requestConnect,
opConsent = Nip46ConsentBridge::requestOp,
// The account's own signer goes to the bridge so a decrypt request can be decrypted
// BEFORE the prompt — the dialog shows the actual plaintext instead of an opaque
// "wants to read your private messages". Local only; nothing is disclosed until approval.
opConsent = { coordinate, clientPubKey, op, request ->
Nip46ConsentBridge.requestOp(coordinate, clientPubKey, op, request, signer)
},
)
init {

View File

@@ -41,12 +41,21 @@ private val Context.nappletPermissionsDataStore by preferencesDataStore(name = "
*/
class DataStoreNappletPermissionStore(
private val dataStore: DataStore<Preferences>,
private val accountPubKey: () -> String,
) : NappletPermissionStore {
constructor(context: Context) : this(context.applicationContext.nappletPermissionsDataStore)
constructor(context: Context, accountPubKey: () -> String) :
this(context.applicationContext.nappletPermissionsDataStore, accountPubKey)
/**
* Grants belong to one account. [accountPubKey] is read at call time, so an account switch moves
* every read and write to that account's namespace with no rebuild — a grant made by one account
* can never authorize another.
*/
private fun scoped(coordinate: String) = "${accountPubKey()}$SEP$coordinate"
override suspend fun load(coordinate: String): Map<NappletCapability, GrantState> {
val prefs = dataStore.data.first()
val prefix = "$coordinate$SEP"
val prefix = "${scoped(coordinate)}$SEP"
val result = mutableMapOf<NappletCapability, GrantState>()
for ((key, value) in prefs.asMap()) {
val name = key.name
@@ -68,7 +77,7 @@ class DataStoreNappletPermissionStore(
}
override suspend fun clear(coordinate: String) {
val prefix = "$coordinate$SEP"
val prefix = "${scoped(coordinate)}$SEP"
dataStore.edit { prefs ->
val toRemove = prefs.asMap().keys.filter { it.name.startsWith(prefix) }
toRemove.forEach { prefs.remove(it) }
@@ -78,11 +87,15 @@ class DataStoreNappletPermissionStore(
override suspend fun all(): Map<String, Map<NappletCapability, GrantState>> {
val prefs = dataStore.data.first()
val result = mutableMapOf<String, MutableMap<NappletCapability, GrantState>>()
val accountPrefix = "${accountPubKey()}$SEP"
for ((key, value) in prefs.asMap()) {
val name = key.name
// Key is "<coordinate> <CAPABILITY>"; the capability is the final space-delimited token.
val capName = name.substringAfterLast(SEP, "")
val coordinate = name.substringBeforeLast(SEP, "")
// Key is "<account><SEP><coordinate><SEP><CAPABILITY>". Only the active account's grants
// are listed, so the Connected Apps screen never surfaces another account's permissions.
if (!name.startsWith(accountPrefix)) continue
val scoped = name.removePrefix(accountPrefix)
val capName = scoped.substringAfterLast(SEP, "")
val coordinate = scoped.substringBeforeLast(SEP, "")
if (capName.isEmpty() || coordinate.isEmpty()) continue
val capability = runCatching { NappletCapability.valueOf(capName) }.getOrNull() ?: continue
val grant = runCatching { GrantState.valueOf(value as String) }.getOrNull() ?: continue
@@ -103,7 +116,7 @@ class DataStoreNappletPermissionStore(
private fun keyOf(
coordinate: String,
capability: NappletCapability,
) = stringPreferencesKey("$coordinate$SEP${capability.name}")
) = stringPreferencesKey("${scoped(coordinate)}$SEP${capability.name}")
companion object {
private const val SEP = "\u0000"

View File

@@ -32,14 +32,20 @@ import kotlinx.coroutines.flow.first
private val Context.nappletStorageDataStore by preferencesDataStore(name = "napplet_storage")
/**
* DataStore-backed [NappletStorage]. Every key is prefixed with the applet's coordinate, so one
* napplet's keys can never collide with another's, and this store is entirely separate from the
* app's own preferences.
* DataStore-backed [NappletStorage]. Every key is prefixed with the **active account** and then the
* applet's coordinate, so one napplet's keys can never collide with another's, one account's data is
* never visible to another, and this store is entirely separate from the app's own preferences.
*
* [accountPubKey] is read at call time rather than captured, so switching accounts moves reads and
* writes to the new namespace with no rebuild — an embedded applet always sees the current account's
* data and never the previous one's.
*/
class DataStoreNappletStorage(
private val dataStore: DataStore<Preferences>,
private val accountPubKey: () -> String,
) : NappletStorage {
constructor(context: Context) : this(context.applicationContext.nappletStorageDataStore)
constructor(context: Context, accountPubKey: () -> String) :
this(context.applicationContext.nappletStorageDataStore, accountPubKey)
override suspend fun get(
coordinate: String,
@@ -62,7 +68,9 @@ class DataStoreNappletStorage(
}
override suspend fun keys(coordinate: String): List<String> {
val prefix = "$coordinate "
// Must match keyOf's separator exactly. This filtered on a space while keys are written
// with NUL, so no key could ever match and keys() always returned an empty list.
val prefix = prefixOf(coordinate)
return dataStore.data
.first()
.asMap()
@@ -72,8 +80,11 @@ class DataStoreNappletStorage(
.map { it.removePrefix(prefix) }
}
/** Account first, then applet: isolates accounts from each other, and applets within an account. */
private fun prefixOf(coordinate: String) = "${accountPubKey()}\u0000$coordinate\u0000"
private fun keyOf(
coordinate: String,
key: String,
) = stringPreferencesKey("$coordinate\u0000$key")
) = stringPreferencesKey(prefixOf(coordinate) + key)
}

View File

@@ -40,7 +40,6 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletBroker
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.amethyst.commons.napplet.NappletRequestRouter
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry
@@ -50,7 +49,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.napplet.gateways.AccountNappletGateways
import com.vitorpamplona.amethyst.napplethost.NappletIpc
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.screen.AccountState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -78,32 +77,37 @@ import kotlinx.coroutines.launch
class NappletBrokerService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// One ledger for the whole service lifetime: persistent grants on disk, session grants in RAM.
private val ledger by lazy { NappletPermissionLedger(Amethyst.instance.nappletPermissionStore) }
// Persistent grants on disk, session grants in RAM. Shared app-wide (see AppModules) so the
// Connected Apps screens revoke the very grants this broker consults; session grants are dropped
// in onDestroy, which is the "all applet surfaces closed" boundary.
private val ledger get() = Amethyst.instance.nappletPermissionLedger
// Per-app internal-signer permission ledger (policy + per-op overrides). Lazy so it's only
// instantiated in the main process where the signer lives; never touched from :napplet.
private val signerLedger by lazy { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) }
// Per-applet sandboxed key-value store (namespaced by coordinate inside the impl).
private val storage by lazy { DataStoreNappletStorage(applicationContext) }
// Per-applet sandboxed key-value store (namespaced by account + coordinate inside the impl).
private val storage by lazy { DataStoreNappletStorage(applicationContext, Amethyst.instance.nappletAccountScope) }
private val incoming by lazy { Messenger(Handler(Looper.getMainLooper(), ::handleMessage)) }
// The broker for the current account, rebuilt only on account switch (see broker()).
private var cachedBroker: Pair<Account, NappletBroker>? = null
// Live relay subscriptions, keyed by the applet's subId; reads the current account live.
private val liveSubscriptions = NappletLiveSubscriptions { Amethyst.instance.sessionManager.loggedInAccount() }
// Live relay subscriptions, keyed by the applet's subId. The account comes per-open from the
// requesting surface's launch token, so a surface's REQs always target the account it acts as.
private val liveSubscriptions = NappletLiveSubscriptions()
// The app-wide inc pub/sub bus: routes inc.emit between live napplet sessions as inc.event pushes.
private val incBus = NappletIncBus { replyTo, payload -> push(replyTo, payload) }
// Streams identity.changed pushes (account switch / connect / disconnect) to a watching applet.
// Streams identity.changed to a watching applet. Bound to the surface's LAUNCH account, not the
// app's active one: a surface acts as the account that opened it for its whole life, so switching
// accounts elsewhere is not an identity change *for it*. Announcing the newly-active pubkey here
// would tell a page it had become someone else while its signatures still came back as the
// original — the same desync the launch binding exists to prevent. What this does still report is
// that account going away (logout/removal), which emits "".
private val identityWatch =
NappletIdentityWatch(scope) {
Amethyst.instance.sessionManager.accountContent
.map { (it as? AccountState.LoggedIn)?.account?.signer?.pubKey ?: "" }
NappletIdentityWatch(scope) { boundPubKey ->
Amethyst.instance.accountsCache.accounts
.map { loaded -> if (loaded.containsKey(boundPubKey)) boundPubKey else "" }
}
// Binding is restricted to our own UID by exported=false in the manifest, enforced by the OS.
@@ -114,6 +118,13 @@ class NappletBrokerService : Service() {
override fun onDestroy() {
liveSubscriptions.closeAll()
identityWatch.stop()
// Every applet/browser surface has unbound, so the "session" the user granted for is over.
// The ledger and the broker cache are now app-wide singletons that outlive this service, so
// their in-memory session grants have to be dropped explicitly here — that keeps the lifetime
// the consent dialog promises ("allow for this session") instead of letting it become
// "allow until the app process dies".
dropCachedBroker()
Amethyst.instance.nappletPermissionLedger.endSession()
// Drop any foreground holds this broker still owns so they don't leak past the service.
synchronized(foregroundLeases) {
repeat(foregroundLeases.size) { SandboxForegroundHold.release() }
@@ -241,7 +252,10 @@ class NappletBrokerService : Service() {
val replyTo = msg.replyTo ?: return true
val origin = data.getString(NappletIpc.KEY_BROWSER_ORIGIN)?.takeIf { it.isNotBlank() } ?: return true
val identity = NappletIdentity(authorPubKey = BROWSER_IDENTITY_AUTHOR, identifier = origin)
val token = NappletLaunchRegistry.register(identity, setOf(NappletCapability.IDENTITY, NappletCapability.RELAY))
// Bind to the account active at mint time: a browser token minted for one account must
// never sign as another if the user switches while the page is still open.
val mintAccount = Amethyst.instance.sessionManager.loggedInAccount() ?: return true
val token = NappletLaunchRegistry.register(identity, setOf(NappletCapability.IDENTITY, NappletCapability.RELAY), mintAccount.pubKey)
val response =
Message.obtain(null, NappletIpc.MSG_BROWSER_TOKEN).apply {
this.data =
@@ -278,17 +292,20 @@ class NappletBrokerService : Service() {
// The shared, host-agnostic router owns decode → broker → encode and the subscribe-vs-reply
// decision (it stays wire-identical with the future desktop host). This service only supplies
// the broker, the Messenger transport, and the live relay subscription each Outcome implies.
val broker = broker()
// The launch token decides whose key signs — not the active account. A surface opened by
// one account can never be handed another's signer, even while it stays open across a switch.
val broker = brokerFor(session.accountPubKey)
if (broker == null) {
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("No account is signed in.")))
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("That account is no longer signed in.")))
return@launch
}
when (val outcome = NappletRequestRouter.route(broker, identity, declared, payload)) {
is NappletRequestRouter.Outcome.Ignore -> {}
is NappletRequestRouter.Outcome.Reply -> reply(replyTo, requestId, outcome.payload)
is NappletRequestRouter.Outcome.OpenSubscription -> liveSubscriptions.open(outcome.subId, outcome.filters) { push(replyTo, it) }
is NappletRequestRouter.Outcome.OpenSubscription ->
liveSubscriptions.open(outcome.subId, outcome.filters, accountFor(session.accountPubKey)) { push(replyTo, it) }
is NappletRequestRouter.Outcome.CloseSubscription -> liveSubscriptions.close(outcome.subId)
is NappletRequestRouter.Outcome.WatchIdentity -> identityWatch.start { push(replyTo, it) }
is NappletRequestRouter.Outcome.WatchIdentity -> identityWatch.start(session.accountPubKey) { push(replyTo, it) }
is NappletRequestRouter.Outcome.UnwatchIdentity -> identityWatch.stop()
is NappletRequestRouter.Outcome.Push -> outcome.payloads.forEach { push(replyTo, it) }
is NappletRequestRouter.Outcome.SubscribeInc -> incBus.subscribe(replyTo, outcome.topic)
@@ -327,28 +344,44 @@ class NappletBrokerService : Service() {
}
}
/** The launched-as account, or null once it is no longer loaded. */
private fun accountFor(accountPubKey: HexKey): Account? = Amethyst.instance.accountsCache.accounts.value[accountPubKey]
/**
* The broker for the *currently* signed-in account, cached and rebuilt only when the account
* changes (reference identity). The gateways capture the account and read its flows live, so a
* cached broker stays correct across requests without per-request allocation.
* The broker for the account a surface was LAUNCHED as — [NappletLaunchRegistry.Session.accountPubKey],
* never whichever account is active right now.
*
* Resolving live was wrong in a way that defeated per-account isolation: a full-screen host is a
* separate activity that an account switch does not tear down, so its WebView kept account A's
* cookies while requests were signed by B. The page displayed one identity while another signed,
* and B's session was written into A's storage jar — after which even the embedded tab, which is
* rebuilt correctly, showed the wrong account.
*
* Binding to the launch account satisfies both halves of the rule with no extra machinery:
* embedded surfaces are torn down and re-minted on a switch, so they follow the active account,
* while a full-screen surface stays on the account it was opened with.
*
* Returns null when that account is no longer loaded (logged out), so requests fail closed
* rather than silently falling back to someone else's key.
*/
@Synchronized
private fun broker(): NappletBroker? {
val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return null
cachedBroker?.let { (acc, broker) -> if (acc === account) return broker }
val broker =
AccountNappletGateways(
account = account,
context = applicationContext,
ledger = ledger,
storage = storage,
// Per-applet Tor decision (see NappletResourceFetcher): the shared manager routes
// through Tor when asked + active, and falls back to clearnet otherwise.
httpClient = { useProxy -> Amethyst.instance.okHttpClients.getHttpClient(useProxy) },
signerLedger = signerLedger,
).broker()
cachedBroker = account to broker
return broker
private fun brokerFor(accountPubKey: HexKey): NappletBroker? {
val account = accountFor(accountPubKey) ?: return null
synchronized(brokerLock) {
cachedBroker?.let { (acc, broker) -> if (acc === account) return broker }
val broker =
AccountNappletGateways(
account = account,
context = applicationContext,
ledger = ledger,
storage = storage,
// Per-applet Tor decision (see NappletResourceFetcher): the shared manager routes
// through Tor when asked + active, and falls back to clearnet otherwise.
httpClient = { useProxy -> Amethyst.instance.okHttpClients.getHttpClient(useProxy) },
signerLedger = signerLedger,
).broker()
cachedBroker = account to broker
return broker
}
}
/**
@@ -403,6 +436,38 @@ class NappletBrokerService : Service() {
}
companion object {
/**
* Guards [cachedBroker]. Both live on the companion rather than the service instance so the
* Connected Apps UI can reach the running broker to revoke its live session grants — the
* screens are plain composables with no binder to this service, and the broker is the only
* holder of the in-memory "allow for this session" signer grants.
*
* Main-process only, like the sibling `Napplet*Registry` objects: the `:napplet` process gets
* its own (unused, empty) copy of these statics and must never touch them.
*/
private val brokerLock = Any()
// The broker for the current account, rebuilt only on account switch (see brokerFor()).
private var cachedBroker: Pair<Account, NappletBroker>? = null
/**
* Drops the live "allow for this session" signer grants the running broker holds for
* [coordinate] (the bare app coordinate). Called when the user revokes or forgets an app in
* Connected Apps: without it the persisted grants are cleared but the in-memory session ones
* keep authorizing signatures until the broker dies, so a revoked app goes on signing.
*
* No-op when no broker has been built yet (no applet has run this process).
*/
suspend fun revokeSessionGrants(coordinate: String) {
val broker = synchronized(brokerLock) { cachedBroker?.second } ?: return
broker.revokeSessionGrants(coordinate)
}
/** Forgets the cached broker, dropping every session grant it holds. */
private fun dropCachedBroker() {
synchronized(brokerLock) { cachedBroker = null }
}
/**
* Sentinel "author" for a browser-mode per-origin identity. The real key is the visited origin,
* carried in the identity's identifier (which the consent dialog shows); this constant only fills

View File

@@ -23,15 +23,19 @@ package com.vitorpamplona.amethyst.napplet
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
@@ -41,11 +45,18 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@@ -54,6 +65,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
/**
@@ -168,20 +180,74 @@ private fun NappletConsentDialog(
)
}
// Operation detail box (may include content preview)
if (info.operationSummary.isNotBlank()) {
// Operation detail box (may include content preview), plus the full event behind a
// toggle: the summary truncates content and cannot spell out every tag, so for kinds
// whose payload IS the tags (3, 5, 10000, 10002) this is the only complete disclosure.
if (info.operationSummary.isNotBlank() || info.rawData.isNotBlank()) {
Spacer(Modifier.height(12.dp))
Surface(
modifier = Modifier.padding(horizontal = 24.dp).fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.medium,
) {
SelectionContainer {
Text(
info.operationSummary,
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodySmall,
)
Column(modifier = Modifier.padding(12.dp)) {
if (info.operationSummary.isNotBlank()) {
SelectionContainer {
Text(
info.operationSummary,
style = MaterialTheme.typography.bodySmall,
)
}
}
// A single-account follow/mute change: show who, so the user recognizes
// the face rather than parsing a name they may not read carefully.
info.subject?.let { subject ->
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
RobohashFallbackAsyncImage(
robot = subject.pubKey,
model = subject.pictureUrl,
contentDescription = subject.name,
modifier = Modifier.size(36.dp).clip(CircleShape),
loadProfilePicture = true,
loadRobohash = true,
)
Spacer(Modifier.width(8.dp))
Text(
subject.name,
style = MaterialTheme.typography.bodyMedium,
)
}
}
if (info.rawData.isNotBlank()) {
var showRawData by remember { mutableStateOf(false) }
if (showRawData) {
Spacer(Modifier.height(8.dp))
Surface(modifier = Modifier.horizontalScroll(rememberScrollState())) {
SelectionContainer {
Text(
info.rawData,
style =
MaterialTheme.typography.labelSmall.copy(
fontFamily = FontFamily.Monospace,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
softWrap = false,
)
}
}
}
TextButton(onClick = { showRawData = !showRawData }) {
Text(
if (showRawData) {
stringResource(R.string.napplet_consent_hide_event)
} else {
stringResource(R.string.napplet_consent_show_event)
},
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
}

View File

@@ -36,6 +36,26 @@ data class NappletConsentInfo(
/** Whether a persistent "Always allow" choice may be offered (false for per-use caps like payments). */
val allowAlways: Boolean,
val iconUrl: String? = null,
/**
* The full unsigned event the applet asked us to sign, pretty-printed, shown behind a
* "Show Event" toggle. Blank for requests that sign nothing. [operationSummary] is a lossy
* rendering — it truncates content and cannot spell out every tag — so this is the only place
* the user can see exactly what a signature would cover.
*/
val rawData: String = "",
/**
* The one account a follow/mute change is about, when the change names exactly one. Rendered as
* an avatar + name so the user can recognize *who* at a glance instead of reading a bare count.
* Null for multi-account edits and every other request.
*/
val subject: ConsentSubject? = null,
)
/** A single account a consent dialog is about: enough to draw an avatar and a name. */
data class ConsentSubject(
val pubKey: String,
val name: String,
val pictureUrl: String?,
)
/**

View File

@@ -21,22 +21,35 @@
package com.vitorpamplona.amethyst.napplet
import android.content.Context
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.nip01Core.core.fastForEach
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
/**
* Turns a pending [NappletRequest] into the human-readable [NappletConsentInfo] the consent dialog
* shows — the applet's title, the capability label, and a per-operation summary (e.g. a note preview
* or a sat amount). Localized via app resources; holds only a [Context], no account state.
* or a sat amount). Localized via app resources.
*
* Reads [account] only to diff a proposed replaceable list (follows, relays, mutes) against the copy
* already cached there, so the dialog can say what a signature would actually change. It never signs,
* mutates, or exposes account state — the values it reads are the user's own public lists.
*/
class NappletConsentSummary(
private val context: Context,
private val account: Account,
) {
fun info(
identity: NappletIdentity,
@@ -51,16 +64,196 @@ class NappletConsentSummary(
} else {
resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled)
}
val consequence = consequenceFor(request)
return NappletConsentInfo(
appletTitle = title,
coordinate = identity.coordinate,
capabilityLabel = context.getString(capability.labelRes()),
operationSummary = summaryFor(request),
operationSummary = listOfNotNull(summaryFor(request).ifBlank { null }, consequence?.text).joinToString("\n\n"),
allowAlways = capability.canGrantAlways,
iconUrl = iconUrl,
rawData = rawEventFor(request),
subject = consequence?.subject,
)
}
/**
* Pretty-prints the unsigned event behind the consent dialog's "Show Event" toggle. Only the
* signing requests carry one; everything else has nothing to disclose.
*/
private fun rawEventFor(request: NappletRequest): String =
when (request) {
is NappletRequest.Publish -> rawEvent(request.kind, request.tags, request.content, null)
is NappletRequest.SignEvent -> rawEvent(request.kind, request.tags, request.content, request.createdAt)
else -> ""
}
private fun rawEvent(
kind: Int,
tags: Array<Array<String>>,
content: String,
createdAt: Long?,
): String =
buildString {
append("kind: ").append(kind).append('\n')
createdAt?.let { append("created_at: ").append(it).append('\n') }
append("tags:")
if (tags.isEmpty()) {
append(" []\n")
} else {
append('\n')
tags.fastForEach { tag -> append(" ").append(tag.joinToString(", ", "[", "]")).append('\n') }
}
append("content: ").append(content.ifEmpty { "(empty)" })
}
/** A consequence line, plus the single account it is about when the change names exactly one. */
private data class Consequence(
val text: String,
val subject: ConsentSubject? = null,
)
/**
* A plain-language warning for the kinds whose payload lives entirely in the tags. Without this
* the dialog reads "publish a kind 3 event" while the user is actually about to replace their
* whole social graph — the summary would be technically true and practically useless.
*/
private fun consequenceFor(request: NappletRequest): Consequence? =
when (request) {
is NappletRequest.Publish -> consequenceFor(request.kind, request.tags)
is NappletRequest.SignEvent -> consequenceFor(request.kind, request.tags)
else -> null
}
private fun consequenceFor(
kind: Int,
tags: Array<Array<String>>,
): Consequence? =
when (kind) {
ContactListEvent.KIND ->
diffOf(
current = account.kind3FollowList.getFollowListEvent()?.tags,
proposed = tags,
tagName = "p",
template = R.string.napplet_consent_diff_follows,
added = R.plurals.napplet_consent_diff_follow_added,
removed = R.plurals.napplet_consent_diff_follow_removed,
oneAdded = R.string.napplet_consent_diff_follow_one,
oneRemoved = R.string.napplet_consent_diff_unfollow_one,
)
AdvertisedRelayListEvent.KIND ->
diffOf(
current = account.nip65RelayList.getNIP65RelayList()?.tags,
proposed = tags,
tagName = "r",
template = R.string.napplet_consent_diff_relays,
added = R.plurals.napplet_consent_diff_relay_added,
removed = R.plurals.napplet_consent_diff_relay_removed,
)
// Public entries only: a mute list also carries encrypted ones, which are not in `tags`
// and so cannot be diffed here.
MuteListEvent.KIND ->
diffOf(
current = account.muteList.getMuteList()?.tags,
proposed = tags,
tagName = "p",
template = R.string.napplet_consent_diff_mutes,
added = R.plurals.napplet_consent_diff_mute_added,
removed = R.plurals.napplet_consent_diff_mute_removed,
oneAdded = R.string.napplet_consent_diff_mute_one,
oneRemoved = R.string.napplet_consent_diff_unmute_one,
)
// Deletions have no prior version to compare against — the tags are the whole request.
DeletionEvent.KIND ->
pluralFor(R.plurals.napplet_consent_effect_deletes, countTag(tags, "e") + countTag(tags, "a"))
?.let { Consequence(it) }
// Any other kind: at least tell the user tags exist and can be inspected, so an empty
// content preview never reads as "there is nothing else here".
else ->
if (tags.isNotEmpty()) {
pluralFor(R.plurals.napplet_consent_effect_tags, tags.size)?.let { Consequence(it) }
} else {
null
}
}
/**
* Describes what a proposed replaceable list changes relative to the copy already on the account.
* A bare total ("a list of 12 accounts") hides the dangerous case: the alarming edit is a list
* that silently drops 130 follows, and only a diff surfaces that. Falls back to the total when
* nothing is cached to compare against.
*/
private fun diffOf(
current: Array<Array<String>>?,
proposed: Array<Array<String>>,
tagName: String,
@StringRes template: Int,
@PluralsRes added: Int,
@PluralsRes removed: Int,
@StringRes oneAdded: Int? = null,
@StringRes oneRemoved: Int? = null,
): Consequence {
val next = valuesOf(proposed, tagName)
val previous =
current?.let { valuesOf(it, tagName) }
?: return Consequence(pluralStringRes(context, R.plurals.napplet_consent_diff_no_baseline, next.size, next.size))
val addedKeys = next.filter { it !in previous }
val removedKeys = previous.filter { it !in next }
if (addedKeys.isEmpty() && removedKeys.isEmpty()) {
return Consequence(context.getString(R.string.napplet_consent_diff_none))
}
// The overwhelmingly common edit is a single follow/unfollow. Naming and picturing that one
// account is far more use than "follows 1 new account" — the user can tell at a glance
// whether it is who they expected.
if (oneAdded != null && addedKeys.size == 1 && removedKeys.isEmpty()) {
subjectOf(addedKeys.first())?.let { return Consequence(context.getString(oneAdded, it.name), it) }
}
if (oneRemoved != null && removedKeys.size == 1 && addedKeys.isEmpty()) {
subjectOf(removedKeys.first())?.let { return Consequence(context.getString(oneRemoved, it.name), it) }
}
val parts = listOfNotNull(pluralFor(added, addedKeys.size), pluralFor(removed, removedKeys.size))
val summary =
if (parts.size == 2) {
context.getString(R.string.napplet_consent_diff_joiner, parts[0], parts[1])
} else {
parts.first()
}
return Consequence(context.getString(template, summary))
}
/** Resolves a pubkey to a name + picture for the dialog, or null when the user isn't cached. */
private fun subjectOf(pubKey: String): ConsentSubject? {
val user = account.cache.getUserIfExists(pubKey) ?: return null
return ConsentSubject(
pubKey = pubKey,
name = user.toBestDisplayName(),
pictureUrl = user.profilePicture(),
)
}
/** The distinct values of every `[tagName, value, …]` tag. */
private fun valuesOf(
tags: Array<Array<String>>,
tagName: String,
): Set<String> {
val out = mutableSetOf<String>()
tags.fastForEach { if (it.size > 1 && it[0] == tagName) out.add(it[1]) }
return out
}
private fun pluralFor(
resId: Int,
count: Int,
): String? = if (count <= 0) null else pluralStringRes(context, resId, count, count)
private fun countTag(
tags: Array<Array<String>>,
name: String,
): Int = tags.count { it.isNotEmpty() && it[0] == name }
private fun summaryFor(request: NappletRequest): String =
when (request) {
is NappletRequest.GetPublicKey -> context.getString(R.string.napplet_consent_get_pubkey)
@@ -91,11 +284,14 @@ class NappletConsentSummary(
}
is NappletRequest.NotifyList, is NappletRequest.NotifyDismiss -> context.getString(R.string.napplet_consent_notify)
is NappletRequest.PayInvoice -> {
// getAmountInSats returns ZERO (not null, not a throw) for an amountless BOLT11, so a
// naive read renders "pay 0 sats" — telling the user a payment is free when the amount
// is in fact unspecified and decided by the payee. Treat non-positive as "no amount".
val sats = runCatching { LnInvoiceUtil.getAmountInSats(request.invoice).toLong() }.getOrNull()
if (sats != null) {
if (sats != null && sats > 0) {
pluralStringRes(context, R.plurals.napplet_consent_pay_amount, sats.toInt(), sats)
} else {
context.getString(R.string.napplet_consent_pay)
context.getString(R.string.napplet_consent_pay_no_amount)
}
}
is NappletRequest.ResourceBytes -> context.getString(R.string.napplet_consent_resource)

View File

@@ -39,15 +39,18 @@ import kotlinx.coroutines.launch
*/
class NappletIdentityWatch(
private val scope: CoroutineScope,
private val pubKey: () -> Flow<String>,
private val pubKey: (boundPubKey: String) -> Flow<String>,
) {
private var job: Job? = null
fun start(push: (String) -> Unit) {
fun start(
boundPubKey: String,
push: (String) -> Unit,
) {
stop()
job =
scope.launch {
pubKey()
pubKey(boundPubKey)
.distinctUntilChanged()
.drop(1)
.collect { push(NappletProtocolJson.encodeIdentityChanged(it)) }

View File

@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.napplet
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import java.security.SecureRandom
@@ -45,6 +46,18 @@ object NappletLaunchRegistry {
data class Session(
val identity: NappletIdentity,
val declared: Set<NappletCapability>,
/**
* The account this surface was launched as. Requests resolve their signer through THIS, not
* through whichever account happens to be active when they arrive.
*
* A full-screen host is a separate activity that an account switch does not tear down, so
* resolving live meant its WebView kept account A's cookies while the broker signed as B —
* a page showing one identity while another signed, and B's session written into A's
* storage jar. Binding here gives both halves of the rule for free: embedded surfaces are
* rebuilt on a switch, so they re-mint and follow the active account, while a full-screen
* surface keeps the account it was opened with.
*/
val accountPubKey: HexKey,
)
// Access-ordered + capped so tokens from long-closed napplets can't accumulate without bound. The
@@ -60,9 +73,10 @@ object NappletLaunchRegistry {
fun register(
identity: NappletIdentity,
declared: Set<NappletCapability>,
accountPubKey: HexKey,
): String {
val token = ByteArray(32).also(secureRandom::nextBytes).toHexKey()
sessions[token] = Session(identity, declared)
sessions[token] = Session(identity, declared, accountPubKey)
return token
}

View File

@@ -120,7 +120,16 @@ object NappletLauncher {
// requests back to THIS identity + declared set, regardless of anything the sandbox sends.
val identity = NappletIdentity(authorPubKey = authorPubKey, identifier = identifier, aggregateHash = aggregateHash)
val declared = profile.declaredCapabilities(requires)
val launchToken = NappletLaunchRegistry.register(identity, declared)
// Bound to the account launching it, so the surface keeps signing as that account even if the
// user switches while it is open (an embedded surface is rebuilt on a switch and re-mints).
// An empty key can never match a loaded account, so a launch with nobody signed in fails
// closed at the broker rather than falling back to whoever signs in later.
val launchAccountPubKey =
Amethyst.instance.sessionManager
.loggedInAccount()
?.pubKey
.orEmpty()
val launchToken = NappletLaunchRegistry.register(identity, declared, launchAccountPubKey)
// Resolve the per-site network choice (Tor default; a site can be opted out to the open web).
// Locked napplets always keep Tor for their blob fetches — only nSites expose the toggle.
@@ -156,6 +165,9 @@ object NappletLauncher {
putString(NappletHostContract.EXTRA_HOST_PROFILE, profile.name)
putBoolean(NappletHostContract.EXTRA_USE_TOR, useTor)
putString(NappletHostContract.EXTRA_THEME, theme)
// Opaque per-account storage partition, so a napplet/nSite can't carry one npub's cookies
// and localStorage into another. Derived here (the sandbox never sees the pubkey).
putString(NappletHostContract.EXTRA_WEBVIEW_PROFILE, NappletWebViewProfiles.current())
}
}
}

View File

@@ -38,12 +38,13 @@ import java.util.concurrent.atomic.AtomicInteger
* `relay.eose`. Encodes the `relay.event`/`relay.eose`/`relay.closed` pushes and hands them to the
* caller-supplied sink — it never touches the transport itself.
*
* [account] is read live (so it always targets the currently signed-in account); [open] is reached
* only after the broker authorized the subscription (RELAY consent).
* The account is supplied per [open] by the caller, which resolves it from the requesting surface's
* LAUNCH account — not from whoever is signed in at the time. A full-screen surface survives an
* account switch, and reading live would have pointed its REQs at the new account's relays while its
* signatures still came from the old one. [open] is reached only after the broker authorized the
* subscription (RELAY consent).
*/
class NappletLiveSubscriptions(
private val account: () -> Account?,
) {
class NappletLiveSubscriptions {
private val liveSubs = ConcurrentHashMap<String, LiveSub>()
private val liveSeq = AtomicInteger(0)
@@ -62,9 +63,9 @@ class NappletLiveSubscriptions(
fun open(
nappletSubId: String,
filters: List<Filter>,
account: Account?,
push: (String) -> Unit,
) {
val account = account()
val relays = account?.homeRelays?.flow?.value ?: emptySet()
if (account == null || filters.isEmpty() || relays.isEmpty()) {
push(NappletProtocolJson.encodeRelayEose(nappletSubId))

View File

@@ -0,0 +1,63 @@
/*
* 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.napplet
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import java.security.MessageDigest
/**
* Mints the opaque per-account WebView storage-profile name the sandbox partitions cookies,
* localStorage, IndexedDB and service workers by.
*
* Every embedded app follows the currently-selected account, and each account gets its OWN storage
* jar: switching from A to B hands B a clean jar, switching back to A restores A's session intact
* (this is a partition, not a wipe).
*
* The name is a hash rather than the pubkey because the `:napplet` sandbox must never learn which
* account it is running for — it only gets a stable, meaningless token. Stability is what makes
* sessions survive a switch, so the derivation must never change once shipped.
*
* The applying half lives in `:nappletHost` (`NappletWebViewProfile`), which validates the shape
* before handing it to `ProfileStore`.
*/
object NappletWebViewProfiles {
/** Domain separator so this hash can never collide with another use of SHA-256(pubkey). */
private const val DOMAIN = "amethyst-webview-profile-v1:"
/** 128 bits of a SHA-256 is far past collision-proof for a handful of on-device accounts. */
private const val NAME_LENGTH = 32
/** The profile name for the account embedded apps currently run as, or null when logged out. */
fun current(): String? = forPubKey(Amethyst.instance.nappletAccountScope())
/** Stable profile name for [pubKey]; null for a blank scope (no account -> shared default jar). */
fun forPubKey(pubKey: HexKey): String? {
if (pubKey.isBlank()) return null
return MessageDigest
.getInstance("SHA-256")
.digest((DOMAIN + pubKey).toByteArray())
.toHexKey()
.take(NAME_LENGTH)
}
}

View File

@@ -24,15 +24,19 @@ import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindNameFor
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.utils.TimeUtils
/** Human-readable label for a [NostrSignerOp]. */
@@ -41,8 +45,24 @@ fun NostrSignerOp.label(context: Context): String =
is NostrSignerOp.SignKind -> context.getString(R.string.napplet_op_sign_kind_named, kindNameFor(context, kind), kind)
NostrSignerOp.Encrypt -> context.getString(R.string.napplet_op_encrypt)
NostrSignerOp.Decrypt -> context.getString(R.string.napplet_op_decrypt)
is NostrSignerOp.DecryptFrom -> context.getString(R.string.napplet_op_decrypt_from, counterpartyLabel(counterparty))
}
/**
* A person's display name for a consent prompt: their profile name when we have it cached, otherwise
* a shortened npub. Never empty — "read your private messages with <nothing>" would be worse than the
* broad wording it replaces.
*/
fun counterpartyLabel(pubKeyHex: HexKey): String {
LocalCache
.getUserIfExists(pubKeyHex)
?.toBestDisplayName()
?.ifBlank { null }
?.let { return it }
val npub = runCatching { NPub.create(pubKeyHex) }.getOrNull()
return if (npub != null) npub.take(12) + "" else pubKeyHex.take(12) + ""
}
/** Builds the [SignerConsentInfo] needed by the per-op consent dialog. */
fun buildSignerConsentInfo(
context: Context,
@@ -105,10 +125,16 @@ fun buildSignerConsentInfo(
)
}
/** Creates a [SignerConnectInfo] for the first-connect dialog. */
/**
* Creates a [SignerConnectInfo] for the first-connect dialog. [declared] is the capability set the
* connection pre-grants as ALLOW_ALWAYS on accept, so it is surfaced as
* [SignerConnectInfo.requestedPermissions] — otherwise the dialog would be asking the user to
* approve a set it never showed them.
*/
fun buildConnectInfo(
context: Context,
identity: NappletIdentity,
declared: Set<NappletCapability> = emptySet(),
): SignerConnectInfo {
val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8))
val (title, iconUrl) =
@@ -124,5 +150,18 @@ fun buildConnectInfo(
} else {
identity.identifier.ifBlank { identity.authorPubKey.take(12) + "" }
}
return SignerConnectInfo(appletTitle = title, coordinate = identity.coordinate, domain = domain, iconUrl = iconUrl)
// Only the capabilities that actually get pre-granted are listed; SHELL/THEME never prompt and
// VALUE is per-use, so listing them would overstate what accepting hands over.
val preGranted =
declared
.filter { it.requiresConsent && !it.requiresPerUseConsent }
.map { context.getString(it.labelRes()) }
.sorted()
return SignerConnectInfo(
appletTitle = title,
coordinate = identity.coordinate,
domain = domain,
iconUrl = iconUrl,
requestedPermissions = preGranted,
)
}

View File

@@ -81,7 +81,9 @@ class AccountNappletGateways(
private val httpClient: (useProxy: Boolean) -> OkHttpClient,
private val signerLedger: NostrSignerPermissionLedger? = null,
) {
private val consentSummary = NappletConsentSummary(context)
// Takes the account so the consent dialog can diff a proposed replaceable list (follows, relays,
// mutes) against the copy already cached here, and say what actually changes.
private val consentSummary = NappletConsentSummary(context, account)
// Reuse the app-wide HTTP client so napplet blob fetches inherit the same Tor
// routing, Onion-Location discovery/rewriting, Blossom cache and pool as the
@@ -138,10 +140,10 @@ class AccountNappletGateways(
}
val connectPrompt =
NostrConnectPrompt { identity ->
NostrConnectPrompt { identity, declared ->
SignerConnectCoordinator.requestConnect(
context = context,
info = buildConnectInfo(context, identity),
info = buildConnectInfo(context, identity, declared),
)
}

View File

@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip60Cashu.mintApi.CashuMintOperations
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpClient
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintUrlException
import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken
import okhttp3.OkHttpClient
import kotlin.coroutines.cancellation.CancellationException
@@ -45,14 +46,23 @@ import kotlin.coroutines.cancellation.CancellationException
* it also picks up NUT-02 per-input fee handling for free.
*/
class MeltProcessor {
/**
* @param knownWalletMints the mint URLs of the user's own NIP-60 wallet. A token
* pointing at one of those was, by definition, issued by a mint the user
* deliberately added, so it is exempt from the private-address block that
* [com.vitorpamplona.quartz.nip60Cashu.mintApi.CashuMintUrlValidator] applies
* to arbitrary pasted tokens (a self-hosted mint on the LAN is legitimate).
*/
suspend fun melt(
token: CashuToken,
lud16: String,
okHttpClient: (String) -> OkHttpClient,
context: Context,
knownWalletMints: Set<String> = emptySet(),
): MeltResult {
try {
val ops = CashuMintOperations(MintHttpClient(token.mint, okHttpClient))
val isOwnMint = knownWalletMints.any { it.trim().trimEnd('/').equals(token.mint.trim().trimEnd('/'), ignoreCase = true) }
val ops = CashuMintOperations(MintHttpClient(token.mint, userConfigured = isOwnMint, okHttpClient = okHttpClient))
val proofs = token.proofs
// A Lightning address must commit to an amount before we know the
@@ -106,6 +116,14 @@ class MeltProcessor {
} catch (e: Exception) {
if (e is CancellationException) throw e
if (e is LightningAddressResolver.LightningAddressError) throw e
// The mint URL was refused before any request went out: this is OUR
// message, not the mint's, so don't dress it up as "the mint said".
if (e is MintUrlException) {
throw LightningAddressResolver.LightningAddressError(
stringRes(context, R.string.cashu_unsafe_mint_url),
stringRes(context, R.string.cashu_unsafe_mint_url_explainer, e.message),
)
}
throw LightningAddressResolver.LightningAddressError(
stringRes(context, R.string.cashu_failed_redemption),
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message),

View File

@@ -91,13 +91,17 @@ abstract class FlowProgressForegroundService<T> : Service() {
protected abstract fun cancelAll()
/** Called for every emission before [render]; use to update derived subclass state. */
protected open fun onEmission(value: T) {}
protected open fun onEmission(value: T) {
// No-op by default: only subclasses that keep derived state need this hook.
}
/** Only consulted for the [refreshMs] clock loop; skip re-renders when nothing is moving. */
protected open fun needsClockRefresh(value: T): Boolean = true
/** One-time setup once the watch loop starts (e.g. a benchmark). */
protected open fun onStarted() {}
protected open fun onStarted() {
// No-op by default: only subclasses with one-time setup (e.g. a benchmark) override this.
}
/** How to draw the progress bar of the card. */
sealed interface Bar {

View File

@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.service.relayClient.authCommand.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
@@ -135,7 +137,7 @@ class AuthCoordinator(
// Remember why we granted this relay so the settings screen can explain it.
account.relayAuthLedger.recordGrant(context)
try {
signed.add(account.signer.sign(authTemplate))
signed.add(account.signer.sign(buzzAugmented(authTemplate, account.pubKey, relayUrl)))
} catch (e: Exception) {
Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e)
}
@@ -169,6 +171,27 @@ class AuthCoordinator(
}
}
/**
* If [relayUrl] speaks the Buzz dialect and this device holds a NIP-OA attestation
* authorizing [accountPubKey], returns [template] with the owner-signed `auth` tag
* appended — so the relay grants virtual membership to an un-enrolled agent key while
* its owner stays a member. Otherwise returns [template] unchanged.
*
* Applied ONLY to an account's own AUTH (the caller passes the account pubkey), never
* to the Concord stream-key AUTHs that share the same [template] object, and it is a
* no-op on non-Buzz relays and for accounts with no held attestation — so it can never
* add an `auth` tag where one isn't wanted.
*/
private fun buzzAugmented(
template: EventTemplate<RelayAuthEvent>,
accountPubKey: HexKey,
relayUrl: NormalizedRelayUrl,
): EventTemplate<RelayAuthEvent> {
if (!BuzzRelayDialect.isBuzz(relayUrl)) return template
val authTag = BuzzHeldAttestations.authTagFor(accountPubKey) ?: return template
return EventTemplate(template.createdAt, template.kind, template.tags + arrayOf(authTag), template.content)
}
// stream secret (hex) -> its local signer. Bounded by joined communities × channels.
private val streamSigners = ConcurrentHashMap<HexKey, NostrSignerSync>()

View File

@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.drafts.
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.marmot.MarmotGroupEventsEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata.AccountMetadataEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromInboxRelaysManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsHistoryEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsHistoryEoseManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
@@ -56,13 +57,20 @@ class AccountFilterAssembler(
// History: older gift wraps, loaded on demand in bounded one-shot slices.
val giftWrapsHistory = AccountGiftWrapsHistoryEoseManager(client, ::allKeys)
// Live tail: the recent week of notifications from the inbox + group host relays.
val notifications = AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys)
// History: older notifications, paged backward by until+limit per relay, driven by the feed's markers.
val notificationsHistory = AccountNotificationsHistoryEoseManager(client, ::allKeys)
val group =
listOf(
AccountMetadataEoseManager(client, ::allKeys),
giftWraps,
giftWrapsHistory,
AccountDraftsEoseManager(client, ::allKeys),
AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys),
notifications,
notificationsHistory,
MarmotGroupEventsEoseManager(client, ::allKeys),
)

View File

@@ -28,7 +28,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
@@ -51,17 +50,35 @@ class AccountNotificationsEoseFromInboxRelaysManager(
key: AccountQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
// Backward-paging boundary: once the feed has filled a page, ask for everything older than
// its oldest card. It stays null until then — see the note on the missing week floor below,
// which is what let it stay null forever on a quiet inbox.
val pagingBoundary = key.feedContentStates.notifications.lastNoteCreatedAtIfFilled()
val inbox =
key.account.notificationRelays.flow.value.flatMap {
// No `since` floor on the first fetch. These filters are scoped by `#p` to my own
// key and carry a relay-side `limit`, so an all-time query costs one index scan and
// returns at most `limit` events, newest first — exactly what Home does (it passes
// `since ?: boundary`, i.e. null on a cold start).
//
// This used to fall back to `oneWeekAgo()`, which silently emptied the tab for
// anyone whose last mention was older than a week: EOSE `since` is in-memory only,
// so EVERY cold start re-pinned the window to 7 days, and the paging boundary above
// could never rescue it — it only arms once the feed holds a full page, and the feed
// could not fill because the query only ever asked for a week. A fresh install of an
// established account hit the same deadlock.
val notificationSince = since?.get(it)?.time ?: pagingBoundary
filterSummaryNotificationsToPubkey(
relay = it,
pubkey = user(key).pubkeyHex,
since = since?.get(it)?.time ?: TimeUtils.oneWeekAgo(),
since = notificationSince,
) +
filterNotificationsToPubkey(
relay = it,
pubkey = user(key).pubkeyHex,
since = since?.get(it)?.time ?: key.feedContentStates.notifications.lastNoteCreatedAtIfFilled() ?: TimeUtils.oneWeekAgo(),
since = notificationSince,
)
}
@@ -76,7 +93,9 @@ class AccountNotificationsEoseFromInboxRelaysManager(
relay = relay,
pubkey = user(key).pubkeyHex,
groupIds = groupIds.distinct(),
since = since?.get(relay)?.time ?: TimeUtils.oneWeekAgo(),
// Same reasoning as the inbox filters above: `#p` + `#h` + `limit = 200`
// already bound this, so a week floor only hides older group activity.
since = since?.get(relay)?.time ?: pagingBoundary,
)
}

View File

@@ -27,7 +27,6 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
@@ -51,8 +50,11 @@ class AccountNotificationsEoseFromRandomRelaysManager(
key: AccountQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
// only loads this after the feed is built
val defaultSince = key.feedContentStates.notifications.lastNoteCreatedAtIfFilled() ?: TimeUtils.oneWeekAgo()
// only loads this after the feed is built, so it stays null on a quiet inbox. No week floor
// behind it: this probe is `#p`-scoped to me with `limit = 20`, so relays answer with the 20
// newest either way — the floor only ever hid notifications older than a week, and since the
// boundary above needs a full page to arm, a quiet inbox could never page past it.
val defaultSince = key.feedContentStates.notifications.lastNoteCreatedAtIfFilled()
return (key.account.followsPerRelay.value.keys - key.account.notificationRelays.flow.value).flatMap {
val since = since?.get(it)?.time ?: defaultSince
filterJustTheLatestNotificationsToPubkeyFromRandomRelays(it, user(key).pubkeyHex, since)

View File

@@ -0,0 +1,222 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications
import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager
import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
/**
* Loads the account's notification **history** — everything older than the one-week live tail
* ([AccountNotificationsEoseFromInboxRelaysManager]) — by **`until`+`limit` paging, per relay, on
* demand**, so the notifications feed can be scrolled back in time instead of being pinned to the
* recent week.
*
* There is no proactive walk: each relay advances exactly one page when the feed's on-screen
* window-limit marker for that relay asks ([advance]), then **parks** at its window limit. The markers
* are the drivers — a relay pages only while its marker is visible, and keeps paging as long as it
* stays visible (see the notifications card feed). So a spam-dense relay never floods: the user has to
* scroll through its notifications to pull more, and nothing is fetched while its marker is off screen.
*
* Relays paged: the same set the live inbox loader covers — the user's inbox relays (all notification
* kinds tagging me) plus each joined NIP-29 group's host relay (group-activity kinds scoped by `#h`).
* The foreground "random follows" straggler query ([AccountNotificationsEoseFromRandomRelaysManager],
* tiny latest-N limits) is deliberately live-tail only and is NOT paged here.
*
* The per-relay cursors live on the [Account] (so they share the account's lifetime); this class binds
* the single-active [BackwardRelayPager] orchestrator to them on [newSub], builds the notification REQ
* filters, and forwards relay callbacks into the pager. A relay is *done* once it answers an empty page;
* one that won't answer (auth CLOSE, unreachable, or silent) is flagged *stalled* but kept. [exhausted]
* flips once every relay is either done or stalled.
*/
class AccountNotificationsHistoryEoseManager(
client: INostrClient,
allKeys: () -> Set<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(client, allKeys) {
override fun user(key: AccountQueryState) = key.account.userProfile()
// A modest page: each marker-triggered advance pulls ~500 older notifications, digestible to render
// and enough to fill a scroll, rather than the gift-wrap default (a whole encrypted-blob band at once).
private val pager = BackwardRelayPager("notifications.history", pageLimit = 500)
val loadingMore: StateFlow<Boolean> = pager.loadingMore
val status: StateFlow<PagingStatus> = pager.status
// Each joined group's id, bucketed by the normalized host relay it lives on. Used both to route the
// group filter and (its keys) to add group host relays to the paged relay set.
private fun groupsByRelay(account: Account): Map<NormalizedRelayUrl, List<String>> =
account.relayGroupList.liveRelayGroupList.value
.groupBy({ RelayUrlNormalizer.normalizeOrNull(it.relayUrl) }, { it.groupId })
.mapNotNull { (relay, ids) -> relay?.let { it to ids.distinct() } }
.toMap()
// The full relay set this account pages notifications back through: inbox relays + group host relays.
private fun notificationRelaySet(account: Account): Set<NormalizedRelayUrl> = account.notificationRelays.flow.value + groupsByRelay(account).keys
override fun updateFilter(
key: AccountQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
if (!key.account.isWriteable()) return emptyList()
val pubkey = user(key).pubkeyHex
val inbox = key.account.notificationRelays.flow.value
val groups = groupsByRelay(key.account)
// Only relays that have been advanced (armed) and aren't done carry a REQ. A relay that finished a
// page keeps the same `until` here, so re-assembly (triggered when ANOTHER relay advances) doesn't
// re-REQ it — it stays parked until the marker advances it again.
val armed = pager.armedRelays(inbox + groups.keys)
if (armed.isEmpty()) return emptyList()
return armed.flatMap { relay ->
val until = pager.requestedUntilFor(relay) ?: return@flatMap emptyList()
Log.d(TAG) { "[notifications.history] REQ ${relay.url} until=$until limit=${pager.pageLimit}" }
buildList {
if (relay in inbox) {
addAll(filterNotificationsHistoryToPubkey(relay, pubkey, until, pager.pageLimit))
}
groups[relay]?.let { groupIds ->
addAll(filterGroupNotificationsHistoryToPubkey(relay, pubkey, groupIds, until, pager.pageLimit))
}
}
}
}
/** Steps a single [relay] to its next, older page. Driven by that relay's on-screen window-limit marker. */
fun advance(relay: NormalizedRelayUrl) {
if (pager.advance(relay)) invalidateFilters()
}
/** Steps every not-done, not-in-flight relay one page. For the empty/initial boundary (nothing to scroll). */
fun advanceAll() {
if (pager.advanceAll()) {
Log.d(TAG) { "[notifications.history] advanceAll (empty-feed bootstrap)" }
invalidateFilters()
}
}
private val userJobMap = mutableMapOf<User, List<Job>>()
@OptIn(FlowPreview::class)
override fun newSub(key: AccountQueryState): Subscription {
// Repoint the single-active orchestrator at this account's notification cursors and the relay set
// it fans out to, refreshing the display flows from the restored progress.
pager.bind(key.account.notificationHistory, key.account.scope) { notificationRelaySet(key.account) }
val user = user(key)
userJobMap[user]?.forEach { it.cancel() }
userJobMap[user] =
listOf(
// A relay joining/leaving the paged set (inbox change, group join/leave) re-issues the REQ
// so a newly-added relay can be armed and a removed one drops out.
key.account.scope.launch(Dispatchers.IO) {
key.account.notificationRelays.flow
.sample(1000)
.collectLatest { invalidateFilters() }
},
key.account.scope.launch(Dispatchers.IO) {
key.account.relayGroupList.liveRelayGroupList
.sample(1000)
.collectLatest { invalidateFilters() }
},
)
return requestNewSubscription(historyListener(key))
}
private fun historyListener(key: AccountQueryState): SubscriptionListener {
// A just-backgrounded account's subscription can still deliver after the orchestrator rebinds to
// another account; gate the pager (single-active) on whether it's still bound to THIS account's
// cursors so a late callback can't move another account's cursors. newEose runs regardless.
val myCursors = key.account.notificationHistory
return object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors) && pager.onEose(relay)) {
Log.d(TAG) { "[notifications.history] ${relay.url} reached the bottom (done)" }
}
// No auto-advance: the relay parks here until its marker asks for the next page.
newEose(key, relay, TimeUtils.now(), forFilters)
}
override fun onClosed(
message: String,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message)
}
override fun onCannotConnect(
relay: NormalizedRelayUrl,
message: String,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message)
}
}
}
override fun endSub(
key: User,
subId: String,
) {
super.endSub(key, subId)
userJobMap[key]?.forEach { it.cancel() }
}
companion object {
private const val TAG = "NotificationPagination"
}
}

View File

@@ -124,6 +124,70 @@ val NotificationsPerKeyKinds3 =
AttestorRecommendationEvent.KIND,
)
/**
* Every kind the notifications feed cares about on the user's inbox relays, flattened into one list.
* The live-tail query ([filterNotificationsToPubkey] / [filterSummaryNotificationsToPubkey]) splits these
* across several filters with different per-kind limits; backward history paging instead asks ONE filter
* per relay so the single until+limit cursor stays gap-proof (an empty page truly means "nothing older").
*/
val AllNotificationKinds =
(SummaryKinds + NotificationsPerKeyKinds + NotificationsPerKeyKinds2 + NotificationsPerKeyKinds3).distinct()
/**
* One backward-paging page of notifications on an inbox relay: the N newest events tagging me
* ([AllNotificationKinds], `#p` = me) strictly older than [until]. A single filter (not the live query's
* split) so the [BackwardRelayPager][com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager]
* cursor tracking the oldest delivered `created_at` can't skip a band that a per-kind sub-limit capped.
*/
fun filterNotificationsHistoryToPubkey(
relay: NormalizedRelayUrl,
pubkey: HexKey?,
until: Long,
limit: Int,
): List<RelayBasedFilter> {
if (pubkey.isNullOrEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = AllNotificationKinds,
tags = mapOf("p" to listOf(pubkey)),
limit = limit,
until = until,
),
),
)
}
/**
* One backward-paging page of NIP-29 group-activity notifications on a group's host relay:
* [GroupNotificationKinds] tagging me (`#p`) inside my joined groups (`#h`), strictly older than [until].
*/
fun filterGroupNotificationsHistoryToPubkey(
relay: NormalizedRelayUrl,
pubkey: HexKey?,
groupIds: List<String>,
until: Long,
limit: Int,
): List<RelayBasedFilter> {
if (pubkey.isNullOrEmpty() || groupIds.isEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = GroupNotificationKinds,
tags = mapOf("p" to listOf(pubkey), "h" to groupIds),
limit = limit,
until = until,
),
),
)
}
fun filterSummaryNotificationsToPubkey(
relay: NormalizedRelayUrl,
pubkey: HexKey?,

View File

@@ -40,28 +40,129 @@ import kotlinx.coroutines.withTimeoutOrNull
* caller should surface that a lightning wallet is required.
*/
object BlossomPaymentHandler {
/**
* Hard ceiling on a single BUD-07 charge, in sats.
*
* BUD-07 charges are per-blob storage fees: real paid Blossom servers ask
* single-digit to low-hundreds of sats for a media upload. 10,000 sats is
* roughly USD 10 at a 100k BTC — one to two orders of magnitude above any
* legitimate per-blob fee, so it never gets in a real user's way, while
* capping what a hostile or compromised server can drain in one prompt.
* Anything above this is refused outright rather than shown to the user,
* because the value is server-chosen and a user tapping through a dialog is
* not a meaningful defence against a four-digit-sat surprise.
*/
const val MAX_PAYMENT_SATS = 10_000L
/** Outcome of [pay]. Everything except [Paid] means no proof and no retry. */
sealed interface PayResult {
data class Paid(
val proof: BlossomPaymentProof,
) : PayResult
/** The amount failed [checkAmount]; [reason] is user-facing. */
data class Refused(
val reason: String,
) : PayResult
/** No invoice, no wallet, or the wallet request could not be sent. */
data object Unavailable : PayResult
/** The wallet never answered. The invoice stays blocked — see [InFlightInvoices]. */
data object TimedOut : PayResult
}
/** Verdict on the invoice amount, before any money moves. */
sealed interface AmountCheck {
data class Ok(
val sats: Long,
) : AmountCheck
/** BOLT-11 with no amount: the payee picks it. Never payable unattended. */
data object Amountless : AmountCheck
data class OverCap(
val sats: Long,
) : AmountCheck
/** The invoice asks for something other than what the dialog told the user. */
data class Mismatch(
val shownSats: Long?,
val actualSats: Long,
) : AmountCheck
}
/**
* Re-derives the amount from the invoice itself and checks it against both the
* cap and [shownSats] — the number the confirmation dialog put in front of the
* user. The amount shown must be the amount paid, so a server that swapped the
* invoice (or leaned on a misleading `X-Reason`) cannot get a different sum
* approved than the one the user agreed to.
*/
fun checkAmount(
payment: BlossomPaymentRequired,
shownSats: Long?,
): AmountCheck {
val actual = amountSats(payment) ?: return AmountCheck.Amountless
if (actual > MAX_PAYMENT_SATS) return AmountCheck.OverCap(actual)
if (shownSats != actual) return AmountCheck.Mismatch(shownSats, actual)
return AmountCheck.Ok(actual)
}
/** Human-readable refusal text for a non-[AmountCheck.Ok] verdict. */
fun refusalReason(check: AmountCheck): String =
when (check) {
is AmountCheck.Ok -> ""
is AmountCheck.Amountless -> "The server's invoice does not state an amount. Amethyst will not pay it."
is AmountCheck.OverCap -> "The server asked for ${check.sats} sats, above the $MAX_PAYMENT_SATS sat limit for a media-server payment. Nothing was paid."
is AmountCheck.Mismatch ->
"The server's invoice is for ${check.actualSats} sats, not the ${check.shownSats ?: 0} sats shown. Nothing was paid."
}
/** True when this account has a wallet we can pay the lightning invoice with. */
fun canPay(
account: Account,
payment: BlossomPaymentRequired,
): Boolean = payment.lightning != null && account.nip47SignerState.hasWalletConnectSetup()
/** The invoice amount in sats, for display in a confirmation prompt. */
/**
* The invoice amount in sats for display in a confirmation prompt, or null when it is absent or
* unreadable. `getAmountInSats` returns ZERO for an amountless BOLT11 rather than null, so a bare
* read renders "Pay 0 sats" — telling the user a payment is free when the amount is actually
* unspecified and chosen by the payee.
*/
fun amountSats(payment: BlossomPaymentRequired): Long? =
payment.lightning?.let {
runCatching { LnInvoiceUtil.getAmountInSats(it).toLong() }.getOrNull()
runCatching { LnInvoiceUtil.getAmountInSats(it).toLong() }.getOrNull()?.takeIf { sats -> sats > 0 }
}
/**
* Pays the challenge's BOLT-11 invoice via NWC and returns the proof, or null if
* there is no payable invoice, no wallet, or the wallet didn't confirm in time.
* Pays the challenge's BOLT-11 invoice via NWC and returns the proof.
*
* [shownSats] is what the confirmation dialog displayed; the invoice is
* re-read here and must match it and sit under [MAX_PAYMENT_SATS], otherwise
* nothing is sent to the wallet at all.
*/
suspend fun pay(
account: Account,
payment: BlossomPaymentRequired,
): BlossomPaymentProof? {
val invoice = payment.lightning ?: return null
if (!account.nip47SignerState.hasWalletConnectSetup()) return null
shownSats: Long?,
): PayResult {
val invoice = payment.lightning ?: return PayResult.Unavailable
if (!account.nip47SignerState.hasWalletConnectSetup()) return PayResult.Unavailable
val check = checkAmount(payment, shownSats)
if (check !is AmountCheck.Ok) {
Log.w("BlossomPayment", "refusing invoice: ${refusalReason(check)}")
return PayResult.Refused(refusalReason(check))
}
// Never send the same invoice twice: an earlier attempt may still settle.
if (!InFlightInvoices.tryClaim(invoice)) {
return PayResult.Refused(
"A payment for this invoice was already sent to your wallet and never confirmed. Amethyst will not pay it again.",
)
}
val preimageResult = CompletableDeferred<String?>()
try {
@@ -71,10 +172,26 @@ object BlossomPaymentHandler {
}
} catch (e: Exception) {
Log.w("BlossomPayment", "Failed to send NWC payment request", e)
return null
// The request never left, so the invoice is definitively not in flight.
InFlightInvoices.release(invoice)
return PayResult.Unavailable
}
val preimage = withTimeoutOrNull(90_000) { preimageResult.await() } ?: return null
return BlossomPaymentProof(lightningPreimage = preimage)
// NIP-47 offers no cancel for an outstanding pay_invoice, so a timeout
// cannot stop the payment — it can only stop us from sending it again.
// Deliberately do NOT release the claim on the timeout path.
val answered = withTimeoutOrNull(PAYMENT_TIMEOUT_MS) { preimageResult.await() }
if (answered == null && !preimageResult.isCompleted) return PayResult.TimedOut
InFlightInvoices.release(invoice)
val preimage = answered ?: return PayResult.Unavailable
return PayResult.Paid(BlossomPaymentProof(lightningPreimage = preimage))
}
/**
* How long we wait for the wallet. Matches the previous behaviour; note the
* NIP-47 filter itself is dropped after 60s, so a reply past 90s cannot reach
* us anyway — which is exactly why the invoice stays blocked afterwards.
*/
private const val PAYMENT_TIMEOUT_MS = 90_000L
}

View File

@@ -0,0 +1,60 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.uploads.blossom
/**
* BOLT-11 invoices handed to the NIP-47 wallet whose fate we never learned.
*
* [BlossomPaymentHandler.pay] waits a bounded time for the wallet to reply, but
* NIP-47 has no "cancel this pay_invoice": the request is fire-and-forget, so
* giving up on the wait does **not** stop the payment. An invoice that settles a
* second after we time out still moved the user's money — and if we then let the
* user (or an automatic retry) send the very same invoice again, they pay twice.
*
* So: claim the invoice before sending it, and release the claim only when the
* wallet gives a definitive answer. A timed-out invoice stays claimed for the
* life of the process and can never be paid a second time from this app.
*
* This is the weaker half of the fix — a genuine cancel would be better, but the
* NIP-47 client offers none, so we settle for "never silently pay it twice".
*/
object InFlightInvoices {
private val claimed = mutableSetOf<String>()
/**
* Claims [invoice] for one payment attempt. Returns false when it was already
* claimed and never resolved — the caller must not send it again.
*/
fun tryClaim(invoice: String): Boolean = synchronized(claimed) { claimed.add(invoice) }
/** The wallet gave a definitive answer (paid or explicitly failed): the claim can go. */
fun release(invoice: String) {
synchronized(claimed) { claimed.remove(invoice) }
}
/** True when [invoice] was sent to the wallet and never resolved. */
fun isAwaiting(invoice: String): Boolean = synchronized(claimed) { invoice in claimed }
/** Test-only reset. */
internal fun clear() {
synchronized(claimed) { claimed.clear() }
}
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.uploads.blossom
/**
* Bounds how often one user action may raise a BUD-07 payment prompt.
*
* The mirror flow retries a target after paying it, and that retry catches every
* exception — including a second `BlossomPaymentException`. Left unbounded, a
* server that pockets the preimage and answers 402 again drives an indefinite
* pay-prompt cycle: pay → 402 → prompt → pay → 402 → … Each cycle is a real
* payment, so "the user can always tap cancel" is not an adequate answer.
*
* Rule: a given (blob, server) pair may prompt at most once per user-initiated
* action. [beginUserAction] resets the ledger; everything downstream of that tap
* — including the post-payment retry — goes through [shouldPrompt].
*/
class PaymentPromptLedger {
private val prompted = mutableSetOf<String>()
/** The user tapped mirror/sync: a fresh budget of one prompt per target. */
fun beginUserAction() {
synchronized(prompted) { prompted.clear() }
}
/**
* True the first time this (blob, server) pair asks for payment in the current
* user action; false on every subsequent 402 from the same pair.
*/
fun shouldPrompt(
hash: String,
server: String,
): Boolean = synchronized(prompted) { prompted.add("$hash|$server") }
}

View File

@@ -68,6 +68,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
@@ -105,7 +106,7 @@ fun BlossomBlobManagerScreen(
BlossomPaymentDialog(
host = pending.targetHost,
amountSats = pending.amountSats,
reason = pending.payment.reason,
reason = pending.payment.sanitizedReason(),
onConfirm = { vm.confirmPendingPayment() },
onDismiss = { vm.cancelPendingPayment() },
)
@@ -419,13 +420,21 @@ private fun BlossomPaymentDialog(
icon = { Icon(symbol = MaterialSymbols.Bolt, contentDescription = null, tint = MaterialTheme.colorScheme.allGoodColor) },
title = { Text(stringRes(R.string.blossom_payment_title)) },
text = {
Text(
text =
listOfNotNull(
stringRes(R.string.blossom_payment_message, host),
reason,
).joinToString("\n\n"),
)
Column {
Text(text = stringRes(R.string.blossom_payment_message, host))
// X-Reason is server-controlled: it is sanitized upstream and rendered
// here attributed to the server, in a dimmer italic, so it can never be
// mistaken for Amethyst's own wording (e.g. a fake "Pay 1 sat").
reason?.let {
Spacer(Modifier.size(12.dp))
Text(
text = stringRes(R.string.blossom_payment_server_says, host, it),
style = MaterialTheme.typography.bodySmall,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
confirmButton = {
FilledTonalButton(onClick = onConfirm) {

View File

@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.service.upload.BlossomPaymentException
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomMirrorQueue
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomPaymentHandler
import com.vitorpamplona.amethyst.service.uploads.blossom.PaymentPromptLedger
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip56Reports.ReportType
@@ -129,6 +130,13 @@ class BlossomBlobManagerViewModel : ViewModel() {
private var resultCollectorStarted = false
/**
* Caps BUD-07 payment prompts at one per (blob, server) per user-initiated
* mirror, so a server that keeps replying 402 after being paid cannot drive an
* unbounded pay-prompt cycle. See [PaymentPromptLedger].
*/
private val promptLedger = PaymentPromptLedger()
fun init(accountViewModel: AccountViewModel) {
this.account = accountViewModel.account
// Reflect the app-level sync sweep's per-server results onto the pills, so an
@@ -299,8 +307,17 @@ class BlossomBlobManagerViewModel : ViewModel() {
}
}
/** BUD-04: mirror a blob to every server that doesn't have it; each pill spins then turns green. */
/**
* BUD-04: mirror a blob to every server that doesn't have it; each pill spins
* then turns green. This is the user-initiated entry point, so it resets the
* "already asked for payment" ledger — see [promptedForPayment].
*/
fun mirrorToMissing(row: BlobRow) {
promptLedger.beginUserAction()
mirrorMissingTargets(row)
}
private fun mirrorMissingTargets(row: BlobRow) {
val source = row.url ?: return
val targets = currentRow(row.hash)?.missingServers ?: row.missingServers
if (targets.isEmpty()) return
@@ -313,6 +330,14 @@ class BlossomBlobManagerViewModel : ViewModel() {
} catch (e: BlossomPaymentException) {
setServerState(row.hash, target, PresenceState.MISSING)
if (BlossomPaymentHandler.canPay(account, e.payment)) {
// Bounded: a server that pockets the preimage and replies 402
// again must not be able to spin up an endless pay-prompt
// cycle. One prompt per target per user-initiated mirror.
if (!promptLedger.shouldPrompt(row.hash, target)) {
Log.w("BlossomBlobManager", "mirror to $target asked for payment again after being paid; not re-prompting")
_error.value = "${hostOf(target)} asked for payment again after being paid. Amethyst stopped to avoid paying twice."
continue
}
_pendingPayment.value =
PendingMirrorPayment(row.hash, source, target, hostOf(target), e.payment, BlossomPaymentHandler.amountSats(e.payment))
return@launch
@@ -369,12 +394,31 @@ class BlossomBlobManagerViewModel : ViewModel() {
_pendingPayment.value = null
viewModelScope.launch(Dispatchers.IO) {
setServerState(pending.hash, pending.target, PresenceState.PENDING)
val proof = BlossomPaymentHandler.pay(account, pending.payment)
if (proof == null) {
setServerState(pending.hash, pending.target, PresenceState.MISSING)
_error.value = "Payment failed or was not confirmed by the wallet."
return@launch
}
// pending.amountSats is exactly what the dialog showed; pay() re-derives
// the amount from the invoice and refuses if the two disagree or the
// amount is above the cap.
val result = BlossomPaymentHandler.pay(account, pending.payment, pending.amountSats)
val proof =
when (result) {
is BlossomPaymentHandler.PayResult.Paid -> result.proof
is BlossomPaymentHandler.PayResult.Refused -> {
setServerState(pending.hash, pending.target, PresenceState.MISSING)
_error.value = result.reason
return@launch
}
BlossomPaymentHandler.PayResult.TimedOut -> {
setServerState(pending.hash, pending.target, PresenceState.MISSING)
_error.value =
"Your wallet did not confirm in time. If the payment does go through, retry the mirror — " +
"Amethyst will not send this invoice again."
return@launch
}
BlossomPaymentHandler.PayResult.Unavailable -> {
setServerState(pending.hash, pending.target, PresenceState.MISSING)
_error.value = "Payment failed or was not confirmed by the wallet."
return@launch
}
}
try {
mirrorOne(pending.sourceUrl, pending.hash, currentRow(pending.hash)?.size, pending.target, proof)
setServerState(pending.hash, pending.target, PresenceState.PRESENT)
@@ -382,8 +426,9 @@ class BlossomBlobManagerViewModel : ViewModel() {
setServerState(pending.hash, pending.target, PresenceState.MISSING)
Log.w("BlossomBlobManager", "paid mirror to ${pending.target} failed", e)
}
// Continue with any remaining missing servers (which may prompt again).
currentRow(pending.hash)?.let { mirrorToMissing(it) }
// Continue with any remaining missing servers. Targets already prompted
// in this action (including this one) will NOT prompt again.
currentRow(pending.hash)?.let { mirrorMissingTargets(it) }
}
}

View File

@@ -88,40 +88,13 @@ fun ConcordInviteCard(
onClick = { nav.nav(Route.ConcordInvite(linkText)) },
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
ConcordInvitePreviewRow(
robotSeed = robotSeed,
title = title,
subtitle = stringRes(R.string.concord_invite_card_subtitle),
accountViewModel = accountViewModel,
autoPlayGif = autoPlayGif,
) {
RobohashFallbackAsyncImage(
robot = robotSeed,
model = null,
contentDescription = title,
modifier =
Modifier
.size(52.dp)
.clip(CircleShape)
.border(1.5.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), CircleShape),
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif = autoPlayGif,
)
Column(Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringRes(R.string.concord_invite_card_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
SymbolIcon(
symbol = MaterialSymbols.ChevronRight,
contentDescription = stringRes(R.string.concord_invite_card_join),
@@ -131,3 +104,57 @@ fun ConcordInviteCard(
}
}
}
/**
* The avatar + title/subtitle row shared by [ConcordInviteCard] (in note content) and
* the deep-link consent screen, so both render an invite identically. Purely
* presentational — it performs no I/O, which is what lets the deep-link screen show a
* preview without contacting the link's (attacker-supplied) relays before the user
* consents.
*/
@Composable
fun ConcordInvitePreviewRow(
robotSeed: String,
title: String,
subtitle: String,
accountViewModel: AccountViewModel,
autoPlayGif: Boolean,
trailing: @Composable () -> Unit = {},
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
RobohashFallbackAsyncImage(
robot = robotSeed,
model = null,
contentDescription = title,
modifier =
Modifier
.size(52.dp)
.clip(CircleShape)
.border(1.5.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), CircleShape),
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif = autoPlayGif,
)
Column(Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
trailing()
}
}

View File

@@ -79,6 +79,7 @@ import com.vitorpamplona.amethyst.ui.note.UpdateReactionTypeScreen
import com.vitorpamplona.amethyst.ui.note.nip22Comments.ReplyCommentPostScreen
import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsImageFileScreen
import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsImageScreen
import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsQrScreen
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -98,6 +99,11 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.podcasts.Boo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories.BookmarkedRepositoriesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.BrowserScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.WebAppScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentAttestationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentConsoleScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentPersonaEditScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzCanvasScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzWorkspacesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarCollectionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarReminderSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsScreen
@@ -153,6 +159,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.N
import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.favorites.FavoriteAlgoFeedsListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabAccountWatcher
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabLayer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabPreloader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabThemeWatcher
@@ -334,6 +341,10 @@ fun AppNavigation(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val bottomBarItems by accountViewModel.settings.uiSettingsFlow.bottomBarItems
.collectAsStateWithLifecycle()
// Move every embedded app to the new account on a switch. Mounted before the layer and
// the preloader so the previous account's sessions are dropped ahead of the first sweep
// (an embed WebView's storage profile is fixed at construction, so it must be rebuilt).
EmbeddedTabAccountWatcher()
EmbeddedTabLayer(bottomBarItems.favoriteIds())
// Warm every pinned tab at startup so the first tap is instant (content already local).
EmbeddedTabPreloader(accountViewModel)
@@ -447,9 +458,13 @@ fun BuildNavigation(
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
composableFromEnd<Route.PublicChats> { PublicChatsScreen(accountViewModel, nav) }
composableFromEnd<Route.RelayGroups> { RelayGroupDiscoveryScreen(accountViewModel, nav) }
composableFromEnd<Route.BuzzWorkspaces> { BuzzWorkspacesScreen(accountViewModel, nav) }
composableFromEnd<Route.FollowPacks> { FollowPacksScreen(accountViewModel, nav) }
composableFromEnd<Route.LiveStreams> { LiveStreamsScreen(accountViewModel, nav) }
composableFromEnd<Route.Nests> { NestsScreen(accountViewModel, nav) }
composableFromEnd<Route.AgentConsole> { AgentConsoleScreen(accountViewModel, nav) }
composableFromEnd<Route.AgentAttestation> { AgentAttestationScreen(accountViewModel, nav) }
composableFromEndArgs<Route.AgentPersonaEdit> { AgentPersonaEditScreen(it.slug, accountViewModel, nav) }
composableFromEndArgs<Route.NestLobby> { NestLobbyScreen(it.addressValue, accountViewModel, nav) }
composableFromEnd<Route.Longs> { LongsScreen(accountViewModel, nav) }
composableFromEnd<Route.Articles> { ArticlesScreen(accountViewModel, nav) }
@@ -578,6 +593,7 @@ fun BuildNavigation(
composableFromEndArgs<Route.Note> { ThreadScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.ShareNoteAsImage> { ShareNoteAsImageScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.ShareNoteAsImageFile> { ShareNoteAsImageFileScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.ShareNoteAsQr> { ShareNoteAsQrScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.ContactListUsers> { ContactListUsersScreen(it.noteId, accountViewModel, nav) }
composableFromEndArgs<Route.Hashtag> { HashtagScreen(it, accountViewModel, nav) }
composableFromEndArgs<Route.Geohash> { GeoHashScreen(it, accountViewModel, nav) }
@@ -745,6 +761,7 @@ fun BuildNavigation(
nav = nav,
)
}
composableFromEndArgs<Route.BuzzCanvas> { BuzzCanvasScreen(it.channelId, accountViewModel, nav) }
composableFromEndArgs<Route.RelayGroupCreate> {
RelayGroupCreateScreen(

View File

@@ -69,6 +69,7 @@ enum class NavBarItem {
PODCASTS,
PUBLIC_CHATS,
RELAY_GROUPS,
BUZZ,
CONCORD,
GEOHASH_CHATS,
FOLLOW_PACKS,
@@ -344,6 +345,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
icon = MaterialSymbols.Forum,
resolveRoute = { Route.RelayGroups },
),
NavBarItem.BUZZ to
NavBarItemDef(
id = NavBarItem.BUZZ,
labelRes = R.string.buzz_workspaces_title,
icon = MaterialSymbols.AutoAwesome,
resolveRoute = { Route.BuzzWorkspaces },
),
NavBarItem.CONCORD to
NavBarItemDef(
id = NavBarItem.CONCORD,
@@ -496,6 +504,7 @@ val BottomBarCategories: List<NavBarCategory> =
listOf(
NavBarItem.PUBLIC_CHATS,
NavBarItem.RELAY_GROUPS,
NavBarItem.BUZZ,
NavBarItem.CONCORD,
NavBarItem.GEOHASH_CHATS,
),

View File

@@ -469,6 +469,14 @@ sealed class Route {
@Serializable object EditNestsServers : Route()
@Serializable object AgentConsole : Route()
@Serializable object AgentAttestation : Route()
@Serializable data class AgentPersonaEdit(
val slug: String? = null,
) : Route()
@Serializable object EditFavoriteAlgoFeeds : Route()
@Serializable object EditPaymentTargets : Route()
@@ -507,6 +515,10 @@ sealed class Route {
val id: String,
) : Route()
@Serializable data class ShareNoteAsQr(
val id: String,
) : Route()
@Serializable data class ContactListUsers(
val noteId: String,
) : Route()
@@ -687,6 +699,10 @@ sealed class Route {
val relayUrl: String,
) : Route()
@Serializable data class BuzzCanvas(
val channelId: String,
) : Route()
@Serializable data class RelayGroupCreate(
val relayUrl: String,
) : Route()
@@ -698,6 +714,8 @@ sealed class Route {
@Serializable object RelayGroups : Route()
@Serializable object BuzzWorkspaces : Route()
@Serializable object RelayGroupBrowse : Route()
// Concord Channels (encrypted communities). Addressed by community id + channel id

View File

@@ -100,7 +100,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
import kotlin.math.abs
/** Zoom the map animates to after a search hit or a "use my location" tap. */
private const val RECENTER_ZOOM = 14.0
@@ -125,15 +124,16 @@ private fun zoomForGeohashLength(length: Int): Double =
else -> 17.5
}
/** Neutral starting center (mid-Atlantic) when the picker opens with no seed. */
private const val WORLD_CENTER_LAT = 20.0
private const val WORLD_CENTER_LON = 0.0
/**
* Minimum center shift (degrees) from the opening center that counts as a real pan,
* so an initial osmdroid layout-scroll at the opening center is not mistaken for a pick.
* Neutral starting center when the picker opens with no seed: a genuinely unnamed
* point in the mid-Atlantic, framing the Americas, Europe and Africa at [WORLD_ZOOM].
*
* Deliberately NOT 20N/0E — that is inland Mali (it reverse-geocodes to Tessalit) and
* sits exactly on the prime meridian, where a sub-kilometre pan flips the geohash
* between the `e…` and `s…` halves of the world and looks like a broken readout.
*/
private const val SELECT_MOVE_EPS = 0.0005
private const val WORLD_CENTER_LAT = 20.0
private const val WORLD_CENTER_LON = -30.0
/** Give up waiting for a GPS fix after this long so the button never spins forever. */
private const val GPS_FIX_TIMEOUT_MS = 20_000L
@@ -201,15 +201,21 @@ fun GeohashLocationPickerContent(
val seed = remember(initialGeohash) { initialGeohash?.takeIf { it.isNotBlank() }?.let { GeoHash.decode(it) } }
val seedLen = initialGeohash?.trim()?.length ?: 0
// The map opens centered here. Without a seed there is no real selection yet — and
// osmdroid can emit an initial scroll at this exact center, which must NOT be treated
// as a pick (else the picker would auto-select the mid-Atlantic and enable Confirm).
// The map opens centered here. Without a seed there is no real selection yet.
val initialLat = seed?.centerLat ?: WORLD_CENTER_LAT
val initialLon = seed?.centerLon ?: WORLD_CENTER_LON
var pickedLat by remember { mutableStateOf(seed?.centerLat) }
var pickedLon by remember { mutableStateOf(seed?.centerLon) }
var hasSelection by remember { mutableStateOf(seed != null) }
// osmdroid emits a scroll event when the MapView is first laid out, reporting a
// pixel-quantized version of the opening center — at world zoom a single pixel is
// ~0.4 degrees, so that phantom "pan" can be hundreds of km away from where we asked
// it to open. Treating it as a pick auto-selected whatever the default center was and
// enabled Confirm with nothing chosen. Only map movement that follows a real finger
// down on the map counts, so an automatic scroll can never become a selection.
var mapTouched by remember { mutableStateOf(false) }
var level by remember {
mutableStateOf(GeohashChannelLevel.forChars(seedLen) ?: GeohashChannelLevel.CITY)
}
@@ -337,8 +343,8 @@ fun GeohashLocationPickerContent(
Column(modifier.fillMaxWidth()) {
Box(Modifier.fillMaxWidth().weight(1f)) {
LocationPickerMap(
latitude = seed?.centerLat ?: 20.0,
longitude = seed?.centerLon ?: 0.0,
latitude = initialLat,
longitude = initialLon,
pickedLatitude = null,
pickedLongitude = null,
zoom = if (seed != null) zoomForGeohashLength(seedLen) else WORLD_ZOOM,
@@ -347,11 +353,12 @@ fun GeohashLocationPickerContent(
zoomTo = zoomTo,
highlight = highlight,
highlightColor = highlightColor,
onUserInteraction = { mapTouched = true },
onCenterChanged = { lat, lon ->
pickedLat = lat
pickedLon = lon
// A pan/zoom away from the opening center is the user's first real pick.
if (!hasSelection && (abs(lat - initialLat) > SELECT_MOVE_EPS || abs(lon - initialLon) > SELECT_MOVE_EPS)) {
// Only a movement the user drove counts as their first real pick.
if (mapTouched) {
pickedLat = lat
pickedLon = lon
hasSelection = true
}
},
@@ -687,13 +694,24 @@ private fun PickerBottomBar(
}
}
Column(Modifier.weight(1f).padding(start = 12.dp)) {
LoadCityName(geohashStr = settledCell ?: cell) { cityName ->
Text(
cityName,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
)
// The place name must never contradict the geohash under it. Resolve
// it only for the settled cell, and only while that IS the current
// cell — mid-pan the debounced [settledCell] still names the previous
// cell, and drawing it beside a fresh geohash is worse than no name.
// LoadCityName echoes the geohash back when it cannot resolve a name
// (no geocoder backend, or a point at sea); drop that too rather than
// repeat the geohash as if it were a place.
if (settledCell == cell) {
LoadCityName(geohashStr = cell) { cityName ->
if (cityName != cell) {
Text(
cityName,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
)
}
}
}
Text(
"#$cell",

View File

@@ -65,6 +65,10 @@ import org.osmdroid.views.overlay.Polygon
* - [recenter] animates the map to a new point when its value changes (e.g. after
* a place search or a "use my location" tap). Passing the same value twice is a
* no-op, so it is safe to hoist in state.
* - [onUserInteraction] fires when a finger first lands on the map. [onCenterChanged]
* alone cannot tell a user pan from osmdroid's own layout-time scroll (which the
* MapView emits at the opening center with pixel-quantized coordinates), so callers
* that must not treat an automatic scroll as a choice gate on this instead.
*/
@Composable
fun LocationPickerMap(
@@ -80,12 +84,14 @@ fun LocationPickerMap(
highlight: BoundingBox? = null,
highlightColor: Int = 0,
onCenterChanged: ((Double, Double) -> Unit)? = null,
onUserInteraction: (() -> Unit)? = null,
onPick: (Double, Double) -> Unit,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val currentOnPick by rememberUpdatedState(onPick)
val currentOnCenterChanged by rememberUpdatedState(onCenterChanged)
val currentOnUserInteraction by rememberUpdatedState(onUserInteraction)
val darkTheme = !MaterialTheme.colorScheme.isLight
// Tracks the last point we animated to, so a recomposition that re-supplies the
@@ -117,7 +123,10 @@ fun LocationPickerMap(
// LocationPreviewMap. Returning false lets the MapView still pan/zoom/tap.
setOnTouchListener { view, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> view.parent?.requestDisallowInterceptTouchEvent(true)
MotionEvent.ACTION_DOWN -> {
view.parent?.requestDisallowInterceptTouchEvent(true)
currentOnUserInteraction?.invoke()
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> view.parent?.requestDisallowInterceptTouchEvent(false)
}
false

View File

@@ -110,12 +110,16 @@ class UserSuggestionState(
.map(::userSearchTermOrNull)
.map { prefix ->
if (prefix != null) {
// NIP-05 resolution: user@domain or bare .bit domain
// NIP-05 resolution: full `name@domain` form, or bare
// `.bit` domain synthesised as the wildcard `_@domain`.
// Bare DNS domains aren't accepted here on purpose: a
// `.com`/`.io`/etc. that happens to host nostr.json is
// ambiguous with a regular URL the user might be typing.
val nip05 =
if (prefix.contains('@')) {
if (prefix.endsWith(".bit", ignoreCase = true) && !prefix.contains('@')) {
Nip05Id.parseLenient(prefix)
} else if (prefix.contains('@')) {
Nip05Id.parse(prefix)
} else if (prefix.endsWith(".bit", ignoreCase = true)) {
Nip05Id("_", prefix.lowercase())
} else {
null
}
@@ -231,7 +235,7 @@ class UserSuggestionState(
item: User,
): TextFieldValue {
val lastWordStart = message.selection.end - word.length
val wordToInsert = "@${item.pubkeyNpub()} "
val wordToInsert = mentionInsertion(word, item)
return TextFieldValue(
message.text.replaceRange(lastWordStart, message.selection.end, wordToInsert),
@@ -244,11 +248,43 @@ class UserSuggestionState(
word: String,
item: User,
) {
val wordToInsert = "@${item.pubkeyNpub()} "
val wordToInsert = mentionInsertion(word, item)
state.edit {
val lastWordStart = selection.end - word.length
replace(lastWordStart, selection.end, wordToInsert)
selection = TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
}
}
/**
* The token to insert into the message text when the author picks [item]
* from the suggestion popover. When the author was typing a NIP-05
* mention (full `m@testls.bit` or bare-domain `.bit` form), we insert
* `nostr:nprofile1…` directly so the send-time tagger doesn't need to
* re-resolve anything — it parses the bech32 inline via its existing
* `nprofile1` branch, with no main-thread I/O. For every other path
* (search by name, typed npub/nprofile, hex) we keep the existing
* `@npub1…` form to preserve current behaviour.
*
* Pre-resolved NIP-05 hits already have their relay hints pushed into
* the account cache by [nip05ResolutionFlow] before this runs, so
* [User.toNProfile] picks them up automatically.
*/
private fun mentionInsertion(
word: String,
item: User,
): String {
val typed = userSearchTermOrNull(word)
val wasNip05Mention =
typed != null &&
(
(typed.endsWith(".bit", ignoreCase = true) && !typed.contains('@')) ||
(typed.contains('@') && Nip05Id.parse(typed) != null)
)
return if (wasNip05Mention) {
"nostr:${item.toNProfile()} "
} else {
"@${item.pubkeyNpub()} "
}
}
}

View File

@@ -38,8 +38,9 @@ import com.vitorpamplona.amethyst.ui.stringRes
* both from the reaction-row Share button and from the note's 3-dot menu).
*
* Only the true "send it somewhere" options live here — browser link, image
* file, image URL. The copy-to-clipboard options stay in the 3-dot menu, so
* they are intentionally NOT part of this shared element.
* file, image URL, and the display-only QR code. The copy-to-clipboard
* options stay in the 3-dot menu, so they are intentionally NOT part of this
* shared element.
*
* Callers only render these for non-private notes: every option exposes the
* note publicly (a shareable web link, or an image of it), which must never
@@ -76,4 +77,8 @@ fun ShareActionRows(
nav.nav(Route.ShareNoteAsImage(shareId))
onDismiss()
}
M3ActionRow(icon = MaterialSymbols.QrCode2, text = stringRes(R.string.share_as_qr)) {
nav.nav(Route.ShareNoteAsQr(shareId))
onDismiss()
}
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.share
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.ui.note.externalLinkForNote
/** Which of the two payloads the QR code currently encodes. */
enum class QrPayloadMode {
/**
* An `https://njump.to/…` link. The default, because a stock phone camera will offer to
* open an http(s) URL but may treat a bare custom scheme as inert text.
*/
Web,
/**
* A `nostr:nevent1…` / `nostr:naddr1…` URI. Resolves without a web round-trip and is what
* in-app scanners expect.
*/
Nostr,
}
/**
* The string encoded into the QR code for [note] in [mode].
*
* Both payloads carry the note's relay hint: [externalLinkForNote] builds its URL from
* `toNAddr()` / `toNEvent()`, which already call `relayHintUrl()`.
*/
fun qrPayloadFor(
note: Note,
mode: QrPayloadMode,
): String =
when (mode) {
QrPayloadMode.Web -> externalLinkForNote(note)
QrPayloadMode.Nostr -> note.toNostrUri()
}

View File

@@ -0,0 +1,258 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.share
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.platform.LocalView
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.getActivityWindow
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.ui.stringRes
// A cap, not a fixed size: QrCodeDrawer's own quiet zone (QR_MARGIN_PX in QrCodeDrawer.kt) is a
// fixed pixel count subtracted from raw size.width, so its share of the tile grows as density
// falls. Hard-sizing this call to a small dp value starved long-form naddr payloads of scannable
// resolution on low-density screens. Deriving the size from the available column width keeps
// enough real pixels per module; this only bounds it from growing unreasonably large on tablets.
private val QrMaxSize = 320.dp
/**
* Display-only screen presenting [id]'s note as a scannable QR code.
*
* There is no export or save action by design — the screen exists to be held up and
* photographed by another device.
*
* F6: the Scaffold (and its back button) lives in this id-based wrapper, OUTSIDE LoadNote's
* null check, so an id that never resolves to a note still leaves the user a way back — only
* the body inside is empty in that case. Rendering nothing else for an unresolved id is
* deliberate, matching ShareNoteAsImageScreen; only the missing chrome was the bug.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShareNoteAsQrScreen(
id: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.share_as_qr), nav) },
) { pad ->
LoadNote(id, accountViewModel) { note ->
if (note != null) {
ShareNoteAsQrScreenContent(note, accountViewModel, nav, pad)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShareNoteAsQrScreen(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.share_as_qr), nav) },
) { pad ->
ShareNoteAsQrScreenContent(note, accountViewModel, nav, pad)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ShareNoteAsQrScreenContent(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
pad: PaddingValues,
) {
var mode by remember { mutableStateOf(QrPayloadMode.Web) }
// F4: keyed on the observed note state, not on `note` alone (a stable object identity that
// does not change while the event and the author's relay list are still loading). A payload
// computed before then would omit the relay hint (Note.relayHintUrl()) and never recompute.
// Keying on `noteState` re-derives the payload once the event arrives — the same observation
// SharedNoteCard uses for its own sensitivity gate (F1).
val noteState by observeNote(note, accountViewModel)
val payload = remember(noteState, mode) { qrPayloadFor(note, mode) }
KeepScreenBrightAndAwake()
// F5: scrollable so the toggle and hint — the screen's only controls — stay reachable on a
// short viewport (landscape, split screen, large font scale) where the square QR plus the
// card above it can otherwise exceed the available height. A plain fillMaxSize() Column would
// silently place that overflow outside its bounds instead of clipping or scrolling to it.
Column(
modifier =
Modifier
.padding(pad)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
SharedNoteCard(note, accountViewModel, nav)
val qrContentDescription =
when (mode) {
QrPayloadMode.Web -> stringRes(R.string.share_as_qr_code_description_web)
QrPayloadMode.Nostr -> stringRes(R.string.share_as_qr_code_description_nostr)
}
QrCodeDrawer(
contents = payload,
modifier =
Modifier
.widthIn(max = QrMaxSize)
.fillMaxWidth()
.semantics { contentDescription = qrContentDescription },
)
// Fill the width so each button gets an equal, generous half (a bare
// SingleChoiceSegmentedButtonRow shrinks to content and clips longer labels), and pass an
// empty `icon` so the selected-state checkmark never steals horizontal room from the
// label — selection is already signalled by the fill colour. Both matter for translated
// labels, which are often longer than the English "Web link" / "Nostr link".
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
val modes = listOf(QrPayloadMode.Web, QrPayloadMode.Nostr)
modes.forEachIndexed { index, candidate ->
SegmentedButton(
selected = mode == candidate,
onClick = { mode = candidate },
shape = SegmentedButtonDefaults.itemShape(index = index, count = modes.size),
icon = {},
) {
Text(
text =
when (candidate) {
QrPayloadMode.Web -> stringRes(R.string.share_as_qr_mode_web)
QrPayloadMode.Nostr -> stringRes(R.string.share_as_qr_mode_nostr)
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
Text(
text =
when (mode) {
QrPayloadMode.Web -> stringRes(R.string.share_as_qr_hint_web)
QrPayloadMode.Nostr -> stringRes(R.string.share_as_qr_hint_nostr)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
/**
* Raises the screen to full brightness and prevents it sleeping while the QR is displayed,
* restoring both on exit.
*
* This is functional, not polish: the screen exists to be photographed, and a dark-theme phone
* on auto-brightness in a dim room is exactly the case that fails.
*
* Residual hazard, not reachable today: [LocalView.current] is the Activity's single shared root
* `ComposeView`, not a view scoped to this screen. During a nav transition two compositions can
* briefly coexist on that same root view, so this screen's `onDispose` could in theory clobber
* brightness/`keepScreenOn` state an incoming screen has already set. Nothing in the current nav
* graph triggers that overlap, so this is left as a comment rather than code.
*/
@Composable
private fun KeepScreenBrightAndAwake() {
val view = LocalView.current
// NOT `(view.context as? Activity)`: under Compose the context is routinely a
// ContextThemeWrapper, so that cast silently yields null and brightness never changes —
// no crash, no log, just a dead feature. getActivityWindow() unwraps the ContextWrapper
// chain (WindowUtils.kt:39-46).
val window = getActivityWindow()
DisposableEffect(window, view) {
// Capture the RAW attribute, not a computed fraction. When no override is set this is
// BRIGHTNESS_OVERRIDE_NONE (-1f), and restoring that value returns the device to auto
// brightness. Restoring a *computed* fraction would install an override where none
// existed and silently disable auto-brightness for the rest of the session.
val previousBrightness = window?.attributes?.screenBrightness
// F8: same capture/replay discipline as brightness above, and for the same reason.
// `view` is the Activity's single shared root ComposeView, and PlayerEventListener
// (ControlWhenPlayerIsActive.kt:150-165) owns this exact flag while media plays.
// Hard-setting `false` on dispose — instead of restoring what was here before this
// screen took it over — would clobber that ownership: navigating back from the QR
// screen while audio or video is still playing would let the screen sleep mid-playback.
val previousKeepScreenOn = view.keepScreenOn
window?.let {
it.attributes = it.attributes.apply { screenBrightness = 1f }
}
view.keepScreenOn = true
onDispose {
// Restore the captured value rather than calling a release helper: resetting to
// BRIGHTNESS_OVERRIDE_NONE unconditionally would clobber an override the user
// already had, e.g. one left by the fullscreen video controls.
window?.let { w ->
previousBrightness?.let { prev ->
w.attributes = w.attributes.apply { screenBrightness = prev }
}
}
view.keepScreenOn = previousKeepScreenOn
}
}
}

View File

@@ -0,0 +1,320 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.share
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
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.Note
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.ui.components.collectContentWarningReasons
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.GalleryThumbnail
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.UrlImageView
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoEvent
import com.vitorpamplona.quartz.nip71Video.alt
import com.vitorpamplona.quartz.nip71Video.blurhash
import com.vitorpamplona.quartz.nip71Video.mimeType
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import com.vitorpamplona.quartz.nip92IMeta.imetas
private val CardHeight = 72.dp
private val ThumbSize = 56.dp
private val ThumbShape = RoundedCornerShape(9.dp)
/**
* A fixed-height preview of the note being shared, shown above the QR code.
*
* The height is fixed on purpose: it keeps the QR code in the same screen position for every
* note, so the screen is predictable to hold up to a scanner. That is why this does not use
* [com.vitorpamplona.amethyst.ui.note.NoteCompose] — see the design spec for the full reasoning,
* but in short, `isQuotedNote` never reaches the media renderer and note images render at their
* natural aspect ratio with no height ceiling.
*/
@Composable
fun SharedNoteCard(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth().height(CardHeight).padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
// Plain SensitivityWarning is NOT enough here: it gates on event.isSensitiveOrNSFW(),
// which only reads the note-level content-warning tag / nsfw hashtag. GalleryThumbnail's
// inner gate (GalleryThumb.kt:236) reads each media entry's per-imeta `contentWarning`,
// but none of GalleryThumb.kt's four media-construction sites ever set that field, so it
// is always null and that inner gate is permanently dead on this path. A note whose only
// warning lives inside an `imeta` tag (e.g. a kind 20 PictureEvent) would then render
// completely unblurred. `hasImetaContentWarning` below checks every imeta for the mere
// PRESENCE of a `content-warning` key, not for non-blank reason text: an imeta warning
// with an empty reason string (`["content-warning"]` with no second element) still counts
// — collectContentWarningReasons()'s `takeIf { it.isNotBlank() }` would silently drop that
// exact case, which is what let it slip the gate before. collectContentWarningReasons()
// is still called for the human-readable *reason text*, shown in the covered box's
// accessibility label when one exists — it never drives the show/hide decision.
//
// `note.event` is a plain field read that never recomposes if this composable is
// rendered before the note's event has arrived over the relay (id-only reference, e.g.
// straight off a deep link). observeNote() subscribes both the relay finder and the
// LocalCache flow, so `event` below updates — and this whole gate recomputes — the
// moment the event loads or changes, the same idiom GalleryThumbnail itself already uses
// (GalleryThumb.kt:78).
//
// This deliberately does NOT use the shared ContentWarningGate: its overlay
// (ContentWarningOverlayBody, SensitivityWarning.kt:254+) opens with an 80.dp icon box
// that consumes this card's entire 56.dp thumbnail height, pushing the title, reasons,
// and "Show anyway" button below the clipped, tappable area. Reshaping that shared
// composable was rejected — six other screens depend on its current layout — so this
// call site renders its own compact, permanently-covered state instead: no reveal
// affordance, because this screen is held up in public and pointed at someone else's
// camera. `accountViewModel.showSensitiveContent()` is still honoured exactly as
// ContentWarningGate honours it (SensitivityWarning.kt:138-140), so a user who has
// opted into seeing sensitive content globally sees the real thumbnail here too. Either
// way the thumbnail box stays a fixed 56.dp, keeping this Row's height fixed at 72.dp.
//
// `nav` is passed because GalleryThumbnail's signature demands it, but it is unused
// there (GalleryThumb.kt:76) — navigation comes from ClickableNote at its other call
// site. This card is not tappable.
val noteState by observeNote(note, accountViewModel)
val event = noteState.note.event
val reasons = remember(event) { event?.let { collectContentWarningReasons(it) } ?: emptySet() }
val hasImetaContentWarning =
remember(event) {
event?.imetas()?.any { it.properties.containsKey(ContentWarningTag.TAG_NAME) } ?: false
}
val isSensitive =
remember(event, hasImetaContentWarning) {
event != null && (event.isSensitiveOrNSFW() || hasImetaContentWarning)
}
val showSensitiveContent by accountViewModel.showSensitiveContent().collectAsStateWithLifecycle()
val isGated = isSensitive && showSensitiveContent != true
// Thumbnail source, in priority order:
// 1. structured media event (kind 20/21/22, gallery, live clip) -> GalleryThumbnail;
// 2. an image carried in an `imeta` tag of an otherwise-unstructured note (a kind 1
// image post — content is typically just the media URL) -> render that image;
// 3. no media at all -> the author's round AVATAR, which (unlike GalleryThumbnail's own
// DisplayGalleryAuthorBanner fallback, a banner crop) cannot be mistaken for the
// note's own picture (F7).
// hasStructuredMedia() mirrors GalleryThumbnail's own per-kind checks (GalleryThumb.kt:
// 82-186); contentImage covers the case GalleryThumbnail does NOT handle — a bare image
// URL in an imeta on a kind 1 — so those posts show the picture instead of avatar + URL.
val hasStructuredMedia = remember(event) { event != null && hasStructuredMedia(event) }
val contentImage = remember(event) { event?.let { firstContentImage(it) } }
Box(Modifier.size(ThumbSize).clip(ThumbShape)) {
if (isGated) {
Box(
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = MaterialSymbols.Warning,
contentDescription =
reasons.firstOrNull()?.let { stringRes(R.string.content_warning_with_reason, it) }
?: stringRes(R.string.share_as_qr_thumbnail_hidden_sensitive),
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else if (hasStructuredMedia) {
GalleryThumbnail(note, accountViewModel, nav)
} else if (contentImage != null) {
// UrlImageView crops to fill (ContentScale.Crop) and honours the account's
// show-images setting and blossom bridge on its own; the enclosing 56.dp Box
// bounds it so the card height stays fixed. No extra SensitivityWarning: the
// isGated branch above already covered the sensitive case.
UrlImageView(contentImage, accountViewModel)
} else {
NoteAuthorPicture(note, ThumbSize, accountViewModel)
}
}
Column(Modifier.weight(1f)) {
Text(
text = note.author?.toBestDisplayName() ?: "",
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = secondaryLineFor(event, isGated, contentImage != null),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/**
* Whether [event] carries the kind of structured media [GalleryThumbnail] would render (a
* profile-gallery entry, kind 20 picture, kind 21/22 video, or live-activity clip with a video
* URL) — used only to pick between the media thumbnail and the author-avatar fallback (F7).
* Mirrors GalleryThumbnail's own when-branch conditions (GalleryThumb.kt:82-186) rather than
* duplicating its full [com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent] construction.
*/
private fun hasStructuredMedia(event: Event): Boolean =
when (event) {
is ProfileGalleryEntryEvent -> event.urls().isNotEmpty()
is PictureEvent -> event.imetaTags().isNotEmpty()
is VideoEvent -> event.imetaTags().isNotEmpty()
is LiveActivitiesClipEvent -> event.videoUrl() != null
else -> false
}
/** True when this `imeta` describes an image — by declared mime type, or failing that its URL. */
internal fun IMetaTag.isImage(): Boolean {
val mime = mimeType()?.firstOrNull()
return if (mime != null) mime.startsWith("image/") else RichTextParser.isImageUrl(url)
}
/**
* The first image carried in an [event]'s `imeta` tags, as a renderable [MediaUrlImage], or null
* if the note has none. Covers the common kind-1 image post whose content is just a media URL —
* a case [GalleryThumbnail] does not handle (its when-branches fall through to the author banner).
* Only images are returned; a video-only imeta yields null and the card falls back to the avatar
* rather than feeding a video URL to the image loader.
*/
internal fun firstContentImage(event: Event): MediaUrlImage? {
val imeta = event.imetas().firstOrNull { it.isImage() } ?: return null
return MediaUrlImage(
url = imeta.url,
description = imeta.alt()?.firstOrNull(),
blurhash = imeta.blurhash()?.firstOrNull(),
mimeType = imeta.mimeType()?.firstOrNull(),
)
}
/**
* The one-or-two-line description under the author name: an article's title, else the note's own
* text (with any embedded media URLs stripped), else the image's alt text, else a kind label.
*
* F3: when [isGated] (the same sensitive-and-not-opted-in decision computed for the thumbnail,
* passed in rather than recomputed) title, content and alt text are all skipped in favor of the
* neutral kind label — otherwise this line would render the sensitive note's own text unblurred
* right next to the covered thumbnail, which is exactly what `Text.kt`'s `SensitivityWarning`
* wrapping exists to prevent for note bodies elsewhere in the app. The author name stays visible
* either way; only this line is affected.
*
* [hasContentImage] lets an image-only post whose text and alt are both empty fall back to a
* "Picture" label rather than a blank line.
*/
@Composable
private fun secondaryLineFor(
event: Event?,
isGated: Boolean,
hasContentImage: Boolean,
): String {
if (event == null) return ""
// F10: remembered against (event, isGated) so a long article body is not re-trimmed on every
// recomposition — only when the underlying event or the gate decision actually changes.
val bodyText = remember(event, isGated) { secondaryBodyTextFor(event, isGated) }
if (bodyText != null) return bodyText
return when {
event is PictureEvent -> stringRes(R.string.share_as_qr_kind_picture)
event is VideoEvent -> stringRes(R.string.kind_video)
event is LongTextNoteEvent -> stringRes(R.string.article)
hasContentImage -> stringRes(R.string.share_as_qr_kind_picture)
else -> ""
}
}
// Plain (non-@Composable) so it can be wrapped in `remember` — `stringRes` calls, which the
// kind-label fallback needs, are not allowed inside a remember calculation lambda.
internal fun secondaryBodyTextFor(
event: Event,
isGated: Boolean,
): String? {
// Gated: never surface the note's own title, content or alt text, only the kind label above.
if (isGated) return null
if (event is LongTextNoteEvent) {
val title = event.title()
if (!title.isNullOrBlank()) return title
}
// An image-only post's content is typically just the media URL(s). Strip any that appear
// verbatim (only when the note actually has imeta media, so a plain article — no imeta — is
// never scanned) so the line does not show a bare CDN URL. If nothing meaningful remains,
// fall through to the alt text, then the kind label.
val mediaUrls = event.imetas().map { it.url }
val stripped =
if (mediaUrls.isEmpty()) {
event.content
} else {
mediaUrls.fold(event.content) { acc, url -> acc.replace(url, "") }
}
// F10: bounded prefix — this line only ever shows two lines of bodySmall text, so there is
// no need to trim() a full long-form article body (potentially tens of KB) to get there.
val content = stripped.take(MAX_SECONDARY_LINE_CHARS).trim()
if (content.isNotEmpty()) return content
return event
.imetas()
.firstOrNull { it.isImage() }
?.alt()
?.firstOrNull()
?.takeIf { it.isNotBlank() }
}
private const val MAX_SECONDARY_LINE_CHARS = 280

View File

@@ -95,8 +95,17 @@ fun RenderPodcastEpisode(
val value = remember(noteEvent) { episode.episodeValue() }
var chaptersExpanded by remember(noteEvent) { mutableStateOf(false) }
// Suppress the markdown block if blank — title + description already describe a short
// episode. Otherwise hand off to RichText below.
val markdown = remember(noteEvent) { noteEvent.content.ifBlank { null } }
// episode — and ALSO when it merely repeats the description. Most feeds put the same text in
// both the `description` tag and the event content, and the thread view renders both blocks
// (`makeItShort` is false there), so the whole description appeared twice inside one card,
// each copy with its own "Show More". Compared on collapsed whitespace so a copy differing
// only in wrapping still counts as a duplicate.
val markdown =
remember(noteEvent, description) {
noteEvent.content.ifBlank { null }?.takeUnless { body ->
description?.let { normalizeForCompare(body) == normalizeForCompare(it) } == true
}
}
Column(MaterialTheme.colorScheme.replyModifier) {
PodcastCoverCard(image, note, accountViewModel)
@@ -230,3 +239,8 @@ fun RenderPodcastEpisode(
}
}
}
/** Collapses whitespace so two copies of the same text that differ only in wrapping compare equal. */
private fun normalizeForCompare(text: String): String = text.trim().replace(WHITESPACE_RUN, " ")
private val WHITESPACE_RUN = Regex("\\s+")

View File

@@ -379,6 +379,14 @@ class AccountSessionManager(
Log.e("Logoff", "Cannot decode npub for account being logged off; aborting cleanup")
return@launch
}
// TODO: also drop this account's WebView storage profile — the cookies/localStorage of every
// site it visited survive here, keyed by NappletWebViewProfiles.forPubKey(hex). It CANNOT be
// done from this process: WebView profiles live in the WebView data directory, which belongs
// to the `:napplet` process (nothing calls setDataDirectorySuffix, so booting WebView here
// too would collide on the same directory). Deleting it needs a broker message that has
// `:napplet` call ProfileStore.deleteProfile(name) — and that must refuse a profile still in
// use by a live WebView. Not wired for this release; there is no existing hook that reaches
// the sandbox on account deletion.
if (accountInfo.npub == currentAccountNPub()) {
// Drop the Nest bridge ref before tearing down the
// current account so the audio-room activity can't

View File

@@ -632,6 +632,24 @@ class AccountViewModel(
if (makeAdmin) account.makeConcordAdmin(communityId, member) else account.removeConcordAdmin(communityId, member)
}
/**
* Set [member]'s CORD-04 roles in [communityId] to exactly [roleIds] (empty revokes
* everything). The Control Plane grant REPLACES the member's role set rather than
* merging into it, so [roleIds] must be the *complete* list the member should end up
* holding — the caller (the Members roster dialog) preselects their current roles for
* that reason. Authority is re-checked at fold time by every client, so the caller must
* also have offered only roles it strictly outranks on a member it strictly outranks.
*/
fun setConcordRoles(
communityId: String,
member: HexKey,
roleIds: List<String>,
) = launchSigner {
if (!account.grantConcordRole(communityId, member, roleIds)) {
toastManager.toast(R.string.concord_members_roles_title, R.string.concord_members_roles_failed)
}
}
/** Ban/unban [member] from [communityId] (from the Members roster). */
fun setConcordBan(
communityId: String,
@@ -667,6 +685,11 @@ class AccountViewModel(
account.sendConcordTyping(communityId, channelIdHex)
}
fun sendBuzzTyping(channel: RelayGroupChannel) =
viewModelScope.launch(Dispatchers.IO) {
account.sendBuzzTyping(channel)
}
@Immutable
data class NoteComposeReportState(
val isPostHidden: Boolean = false,
@@ -1580,6 +1603,15 @@ class AccountViewModel(
fun leaveRelayGroup(channel: RelayGroupChannel) = launchSigner { account.leaveRelayGroup(channel) }
/**
* Drop a Concord community from this account's private kind-13302 list. Fire-and-forget on the
* signer dispatcher: the removal lands in the local cache (so the UI updates immediately) and the
* new list event is best-effort published to our outbox. Nothing here waits on a relay, which is
* what makes leaving a community whose own relays are dead work at all — the list lives in *our*
* outbox, not in the community's relays.
*/
fun leaveConcordCommunity(communityId: String) = launchSigner { account.leaveConcordCommunity(communityId) }
fun createRelayGroup(
relay: NormalizedRelayUrl,
groupId: String,
@@ -2380,7 +2412,18 @@ class AccountViewModel(
if (lud16 != null) {
viewModelScope.launch(Dispatchers.IO) {
try {
val meltResult = MeltProcessor().melt(token, lud16, httpClientBuilder::okHttpClientForMoney, context)
val meltResult =
MeltProcessor().melt(
token,
lud16,
httpClientBuilder::okHttpClientForMoney,
context,
// Mints the user deliberately added are exempt from the
// private-address block (self-hosted LAN mints are legit).
knownWalletMints =
account.cashuWalletState.mints.value
.toSet(),
)
onDone(
stringRes(context, R.string.cashu_successful_redemption),
stringRes(

View File

@@ -137,6 +137,10 @@ private fun PreloadFor(
// this tab needs no extra preload.
NavBarItem.RELAY_GROUPS -> Unit
// Buzz workspaces are relay groups on Buzz-dialect relays; they ride the same
// always-on relay-group state subscription, so there's nothing extra to preload.
NavBarItem.BUZZ -> Unit
NavBarItem.CONCORD -> ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel)
NavBarItem.FOLLOW_PACKS -> FollowPacksFilterAssemblerSubscription(accountViewModel)

View File

@@ -37,6 +37,7 @@ import androidx.compose.runtime.mutableStateListOf
import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles
import com.vitorpamplona.amethyst.napplethost.NappletBrowserContract
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleBridge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleLogEntry
@@ -73,6 +74,13 @@ class EmbeddedWebAppController(
private var sandboxedSdkView: SandboxedSdkView? = null
private var pendingAdapter: SandboxedUiAdapter? = null
/**
* True once this controller's adapter has actually been handed to a [SandboxedSdkView]. An adapter can
* only ever serve ONE view: when that view is disposed, privacysandbox closes the remote session and the
* sandbox destroys its WebView, so the adapter is dead. See [attachView] for why this matters.
*/
private var adapterDelivered = false
private var startUrl: String = "about:blank"
private var hasLoadedReal = false
@@ -92,7 +100,9 @@ class EmbeddedWebAppController(
// A single NappletBrowserService instance serves every embedded browser tab, so each controller
// stamps its own id on every message; the provider uses it to route controls/updates to this tab.
private val sessionId: String = "browser-${SESSION_SEQ.incrementAndGet()}"
// Re-minted whenever the remote session is re-created (see [attachView]), so a late close() from the
// previous view can never reap the replacement.
private var sessionId: String = "browser-${SESSION_SEQ.incrementAndGet()}"
/** Invoked on the main thread when the page navigates: (url, canGoBack). */
var onUrlChanged: ((String, Boolean) -> Unit)? = null
@@ -132,6 +142,7 @@ class EmbeddedWebAppController(
serviceMessenger = null
sandboxedSdkView = null
pendingAdapter = null
adapterDelivered = false
onUrlChanged = null
onImeEvent = null
onMagnifierFrame = null
@@ -141,15 +152,48 @@ class EmbeddedWebAppController(
override fun teardown() = unbind()
/** Hands the surface view to the controller; applies the adapter if it already arrived. */
/**
* Hands the surface view to the controller; applies the adapter if it already arrived, and re-arms the
* remote session when this controller is being re-used by a *second* view.
*
* A warm controller outlives the composition (it lives in the process-scoped [EmbeddedTabHost]), but its
* [SandboxedSdkView] does not: an account switch rebuilds the whole logged-in subtree, disposing every
* surface. That disposal makes privacysandbox close the remote session, which destroys the sandbox's
* WebView — so the adapter this controller already handed out is dead and cannot be given to the fresh
* view. A [SandboxedSdkView] with no adapter never builds a ContentView/SurfaceView and paints nothing
* but its background colour, forever (the load overlay's retry can't help — it re-navigates a WebView
* that no longer exists).
*
* So when a new view attaches after the adapter was already delivered, ask the sandbox for a brand new
* session; the [NappletBrowserContract.MSG_SESSION_READY] reply arms this view. The sandbox stamps the
* CURRENT account's storage profile on that new session (see [sendCreateSession]), so re-arming can
* never resurrect the previous account's cookie jar.
*/
override fun attachView(view: SandboxedSdkView) {
sandboxedSdkView = view
// Paint the surface placeholder in the app's theme background so there's no white flash before
// the remote WebView delivers its first frame.
view.setBackgroundColor(backgroundColor)
pendingAdapter?.let {
view.setAdapter(it)
pendingAdapter = null
val adapter = pendingAdapter
when {
adapter != null -> {
pendingAdapter = null
adapterDelivered = true
view.setAdapter(adapter)
}
// No adapter in hand and one was already spent on a previous (now disposed) view: the session
// behind it is gone, so this view would stay blank forever. Re-create it.
adapterDelivered -> {
// Mint a FRESH session id. The disposed view's Session.close() reaches the sandbox
// asynchronously (it posts to the sandbox's main thread) and was measured landing ~1 s
// AFTER this create: reusing the id let that late close reap the session we had just asked
// for — a new WebView was built, destroyed, and the surface stayed black. A new id makes
// the stale close target only the corpse it belongs to.
sessionId = "browser-${SESSION_SEQ.incrementAndGet()}"
adapterDelivered = false
sendCreateSession()
}
// else: the first session is still in flight; MSG_SESSION_READY will arm this view.
}
}
@@ -165,6 +209,9 @@ class EmbeddedWebAppController(
putBoolean(NappletBrowserContract.KEY_USE_TOR, initialUseTor)
putInt(NappletBrowserContract.KEY_BG_COLOR, backgroundColor)
putString(NappletBrowserContract.KEY_THEME, themeType)
// Opaque per-account storage partition, so an embedded site can't carry one
// npub's session into another. Derived here (the sandbox never sees the pubkey).
putString(NappletBrowserContract.KEY_WEBVIEW_PROFILE, NappletWebViewProfiles.current())
}
}
runCatching { serviceMessenger?.send(msg) }
@@ -176,7 +223,12 @@ class EmbeddedWebAppController(
val coreLibInfo = msg.data?.getBundle(NappletBrowserContract.KEY_CORE_LIB_INFO) ?: return true
val adapter = SandboxedUiAdapterFactory.createFromCoreLibInfo(coreLibInfo)
val view = sandboxedSdkView
if (view != null) view.setAdapter(adapter) else pendingAdapter = adapter
if (view != null) {
adapterDelivered = true
view.setAdapter(adapter)
} else {
pendingAdapter = adapter
}
}
NappletBrowserContract.MSG_URL_CHANGED -> {
val url = msg.data?.getString(NappletBrowserContract.KEY_URL).orEmpty()

View File

@@ -116,7 +116,7 @@ private fun EmbeddedWebAppTab(
// Keyed on the theme epoch too: when the app theme flips, the warm session is torn down and this
// re-acquires a freshly-themed one (the embed WebView's theme is fixed at construction).
val controller =
remember(id, EmbeddedTabHost.themeEpoch) {
remember(id, EmbeddedTabHost.rebuildEpoch) {
EmbeddedTabFactory.acquireWebApp(context, url, backgroundColor)
}

View File

@@ -0,0 +1,469 @@
/*
* 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.buzz
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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.LocalClipboard
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.AttestationConditions
import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
/**
* Owner-side NIP-OA attestation issuance. The owner signs a standalone commitment
* ([OwnerAttestation]) authorizing an agent pubkey to publish under optional
* [AttestationConditions]; the agent then attaches the resulting `auth` tag to its
* events and the relay grants it virtual membership while the owner stays a member.
*
* This is entirely offline: the signature covers a hashed commitment string, not a
* Nostr event, so it needs the owner's **raw private key** — a NIP-46 bunker or NIP-55
* external signer cannot produce it. Read-only accounts see an explanation instead of
* the form. Nothing is published; the signed tag is a credential the owner hands to the
* agent operator out-of-band.
*/
@Composable
fun AgentAttestationScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val keyPair = accountViewModel.account.settings.keyPair
val myPubkey = accountViewModel.account.userProfile().pubkeyHex
Scaffold(
topBar = { TopBarWithBackButton("Attestations", nav) },
) { padding ->
Column(
modifier =
Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Agent side: hold an attestation an owner gave you, so this account
// authenticates to the owner's Buzz relays as a virtual member. Available to
// any signer — holding a credential doesn't require the raw key.
HoldAttestationSection(myPubkey = myPubkey)
// Owner side: issue an attestation for an agent key. Needs the raw private key.
val privKey = keyPair.privKey
if (privKey == null) {
ReadOnlyKeyNotice()
} else {
AttestationForm(ownerKey = keyPair)
}
}
}
}
/**
* Agent-side: paste an `auth` tag an owner issued to this account's key. It is verified
* against [myPubkey] and, on success, stored in [BuzzHeldAttestations] so the auth
* coordinator attaches it when this account AUTHs to a Buzz relay. In-memory only for
* now — re-paste after an app restart.
*/
@Composable
private fun HoldAttestationSection(myPubkey: String) {
val held by BuzzHeldAttestations.flow.collectAsState()
val mine = held[myPubkey]
var input by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Hold an attestation",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
if (mine != null) {
Text(
text = "Holding an attestation for this account. It is attached automatically when you authenticate to a Buzz relay.",
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = "Grants: " + mine.conditions.ifEmpty { "any kind, any time (unrestricted)" },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedButton(onClick = { BuzzHeldAttestations.remove(myPubkey) }) {
Text("Remove")
}
} else {
Text(
text = "Paste an owner-signed auth tag issued to this account to authenticate to their Buzz workspace as a virtual member.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = input,
onValueChange = {
input = it
error = null
},
label = { Text("auth tag JSON") },
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
error?.let {
Text(text = it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error)
}
Button(
onClick = {
when (val outcome = parseHeldAttestation(input, myPubkey)) {
is HoldOutcome.Failure -> error = outcome.message
is HoldOutcome.Success -> {
BuzzHeldAttestations.put(myPubkey, outcome.attestation)
input = ""
error = null
}
}
},
enabled = input.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
Text("Hold attestation")
}
}
}
}
}
private sealed interface HoldOutcome {
data class Success(
val attestation: OwnerAttestation,
) : HoldOutcome
data class Failure(
val message: String,
) : HoldOutcome
}
/**
* Parses a pasted `["auth", owner, conditions, sig]` JSON array and verifies it
* authorizes [myPubkey]. Returns a human-readable failure on malformed JSON, a
* non-`auth` tag, or a signature that doesn't verify for this key.
*/
private fun parseHeldAttestation(
input: String,
myPubkey: String,
): HoldOutcome {
val tag =
try {
Json
.parseToJsonElement(input.trim())
.jsonArray
.map { it.jsonPrimitive.content }
.toTypedArray()
} catch (e: Exception) {
return HoldOutcome.Failure("Not a valid JSON tag array. Paste the [\"auth\", …] tag you were given.")
}
val attestation =
OwnerAttestation.parse(tag)
?: return HoldOutcome.Failure("Not a NIP-OA auth tag.")
if (!attestation.verify(myPubkey)) {
return HoldOutcome.Failure("This attestation does not authorize the current account, or its signature is invalid.")
}
return HoldOutcome.Success(attestation)
}
@Composable
private fun ReadOnlyKeyNotice() {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Local key required",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text =
"A NIP-OA attestation is a signature over a hashed commitment, not a Nostr event, " +
"so it can only be produced by a signer that holds your raw private key. This " +
"account uses a remote (NIP-46 bunker) or external (NIP-55) signer, which cannot " +
"sign an attestation.",
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
@Composable
private fun AttestationForm(ownerKey: KeyPair) {
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
var agentInput by remember { mutableStateOf("") }
var kindInput by remember { mutableStateOf("") }
var afterInput by remember { mutableStateOf("") }
var beforeInput by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
var result by remember { mutableStateOf<OwnerAttestation?>(null) }
Text(
text =
"Authorize an agent pubkey to publish in your workspace without enrolling its key. " +
"The agent attaches the signed tag below to its events.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = agentInput,
onValueChange = {
agentInput = it
error = null
result = null
},
label = { Text("Agent public key (npub or hex)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Text(
text = "Conditions (optional) — leave blank for an unrestricted attestation.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = kindInput,
onValueChange = {
kindInput = it.filter(Char::isDigit)
error = null
result = null
},
label = { Text("Restrict to kind (065535)") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = afterInput,
onValueChange = {
afterInput = it.filter(Char::isDigit)
error = null
result = null
},
label = { Text("Only events after (unix seconds)") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = beforeInput,
onValueChange = {
beforeInput = it.filter(Char::isDigit)
error = null
result = null
},
label = { Text("Only events before (unix seconds)") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
error?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
Button(
onClick = {
when (val outcome = buildAttestation(agentInput, kindInput, afterInput, beforeInput, ownerKey)) {
is AttestationOutcome.Failure -> {
error = outcome.message
result = null
}
is AttestationOutcome.Success -> {
error = null
result = outcome.attestation
}
}
},
enabled = agentInput.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
Text("Generate attestation")
}
result?.let { attestation ->
AttestationResultCard(
attestation = attestation,
onCopy = { scope.launch { clipboard.setText(attestation.toTagJson()) } },
)
}
}
@Composable
private fun AttestationResultCard(
attestation: OwnerAttestation,
onCopy: () -> Unit,
) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Signed attestation",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text =
"Grants: " +
(attestation.conditions.ifEmpty { "any kind, any time (unrestricted)" }),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = attestation.toTagJson(),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
modifier = Modifier.fillMaxWidth(),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
OutlinedButton(onClick = onCopy) {
Text("Copy tag")
}
}
Text(
text =
"⚠ Hand this to the agent operator only. While it is valid and you remain a " +
"workspace member, the relay lets this agent post as a member under the " +
"conditions above.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
private sealed interface AttestationOutcome {
data class Success(
val attestation: OwnerAttestation,
) : AttestationOutcome
data class Failure(
val message: String,
) : AttestationOutcome
}
/**
* Validates the form inputs and signs an [OwnerAttestation], or returns a
* human-readable failure. Kept out of composition so the signing (raw-key) path is a
* plain function.
*/
private fun buildAttestation(
agentInput: String,
kindInput: String,
afterInput: String,
beforeInput: String,
ownerKey: KeyPair,
): AttestationOutcome {
val agentHex =
decodePublicKeyAsHexOrNull(agentInput.trim())
?: return AttestationOutcome.Failure("Invalid agent public key. Enter an npub or 64-char hex key.")
if (!agentHex.isValid()) {
return AttestationOutcome.Failure("Invalid agent public key. Enter an npub or 64-char hex key.")
}
val kind =
if (kindInput.isBlank()) {
null
} else {
val parsed = kindInput.toIntOrNull()
if (parsed == null || parsed !in 0..65535) {
return AttestationOutcome.Failure("Kind must be between 0 and 65535.")
}
parsed
}
val after = parseOptionalUnix(afterInput) ?: return AttestationOutcome.Failure("\"After\" must be a unix time in 04294967295.")
val before = parseOptionalUnix(beforeInput) ?: return AttestationOutcome.Failure("\"Before\" must be a unix time in 04294967295.")
val conditions = AttestationConditions(kind = kind, createdAtBefore = before.value, createdAtAfter = after.value)
return try {
AttestationOutcome.Success(OwnerAttestation.sign(agentHex, conditions, ownerKey))
} catch (e: IllegalArgumentException) {
AttestationOutcome.Failure(e.message ?: "Could not sign the attestation.")
}
}
/** Wraps a nullable parse so "absent" and "invalid" are distinguishable from the caller. */
private class OptionalUnix(
val value: Long?,
)
private fun parseOptionalUnix(input: String): OptionalUnix? {
if (input.isBlank()) return OptionalUnix(null)
val parsed = input.toLongOrNull() ?: return null
if (parsed !in 0..4294967295L) return null
return OptionalUnix(parsed)
}
/** Serializes the `auth` tag to a JSON array string (values are hex / canonical ASCII). */
private fun OwnerAttestation.toTagJson(): String = toTag().joinToString(prefix = "[", postfix = "]") { "\"$it\"" }

View File

@@ -0,0 +1,404 @@
/*
* 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.buzz
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.buzz.AgentFleetMetrics
import com.vitorpamplona.amethyst.commons.model.buzz.AgentUsageSummary
import com.vitorpamplona.amethyst.commons.model.buzz.TokenTotals
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
* The workspace owner's agent-owner console — a dashboard over the fleet of AI agents
* that publish to them. Three tabs:
* - **Costs** — fleet totals and a per-agent breakdown (turns, sessions, tokens, spend),
* derived from decrypted NIP-AM turn metrics (`kind:44200`).
* - **Personas** — the owner's published persona definitions (NIP-AP `kind:30175`).
* - **Observer** — a live stream of ephemeral NIP-AO telemetry frames (`kind:24200`).
*
* A "Attest" FAB opens [AgentAttestationScreen] to issue a NIP-OA authorization for an
* agent key. Data comes from [AgentConsoleViewModel], keyed by the owner pubkey so the
* fetch/decrypt work survives navigation. The tabs are read-only; persona editing is a
* follow-up.
*/
@Composable
fun AgentConsoleScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val pubkey = accountViewModel.account.userProfile().pubkeyHex
val viewModel: AgentConsoleViewModel = viewModel(key = "AgentConsole-$pubkey")
viewModel.bindAccountIfMissing(accountViewModel.account)
val metrics by viewModel.metrics.collectAsStateWithLifecycle()
val personas by viewModel.personas.collectAsStateWithLifecycle()
val observerFrames by viewModel.observerFrames.collectAsStateWithLifecycle()
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
var selectedTab by rememberSaveable { mutableIntStateOf(0) }
val tabs = remember { listOf("Costs", "Personas", "Observer") }
// The observer stream is a live REQ; only keep it open while its tab is on screen.
if (selectedTab == 2) {
DisposableEffect(Unit) {
viewModel.startObserving()
onDispose { viewModel.stopObserving() }
}
}
Scaffold(
topBar = { TopBarWithBackButton("Agent Console", nav) },
floatingActionButton = {
ExtendedFloatingActionButton(
text = { Text("Attest") },
icon = { Icon(symbol = MaterialSymbols.Key, contentDescription = null) },
onClick = { nav.nav(Route.AgentAttestation) },
)
},
) { padding ->
Column(modifier = Modifier.padding(padding).fillMaxSize()) {
TabRow(selectedTabIndex = selectedTab) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(title) },
)
}
}
Box(modifier = Modifier.fillMaxSize()) {
when (selectedTab) {
0 -> CostsTab(metrics)
1 -> PersonasTab(personas, nav)
else -> ObserverTab(observerFrames)
}
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp).size(24.dp),
)
}
}
}
}
}
@Composable
private fun CostsTab(metrics: AgentFleetMetrics) {
if (metrics.agents.isEmpty()) {
EmptyState("No agent turn metrics yet. Metrics appear once your agents publish kind:44200 events to you.")
return
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
FleetSummaryCard(metrics)
}
items(metrics.agents) { agent ->
AgentCard(agent)
}
}
}
@Composable
private fun FleetSummaryCard(metrics: AgentFleetMetrics) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = "Fleet total",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = formatCost(metrics.totals.costUsd),
style = MaterialTheme.typography.headlineMedium,
)
MetaRow("Agents", metrics.agents.size.toString())
MetaRow("Sessions", metrics.totalSessions.toString())
MetaRow("Turns", metrics.totalTurns.toString())
TokenBreakdown(metrics.totals)
if (metrics.hasUnreliableEstimates) {
Text(
text = "⚠ Some totals are estimated from per-turn deltas (a session reported no cumulative counts).",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
@Composable
private fun AgentCard(agent: AgentUsageSummary) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = shortKey(agent.agentPubKey),
style = MaterialTheme.typography.titleSmall,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
)
Text(
text = formatCost(agent.totals.costUsd),
style = MaterialTheme.typography.titleLarge,
)
MetaRow("Sessions", agent.sessions.toString())
MetaRow("Turns", agent.turns.toString())
if (agent.models.isNotEmpty()) MetaRow("Models", agent.models.sorted().joinToString(", "))
if (agent.harnesses.isNotEmpty()) MetaRow("Harness", agent.harnesses.sorted().joinToString(", "))
agent.lastActivity?.let { MetaRow("Last turn", it) }
TokenBreakdown(agent.totals)
}
}
}
@Composable
private fun TokenBreakdown(totals: TokenTotals) {
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
MetaRow("Input tokens", formatCount(totals.inputTokens))
MetaRow("Output tokens", formatCount(totals.outputTokens))
MetaRow("Total tokens", formatCount(totals.totalTokens))
if (totals.cacheReadTokens > 0) MetaRow("Cache read", formatCount(totals.cacheReadTokens))
if (totals.cacheWriteTokens > 0) MetaRow("Cache write", formatCount(totals.cacheWriteTokens))
}
@Composable
private fun PersonasTab(
personas: List<AgentConsoleViewModel.PersonaCard>,
nav: INav,
) {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
OutlinedButton(
onClick = { nav.nav(Route.AgentPersonaEdit()) },
modifier = Modifier.fillMaxWidth(),
) {
Icon(symbol = MaterialSymbols.AddCircle, contentDescription = null)
Text(" New persona")
}
}
if (personas.isEmpty()) {
item {
Text(
text = "No personas published yet. Create one to define an agent (kind:30175).",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
}
items(personas) { persona ->
PersonaCardView(persona) { nav.nav(Route.AgentPersonaEdit(persona.slug)) }
}
}
}
@Composable
private fun PersonaCardView(
persona: AgentConsoleViewModel.PersonaCard,
onClick: () -> Unit,
) {
Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = persona.displayName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = persona.slug,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
persona.model?.let { MetaRow("Model", it) }
persona.provider?.let { MetaRow("Provider", it) }
persona.runtime?.let { MetaRow("Runtime", it) }
persona.systemPrompt?.takeIf { it.isNotBlank() }?.let {
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
@Composable
private fun ObserverTab(frames: List<AgentConsoleViewModel.ObserverRow>) {
if (frames.isEmpty()) {
EmptyState("Listening for live agent telemetry (kind:24200). Frames appear here while your agents are running.")
return
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(frames) { frame ->
ObserverFrameRow(frame)
HorizontalDivider()
}
}
}
@Composable
private fun ObserverFrameRow(frame: AgentConsoleViewModel.ObserverRow) {
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = frame.kind,
style = MaterialTheme.typography.titleSmall,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
)
Text(
text = frame.timestamp,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
val context =
buildString {
append(shortKey(frame.agentPubKey))
frame.sessionId?.let { append(" · session ${it.take(8)}") }
frame.turnId?.let { append(" · turn ${it.take(8)}") }
append(" · #${frame.seq}")
}
Text(
text = context,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun MetaRow(
label: String,
value: String,
) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
)
}
}
@Composable
private fun EmptyState(message: String) {
Box(modifier = Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
private fun formatCost(cost: Double): String = "$" + ((cost * 100).toLong() / 100.0).toString().let { padCents(it) }
/** Pads a dollar string to exactly two decimals (e.g. "1.5" -> "1.50", "3" -> "3.00"). */
private fun padCents(value: String): String {
val dot = value.indexOf('.')
if (dot < 0) return "$value.00"
val decimals = value.length - dot - 1
return when {
decimals >= 2 -> value.substring(0, dot + 3)
decimals == 1 -> value + "0"
else -> value + "00"
}
}
/** Groups a token count with thin spaces every three digits (1234567 -> "1 234 567"). */
private fun formatCount(n: Long): String {
val s = n.toString()
if (s.length <= 3) return s
val sb = StringBuilder()
val firstGroup = s.length % 3
if (firstGroup > 0) {
sb.append(s, 0, firstGroup)
}
var i = firstGroup
while (i < s.length) {
if (sb.isNotEmpty()) sb.append('')
sb.append(s, i, i + 3)
i += 3
}
return sb.toString()
}
private fun shortKey(hex: String): String = if (hex.length <= 16) hex else hex.take(8) + "" + hex.takeLast(8)

View File

@@ -0,0 +1,266 @@
/*
* 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.buzz
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.buzz.AgentFleetAggregator
import com.vitorpamplona.amethyst.commons.model.buzz.AgentFleetMetrics
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.filter
import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricEvent
import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricPayload
import com.vitorpamplona.quartz.buzz.aoObserver.ObserverFrameEvent
import com.vitorpamplona.quartz.buzz.aoObserver.tags.FrameTag
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.Collections
/**
* Backing ViewModel for the [AgentConsoleScreen] — the workspace owner's read-only
* dashboard over their AI-agent fleet.
*
* It reads two Buzz kinds the owner authored or received:
* - **Personas** ([PersonaEvent], `kind:30175`) — plaintext, authored by the owner.
* - **Turn metrics** ([AgentTurnMetricEvent], `kind:44200`) — NIP-44 ciphertext an
* agent published to the owner (`p` tag = owner). Either party can decrypt; here the
* owner's [Account.signer] does. Decryption is cached by event id so a refresh that
* re-reads the cache never re-runs NIP-44 on an event it already opened.
*
* [refresh] fetches both kinds from the Buzz-dialect relays plus the owner's outbox set,
* lets [LocalCache] consume them, then re-derives the aggregate via the pure
* [AgentFleetAggregator] (which owns the cost/token semantics). The ViewModel is keyed by
* the owner pubkey in the screen, so one instance survives navigation in and out.
*/
class AgentConsoleViewModel : ViewModel() {
@Volatile private var account: Account? = null
/** event id -> decrypted payload (null = decryption failed; don't retry). */
private val decryptCache = HashMap<HexKey, AgentTurnMetricPayload?>()
private val refreshMutex = Mutex()
private val _metrics = MutableStateFlow(AgentFleetMetrics.EMPTY)
val metrics: StateFlow<AgentFleetMetrics> = _metrics.asStateFlow()
private val _personas = MutableStateFlow<List<PersonaCard>>(emptyList())
val personas: StateFlow<List<PersonaCard>> = _personas.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _observerFrames = MutableStateFlow<List<ObserverRow>>(emptyList())
val observerFrames: StateFlow<List<ObserverRow>> = _observerFrames.asStateFlow()
private var observerJob: Job? = null
/** Dedups ephemeral frames across relays and re-emissions (accessed off multiple readers). */
private val observerSeen = Collections.synchronizedSet(HashSet<HexKey>())
fun bindAccountIfMissing(account: Account) {
if (this.account != null) return
this.account = account
refresh()
}
fun refresh() {
val account = account ?: return
viewModelScope.launch(Dispatchers.IO) {
refreshMutex.withLock {
_isLoading.value = true
try {
fetchFromRelays(account)
reloadFromCache(account)
} finally {
_isLoading.value = false
}
}
}
}
/**
* One-shot paged fetch of the owner's personas (authored by owner) and turn metrics
* (addressed to the owner via the `p` tag) from every Buzz-dialect relay and the
* owner's own outbox relays. Events land in [LocalCache] via the normal consume path;
* this only primes the cache before [reloadFromCache] reads it back.
*/
private suspend fun fetchFromRelays(account: Account) {
val myPubkey = account.userProfile().pubkeyHex
val relays = BuzzRelayDialect.flow.value + account.outboxRelays.flow.value
if (relays.isEmpty()) return
val filters =
listOf(
Filter(kinds = listOf(AgentTurnMetricEvent.KIND), tags = mapOf("p" to listOf(myPubkey))),
Filter(kinds = listOf(PersonaEvent.KIND), authors = listOf(myPubkey)),
)
account.client.fetchAllPagesFromPool(relays.associateWith { filters }) { _, _ -> }
}
private suspend fun reloadFromCache(account: Account) {
val myPubkey = account.userProfile().pubkeyHex
val signer = account.signer
_personas.value =
LocalCache.addressables
.filter(PersonaEvent.KIND, myPubkey) { _, note -> note.event is PersonaEvent }
.mapNotNull { note ->
val event = note.event as? PersonaEvent ?: return@mapNotNull null
val content = event.personaOrNull()
PersonaCard(
slug = event.slug() ?: "",
displayName = content?.displayName ?: event.slug() ?: "",
model = content?.model,
runtime = content?.runtime,
provider = content?.provider,
systemPrompt = content?.systemPrompt,
)
}.sortedBy { it.displayName.lowercase() }
val metricNotes =
LocalCache.filter(
Filter(kinds = listOf(AgentTurnMetricEvent.KIND), tags = mapOf("p" to listOf(myPubkey))),
)
val decrypted =
metricNotes.mapNotNull { note ->
val event = note.event as? AgentTurnMetricEvent ?: return@mapNotNull null
val payload =
if (decryptCache.containsKey(event.id)) {
decryptCache[event.id]
} else {
event.decryptOrNull(signer).also { decryptCache[event.id] = it }
} ?: return@mapNotNull null
val agent = event.agentPubKey() ?: event.pubKey
agent to payload
}
_metrics.value = AgentFleetAggregator.aggregate(decrypted)
}
/**
* Opens a live subscription to the owner's ephemeral observer telemetry frames
* ([ObserverFrameEvent], `kind:24200`, `p` = owner) across every Buzz-dialect relay,
* decrypts the `frame:telemetry` bodies, and pushes them newest-first into
* [observerFrames] (bounded to [MAX_OBSERVER_ROWS]). Idempotent; call [stopObserving]
* to tear the subscription down. Observer frames are never stored by relays or
* [LocalCache], so this live REQ is the only way to see them.
*/
fun startObserving() {
val account = account ?: return
if (observerJob != null) return
val relays = BuzzRelayDialect.flow.value
if (relays.isEmpty()) return
val signer = account.signer
val myPubkey = account.userProfile().pubkeyHex
val filter = Filter(kinds = listOf(ObserverFrameEvent.KIND), tags = mapOf("p" to listOf(myPubkey)))
observerJob =
viewModelScope.launch(Dispatchers.IO) {
relays.forEach { relay ->
launch {
account.client.subscribeAsFlow(relay, filter).collect { events ->
val fresh =
events
.filterIsInstance<ObserverFrameEvent>()
.filter { observerSeen.add(it.id) }
if (fresh.isEmpty()) return@collect
val rows =
fresh.mapNotNull { frame ->
if (frame.frame() != FrameTag.TELEMETRY) return@mapNotNull null
val payload = frame.decryptTelemetryOrNull(signer) ?: return@mapNotNull null
ObserverRow(
seq = payload.seq,
timestamp = payload.timestamp,
kind = payload.kind,
agentPubKey = frame.agentPubKey() ?: frame.pubKey,
sessionId = payload.sessionId,
turnId = payload.turnId,
)
}
if (rows.isNotEmpty()) {
_observerFrames.update { existing ->
(rows + existing)
.sortedByDescending { it.timestamp }
.take(MAX_OBSERVER_ROWS)
}
}
}
}
}
}
}
fun stopObserving() {
observerJob?.cancel()
observerJob = null
}
override fun onCleared() {
stopObserving()
super.onCleared()
}
/** One decrypted observer telemetry frame rendered on the Observer tab. */
@Immutable
data class ObserverRow(
val seq: Long,
val timestamp: String,
val kind: String,
val agentPubKey: HexKey,
val sessionId: String?,
val turnId: String?,
)
/** A persona rendered on the Personas tab; a flattened projection of [PersonaEvent]. */
@Immutable
data class PersonaCard(
val slug: String,
val displayName: String,
val model: String?,
val runtime: String?,
val provider: String?,
val systemPrompt: String?,
)
companion object {
/** Ring size for the ephemeral observer stream — enough to scroll recent activity. */
const val MAX_OBSERVER_ROWS = 200
}
}

View File

@@ -0,0 +1,145 @@
/*
* 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.buzz
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
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 androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
* Create or edit a Buzz Agent Persona (NIP-AP `kind:30175`). [slug] null → a new persona;
* otherwise edits the existing one (slug locked). Publishes to the workspace's Buzz relays
* on save and pops back. Backed by [AgentPersonaEditViewModel], keyed by owner+slug so an
* in-progress edit survives recomposition.
*/
@Composable
fun AgentPersonaEditScreen(
slug: String?,
accountViewModel: AccountViewModel,
nav: INav,
) {
val pubkey = accountViewModel.account.userProfile().pubkeyHex
val viewModel: AgentPersonaEditViewModel = viewModel(key = "PersonaEdit-$pubkey-${slug ?: "new"}")
viewModel.bind(accountViewModel.account, slug)
val state by viewModel.state.collectAsStateWithLifecycle()
val title = if (slug == null) "New persona" else "Edit persona"
Scaffold(
topBar = { TopBarWithBackButton(title, nav) },
) { padding ->
Column(
modifier =
Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = state.slug,
onValueChange = viewModel::onSlugChange,
label = { Text("Slug (persona id)") },
singleLine = true,
enabled = !state.slugLocked,
supportingText = { Text("a-z, 0-9, '-' or '_'. Cannot change after creation.") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.displayName,
onValueChange = viewModel::onDisplayNameChange,
label = { Text("Display name") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.systemPrompt,
onValueChange = viewModel::onSystemPromptChange,
label = { Text("System prompt") },
minLines = 3,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.model,
onValueChange = viewModel::onModelChange,
label = { Text("Model (optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.provider,
onValueChange = viewModel::onProviderChange,
label = { Text("Provider (optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.runtime,
onValueChange = viewModel::onRuntimeChange,
label = { Text("Runtime (optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.avatarUrl,
onValueChange = viewModel::onAvatarUrlChange,
label = { Text("Avatar URL (optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
state.error?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
Button(
onClick = { viewModel.save(onDone = nav::popBack) },
enabled = state.canSave,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (state.isSaving) "Publishing…" else "Publish persona")
}
}
}
}

View File

@@ -0,0 +1,174 @@
/*
* 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.buzz
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaContent
import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
/**
* Backing ViewModel for [AgentPersonaEditScreen] — creates or edits a Buzz Agent Persona
* (NIP-AP `kind:30175`), a world-readable addressable event authored by the workspace
* owner and addressed by `(owner, 30175, slug)`.
*
* On edit, the existing [PersonaEvent] is loaded from [LocalCache] so fields the form does
* not expose (avatar, name pool, respond-to allowlist, parallelism) are preserved rather
* than dropped on the next publish. The slug (the `d` tag) is immutable once chosen — a
* different slug is a different persona.
*
* Published to the workspace's Buzz-dialect relays (falling back to the owner's outbox when
* none are known), so the workspace and other members see it.
*/
class AgentPersonaEditViewModel : ViewModel() {
@Volatile private var account: Account? = null
/** Set on edit; preserves fields the form doesn't surface. Null for a new persona. */
private var existing: PersonaContent? = null
private var editingSlug: String? = null
private val _state = MutableStateFlow(FormState())
val state: StateFlow<FormState> = _state.asStateFlow()
/** Binds the account and, when [slug] names an existing persona, seeds the form from it. */
fun bind(
account: Account,
slug: String?,
) {
if (this.account != null) return
this.account = account
if (slug.isNullOrBlank()) return
val note = LocalCache.getAddressableNoteIfExists(Address(PersonaEvent.KIND, account.userProfile().pubkeyHex, slug))
val event = note?.event as? PersonaEvent ?: return
val content = event.personaOrNull() ?: return
existing = content
editingSlug = slug
_state.value =
FormState(
slug = slug,
slugLocked = true,
displayName = content.displayName,
systemPrompt = content.systemPrompt.orEmpty(),
model = content.model.orEmpty(),
runtime = content.runtime.orEmpty(),
provider = content.provider.orEmpty(),
avatarUrl = content.avatarUrl.orEmpty(),
)
}
fun onSlugChange(v: String) = _state.update { it.copy(slug = v.trim(), error = null) }
fun onDisplayNameChange(v: String) = _state.update { it.copy(displayName = v, error = null) }
fun onSystemPromptChange(v: String) = _state.update { it.copy(systemPrompt = v, error = null) }
fun onModelChange(v: String) = _state.update { it.copy(model = v.trim(), error = null) }
fun onRuntimeChange(v: String) = _state.update { it.copy(runtime = v.trim(), error = null) }
fun onProviderChange(v: String) = _state.update { it.copy(provider = v.trim(), error = null) }
fun onAvatarUrlChange(v: String) = _state.update { it.copy(avatarUrl = v.trim(), error = null) }
/**
* Validates, builds a [PersonaEvent] (preserving unexposed fields on edit), signs and
* publishes it to the Buzz relays. Calls [onDone] on the main thread on success.
*/
fun save(onDone: () -> Unit) {
val account = account ?: return
val current = _state.value
val slug = current.slug.trim()
if (!isValidSlug(slug)) {
_state.update { it.copy(error = "Slug must match a-z, 0-9, '-' or '_' (max 64 chars).") }
return
}
if (current.displayName.isBlank()) {
_state.update { it.copy(error = "Display name is required.") }
return
}
_state.update { it.copy(isSaving = true, error = null) }
viewModelScope.launch(Dispatchers.IO) {
try {
val base = existing ?: PersonaContent(displayName = current.displayName)
val content =
base.copy(
displayName = current.displayName.trim(),
systemPrompt = current.systemPrompt.blankToNull(),
model = current.model.blankToNull(),
runtime = current.runtime.blankToNull(),
provider = current.provider.blankToNull(),
avatarUrl = current.avatarUrl.blankToNull(),
)
val template = PersonaEvent.build(content, slug)
account.signAndSendPrivatelyOrBroadcast(template) {
BuzzRelayDialect.flow.value
.toList()
.ifEmpty { null }
}
viewModelScope.launch(Dispatchers.Main) { onDone() }
} catch (e: Exception) {
if (e is CancellationException) throw e
_state.update { it.copy(isSaving = false, error = "Failed to publish: ${e.message ?: e::class.simpleName}") }
}
}
}
private fun String.blankToNull(): String? = trim().takeIf { it.isNotBlank() }
data class FormState(
val slug: String = "",
/** True when editing an existing persona — the slug (address `d` tag) can't change. */
val slugLocked: Boolean = false,
val displayName: String = "",
val systemPrompt: String = "",
val model: String = "",
val runtime: String = "",
val provider: String = "",
val avatarUrl: String = "",
val isSaving: Boolean = false,
val error: String? = null,
) {
val canSave: Boolean get() = slug.isNotBlank() && displayName.isNotBlank() && !isSaving
}
companion object {
private val SLUG_REGEX = Regex("^[a-z0-9][a-z0-9_-]{0,63}$")
/** The persona slug grammar enforced by the relay (`validate_persona_envelope`). */
fun isValidSlug(slug: String): Boolean = SLUG_REGEX.matches(slug)
}
}

View File

@@ -0,0 +1,111 @@
/*
* 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.buzz
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
/**
* Renders a Buzz workspace **canvas** (NIP kind 40100): the newest shared markdown
* document for a channel. The canvas is overlay state held in [BuzzWorkspaceStates]
* (never a timeline row — a workspace has one live canvas, last-write-wins), so this
* reads the registry by the channel's `h` id and re-composes off `canvasUpdates` when a
* newer revision lands.
*/
@Composable
fun BuzzCanvasScreen(
channelId: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val state = remember(channelId) { BuzzWorkspaceStates.getOrCreate(channelId) }
// Re-read canvasNote whenever a newer canvas is consumed.
val version by state.canvasUpdates.collectAsStateWithLifecycle()
val canvas = remember(version) { state.canvasNote }
val content = canvas?.event?.content
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.buzz_canvas_title), nav) },
) { padding ->
if (content.isNullOrBlank() || canvas == null) {
Box(
modifier = Modifier.padding(padding).fillMaxSize().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringRes(R.string.buzz_canvas_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
return@Scaffold
}
val bgColor = remember { mutableStateOf(Color.Transparent) }
Box(
modifier =
Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
TranslatableRichTextViewer(
content = content,
canPreview = true,
quotesLeft = 1,
modifier = Modifier,
tags = EmptyTagList,
backgroundColor = bgColor,
id = canvas.idHex,
callbackUri = canvas.toNostrUri(),
authorPubKey = canvas.author?.pubkeyHex,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}

View File

@@ -0,0 +1,341 @@
/*
* 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.buzz
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.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.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupChannelHasUnreadFlow
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
/**
* The **Workspaces** tab — a first-class navigation home for `block/buzz` workspaces,
* giving Buzz a distinct identity instead of hiding inside the generic relay-group list.
* A Buzz workspace is a NIP-29 group on a Buzz-dialect relay, so this filters the user's
* joined groups to those relays ([BuzzRelayDialect]) and adds the Buzz-only surfaces (the
* Agent Console) up top.
*
* Read-only aggregation: it reuses the always-on relay-group state subscription (mounted
* at login), so no per-screen fetch is needed — it just projects the joined set.
*/
@Composable
fun BuzzWorkspacesScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val joined by accountViewModel.account.relayGroupList.liveRelayGroupList
.collectAsStateWithLifecycle()
val buzzRelays by BuzzRelayDialect.flow.collectAsStateWithLifecycle()
val workspaces =
remember(joined, buzzRelays) {
joined
.mapNotNull { tag ->
val relay = RelayUrlNormalizer.normalizeOrNull(tag.relayUrl) ?: return@mapNotNull null
if (relay !in buzzRelays) return@mapNotNull null
GroupId(tag.groupId, relay)
}.sortedBy { it.id }
}
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
UserDrawerSearchTopBar(accountViewModel, nav) {
Text(
text = stringRes(R.string.buzz_workspaces_title),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
}
},
bottomBar = {
AppBottomBar(Route.BuzzWorkspaces, nav, accountViewModel) { route ->
nav.navBottomBar(route)
}
},
accountViewModel = accountViewModel,
) { padding ->
LazyColumn(
modifier = Modifier.padding(padding).fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { AgentConsoleHeroCard(onClick = { nav.nav(Route.AgentConsole) }) }
if (workspaces.isEmpty()) {
item { EmptyWorkspaces(onBrowse = { nav.nav(Route.RelayGroups) }) }
} else {
item {
Text(
text = stringRes(R.string.buzz_workspaces_section),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp, start = 4.dp),
)
}
items(workspaces, key = { it.id + it.relayUrl.url }) { groupId ->
WorkspaceRow(groupId, accountViewModel, nav)
}
}
}
}
}
/** A bold accent card leading to the Buzz-only Agent Console — the tab's signature surface. */
@Composable
private fun AgentConsoleHeroCard(onClick: () -> Unit) {
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier =
Modifier
.size(44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = MaterialSymbols.AutoAwesome,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(24.dp),
)
}
Spacer(Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringRes(R.string.buzz_console_card_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
Text(
text = stringRes(R.string.buzz_console_card_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
Icon(
symbol = MaterialSymbols.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(22.dp),
)
}
}
}
/** One workspace: a colored monogram avatar, live name + host, member count, unread dot. */
@Composable
private fun WorkspaceRow(
groupId: GroupId,
accountViewModel: AccountViewModel,
nav: INav,
) {
val baseChannel = remember(groupId) { LocalCache.getOrCreateRelayGroupChannel(groupId) }
val channelState by observeChannel(baseChannel, accountViewModel)
val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel
val hasUnread by remember(groupId) {
relayGroupChannelHasUnreadFlow(accountViewModel.account, groupId)
}.collectAsStateWithLifecycle(initialValue = false)
val name = channel.toBestDisplayName()
val memberCount = channel.memberCount()
Card(
onClick = { nav.nav(routeFor(channel)) },
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
WorkspaceAvatar(name = name, seed = groupId.id)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = name,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = groupId.relayUrl.displayUrl(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (memberCount > 0) {
Icon(
symbol = MaterialSymbols.Group,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(14.dp),
)
Spacer(Modifier.width(3.dp))
Text(
text = "$memberCount",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (hasUnread) {
Spacer(Modifier.width(10.dp))
Box(
modifier =
Modifier
.size(10.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
)
}
}
}
}
/** A round monogram whose color is derived deterministically from the workspace id. */
@Composable
private fun WorkspaceAvatar(
name: String,
seed: String,
) {
val hue = remember(seed) { (seed.hashCode().toLong() and 0xFFFFFF).toFloat() % 360f }
val color = remember(hue) { Color.hsl(hue, 0.55f, 0.5f) }
val initial =
remember(name) {
name
.trim()
.firstOrNull()
?.uppercaseChar()
?.toString() ?: "#"
}
Box(
modifier = Modifier.size(42.dp).clip(CircleShape).background(color),
contentAlignment = Alignment.Center,
) {
Text(
text = initial,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = Color.White,
)
}
}
/** Inviting empty state: what a Buzz workspace is + a way to go find one. */
@Composable
private fun EmptyWorkspaces(onBrowse: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(
modifier = Modifier.size(56.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = MaterialSymbols.AutoAwesome,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(28.dp),
)
}
Text(
text = stringRes(R.string.buzz_workspaces_empty_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = stringRes(R.string.buzz_workspaces_empty_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 8.dp),
)
Spacer(Modifier.size(2.dp))
FilledTonalButton(onClick = onBrowse) {
Text(stringRes(R.string.buzz_workspaces_browse))
}
}
}
}

View File

@@ -80,6 +80,7 @@ fun RefreshingChatroomFeedView(
// callers with no external jump affordance.
jumpToNoteId: State<String?>? = null,
onJumpHandled: () -> Unit = {},
onWantsToEditBuzz: ((Note) -> Unit)? = null,
) {
SaveableFeedState(feedContentState, scrollStateKey) { listState ->
listStateObserver(listState)
@@ -97,6 +98,7 @@ fun RefreshingChatroomFeedView(
sentinels,
jumpToNoteId,
onJumpHandled,
onWantsToEditBuzz,
)
}
}
@@ -116,6 +118,7 @@ fun RenderChatFeedView(
sentinels: (@Composable (items: List<Note>, listState: LazyListState) -> Unit)? = null,
jumpToNoteId: State<String?>? = null,
onJumpHandled: () -> Unit = {},
onWantsToEditBuzz: ((Note) -> Unit)? = null,
) {
val feedState by feed.feedContent.collectAsStateWithLifecycle()
@@ -148,6 +151,7 @@ fun RenderChatFeedView(
sentinels,
jumpToNoteId,
onJumpHandled,
onWantsToEditBuzz,
)
}
}
@@ -169,6 +173,7 @@ fun ChatFeedLoaded(
sentinels: (@Composable (items: List<Note>, listState: LazyListState) -> Unit)? = null,
jumpToNoteId: State<String?>? = null,
onJumpHandled: () -> Unit = {},
onWantsToEditBuzz: ((Note) -> Unit)? = null,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
@@ -233,6 +238,24 @@ fun ChatFeedLoaded(
}
Column(modifier = itemModifier) {
// A day/subject header belongs ABOVE the message it introduces. `reverseLayout`
// flips the order of the lazy list's items, but NOT the content inside one item:
// this Column still lays out top-to-bottom, so the divisor must be composed
// before the bubble. Composing it after put the header below its own message —
// i.e. visually heading the NEXT (newer) message while showing this one's date,
// which is why a "Jul 1, 2025" header sat on top of a Sep 23 bubble.
NewDateOrSubjectDivisor(older, item)
// Per-relay paging markers for the gap toward the next-older message. Older items sit
// ABOVE newer ones under `reverseLayout`, so that gap is the space above this bubble —
// which means these belong before it, for the same reason the divisor does. Composed
// after the bubble they rendered in the gap toward the NEWER message, contradicting the
// bounds they are handed.
markersInGap?.invoke(
item.event?.createdAt,
older?.event?.createdAt,
)
ChatroomMessageCompose(
baseNote = item,
routeForLastRead = routeForLastRead,
@@ -245,19 +268,7 @@ fun ChatFeedLoaded(
onHighlightFinished = { highlightedNoteId.value = null },
groupPosition = watchChatGroupPosition(newer, item, older),
previousNoteId = older?.idHex,
)
NewDateOrSubjectDivisor(items.list.getOrNull(index + 1), item)
// Per-relay paging markers belonging in the gap toward the next-older message. With the
// reverse layout this draws just above the message (the older side), so a relay's marker
// appears right below the oldest message it has reached and slides down as it pages.
markersInGap?.invoke(
item.event?.createdAt,
items.list
.getOrNull(index + 1)
?.event
?.createdAt,
onWantsToEditBuzz = onWantsToEditBuzz,
)
}
}

View File

@@ -92,6 +92,7 @@ import com.vitorpamplona.amethyst.ui.theme.SmallishBorder
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.reactionBox
import com.vitorpamplona.amethyst.ui.theme.selectedReactionBoxModifier
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.nip01Core.core.Event
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -119,6 +120,7 @@ fun ChatMessageActionSheet(
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
onWantsToEditBuzz: ((Note) -> Unit)? = null,
) {
var showShareSheet by remember { mutableStateOf(false) }
var wantsToEditPost by remember { mutableStateOf(false) }
@@ -265,6 +267,23 @@ fun ChatMessageActionSheet(
// Stage one: the primary chat action (reply / edit draft) is always shown.
ChatOnlyRow(note, state, onWantsToReply, onWantsToEditDraft, onDismiss)
// Buzz: edit my own kind-40002 stream message (publishes a kind-40003 edit).
// A 40002 event is inherently a Buzz message, so the type alone is the gate;
// authorship restricts it to my own messages.
val canEditBuzz =
onWantsToEditBuzz != null &&
note.event is StreamMessageV2Event &&
note.author?.pubkeyHex == accountViewModel.userProfile().pubkeyHex
if (canEditBuzz) {
SectionDivider()
TileRow {
ActionTile(MaterialSymbols.Edit, stringRes(R.string.buzz_edit_message)) {
onWantsToEditBuzz!!(note)
onDismiss()
}
}
}
val handlers =
NoteActionHandlers(
onShare = { showShareSheet = true },

View File

@@ -56,6 +56,11 @@ import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.DisplayZapSplits
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatBubbleLayout
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatGroupPosition
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzActivityRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzDiff
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzEditedNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzForumVote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzSystemMessage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChannelAdminSystemMessage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatRaid
@@ -65,8 +70,13 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderEncr
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderMarmotEncryptedMedia
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderRegularTextNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasMip04Media
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.isBuzzActivityRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeBuzzEdit
import com.vitorpamplona.amethyst.ui.theme.ReactionRowZapraiser
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent
@@ -104,6 +114,9 @@ fun ChatroomMessageCompose(
// reply quotes inside a DM, where the target is simply older than the loaded window (see
// LoadingReplyNote). Null keeps the default blank for every other caller.
onBlank: (@Composable () -> Unit)? = null,
// Buzz-only: edit my own kind-40002 stream message (publishes a 40003 edit). Null for
// every non-Buzz chat surface, which hides the action.
onWantsToEditBuzz: ((Note) -> Unit)? = null,
) {
// Re-skin inline `nostr:...` quotes for everything inside this bubble: a quoted
// chat message renders with the chat reply design instead of the quoted-note card.
@@ -129,6 +142,19 @@ fun ChatroomMessageCompose(
RenderChatClip(baseNote, accountViewModel, nav)
} else if (event is ChannelCreateEvent || event is ChannelMetadataEvent) {
RenderChannelAdminSystemMessage(baseNote, accountViewModel, nav)
} else if (event is SystemMessageEvent) {
// Buzz kind-40099: relay-signed room narration (join/leave/topic).
RenderBuzzSystemMessage(baseNote)
} else if (event is StreamMessageDiffEvent) {
// Buzz kind-40008: a code/text diff pushed into the channel.
RenderBuzzDiff(baseNote)
} else if (event is ForumVoteEvent) {
// Buzz kind-45002: a forum up/down vote.
RenderBuzzForumVote(baseNote)
} else if (isBuzzActivityRow(event)) {
// Buzz agent-job (43xxx) and huddle (48xxx) lifecycle narration. Huddles
// especially must be caught here — their content is JSON, not chat text.
RenderBuzzActivityRow(baseNote)
} else {
NormalChatNote(
baseNote,
@@ -145,6 +171,7 @@ fun ChatroomMessageCompose(
onHighlightFinished,
groupPosition,
previousNoteId,
onWantsToEditBuzz,
)
}
}
@@ -175,6 +202,7 @@ fun NormalChatNote(
onHighlightFinished: (() -> Unit)? = null,
groupPosition: ChatGroupPosition = ChatGroupPosition.SINGLE,
previousNoteId: HexKey? = null,
onWantsToEditBuzz: ((Note) -> Unit)? = null,
) {
// A geohash chat renders "as" its anonymous per-cell identity (and the account, when posting as
// self); LocalChatActingIdentities lets the renderer treat those pubkeys as "me" (alignment,
@@ -305,6 +333,7 @@ fun NormalChatNote(
onDismiss = onDismiss,
accountViewModel = accountViewModel,
nav = nav,
onWantsToEditBuzz = onWantsToEditBuzz,
)
},
reactionsRow =
@@ -582,7 +611,17 @@ fun NoteRow(
note.event is DraftWrapEvent -> RenderDraftEvent(note, canPreview, innerQuote, onWantsToReply, onWantsToEditDraft, bgColor, accountViewModel, nav)
note.event is ChatMessageEncryptedFileHeaderEvent -> RenderEncryptedFile(note, bgColor, accountViewModel, nav)
hasMip04Media(note.event) -> RenderMarmotEncryptedMedia(note, bgColor, accountViewModel, nav)
else -> RenderRegularTextNote(note, canPreview, innerQuote, bgColor, accountViewModel, nav)
else -> {
// Buzz channels overlay kind-40003 edits on their messages: when one
// exists, render the newest edit's content instead of the stale
// original. Null for every non-Buzz chat surface.
val buzzEdit = observeBuzzEdit(note)
if (buzzEdit != null) {
RenderBuzzEditedNote(note, buzzEdit, canPreview, innerQuote, bgColor, accountViewModel, nav)
} else {
RenderRegularTextNote(note, canPreview, innerQuote, bgColor, accountViewModel, nav)
}
}
}
}
}

View File

@@ -0,0 +1,268 @@
/*
* 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.feed.types
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatSystemMessage
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleEndedEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantJoinedEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantLeftEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleStartedEvent
import com.vitorpamplona.quartz.buzz.jobs.JobAcceptedEvent
import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent
import com.vitorpamplona.quartz.buzz.jobs.JobErrorEvent
import com.vitorpamplona.quartz.buzz.jobs.JobProgressEvent
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
/**
* Observes the newest kind-40003 edit overlaying [note], recomposing when new edits
* arrive. Returns null when the message is unedited or has no channel scope.
*
* Resolution goes through [BuzzWorkspaceStates] keyed by the note's `h` channel id
* (a Buzz UUID) rather than any channel object: the state exists independently of
* when — or whether — the channel materialized, so a row composed before the first
* edit arrived still starts rendering overlays the moment one lands.
*/
@Composable
fun observeBuzzEdit(note: Note): Note? {
val channelId = remember(note) { note.event?.groupId() } ?: return null
val state = remember(channelId) { BuzzWorkspaceStates.getOrCreate(channelId) }
// Subscribing to the version counter is what re-runs editFor on new arrivals.
val version by state.editUpdates.collectAsState()
return remember(note, version) { state.editFor(note.idHex) }
}
/**
* A Buzz stream message whose content has been superseded by a kind-40003 edit:
* renders the NEWEST edit's content (never the stale original) plus an "(edited)"
* marker, mirroring Buzz's own last-write-wins presentation.
*/
@Composable
fun RenderBuzzEditedNote(
note: Note,
editNote: Note,
canPreview: Boolean,
innerQuote: Boolean,
bgColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
// The edit note may still be loading; fall back to the original rendering rather
// than committing to an edited branch that would show a blank row.
val content = editNote.event?.content
if (content == null) {
RenderRegularTextNote(note, canPreview, innerQuote, bgColor, accountViewModel, nav)
return
}
val tags = remember(note.event) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }
Column {
TranslatableRichTextViewer(
content = content,
canPreview = canPreview,
quotesLeft = if (innerQuote) 0 else 1,
modifier = Modifier,
tags = tags,
backgroundColor = bgColor,
id = note.idHex,
callbackUri = note.toNostrUri(),
authorPubKey = note.author?.pubkeyHex,
accountViewModel = accountViewModel,
nav = nav,
)
Text(
text = stringRes(R.string.buzz_message_edited),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 10.sp,
)
}
}
/**
* A Buzz kind-40099 system message ("X joined", "channel created", "topic changed"):
* narrates the room rather than speaking in it, so it renders as a centered system
* line like the NIP-28 admin events, from the relay-signed JSON payload.
*/
@Composable
fun RenderBuzzSystemMessage(note: Note) {
val event = note.event as? SystemMessageEvent ?: return
// Relay-emitted machine text (join/leave/topic); shown as-is rather than through
// string resources — the payload vocabulary is Buzz's, not ours to translate yet.
val text =
remember(event) {
val payload = event.payload()
when (payload?.type) {
"topic_changed" -> payload.topic?.let { "topic: $it" } ?: "topic changed"
"purpose_changed" -> payload.purpose?.let { "purpose: $it" } ?: "purpose changed"
else -> payload?.type?.replace('_', ' ')
} ?: event.content.take(120)
}
ChatSystemMessage(text = text)
}
/**
* True for the Buzz agent-job and huddle lifecycle kinds that [RenderBuzzActivityRow]
* narrates as a centered system line rather than a chat bubble. Huddle events in
* particular MUST be caught here — their `content` is JSON, so rendering them as a plain
* chat message would show raw `{"ephemeral_channel_id":…}`.
*/
fun isBuzzActivityRow(event: Event?): Boolean =
event is JobRequestEvent ||
event is JobAcceptedEvent ||
event is JobProgressEvent ||
event is JobResultEvent ||
event is JobCancelEvent ||
event is JobErrorEvent ||
event is HuddleStartedEvent ||
event is HuddleParticipantJoinedEvent ||
event is HuddleParticipantLeftEvent ||
event is HuddleEndedEvent
/**
* Narrates a Buzz agent-job or huddle lifecycle event as a centered system line. The
* label is derived from the kind; job progress/result/error also append a short content
* snippet (the human-readable status/result/error the agent wrote).
*/
@Composable
fun RenderBuzzActivityRow(note: Note) {
val event = note.event ?: return
val text =
remember(event) {
when (event) {
is JobRequestEvent -> "⚙ job requested" + event.request().snippet()
is JobAcceptedEvent -> "⚙ job accepted"
is JobProgressEvent -> "⚙ job progress" + (event.status()?.let { ": $it" } ?: "") + event.content.snippet()
is JobResultEvent -> "⚙ job result" + event.result().snippet()
is JobCancelEvent -> "⚙ job cancelled"
is JobErrorEvent -> "⚠ job error" + event.error().snippet()
is HuddleStartedEvent -> "🔊 huddle started"
is HuddleParticipantJoinedEvent -> "🔊 someone joined the huddle"
is HuddleParticipantLeftEvent -> "🔊 someone left the huddle"
is HuddleEndedEvent -> "🔊 huddle ended"
else -> event.content.take(120)
}
}
ChatSystemMessage(text = text)
}
/** A short one-line snippet of free-text content appended after a label, or "" if blank. */
private fun String.snippet(): String {
val oneLine = trim().replace('\n', ' ')
if (oneLine.isEmpty()) return ""
return ": " + if (oneLine.length > 80) oneLine.take(80) + "" else oneLine
}
/**
* A Buzz kind-40008 stream diff: a code/text diff pushed into the channel. Renders the
* repo/commit/file header from the `DiffMeta` tags plus the diff body in a monospace,
* horizontally-scrollable block, rather than the plain-text fallback (which mangles the
* `+`/`-` gutters into wrapped prose).
*/
@Composable
fun RenderBuzzDiff(note: Note) {
val event = note.event as? StreamMessageDiffEvent ?: return
val meta = remember(event) { event.diffMeta() }
Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(modifier = Modifier.padding(12.dp)) {
val header =
remember(meta) {
meta?.let {
buildString {
it.filePath?.let(::append)
it.language?.let { lang -> append(if (isEmpty()) lang else " · $lang") }
it.prNumber?.let { pr -> append(" · PR #$pr") }
it.commitSha
?.takeIf { sha -> sha.isNotBlank() }
?.let { sha -> append(" · ${sha.take(8)}") }
}.ifBlank { null }
}
}
if (header != null) {
Text(
text = header,
style = MaterialTheme.typography.labelMedium,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = event.content,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
)
if (meta?.truncated == true) {
Text(
text = stringRes(R.string.buzz_diff_truncated),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 10.sp,
)
}
}
}
}
/** A Buzz kind-45002 forum vote: a lightweight up/down signal, shown as a system line. */
@Composable
fun RenderBuzzForumVote(note: Note) {
val event = note.event as? ForumVoteEvent ?: return
// Content is the vote token ("+"/"-" or similar); show a compact glyph line.
val text = remember(event) { if (event.content.trim().startsWith("-")) "▼ downvoted a post" else "▲ upvoted a post" }
ChatSystemMessage(text = text)
}

View File

@@ -126,6 +126,41 @@ fun ConcordChannelListScreen(
var inviteLink by remember { mutableStateOf<String?>(null) }
var minting by remember { mutableStateOf(false) }
// Prefer the folded metadata name, then the stored community name from the list entry (always
// present from the join/create — this is what shows everywhere else). Fall back to the app name
// only if neither exists (should be unreachable), never as the normal "metadata hasn't folded
// yet" placeholder — that showed "Amy Debug".
val communityName =
state?.metadata?.name
?: session?.entry?.name?.ifBlank { null }
?: stringRes(com.vitorpamplona.amethyst.R.string.app_name)
// Owner from the list entry, not the folded authority: a community whose relays are dead never
// folds a Control Plane, and that is exactly the case where leaving matters most.
val isOwner = session?.entry?.owner == account.signer.pubKey
// Read once here (it is @Composable) so the post-leave navigation can use it from a callback.
val canPop = nav.canPop()
var showLeave by remember { mutableStateOf(false) }
if (showLeave) {
ConcordLeaveDialog(
communityName = communityName,
isOwner = isOwner,
onDismiss = { showLeave = false },
onConfirm = {
showLeave = false
// Fire-and-forget: the list edit is local + a best-effort publish to our own outbox,
// so we never hold the user behind a spinner waiting on a relay that may be dead.
accountViewModel.leaveConcordCommunity(communityId)
// Don't strand the user on the server view of a community they just left. Popping is
// right when we were pushed here; when this community is a bottom-nav root there is
// nothing to pop, so restart the stack on the Concord hub.
if (canPop) nav.popBack() else nav.newStack(Route.Concords)
},
)
}
// Channel create/rename/delete are gated on MANAGE_CHANNELS (or owner) — the same predicate the
// fold enforces, so an unauthorized action would be a silent no-op we shouldn't even offer.
val canManageChannels =
@@ -185,20 +220,10 @@ fun ConcordChannelListScreen(
Scaffold(
topBar = {
TopAppBar(
title = {
// Prefer the folded metadata name, then the stored community name from the list
// entry (always present from the join/create — this is what shows everywhere else).
// Fall back to the app name only if neither exists (should be unreachable), never as
// the normal "metadata hasn't folded yet" placeholder — that showed "Amy Debug".
val title =
state?.metadata?.name
?: session?.entry?.name?.ifBlank { null }
?: stringRes(com.vitorpamplona.amethyst.R.string.app_name)
Text(title, maxLines = 1)
},
title = { Text(communityName, maxLines = 1) },
navigationIcon = {
// Back arrow only when pushed from elsewhere; as a bottom-nav tab the bar takes its place.
if (nav.canPop()) {
if (canPop) {
IconButton(onClick = { nav.popBack() }) {
SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.back))
}
@@ -236,6 +261,27 @@ fun ConcordChannelListScreen(
) {
SymbolIcon(symbol = MaterialSymbols.PersonAdd, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_action))
}
// Overflow, mirroring the NIP-29 relay-group top bar: destructive membership
// actions live behind the menu, never as a one-tap icon.
var menuOpen by remember { mutableStateOf(false) }
IconButton(onClick = { menuOpen = true }) {
SymbolIcon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.more_options))
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
text = {
Text(
stringRes(com.vitorpamplona.amethyst.R.string.concord_leave_community),
color = MaterialTheme.colorScheme.error,
)
},
onClick = {
menuOpen = false
showLeave = true
},
)
}
},
)
},
@@ -472,6 +518,50 @@ private fun rememberConcordDisplayName(
return name
}
/**
* Confirms leaving a community. Deliberately explicit about the blast radius: leaving is a private
* edit of *this account's* kind-13302 list — nobody is told, no roster changes — but it also drops
* the entry that carries the community's keys, so history this account can no longer derive may be
* gone for good. The owner gets an extra line: the entry is the only place their owner salt lives,
* so leaving is what actually retires the community for them.
*/
@Composable
private fun ConcordLeaveDialog(
communityName: String,
isOwner: Boolean,
onDismiss: () -> Unit,
onConfirm: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_leave_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_leave_message, communityName))
if (isOwner) {
Text(
stringRes(com.vitorpamplona.amethyst.R.string.concord_leave_owner_warning),
color = MaterialTheme.colorScheme.error,
)
}
}
},
confirmButton = {
TextButton(onClick = onConfirm) {
Text(
stringRes(com.vitorpamplona.amethyst.R.string.leave),
color = MaterialTheme.colorScheme.error,
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringRes(com.vitorpamplona.amethyst.R.string.cancel))
}
},
)
}
/** A pending channel create ([channelIdHex] null) or rename target. */
private data class ConcordChannelEditor(
val channelIdHex: String?,

View File

@@ -23,9 +23,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.conco
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -38,13 +40,21 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.model.ConcordInviteResult
import com.vitorpamplona.amethyst.ui.components.ConcordInvitePreviewRow
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.concord.cord05Invites.ParsedInviteLink
private sealed interface RedeemState {
/** Showing the local preview, waiting for the user to tap Join. Nothing has been sent. */
data object AwaitingConsent : RedeemState
data object Working : RedeemState
data class Done(
@@ -63,10 +73,25 @@ private sealed interface RedeemState {
}
/**
* Auto-redeems a Concord invite link (deep-link target for [Route.ConcordInvite]).
* On open it fetches + unlocks the bundle, joins the community, and forwards to its
* channel list. On failure it offers a retry, so a transient relay miss doesn't
* strand the user.
* Redeems a Concord invite link (deep-link target for [Route.ConcordInvite]).
*
* **This screen must never act before the user consents.** It is reachable from any
* `https://amethyst.social/invite/…` link on any web page, in any QR code, or in a
* push — i.e. from a URL the user may never have meant to open. Redeeming is a
* side-effecting act: it connects to up to three relay URLs *chosen by whoever minted
* the link* (disclosing the user's IP to them), publishes a Guestbook JOIN signed by
* the user's own identity to those relays, and writes the community into the user's
* private kind-13302 list. Doing that on arrival turned any link into a one-click
* deanonymize-and-enroll primitive, so the screen now opens on a local-only preview
* and only calls [com.vitorpamplona.amethyst.model.Account.joinConcordViaInvite] from
* the Join button.
*
* Everything shown before that tap comes from decoding the URL itself
* ([ConcordActions.parseInviteLink] — pure base64 + NIP-19, no I/O): the link's
* signer key and the bootstrap relays it would contact. The community's *name* lives
* inside the kind-33301 bundle, which only those relays can serve, so it is
* deliberately left unknown rather than fetched — fetching it is precisely the IP
* disclosure this screen exists to gate.
*/
@Composable
fun ConcordInviteScreen(
@@ -74,7 +99,19 @@ fun ConcordInviteScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
var state by remember(link) { mutableStateOf<RedeemState>(RedeemState.Working) }
// Local decode only: base64 fragment + NIP-19 naddr. No relay is contacted here.
val parsed = remember(link) { ConcordActions.parseInviteLink(link) }
var state by
remember(link) {
mutableStateOf<RedeemState>(
if (parsed == null) {
RedeemState.Failed(R.string.concord_invite_failed_invalid, canRetry = false)
} else {
RedeemState.AwaitingConsent
},
)
}
LaunchedEffect(link, state) {
if (state is RedeemState.Working) {
@@ -82,13 +119,15 @@ fun ConcordInviteScreen(
when (val result = accountViewModel.account.joinConcordViaInvite(link)) {
is ConcordInviteResult.Joined -> RedeemState.Done(result.communityId)
is ConcordInviteResult.InvalidLink ->
RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed_invalid, canRetry = false)
RedeemState.Failed(R.string.concord_invite_failed_invalid, canRetry = false)
is ConcordInviteResult.Incompatible ->
RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed_incompatible, canRetry = false)
RedeemState.Failed(R.string.concord_invite_failed_incompatible, canRetry = false)
is ConcordInviteResult.Revoked ->
RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed_revoked, canRetry = false)
RedeemState.Failed(R.string.concord_invite_failed_revoked, canRetry = false)
is ConcordInviteResult.Expired ->
RedeemState.Failed(R.string.concord_invite_failed_expired, canRetry = false)
is ConcordInviteResult.NotReachable ->
RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed, canRetry = true)
RedeemState.Failed(R.string.concord_invite_failed, canRetry = true)
}
}
}
@@ -96,8 +135,8 @@ fun ConcordInviteScreen(
LaunchedEffect(state) {
(state as? RedeemState.Done)?.let { done ->
// Replace this invite screen with the community, dropping it from the back stack. If it
// stayed, Back from the community would land on the auto-redeeming spinner, which would
// immediately re-join and forward here again — trapping the user in a Back→forward loop.
// stayed, Back from the community would land on a consent screen for a community the
// user has already joined — a dead end offering to re-do what just happened.
nav.popUpTo(Route.ConcordServer(done.communityId), Route.ConcordInvite::class)
}
}
@@ -108,10 +147,19 @@ fun ConcordInviteScreen(
horizontalAlignment = Alignment.CenterHorizontally,
) {
when (state) {
is RedeemState.AwaitingConsent ->
parsed?.let {
ConcordInviteConsent(
parsed = it,
accountViewModel = accountViewModel,
onJoin = { state = RedeemState.Working },
)
}
is RedeemState.Working -> {
CircularProgressIndicator()
Text(
stringRes(com.vitorpamplona.amethyst.R.string.concord_redeeming_invite),
stringRes(R.string.concord_redeeming_invite),
modifier = Modifier.padding(top = 16.dp),
textAlign = TextAlign.Center,
)
@@ -129,7 +177,7 @@ fun ConcordInviteScreen(
onClick = { state = RedeemState.Working },
modifier = Modifier.padding(top = 16.dp),
) {
Text(stringRes(com.vitorpamplona.amethyst.R.string.retry))
Text(stringRes(R.string.retry))
}
}
}
@@ -138,3 +186,55 @@ fun ConcordInviteScreen(
}
}
}
/**
* The pre-consent preview. Renders only what the URL itself decodes to — the link
* signer (used as the avatar seed) and the bootstrap relays the join would contact —
* plus a plain-language statement of what tapping Join will do. It performs **no**
* network I/O: the community name would require fetching the bundle from those very
* relays, which is the IP disclosure the consent gate exists to prevent, so it shows
* an explicit "name unknown until you join" instead.
*/
@Composable
private fun ConcordInviteConsent(
parsed: ParsedInviteLink,
accountViewModel: AccountViewModel,
onJoin: () -> Unit,
) {
val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle()
val relayList = remember(parsed) { parsed.fragment.relays.joinToString(", ") }
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
ConcordInvitePreviewRow(
robotSeed = parsed.linkSignerPubKey,
title = stringRes(R.string.concord_invite_card_subtitle),
subtitle = stringRes(R.string.concord_invite_preview_unknown_name),
accountViewModel = accountViewModel,
autoPlayGif = autoPlayGif,
)
}
Text(
stringRes(R.string.concord_invite_preview_explainer),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 20.dp),
)
if (relayList.isNotEmpty()) {
Text(
stringRes(R.string.concord_invite_preview_relays, relayList),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 12.dp),
)
}
Button(
onClick = onJoin,
modifier = Modifier.padding(top = 24.dp),
) {
Text(stringRes(R.string.concord_invite_card_join))
}
}

View File

@@ -20,9 +20,11 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -30,6 +32,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -43,6 +46,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -124,12 +128,28 @@ fun ConcordMembersScreen(
.minByOrNull { r -> r.position }
?.name
?.takeIf { n -> n.isNotBlank() }
RosterEntry(it, ConcordMembership.of(authority, it), roleName)
RosterEntry(it, ConcordMembership.of(authority, it), roleName, authority.rolesOf(it))
}.sortedWith(compareBy({ it.membership.sortRank() }, { it.pubkey }))
}
val iAmOwner = state?.authority?.isOwner(myPubKey) == true
val iCanBan = state?.let { it.authority.isOwner(myPubKey) || it.authority.effectivePermissions(myPubKey).has(ConcordPermissions.BAN) } == true
val iCanManageRoles = state?.authority?.hasPermission(myPubKey, ConcordPermissions.MANAGE_ROLES) == true
// The roles this viewer may actually hand out. The fold drops a grant whose granter does
// not *strictly* outrank every assigned role, so offering a role at or above our own
// position would publish an edition that every client then silently discards. The owner
// sits at rank 0 and no role may claim position 0, so this admits everything for them.
val assignableRoles =
remember(state, myPubKey) {
val authority = state?.authority ?: return@remember emptyList<AssignableRole>()
val myRank = authority.rank(myPubKey) ?: return@remember emptyList()
authority
.roles()
.filter { (_, role) -> myRank < role.position }
.map { (id, role) -> AssignableRole(id, role.name, role.position) }
.sortedBy { it.position }
}
Scaffold(
topBar = {
@@ -168,6 +188,19 @@ fun ConcordMembersScreen(
isSelf = entry.pubkey.equals(myPubKey, ignoreCase = true),
viewerIsOwner = iAmOwner,
viewerCanBan = iCanBan,
// Ban/Remove are rank-gated the same way Roles… is. The owner short-circuits
// because canActOn begins at hasPermission, which is false while banned, and
// a rogue BAN holder can currently banlist the owner — see the note on
// Account.concordBanTarget.
canBanTarget =
iAmOwner ||
state?.authority?.canActOn(myPubKey, entry.pubkey, ConcordPermissions.BAN) == true,
viewerCanManageRoles = iCanManageRoles,
// canActOn folds the whole rank rule for us: we hold MANAGE_ROLES, we're not
// banned, the target isn't the owner (unremovable), and we strictly outrank
// them — which also rules out acting on ourselves (equal cannot act on equal).
canManageRolesOnTarget = state?.authority?.canActOn(myPubKey, entry.pubkey, ConcordPermissions.MANAGE_ROLES) == true,
assignableRoles = assignableRoles,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -185,6 +218,10 @@ private fun ConcordMemberRow(
isSelf: Boolean,
viewerIsOwner: Boolean,
viewerCanBan: Boolean,
canBanTarget: Boolean,
viewerCanManageRoles: Boolean,
canManageRolesOnTarget: Boolean,
assignableRoles: List<AssignableRole>,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -193,13 +230,38 @@ private fun ConcordMemberRow(
val isBanned = entry.membership == ConcordMembership.BANNED
val isAdmin = entry.membership == ConcordMembership.ADMIN
// Owner can promote/demote anyone but the owner; ban is available to owner + BAN holders,
// never against the owner or yourself. A banned user only offers "unban".
// Owner can promote/demote anyone but the owner; ban is available to owner + BAN holders that
// strictly outrank the target, never against the owner or yourself. A banned user only offers
// "unban" — and unban is rank-gated too, so whoever cannot ban you cannot lift your ban either.
val canToggleAdmin = viewerIsOwner && !isOwnerTarget && !isBanned && !isSelf
val canBan = viewerCanBan && !isOwnerTarget && !isSelf
val canBan = viewerCanBan && canBanTarget && !isOwnerTarget && !isSelf
// Hard removal (CORD-06 Refounding) rotates the community key; same authority as ban.
val canRemove = viewerCanBan && !isOwnerTarget && !isSelf
val hasMenu = canToggleAdmin || canBan || canRemove
val canRemove = viewerCanBan && canBanTarget && !isOwnerTarget && !isSelf
// Shown to any MANAGE_ROLES holder, but disabled with a reason when this particular
// member (or every defined role) is out of our reach — a grant we don't outrank
// publishes fine and is then dropped by every client's fold, so a silently no-op
// control would be worse than none. The owner's own row never offers it: the owner
// is unremovable and outranks everyone, so canManageRolesOnTarget is false there.
val rolesBlockedReason =
when {
!canManageRolesOnTarget -> stringRes(R.string.concord_members_roles_out_of_reach)
assignableRoles.isEmpty() -> stringRes(R.string.concord_members_roles_none_assignable)
else -> null
}
val hasMenu = canToggleAdmin || canBan || canRemove || viewerCanManageRoles
var editRoles by remember { mutableStateOf(false) }
if (editRoles) {
ConcordRolesDialog(
assignable = assignableRoles,
current = entry.roleIds,
onConfirm = { selected ->
accountViewModel.setConcordRoles(communityId, entry.pubkey, selected)
editRoles = false
},
onDismiss = { editRoles = false },
)
}
var confirmRemove by remember { mutableStateOf(false) }
if (confirmRemove) {
@@ -212,7 +274,7 @@ private fun ConcordMemberRow(
)
}
androidx.compose.foundation.layout.Row(
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
@@ -241,6 +303,27 @@ private fun ConcordMemberRow(
},
)
}
if (viewerCanManageRoles) {
DropdownMenuItem(
text = {
Column {
Text(stringRes(R.string.concord_members_roles))
rolesBlockedReason?.let {
Text(
it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
enabled = rolesBlockedReason == null,
onClick = {
editRoles = true
expanded = false
},
)
}
if (canBan) {
DropdownMenuItem(
text = { Text(stringRes(if (isBanned) R.string.concord_members_unban else R.string.concord_members_ban)) },
@@ -298,6 +381,64 @@ private fun MemberBadge(
}
}
/**
* Multi-select over the roles the viewer may assign (CORD-04 role grant).
*
* A grant REPLACES the member's role set rather than merging into it, so the box starts
* checked on everything they already hold — otherwise saving would silently strip the
* roles that weren't re-checked. Every currently-held role is guaranteed to appear in
* [assignable]: the caller only opens this when it strictly outranks the member, and the
* member's rank is the *lowest* position they hold, so all of their roles sit strictly
* below us too. Like "Make admin", saving applies immediately — no extra confirmation.
*/
@Composable
private fun ConcordRolesDialog(
assignable: List<AssignableRole>,
current: Set<String>,
onConfirm: (List<String>) -> Unit,
onDismiss: () -> Unit,
) {
val selected = remember(current) { mutableStateListOf<String>().apply { addAll(current) } }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(R.string.concord_members_roles_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
stringRes(R.string.concord_members_roles_message),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
assignable.forEach { role ->
val checked = role.id in selected
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable {
if (checked) selected.remove(role.id) else selected.add(role.id)
}.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Checkbox(checked = checked, onCheckedChange = null)
Text(role.name.ifBlank { role.id.take(8) }, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
},
confirmButton = {
TextButton(onClick = { onConfirm(selected.toList()) }) {
Text(stringRes(R.string.concord_members_roles_save))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) }
},
)
}
/** Confirms a hard removal — spells out that it rotates the community key (CORD-06). */
@Composable
private fun ConcordRemoveMemberDialog(
@@ -324,6 +465,15 @@ private class RosterEntry(
val membership: ConcordMembership,
/** The member's most-privileged role name (e.g. "Admin"/"Moderator"), null for a plain member. */
val roleName: String?,
/** Every role id the member currently holds — the preselection for the role picker. */
val roleIds: Set<String>,
)
/** One role the viewer is allowed to hand out, ordered by [position] (lower ranks higher). */
private class AssignableRole(
val id: String,
val name: String,
val position: Long,
)
/** Owner first, then admins, then plain members, then banned last. */

View File

@@ -22,45 +22,34 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datas
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_METADATA_KINDS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_PIN_KINDS
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent
/** Relay-signed group directory kinds: metadata + admins + members + roles + pins. */
private val RELAY_GROUP_METADATA_KINDS =
listOf(
GroupMetadataEvent.KIND,
GroupAdminsEvent.KIND,
GroupMembersEvent.KIND,
SupportedRolesEvent.KIND,
GroupPinnedEvent.KIND,
)
/**
* The relay-signed metadata for a NIP-29 group (name/picture/about + admin,
* member and role lists), addressed by the group id (`d` tag) and pinned to the
* group's host relay. The relay signs these with its own key, so a single-relay
* query scoped by `#d` returns exactly this group's directory.
*
* The 39000-39003 metadata block and the 39005 pin list go out as **two separate filters**: relay29-family
* relays (0xchat's included) reject a filter that mixes them and drop the whole REQ, which would leave the
* group with no name, no roster and no membership. See
* [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_PIN_KINDS].
*/
fun filterRelayGroupState(
channel: RelayGroupChannel,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val relays = channel.relays().toSet()
val scope = mapOf("d" to listOf(channel.groupId.id))
val directory =
relays.map {
RelayBasedFilter(
relay = it,
filter =
Filter(
kinds = RELAY_GROUP_METADATA_KINDS,
tags = mapOf("d" to listOf(channel.groupId.id)),
since = since?.get(it)?.time,
),
relays.flatMap {
val floor = since?.get(it)?.time
listOf(
RelayBasedFilter(relay = it, filter = Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = scope, since = floor)),
RelayBasedFilter(relay = it, filter = Filter(kinds = RELAY_GROUP_PIN_KINDS, tags = scope, since = floor)),
)
}

View File

@@ -0,0 +1,187 @@
/*
* 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.animation.AnimatedVisibility
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
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.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.delay
/**
* A live "… is typing" row for a Buzz workspace channel, driven by the ephemeral
* kind-20002 heartbeats collected into [BuzzTypingState]. Placed just above the composer,
* it slides in when someone starts typing and out when they stop (heartbeats age out of
* the freshness window). Own typing is filtered by [myPubkey].
*
* The three animated dots are a small modern flourish; the wording collapses to a count
* once more than two people type at once so the row never overflows.
*/
@Composable
fun BuzzTypingIndicator(
channelId: String,
myPubkey: HexKey,
accountViewModel: AccountViewModel,
) {
val typingByChannel by BuzzTypingState.flow.collectAsStateWithLifecycle()
// Re-evaluate on a light timer so typists fade out when their heartbeats go stale,
// but only while this channel actually has any — an idle channel never wakes the loop.
var nowSecs by remember { mutableLongStateOf(TimeUtils.now()) }
val raw = typingByChannel[channelId]
LaunchedEffect(channelId, raw) {
if (raw.isNullOrEmpty()) return@LaunchedEffect
while (true) {
nowSecs = TimeUtils.now()
if (raw.values.none { nowSecs - it <= BuzzTypingState.TYPING_STALE_SECS }) break
delay(2000L)
}
}
val typers =
remember(raw, myPubkey, nowSecs) {
(raw ?: emptyMap())
.filterKeys { it != myPubkey }
.filterValues { nowSecs - it <= BuzzTypingState.TYPING_STALE_SECS }
.keys
.sorted()
}
AnimatedVisibility(
visible = typers.isNotEmpty(),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically,
) {
TypingDots()
Spacer(Modifier.width(8.dp))
Text(
text = typingLabel(typers, accountViewModel),
style = MaterialTheme.typography.labelSmall,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
)
}
}
}
@Composable
private fun typingLabel(
typers: List<HexKey>,
accountViewModel: AccountViewModel,
): String =
when (typers.size) {
1 -> stringRes(R.string.buzz_typing_one, rememberTypistName(typers[0], accountViewModel))
2 ->
stringRes(
R.string.buzz_typing_two,
rememberTypistName(typers[0], accountViewModel),
rememberTypistName(typers[1], accountViewModel),
)
else -> stringRes(R.string.buzz_typing_many)
}
/** Three dots that bounce in a staggered wave — a lightweight, always-on micro-animation. */
@Composable
private fun TypingDots() {
val transition = rememberInfiniteTransition(label = "buzz-typing")
Row(horizontalArrangement = Arrangement.spacedBy(3.dp), verticalAlignment = Alignment.CenterVertically) {
repeat(3) { index ->
val phase by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec =
infiniteRepeatable(
animation = tween(durationMillis = 900, delayMillis = index * 150),
repeatMode = RepeatMode.Reverse,
),
label = "dot-$index",
)
Spacer(
modifier =
Modifier
.size(5.dp)
.graphicsLayer {
translationY = -3f * phase
val s = 0.7f + 0.3f * phase
scaleX = s
scaleY = s
}.alpha(0.4f + 0.6f * phase)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
)
}
}
}
/** Resolves [hex] to its best display name, reactively, falling back to a short hex. */
@Composable
private fun rememberTypistName(
hex: HexKey,
accountViewModel: AccountViewModel,
): String {
val user = remember(hex) { accountViewModel.checkGetOrCreateUser(hex) } ?: return remember(hex) { hex.take(8) }
val info by observeUserInfo(user, accountViewModel)
return info?.info?.bestName() ?: remember(user) { user.pubkeyDisplayHex() }
}

View File

@@ -53,6 +53,7 @@ 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.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
@@ -101,13 +102,13 @@ fun RelayGroupChannelListScreen(
// updates as directory events arrive with no polling. The initial value is sorted too
// so the first frame doesn't reshuffle when the first emission arrives.
val allChannels by produceState(
initialValue = accountViewModel.getRelayGroupChannelsOnRelay(relay).sortedBy { it.toBestDisplayName().lowercase() },
initialValue = accountViewModel.getRelayGroupChannelsOnRelay(relay).sortedBySnapshot { it.toBestDisplayName().lowercase() },
relay,
) {
LocalCache
.observeEvents<GroupMetadataEvent>(Filter(kinds = listOf(GroupMetadataEvent.KIND)))
.collect {
value = accountViewModel.getRelayGroupChannelsOnRelay(relay).sortedBy { it.toBestDisplayName().lowercase() }
value = accountViewModel.getRelayGroupChannelsOnRelay(relay).sortedBySnapshot { it.toBestDisplayName().lowercase() }
}
}

View File

@@ -40,6 +40,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
@@ -216,6 +217,7 @@ private fun ChannelView(
avoidDraft = newPostModel.draftTag,
onWantsToReply = newPostModel::reply,
onWantsToEditDraft = newPostModel::editFromDraft,
onWantsToEditBuzz = newPostModel::editBuzzMessage,
jumpToNoteId = jumpToNoteId,
onJumpHandled = { jumpToNoteId.value = null },
// A status card at the oldest end: what it's reaching for while paging, "All caught up" when dry.
@@ -251,6 +253,17 @@ private fun ChannelView(
)
}
// Buzz workspaces surface a live "… is typing" row just above the composer, fed by
// ephemeral kind-20002 heartbeats. Only rendered on a Buzz-dialect relay (the kind is
// Buzz-only); a no-op otherwise.
if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) {
BuzzTypingIndicator(
channelId = channel.groupId.id,
myPubkey = accountViewModel.userProfile().pubkeyHex,
accountViewModel = accountViewModel,
)
}
Spacer(modifier = DoubleVertSpacer)
// NIP-29 relays reject writes from non-members, so only show the composer when the

View File

@@ -65,6 +65,7 @@ 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.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
@@ -467,8 +468,8 @@ private fun pickCandidates(
.asSequence()
.filter { it.groupId.id !in forbidden }
.filter { it.event != null && isRelaySignedRelayGroup(it, relayInfo) }
.sortedBy { it.toBestDisplayName().lowercase() }
.toList()
.sortedBySnapshot { it.toBestDisplayName().lowercase() }
/**
* The set of group ids reachable as descendants of [rootId] on [relay], following each group's

View File

@@ -37,6 +37,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -50,6 +51,8 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
@@ -164,6 +167,24 @@ fun RelayGroupTopBar(
)
}
// Buzz workspace canvas (kind 40100): shown only on a Buzz-dialect relay once
// a canvas has arrived for this channel. Observing canvasUpdates flips it on
// the moment the first canvas is consumed, without swapping the channel object.
if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) {
val canvasState = remember(channel.groupId.id) { BuzzWorkspaceStates.getOrCreate(channel.groupId.id) }
val canvasVersion by canvasState.canvasUpdates.collectAsState()
val hasCanvas = remember(canvasVersion) { canvasState.canvasNote != null }
if (hasCanvas) {
IconButton(onClick = { nav.nav(Route.BuzzCanvas(channel.groupId.id)) }) {
Icon(
symbol = MaterialSymbols.Dashboard,
contentDescription = stringRes(R.string.buzz_canvas_title),
modifier = Modifier.size(20.dp),
)
}
}
}
val naddr = channel.toNAddr()
if (naddr != null) {
val context = LocalContext.current

View File

@@ -20,6 +20,25 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource
import com.vitorpamplona.quartz.buzz.forum.ForumCommentEvent
import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent
import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleEndedEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantJoinedEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantLeftEvent
import com.vitorpamplona.quartz.buzz.huddles.HuddleStartedEvent
import com.vitorpamplona.quartz.buzz.jobs.JobAcceptedEvent
import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent
import com.vitorpamplona.quartz.buzz.jobs.JobErrorEvent
import com.vitorpamplona.quartz.buzz.jobs.JobProgressEvent
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
import com.vitorpamplona.quartz.buzz.stream.CanvasEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -45,19 +64,98 @@ import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
* See amethyst/plans/2026-07-18-nip29-group-chat-subscriptions.md and the companion test plan.
*/
/** Relay-signed group *state*: metadata + admins + members + roles + pins. Small replaceable events. */
val RELAY_GROUP_STATE_KINDS =
/**
* The relay's **directory** kinds for a group — metadata + admins + members + roles (39000-39003).
* These four are what NIP-29 relays treat as a group's "metadata" block, and they must be requested
* **alone**: see [RELAY_GROUP_PIN_KINDS].
*/
val RELAY_GROUP_METADATA_KINDS =
listOf(
GroupMetadataEvent.KIND,
GroupAdminsEvent.KIND,
GroupMembersEvent.KIND,
SupportedRolesEvent.KIND,
GroupPinnedEvent.KIND,
)
/**
* The pin list (39005), deliberately kept in its **own** filter rather than merged into
* [RELAY_GROUP_METADATA_KINDS].
*
* NIP-29 relays derived from `relay29`/`khatru29` (0xchat's `groups.0xchat.com` among them) reject a REQ
* whose filter mixes the 39000-39003 metadata kinds with any other kind, replying
* `CLOSED … "blocked: it's not allowed to mix metadata kinds with others"`. A single filter asking for
* 39000-39003 **plus** 39005 is therefore dropped **whole** — the group never resolves its name, roster,
* roles or the user's own membership, so it renders as a raw id and offers "Join" to somebody the relay
* already lists as an admin.
*
* Splitting into two filter objects fixes it: those relays evaluate the rule per filter, so the
* metadata filter is served normally and the pins filter is served (or harmlessly ignored) on its own.
*/
val RELAY_GROUP_PIN_KINDS = listOf(GroupPinnedEvent.KIND)
/**
* Every relay-signed group *state* kind: metadata + admins + members + roles + pins. Small replaceable
* events. **Never put this list on the wire as one filter** — request [RELAY_GROUP_METADATA_KINDS] and
* [RELAY_GROUP_PIN_KINDS] as separate filters instead (see [RELAY_GROUP_PIN_KINDS]). Kept as the
* semantic "all state kinds" set for cache/consume-side code.
*/
val RELAY_GROUP_STATE_KINDS = RELAY_GROUP_METADATA_KINDS + RELAY_GROUP_PIN_KINDS
/** Timeline kinds shown in a group's chat — chat messages and polls. */
val RELAY_GROUP_TIMELINE_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND)
/**
* Extra timeline kinds a `block/buzz` workspace relay serves in the same `h`-scoped
* channels, requested UNCONDITIONALLY alongside the NIP-29 set — on a vanilla relay the
* kinds simply match nothing. A dialect-gated version was tried and reverted: gating
* creates a bootstrap hole (nothing asks for a Buzz kind until one is consumed) and a
* worse one — history pages fetched before the mark advance their cursors past ranges
* queried WITHOUT Buzz kinds, permanently skipping older workspace messages.
*
* All are `h`-scoped (`GroupIdTag`), so the same `#h` group REQ returns them:
* - stream messages v2 (40002), edits (40003), diffs (40008), system rows (40099), canvas (40100)
* - forum posts/votes/comments (45001-45003)
* - agent jobs (43001-43006)
* - huddle lifecycle (48100-48103)
*
* Consumption for every one of these already exists in `LocalCache` (see
* `consumeBuzzTimelineEvent`); requesting them here is what lets them actually arrive for
* a group feed instead of only appearing if another subscription happened to fetch them.
*/
val BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS =
listOf(
StreamMessageV2Event.KIND,
StreamMessageEditEvent.KIND,
StreamMessageDiffEvent.KIND,
SystemMessageEvent.KIND,
CanvasEvent.KIND,
ForumPostEvent.KIND,
ForumVoteEvent.KIND,
ForumCommentEvent.KIND,
JobRequestEvent.KIND,
JobAcceptedEvent.KIND,
JobProgressEvent.KIND,
JobResultEvent.KIND,
JobCancelEvent.KIND,
JobErrorEvent.KIND,
HuddleStartedEvent.KIND,
HuddleParticipantJoinedEvent.KIND,
HuddleParticipantLeftEvent.KIND,
HuddleEndedEvent.KIND,
)
/** The timeline kinds requested for every relay-group REQ (NIP-29 + Buzz; see above). */
val RELAY_GROUP_ALL_TIMELINE_KINDS = RELAY_GROUP_TIMELINE_KINDS + BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS
/**
* Kinds requested on the **open channel's live tail only** — the timeline set plus the
* ephemeral kind-20002 typing indicator. Typing is scoped to the one channel on screen
* (not the whole joined fleet) because it's a live "someone is typing" signal, never
* stored (20000-29999) and never a feed row (`LocalCache` records it into `BuzzTypingState`
* and drops it). It matches nothing on a vanilla relay.
*/
val RELAY_GROUP_OPEN_TAIL_KINDS = RELAY_GROUP_ALL_TIMELINE_KINDS + TypingIndicatorEvent.KIND
/** Forum-thread kinds shown in a group's Threads tab. */
val RELAY_GROUP_THREAD_KINDS = listOf(ThreadEvent.KIND, CommentEvent.KIND)
@@ -69,13 +167,7 @@ val RELAY_GROUP_CARD_WARMUP_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND, Threa
* Narrower than [RELAY_GROUP_STATE_KINDS] on purpose: the directory lists groups, it doesn't need each
* group's pin list.
*/
val RELAY_GROUP_DIRECTORY_KINDS =
listOf(
GroupMetadataEvent.KIND,
GroupAdminsEvent.KIND,
GroupMembersEvent.KIND,
SupportedRolesEvent.KIND,
)
val RELAY_GROUP_DIRECTORY_KINDS = RELAY_GROUP_METADATA_KINDS
/** How many directory entries to pull per relay when browsing its whole group list. */
const val RELAY_GROUP_DIRECTORY_LIMIT = 500
@@ -93,22 +185,21 @@ private fun byHostRelay(joined: Collection<GroupTag>): Map<NormalizedRelayUrl, L
}
/**
* State (39000-39005) for every joined group, **one `#d` filter per host relay** carrying that relay's
* group ids. `since` is per-relay (replaceable events; a reconnect just re-confirms).
* State (39000-39005) for every joined group, **two `#d` filters per host relay** carrying that relay's
* group ids: the 39000-39003 metadata block and the 39005 pin list, kept apart because relay29-family
* relays refuse a filter that mixes them (see [RELAY_GROUP_PIN_KINDS]). `since` is per-relay (replaceable
* events; a reconnect just re-confirms).
*/
fun buildRelayGroupStateFilters(
joined: Collection<GroupTag>,
sinceForRelay: (NormalizedRelayUrl) -> Long?,
): List<RelayBasedFilter> =
byHostRelay(joined).map { (relay, ids) ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = RELAY_GROUP_STATE_KINDS,
tags = mapOf(D_TAG to ids.distinct()),
since = sinceForRelay(relay),
),
byHostRelay(joined).flatMap { (relay, ids) ->
val scope = mapOf(D_TAG to ids.distinct())
val since = sinceForRelay(relay)
listOf(
RelayBasedFilter(relay = relay, filter = Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = scope, since = since)),
RelayBasedFilter(relay = relay, filter = Filter(kinds = RELAY_GROUP_PIN_KINDS, tags = scope, since = since)),
)
}
@@ -126,7 +217,7 @@ fun buildRelayGroupJoinedChatTailFilters(
relay = relay,
filter =
Filter(
kinds = RELAY_GROUP_TIMELINE_KINDS,
kinds = RELAY_GROUP_ALL_TIMELINE_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to ids.distinct()),
since = sinceEpoch,
),
@@ -142,7 +233,7 @@ fun buildRelayGroupOpenChatTailFilter(
relay = groupId.relayUrl,
filter =
Filter(
kinds = RELAY_GROUP_TIMELINE_KINDS,
kinds = RELAY_GROUP_OPEN_TAIL_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)),
since = sinceEpoch,
),
@@ -165,7 +256,7 @@ fun buildRelayGroupHistoryFilters(
relay = relay,
filter =
Filter(
kinds = RELAY_GROUP_TIMELINE_KINDS,
kinds = RELAY_GROUP_ALL_TIMELINE_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)),
until = until,
limit = limit,

View File

@@ -34,6 +34,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
@@ -68,6 +69,11 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaA
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.buzz.threading.buzzThread
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
@@ -85,6 +91,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash
import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag
import com.vitorpamplona.quartz.nip01Core.tags.references.references
@@ -156,6 +163,11 @@ open class ChannelNewMessageViewModel :
// thread comment that opens as a minichat. Only meaningful while replyTo is set.
val replyMode = mutableStateOf(ReplyMode.INLINE)
// When set, the composer is editing an existing Buzz stream message (kind 40002):
// the next send publishes a kind-40003 edit targeting this note instead of a new
// message. Only ever set for own messages on a Buzz-dialect relay (see editBuzzMessage).
val editingBuzzMessage = mutableStateOf<Note?>(null)
var uploadState by mutableStateOf<ChatFileUploadState?>(null)
// Stripping failure dialog
@@ -264,6 +276,26 @@ open class ChannelNewMessageViewModel :
draftTag.newVersion()
}
/**
* Enters Buzz edit mode: pre-fills the composer with [note]'s current text and marks
* the next send as a kind-40003 edit of it. Editing and replying are mutually
* exclusive, so any pending reply is cleared. The caller gates this to the user's own
* kind-40002 messages on a Buzz relay.
*/
fun editBuzzMessage(note: Note) {
replyTo.value = null
replyMode.value = ReplyMode.INLINE
editingBuzzMessage.value = note
message.setTextAndPlaceCursorAtEnd(note.event?.content ?: "")
draftTag.newVersion()
}
fun clearBuzzEdit() {
editingBuzzMessage.value = null
message.setTextAndPlaceCursorAtEnd("")
draftTag.newVersion()
}
open fun editFromDraft(draft: Note) {
val noteEvent = draft.event
val noteAuthor = draft.author
@@ -525,7 +557,12 @@ open class ChannelNewMessageViewModel :
// channel type (NIP-29 groups additionally carry the `h` tag). It carries the same mention/
// hashtag/quote/emoji/attachment enrichment an inline message does — built from tagger.message,
// not the raw text — so replying in a thread never silently drops any of them.
val minichatParent = replyTo.value?.takeIf { replyMode.value == ReplyMode.MINICHAT }?.event
// Buzz relays reject unknown kinds outright, and kind 1111 is not in Buzz's
// registry — a minichat comment sent to a workspace channel would be refused
// by the relay AFTER the composer already cleared. Buzz replies always go
// through the 40002 branch with its thread markers instead.
val minichatAllowed = !(channel is RelayGroupChannel && BuzzRelayDialect.isBuzz(channel.groupId.relayUrl))
val minichatParent = replyTo.value?.takeIf { minichatAllowed && replyMode.value == ReplyMode.MINICHAT }?.event
if (minichatParent != null) {
return CommentEvent.replyBuilder(tagger.message, EventHintBundle(minichatParent, channelRelays.firstOrNull())) {
if (channel is RelayGroupChannel) {
@@ -676,6 +713,56 @@ open class ChannelNewMessageViewModel :
}
}
channel is RelayGroupChannel &&
BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) &&
editingBuzzMessage.value != null -> {
// Buzz edit (kind 40003): replaces the text of an existing kind-40002 message.
// build() sets the group's `h` tag plus the `e` tag pointing at the edited
// message; content is the replacement text. Kept minimal to mirror Buzz's own
// `build_edit` (buzz-sdk builders.rs) — the relay validates edits and the author
// match. LocalCache overlays the newest edit last-write-wins, so the edited row
// re-renders with this content.
val target = editingBuzzMessage.value!!
StreamMessageEditEvent.build(channel.groupId.id, target.idHex, tagger.message)
}
channel is RelayGroupChannel && BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) -> {
// Buzz workspace message: the native kind is 40002 (stream message v2)
// scoped with the group's `h` tag; Buzz threads replies with NIP-10
// marked e-tags (["e", root, "", "root"] + ["e", parent, "", "reply"],
// collapsing to a single "reply" when the parent IS the root — mirrors
// thread_tags in buzz-sdk builders.rs) plus a `p` notify to the parent
// author. The dialect check comes from BuzzRelayDialect (marked off
// verified Buzz events), not a channel subtype: channel instances are
// captured by screens for their whole life, so the dialect must be
// able to flip mid-session without swapping objects.
StreamMessageV2Event.build(channel.groupId.id, tagger.message) {
replyTo.value?.let { parent ->
// The parent's root marker (when it is itself a nested reply),
// else the parent's OWN reply target (a direct reply's collapsed
// form carries the root as its "reply" marker — Buzz's relay
// validates ancestry and rejects a mis-derived root), else the
// parent starts the thread.
val parentTags = parent.event?.tags
val root =
parentTags?.buzzThreadRoot()
?: parentTags?.buzzThreadReply()
?: parent.idHex
buzzThread(root, parent.idHex)
parent.author?.pubkeyHex?.let { pTag(PTag(it)) }
}
hashtags(findHashtags(tagger.message))
references(findURLs(tagger.message))
quotes(findNostrUris(tagger.message))
contentWarningReason?.let { contentWarning(it) }
localExpirationDate?.let { expiration(it) }
emojis(emojis)
imetas(usedAttachments)
}
}
channel is RelayGroupChannel -> {
// NIP-29 group message: a kind-9 chat scoped to the group with an
// `h` tag. The event is published only to the group's host relay
@@ -729,6 +816,7 @@ open class ChannelNewMessageViewModel :
message.setTextAndPlaceCursorAtEnd("")
replyTo.value = null
editingBuzzMessage.value = null
urlPreview = null

View File

@@ -22,21 +22,31 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
@@ -56,6 +66,7 @@ import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.FlowPreview
@@ -87,6 +98,35 @@ fun EditFieldRow(
)
}
// Buzz edit mode: a banner reminding the user the next send replaces an existing
// message (a kind-40003 edit), with an X to abandon the edit and clear the field.
channelScreenModel.editingBuzzMessage.value?.let {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = MaterialSymbols.Edit,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = stringRes(R.string.buzz_editing_banner),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f).padding(start = 8.dp),
)
IconButton(onClick = { channelScreenModel.clearBuzzEdit() }) {
Icon(
symbol = MaterialSymbols.Close,
contentDescription = stringRes(R.string.cancel),
modifier = Modifier.size(16.dp),
)
}
}
}
channelScreenModel.uploadState?.let { uploading ->
uploading.multiOrchestrator?.let { _ ->
ChannelFileUploadDialog(
@@ -121,9 +161,28 @@ fun EditFieldRow(
)
}
// Client-side throttle for the Buzz kind-20002 typing heartbeat (below).
val lastTypingSecs = remember { longArrayOf(0L) }
ThinPaddingTextField(
state = channelScreenModel.message,
onTextChanged = { channelScreenModel.onMessageChanged() },
onTextChanged = {
channelScreenModel.onMessageChanged()
// Buzz typing indicator: fire a throttled heartbeat while composing in a
// Buzz workspace channel, so other members see "… is typing". No-op on any
// other chat (the kind is Buzz-only and the relay would ignore it anyway).
val channel = channelScreenModel.channel
if (channel is RelayGroupChannel &&
BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) &&
channelScreenModel.message.text.isNotEmpty()
) {
val now = TimeUtils.now()
if (now - lastTypingSecs[0] >= BuzzTypingState.TYPING_HEARTBEAT_SECS) {
lastTypingSecs[0] = now
accountViewModel.sendBuzzTyping(channel)
}
}
},
onContentReceived = { uri, mimeType ->
channelScreenModel.pickedMedia(persistentListOf(SelectedMedia(uri, mimeType)))
},

View File

@@ -24,6 +24,7 @@ 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
@@ -58,6 +59,8 @@ import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.privateChats.ChatPreview
import com.vitorpamplona.amethyst.commons.model.privateChats.chatPreviewOf
import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -762,26 +765,7 @@ private fun UserRoomCompose(
TimeAgo(lastMessage.createdAt())
},
secondRow = {
LoadDecryptedContentOrNull(lastMessage, accountViewModel) { content ->
if (content != null) {
Text(
content,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
modifier = Modifier.weight(1f),
)
} else {
Text(
stringRes(R.string.referenced_event_not_found),
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
LastMessagePreview(lastMessage, accountViewModel)
// A sent message I authored counts as read (#1286, #1287); an unsent draft still needs my attention.
val newestEvent = lastMessage.event
@@ -816,6 +800,51 @@ private fun UserRoomCompose(
}
}
/**
* The one-line preview of the room's newest message.
*
* NIP-04 rooms carry ciphertext in `event.content`, so the preview may only ever come from the
* decryption cache. [chatPreviewOf] keeps the three not-a-body outcomes apart — still decrypting,
* never decryptable, and no event at all — so a message that simply hasn't been opened yet isn't
* mislabelled as unreadable. The pending state resolves on its own: [LoadDecryptedContentOrNull]
* pushes the plaintext into its state as soon as the signer answers.
*/
@Composable
private fun RowScope.LastMessagePreview(
lastMessage: Note,
accountViewModel: AccountViewModel,
) {
LoadDecryptedContentOrNull(lastMessage, accountViewModel) { content ->
// Keyed so a scrolling list doesn't re-scan the DM's `p` tags on every recomposition.
val preview =
remember(lastMessage.event, content) {
chatPreviewOf(
event = lastMessage.event,
decrypted = content,
myPubKey = accountViewModel.account.signer.pubKey,
canDecrypt = accountViewModel.account.isWriteable(),
)
}
val text =
when (preview) {
is ChatPreview.Body -> preview.text
ChatPreview.Decrypting -> stringRes(R.string.chat_preview_decrypting)
ChatPreview.Undecryptable -> stringRes(R.string.could_not_decrypt_the_message)
ChatPreview.Missing -> stringRes(R.string.referenced_event_not_found)
}
Text(
text,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
modifier = Modifier.weight(1f),
)
}
}
@Composable
fun LoadUser(
baseUserHex: String,

View File

@@ -0,0 +1,61 @@
/*
* 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.embed
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles
/**
* Makes every embedded tab (browser / nsite / napplet) follow an account switch.
*
* Each account gets its own WebView storage jar, and that partition is chosen once, when the WebView is
* constructed — so a warm session built for account A can never serve account B. This asks
* [EmbeddedTabHost] to tear down and re-warm every session whenever the active account's profile changes;
* see [EmbeddedTabHost.rebuildIfProfileChanged] for why reusing them also leaves the tab blank.
*
* The whole logged-in subtree is recreated per account, so this composable is itself brand new after a
* switch — which is exactly why the "which account are the warm sessions built for" marker lives in
* [EmbeddedTabHost] (process-scoped) and not in a `remember` here.
*
* Mount once next to [EmbeddedTabLayer]/[EmbeddedTabPreloader], before them so the stale sessions are
* dropped ahead of the first preload sweep. Draws nothing.
*/
@RequiresApi(Build.VERSION_CODES.R)
@Composable
fun EmbeddedTabAccountWatcher() {
// Read the same opaque profile name the controllers stamp on their sessions, from the same source, so
// the sessions we rebuild are guaranteed to be built against the account we just checked for.
val profile = NappletWebViewProfiles.current()
// SideEffect, not LaunchedEffect: this must land in the SAME frame as the switch. A LaunchedEffect
// dispatches through the composition's coroutine scope, and an account switch floods the main thread —
// measured 3.0 s and 4.3 s of delay on a real device. For that whole window the tab layer had already
// rebuilt every SandboxedSdkView against the surviving (now-dead) controllers, so every embedded tab
// was a black rectangle. SideEffect runs at the end of the apply phase, so the stale sessions are torn
// down and the epoch bumped before the next composition renders any surface.
//
// Safe to run on every recomposition: [EmbeddedTabHost.rebuildIfProfileChanged] is idempotent — it
// compares against the profile the warm sessions were built for and no-ops when nothing changed.
SideEffect { EmbeddedTabHost.rebuildIfProfileChanged(profile) }
}

View File

@@ -57,12 +57,14 @@ object EmbeddedTabHost {
private set
/**
* Bumped whenever the app's resolved DARK/LIGHT theme flips (see [rebuildAllForTheme]). The embed
* WebView's theme is locked in at construction (`nightThemedContext`), so following a theme change
* means rebuilding the surface — the favorite screens and the preloader key their acquisition on this
* so they re-acquire a freshly-themed session instead of the stale warm one.
* Bumped whenever every warm session must be rebuilt from scratch (see [rebuildAll]) — a DARK/LIGHT
* theme flip, or an account switch. Both the theme (`nightThemedContext`) and the per-account storage
* profile ([com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles]) are locked in at WebView
* construction and can't be changed on a live WebView, so following either means building a new one.
* The favorite screens and the preloader key their acquisition on this, so they re-acquire a freshly
* built session instead of the stale warm one.
*/
var themeEpoch by mutableStateOf(0)
var rebuildEpoch by mutableStateOf(0)
private set
/** Window-space bounds of the active tab's reserved content area. */
@@ -169,15 +171,48 @@ object EmbeddedTabHost {
}
/**
* The app theme changed: tear down every warm session (their WebViews are pinned to the old theme)
* and bump [themeEpoch] so the visible screen and the preloader re-acquire freshly-themed sessions.
* Unlike [evictAll] this keeps [activeId], so the visible tab re-activates the instant its screen
* re-acquires — the user just sees the current tab reload in the new theme, not a blanked-out surface.
* Something a WebView can only pick up at construction changed (the theme, or the account): tear down
* every warm session and bump [rebuildEpoch] so the visible screen and the preloader re-acquire freshly
* built sessions. Unlike [evictAll] this keeps [activeId], so the visible tab re-activates the instant
* its screen re-acquires — the user just sees the current tab reload, not a blanked-out surface.
*/
fun rebuildAllForTheme() {
fun rebuildAll() {
val copy = warm.toList()
warm.clear()
copy.forEach { it.controller.teardown() }
themeEpoch += 1
rebuildEpoch += 1
}
/**
* Account the warm sessions were built for, as the opaque WebView storage-profile name (null while
* logged out). Kept HERE, next to the sessions it describes, rather than in a composable's `remember`:
* the whole logged-in subtree is rebuilt per account (`key(pubKey)` in `SetAccountCentricViewModelStore`),
* so a remembered "last applied" value would be re-seeded to the NEW account on the very first
* composition after a switch and the change would never be detected.
*/
private var builtForProfile: String? = null
private var profileSeeded = false
/**
* Rebuilds every warm session when the active account changes, so all embedded apps follow the switch.
*
* A WebView's storage profile is fixed at construction (`WebViewCompat.setProfile` throws once it has
* loaded content), so a live session can't be re-pointed at the new account's jar — it has to be
* rebuilt. Rebuilding is also what makes the tab work at all after a switch: the account-keyed subtree
* is recreated, which disposes each session's `SandboxedSdkView` and closes the sandbox-side session,
* leaving the warm controller holding an already-consumed (and now dead) adapter. Reused as-is, it
* would hand the fresh view no adapter and the tab would render permanently blank.
*
* Covers tabs that aren't on screen too: every warm session is torn down, and the preloader re-warms
* the pinned ones against the new profile, so none can come back still bound to the old account.
*/
fun rebuildIfProfileChanged(profileName: String?) {
if (profileSeeded && builtForProfile == profileName) return
val isFirstCall = !profileSeeded
profileSeeded = true
builtForProfile = profileName
// Seeding on the first call (app start) must not bump the epoch: nothing is stale yet, and a
// needless bump would restart the preload sweep that is just getting going.
if (!isFirstCall) rebuildAll()
}
}

View File

@@ -215,13 +215,22 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
// one's (opaque, pre-first-frame) surface — which itself sits above the nav screens, so the overlay
// can't live in the screen. A spinner until a real page paints, or an error+retry when the load
// failed or stalled, so a slow / blank / failed load isn't a bare black/white void.
//
// Gated on the active *id*, not on a live controller: right after [EmbeddedTabHost.rebuildAll] (an
// account switch or a theme flip) the active tab has no session at all for a frame or more, and a
// SandboxedSdkView with no adapter paints nothing but its background — a black rectangle that reads
// as broken. Treat "no session yet" as "still loading" so the spinner covers that window too.
val activeController = EmbeddedTabHost.sessions.firstOrNull { it.id == activeId }?.controller
if (activeController != null && bounds.width > 0f && bounds.height > 0f) {
var loadStatus by remember(activeId) { mutableStateOf(activeController.loadStatus) }
if (activeId != null && bounds.width > 0f && bounds.height > 0f) {
var loadStatus by remember(activeId) { mutableStateOf(activeController?.loadStatus ?: EmbeddedLoadStatus()) }
var timedOut by remember(activeId) { mutableStateOf(false) }
DisposableEffect(activeId, activeController) {
activeController.onLoadStatusChanged = { loadStatus = it }
onDispose { activeController.onLoadStatusChanged = null }
// No session yet — the tab is mid-rebuild after an account switch, or being warmed for the
// first time. Fall back to the "still loading" state so the spinner covers the surface
// instead of leaving a bare black rectangle where a dead/absent adapter paints nothing.
loadStatus = activeController?.loadStatus ?: EmbeddedLoadStatus()
activeController?.onLoadStatusChanged = { loadStatus = it }
onDispose { activeController?.onLoadStatusChanged = null }
}
// Safety net: nothing painted and nothing actively loading after a grace period → offer a retry.
LaunchedEffect(activeId, loadStatus) {
@@ -241,10 +250,12 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
).size(bounds.width.toDp(), bounds.height.toDp()),
) {
EmbeddedLoadOverlay(
failed = loadStatus.failed || timedOut,
// A tab with no session yet can't have failed — it hasn't started. Keep it on
// the spinner so a re-arming tab never flashes an error the user can't act on.
failed = activeController != null && (loadStatus.failed || timedOut),
onRetry = {
timedOut = false
activeController.retry()
activeController?.retry()
},
)
}

View File

@@ -0,0 +1,128 @@
/*
* 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.embed
import android.content.Context
import android.os.Build
import androidx.annotation.RequiresApi
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
// Up to PRELOAD_ATTEMPTS sweeps, PRELOAD_RETRY_MS apart, give a slow napplet event or a still-connecting
// Tor proxy time to settle before we give up and leave that tab to load on first visit (~45 s total).
private const val PRELOAD_ATTEMPTS = 15
private const val PRELOAD_RETRY_MS = 3_000L
/**
* Process-scoped owner of the bottom-bar warm-up sweep, driven by [EmbeddedTabPreloader].
*
* **Why this isn't just a `LaunchedEffect` in the composable.** After an account switch every warm
* session is torn down ([EmbeddedTabHost.rebuildAll]) and must be rebuilt against the new storage
* profile — until that happens the tabs have nothing to show. A `LaunchedEffect` dispatches its body
* through the composition's coroutine scope, and an account switch floods the main thread: the same
* construct made [EmbeddedTabAccountWatcher] fire 3-4 s late before it became a `SideEffect`. Waiting
* that long to even *start* the re-warm is what left the tab blank for seconds after a switch.
*
* So the **kickoff** is synchronous — [request] is called from a `SideEffect`, in the same apply phase
* that bumped the epoch — while the sweep itself stays a coroutine, because it genuinely has to suspend
* (it awaits the network-registry hydration, then retries pending favorites for ~45 s).
* [CoroutineStart.UNDISPATCHED] on [Dispatchers.Main.immediate] means the body runs *inline* up to its
* first real suspension, so on a re-warm (registries already hydrated, so `awaitReady` returns without
* suspending) the first tab is rebuilt before [request] even returns.
*/
@RequiresApi(Build.VERSION_CODES.R)
object EmbeddedTabPreloadSweeper {
private data class SweepKey(
val favoriteIds: List<String>,
val backgroundColor: Int,
val rebuildEpoch: Int,
)
private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())
private var job: Job? = null
private var lastKey: SweepKey? = null
/**
* Starts (or restarts) the sweep for [favoriteIds]. Idempotent: repeated calls with the same inputs
* no-op, so it is safe to call from a `SideEffect` that runs on every recomposition. A changed
* [rebuildEpoch] — a theme flip or an account switch — cancels the in-flight sweep and starts a fresh
* one, which is what re-warms the tabs the user never opened so none survives bound to the old
* account's storage profile.
*/
fun request(
context: Context,
favoriteIds: List<String>,
backgroundColor: Int,
rebuildEpoch: Int,
) {
val key = SweepKey(favoriteIds, backgroundColor, rebuildEpoch)
if (lastKey == key) return
lastKey = key
job?.cancel()
job = null
if (favoriteIds.isEmpty()) return
// The sweep outlives any single composition, so it must not pin an Activity.
val appContext = context.applicationContext
job =
scope.launch(start = CoroutineStart.UNDISPATCHED) {
sweep(appContext, favoriteIds, backgroundColor)
}
}
private suspend fun CoroutineScope.sweep(
context: Context,
favoriteIds: List<String>,
backgroundColor: Int,
) {
// Hydrate the per-site Tor/open-web choices BEFORE the first preload: a cold start otherwise reads
// the bare Tor default and would route a site the user pinned to the open web through Tor (or stall
// it waiting for Tor), which is exactly what breaks Tor-incompatible servers. On a re-warm these
// are already hydrated, so they return without suspending and the first tab is built inline.
WebAppNetworkRegistry.init(context)
NappletNetworkRegistry.init(context)
WebAppNetworkRegistry.awaitReady()
NappletNetworkRegistry.awaitReady()
var attempt = 0
while (isActive) {
val byId = FavoriteAppsRegistry.favorites.value.associateBy { it.id }
var stillPending = false
for (id in favoriteIds) {
val app = byId[id] ?: continue
if (!EmbeddedTabFactory.preload(context, app, backgroundColor)) stillPending = true
// Each preload may build + attach a WebView on this (main) thread; yield between favorites
// so the sweep doesn't monopolize the frame and jank the paint that follows.
yield()
}
if (!stillPending || ++attempt >= PRELOAD_ATTEMPTS) break
delay(PRELOAD_RETRY_MS)
}
}
}

View File

@@ -24,7 +24,7 @@ import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.toArgb
@@ -33,19 +33,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.yield
// Up to PRELOAD_ATTEMPTS sweeps, PRELOAD_RETRY_MS apart, give a slow napplet event or a still-connecting
// Tor proxy time to settle before we give up and leave that tab to load on first visit (~45 s total).
private const val PRELOAD_ATTEMPTS = 15
private const val PRELOAD_RETRY_MS = 3_000L
/**
* Warms every bottom-bar favorite at startup so the first tap is instant — the surfaces are built,
@@ -54,7 +43,9 @@ private const val PRELOAD_RETRY_MS = 3_000L
*
* Mount once next to [EmbeddedTabLayer]. Draws nothing; it just drives acquisition. Napplet favorites
* whose events haven't synced yet, and Tor-routed sites whose proxy isn't up yet, are retried for a
* bounded window (the latter so a clearnet preload can never race ahead of Tor).
* bounded window (the latter so a clearnet preload can never race ahead of Tor) — that retry loop, and
* everything else that has to suspend, lives in [EmbeddedTabPreloadSweeper]; this composable only kicks
* it off, synchronously.
*/
@RequiresApi(Build.VERSION_CODES.R)
@Composable
@@ -66,40 +57,37 @@ fun EmbeddedTabPreloader(accountViewModel: AccountViewModel) {
.collectAsStateWithLifecycle()
val favoriteIds = bottomBarItems.favoriteIds()
// Give preloaded surfaces a realistic viewport before any tab is visited, so they download as a
// full-size page rather than at the 1dp off-screen fallback. The first real visit corrects it.
// Subscribe to the epoch here (a read inside a SideEffect wouldn't recompose us), so a bump that lands
// outside our apply phase — [EmbeddedTabThemeWatcher] bumps it from a LaunchedEffect — still re-runs the
// kickoff below. The SideEffect re-reads it, so a bump in the SAME apply phase is picked up immediately.
val observedEpoch = EmbeddedTabHost.rebuildEpoch
val density = LocalDensity.current
val configuration = LocalConfiguration.current
LaunchedEffect(configuration) {
// Give preloaded surfaces a realistic viewport before any tab is visited, so they download as a
// full-size page rather than at the 1dp off-screen fallback. The first real visit corrects it.
// Synchronous, and declared BEFORE the kickoff, because the kickoff is synchronous too: as a
// LaunchedEffect this would now land *after* the first preload and hand it the off-screen fallback.
SideEffect {
with(density) {
EmbeddedTabHost.seedBoundsIfUnset(Rect(0f, 0f, configuration.screenWidthDp.dp.toPx(), configuration.screenHeightDp.dp.toPx()))
}
}
// Re-warms after a theme flip: [rebuildAllForTheme] tears down the warm sessions and bumps the epoch,
// so this sweep re-acquires them in the new theme (keying on the epoch also orders it after the teardown).
LaunchedEffect(favoriteIds, backgroundColor, EmbeddedTabHost.themeEpoch) {
if (favoriteIds.isEmpty()) return@LaunchedEffect
// Hydrate the per-site Tor/open-web choices BEFORE the first preload: a cold start otherwise reads
// the bare Tor default and would route a site the user pinned to the open web through Tor (or stall
// it waiting for Tor), which is exactly what breaks Tor-incompatible servers.
WebAppNetworkRegistry.init(context)
NappletNetworkRegistry.init(context)
WebAppNetworkRegistry.awaitReady()
NappletNetworkRegistry.awaitReady()
var attempt = 0
while (isActive) {
val byId = FavoriteAppsRegistry.favorites.value.associateBy { it.id }
var stillPending = false
for (id in favoriteIds) {
val app = byId[id] ?: continue
if (!EmbeddedTabFactory.preload(context, app, backgroundColor)) stillPending = true
// Each preload may build + attach a WebView on this (main) thread; yield between favorites
// so the startup sweep doesn't monopolize the frame and jank the first paint.
yield()
}
if (!stillPending || ++attempt >= PRELOAD_ATTEMPTS) break
delay(PRELOAD_RETRY_MS)
}
// Re-warms after a theme flip or an account switch: [rebuildAll] tears down the warm sessions and bumps
// the epoch, so this sweep re-acquires them freshly built (keying on the epoch also orders it after the
// teardown). This is also what re-warms tabs the user never opened, so none survives bound to the old
// account's storage profile.
//
// SideEffect, not LaunchedEffect: [EmbeddedTabAccountWatcher] tears the sessions down in the apply phase
// of the switch, and until this sweep runs there is nothing for the tab to show. A LaunchedEffect
// dispatches through the composition's scope, and an account switch floods the main thread — the very
// congestion that made the watcher itself fire 3-4 s late. Running here re-arms the tabs in the same
// frame that dropped them. The epoch is re-read inside the lambda so we see the watcher's bump (its
// SideEffect is ordered before ours), and [EmbeddedTabPreloadSweeper.request] is idempotent, so running
// on every recomposition is free.
SideEffect {
EmbeddedTabPreloadSweeper.request(context, favoriteIds, backgroundColor, maxOf(observedEpoch, EmbeddedTabHost.rebuildEpoch))
}
}

View File

@@ -61,7 +61,7 @@ fun EmbeddedTabThemeWatcher() {
LaunchedEffect(resolvedDark) {
if (applied.value != resolvedDark) {
applied.value = resolvedDark
EmbeddedTabHost.rebuildAllForTheme()
EmbeddedTabHost.rebuildAll()
}
}
}

View File

@@ -36,6 +36,7 @@ import androidx.annotation.RequiresApi
import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles
import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedImeBridge
@@ -72,9 +73,18 @@ class EmbeddedNostrAppController(
private var sandboxedSdkView: SandboxedSdkView? = null
private var pendingAdapter: SandboxedUiAdapter? = null
/**
* True once this controller's adapter has actually been handed to a [SandboxedSdkView]. An adapter can
* only ever serve ONE view: when that view is disposed, privacysandbox closes the remote session and the
* sandbox destroys its WebView, so the adapter is dead. See [attachView].
*/
private var adapterDelivered = false
// A single NappletHostService instance serves every embedded napplet tab, so each controller stamps
// its own id on every message; the provider uses it to route controls/state/IME to this tab.
private val sessionId: String = "napplet-${SESSION_SEQ.incrementAndGet()}"
// Re-minted whenever the remote session is re-created (see [attachView]), so a late close() from the
// previous view can never reap the replacement.
private var sessionId: String = "napplet-${SESSION_SEQ.incrementAndGet()}"
// A parked tab can be hidden (paused) before the service even binds, so the pause message is
// dropped (no messenger yet). Remember the intent and replay it right after the session is created,
@@ -129,6 +139,7 @@ class EmbeddedNostrAppController(
serviceMessenger = null
sandboxedSdkView = null
pendingAdapter = null
adapterDelivered = false
onStateChanged = null
onNotice = null
onImeEvent = null
@@ -136,14 +147,44 @@ class EmbeddedNostrAppController(
onLoadStatusChanged = null
}
/**
* Hands the surface view to the controller; applies the adapter if it already arrived, and re-arms the
* remote session when this controller is being re-used by a *second* view.
*
* A warm controller outlives the composition (it lives in the process-scoped
* [com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabHost]), but its [SandboxedSdkView]
* does not: an account switch rebuilds the whole logged-in subtree, disposing every surface. That
* disposal makes privacysandbox close the remote session and the sandbox destroy its WebView, so the
* adapter already handed out is dead and cannot serve the fresh view. A [SandboxedSdkView] with no
* adapter never builds a ContentView/SurfaceView and paints nothing but its background colour, forever.
*
* So when a new view attaches after the adapter was already delivered, ask the sandbox for a brand new
* session; the reply arms this view. [sendCreateSession] re-stamps the CURRENT account's storage
* profile, so re-arming can never resurrect the previous account's jar.
*/
override fun attachView(view: SandboxedSdkView) {
sandboxedSdkView = view
// Paint the surface placeholder in the app's theme background so there's no white flash before
// the remote WebView delivers its first frame.
view.setBackgroundColor(params.getInt(NappletHostContract.EXTRA_BG_COLOR, android.graphics.Color.WHITE))
pendingAdapter?.let {
view.setAdapter(it)
pendingAdapter = null
val adapter = pendingAdapter
when {
adapter != null -> {
pendingAdapter = null
adapterDelivered = true
view.setAdapter(adapter)
}
// No adapter in hand and one was already spent on a previous (now disposed) view: the session
// behind it is gone, so this view would stay blank forever. Re-create it.
adapterDelivered -> {
// Mint a FRESH session id: the disposed view's Session.close() reaches the sandbox
// asynchronously and can land AFTER this create. Reusing the id would let that late close
// reap the session we just asked for, leaving the surface black.
sessionId = "napplet-${SESSION_SEQ.incrementAndGet()}"
adapterDelivered = false
sendCreateSession()
}
// else: the first session is still in flight; MSG_SESSION_READY will arm this view.
}
}
@@ -157,7 +198,15 @@ class EmbeddedNostrAppController(
val msg =
Message.obtain(null, NappletEmbedContract.MSG_CREATE_SESSION).apply {
replyTo = incoming
data = Bundle(params).apply { putString(NappletEmbedContract.KEY_SESSION_ID, sessionId) }
data =
Bundle(params).apply {
putString(NappletEmbedContract.KEY_SESSION_ID, sessionId)
// Re-stamp the storage partition at SEND time rather than trusting the one baked
// into [params] at construction: a session re-created for a new view (see
// [attachView]) must land in the CURRENT account's jar, never the one this
// controller was originally built for.
putString(NappletHostContract.EXTRA_WEBVIEW_PROFILE, NappletWebViewProfiles.current())
}
}
runCatching { serviceMessenger?.send(msg) }
// Replay a pause that was requested before we had a messenger to send it on (parked-before-bound),
@@ -172,7 +221,12 @@ class EmbeddedNostrAppController(
val coreLibInfo = msg.data?.getBundle(NappletEmbedContract.KEY_CORE_LIB_INFO) ?: return true
val adapter = SandboxedUiAdapterFactory.createFromCoreLibInfo(coreLibInfo)
val view = sandboxedSdkView
if (view != null) view.setAdapter(adapter) else pendingAdapter = adapter
if (view != null) {
adapterDelivered = true
view.setAdapter(adapter)
} else {
pendingAdapter = adapter
}
}
NappletEmbedContract.MSG_STATE -> {
val canGoBack = msg.data?.getBoolean(NappletEmbedContract.KEY_CAN_GO_BACK, false) ?: false

View File

@@ -114,7 +114,7 @@ private fun EmbeddedNostrAppTab(
// Mint the verified launch params (a fresh token per resolve); null until the event loads. Re-minted
// on a theme flip (the params carry the resolved theme into the sandbox host's WebView).
val params = remember(coordinate, EmbeddedTabHost.themeEpoch) { FavoriteAppLauncher.embedParams(context, coordinate) }
val params = remember(coordinate, EmbeddedTabHost.rebuildEpoch) { FavoriteAppLauncher.embedParams(context, coordinate) }
if (params == null) {
UnavailableTab(coordinate, accountViewModel, nav)
return
@@ -134,7 +134,7 @@ private fun EmbeddedNostrAppTab(
val isFavorite = remember(apps, coordinate) { apps.any { it.id == "nostr:$coordinate" } }
val controller =
remember(id, EmbeddedTabHost.themeEpoch) {
remember(id, EmbeddedTabHost.rebuildEpoch) {
EmbeddedTabFactory.acquireNostrApp(context, coordinate, params, backgroundColor)
}

View File

@@ -82,6 +82,8 @@ import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionL
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.favorites.rememberManifestIconModel
import com.vitorpamplona.amethyst.favorites.rememberWebAppIconModel
import com.vitorpamplona.amethyst.napplet.NappletBrokerService
import com.vitorpamplona.amethyst.napplet.counterpartyLabel
import com.vitorpamplona.amethyst.napplet.descriptionRes
import com.vitorpamplona.amethyst.napplet.labelRes
import com.vitorpamplona.amethyst.napplet.resolveNappletMeta
@@ -117,7 +119,7 @@ fun ConnectedAppDetailScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val capabilityLedger = remember { NappletPermissionLedger(Amethyst.instance.nappletPermissionStore) }
val capabilityLedger = Amethyst.instance.nappletPermissionLedger
val signerLedger = remember { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) }
val untitled = stringResource(CommonsR.string.napplet_untitled)
@@ -219,7 +221,15 @@ fun ConnectedAppDetailScreen(
PolicyPicker(
selected = current.signerPolicy,
onSelect = { newPolicy ->
mutate { signerLedger.setPolicy(coordinate, newPolicy) }
mutate {
signerLedger.setPolicy(coordinate, newPolicy)
// Live session grants are consulted BEFORE the policy, so tightening an app
// to PARANOID would not have stopped it signing — the grant it already holds
// short-circuits the check the new policy would fail. Changing the trust
// level is a decision about how this app is treated from now on, so drop
// what it is holding and let the new policy actually apply.
NappletBrokerService.revokeSessionGrants(coordinate)
}
},
)
}
@@ -238,7 +248,14 @@ fun ConnectedAppDetailScreen(
OpOverrideRow(
opKey = opKey,
decision = decision,
onRevoke = { mutate { signerLedger.revokeOpDecision(coordinate, NostrSignerOp.fromKey(opKey) ?: return@mutate) } },
onRevoke = {
mutate {
signerLedger.revokeOpDecision(coordinate, NostrSignerOp.fromKey(opKey) ?: return@mutate)
// The persisted override is gone, but a live "allow for this session"
// grant would keep authorizing this app until the broker dies.
NappletBrokerService.revokeSessionGrants(coordinate)
}
},
)
}
}
@@ -294,6 +311,11 @@ fun ConnectedAppDetailScreen(
signerLedger.revokeAll(coordinate)
}
capabilityLedger.revokeAll(identity)
// Forgetting an app has to stop it signing *now*. The two ledgers above only
// drop persisted + capability grants; the broker separately holds the signer's
// in-memory "allow for this session" grants, which would otherwise keep the
// app authorized for as long as any applet surface stays open.
NappletBrokerService.revokeSessionGrants(coordinate)
}
nav.popBack()
},
@@ -703,6 +725,7 @@ private fun NostrSignerOp.opLabel(): String =
is NostrSignerOp.SignKind -> stringResource(R.string.napplet_op_sign_kind, kind)
NostrSignerOp.Encrypt -> stringResource(R.string.napplet_op_encrypt)
NostrSignerOp.Decrypt -> stringResource(R.string.napplet_op_decrypt)
is NostrSignerOp.DecryptFrom -> stringResource(R.string.napplet_op_decrypt_from, counterpartyLabel(counterparty))
}
@Composable

View File

@@ -96,7 +96,7 @@ fun ConnectedAppsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val capabilityLedger = remember { NappletPermissionLedger(Amethyst.instance.nappletPermissionStore) }
val capabilityLedger = Amethyst.instance.nappletPermissionLedger
val signerLedger = remember { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) }
var items by remember { mutableStateOf<List<ConnectedAppEntry>?>(null) }

View File

@@ -53,6 +53,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachDetailDialog
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.commons.ui.notifications.Card
import com.vitorpamplona.amethyst.commons.ui.notifications.CardFeedState
@@ -70,6 +73,7 @@ import com.vitorpamplona.amethyst.ui.note.NutzapUserSetCompose
import com.vitorpamplona.amethyst.ui.note.ZapUserSetCompose
import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.formatHistoryReachDate
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.donations.ShowDonationCard
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
@@ -89,9 +93,21 @@ fun RenderCardFeed(
routeForLastRead: String,
scrollToEventId: String? = null,
headerContent: (@Composable () -> Unit)? = null,
// Whether THIS feed drives the shared account history pager. False for an off-screen split tab / second
// pane, so only the feed the user is actually looking at pages the pager (see #2 in the audit).
drivesPaging: Boolean = true,
) {
val feedState by feedContent.feedContent.collectAsStateWithLifecycle()
// A genuinely empty feed has no rows for the look-ahead buffer driver to measure, so step every relay
// one page at a time to hunt for the first notifications. Once cards appear the buffer driver takes
// over. Gated on Empty only (never the transient Loading navigation flashes through), and only for the
// active feed so an off-screen tab doesn't hunt on the shared pager.
val history = remember(accountViewModel) { accountViewModel.dataSources().account.notificationsHistory }
if (drivesPaging) {
BootstrapNotificationHistoryWhenEmpty(feedState is CardFeedState.Empty, history.loadingMore, history.status) { history.advanceAll() }
}
// Direct switch instead of CrossfadeIfEnabled: the crossfade's `currentlyVisible`
// accumulator can leave a previous `Loaded` instance composed alongside the new
// one when refreshes (e.g. double-tap on the Notifications tab) bounce the
@@ -116,6 +132,7 @@ fun RenderCardFeed(
nav = nav,
scrollToEventId = scrollToEventId,
headerContent = headerContent,
drivesPaging = drivesPaging,
)
}
@@ -149,10 +166,30 @@ private fun FeedLoaded(
nav: INav,
scrollToEventId: String? = null,
headerContent: (@Composable () -> Unit)? = null,
drivesPaging: Boolean = true,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
val openPolls by polls.flow.collectAsStateWithLifecycle()
// Infinite-scroll backward pagination of the notifications history. All the driving (look-ahead buffer,
// auto-retry, per-relay sentinels) lives in [rememberNotificationHistoryPaging]; here we just get back
// the per-relay cursors to draw as frontier markers, and keep [history] for the detail dialog's retry.
val history = remember(accountViewModel) { accountViewModel.dataSources().account.notificationsHistory }
// Count of items above the notification cards in the LazyColumn (scaffold header + donation card + open
// polls), so the hoisted sentinel can map a visible LazyColumn index back to a card.
val leadingItemCount = (if (headerContent != null) 1 else 0) + 1 + openPolls.size
val limits =
rememberNotificationHistoryPaging(history, listState, drivesPaging) { index ->
items.list.getOrNull(index - leadingItemCount)?.createdAt()
}
// The relays behind a tapped in-stream marker; non-null shows the per-relay breakdown popup.
var syncDetail by remember { mutableStateOf<List<RelayReachCursor>?>(null) }
syncDetail?.let { detail ->
// Tap-through offers a Try Again on stalled relays, so a user who sees a bad relay can act on it.
RelayReachDetailDialog(detail, ::formatHistoryReachDate, onRetry = { history.advanceAll() }) { syncDetail = null }
}
StickToTopOnPrepend(listState, items.list.firstOrNull()?.id())
// Track which card is highlighted (will auto-clear after animation)
@@ -233,7 +270,7 @@ private fun FeedLoaded(
items = items.list,
key = { _, item -> item.id() },
contentType = { _, item -> item.javaClass.simpleName },
) { _, item ->
) { index, item ->
val isHighlighted = highlightedCardId == item.id()
val highlightColor by animateColorAsState(
targetValue = if (isHighlighted) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) else Color.Transparent,
@@ -257,6 +294,18 @@ private fun FeedLoaded(
HorizontalDivider(
thickness = DividerThickness,
)
// Per-relay markers in the gap toward the next-older card, at the depth each relay has paged to.
// The bulk load is buffer-driven (above); these mark each relay's frontier and drive the
// stalled-relay retry when scrolled into view (see the sentinel above). olderCreatedAt is null
// past the oldest loaded card, so relays that reached the bottom sit there as "fully loaded".
if (limits.isNotEmpty()) {
RelayReachMarkers(
limits,
item.createdAt(),
items.list.getOrNull(index + 1)?.createdAt(),
) { syncDetail = it }
}
}
}
}

View File

@@ -0,0 +1,202 @@
/*
* 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.notifications
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachSentinels
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsHistoryEoseManager
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
/**
* Drives the notifications feed's infinite-scroll backward pagination over the account's per-relay
* [AccountNotificationsHistoryEoseManager] (a [BackwardRelayPager][com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager]:
* each relay keeps its own until+limit cursor so faulty relays with different datasets page independently
* and can't gap each other) and returns the per-relay [RelayReachCursor]s the feed draws as frontier
* markers.
*
* Three cooperating drivers, all gated on [drivesPaging] so only the on-screen feed pages the shared
* account pager (an off-screen split tab / second pane stays idle):
* 1. **Look-ahead buffer** — keeps a fat runway of older notifications loaded ahead of the viewport, so
* healthy relays fill the feed and the user practically never reaches the end. Bounded per burst
* ([NOTIFICATION_MAX_PAGES_PER_BURST]) and reset by scrolling, so a dense account whose events collapse
* into few cards can't burst-download its whole history to hit the row target, while a normal account
* still preloads the full look-ahead from the top.
* 2. **Auto-retry** — when every relay is done-or-stalled (exhausted) but some are merely stalled (a
* slow/unreachable relay, not a real end), re-advances them on a backoff so recovery doesn't depend on
* the user scrolling to the marker or reopening.
* 3. **Per-relay sentinels** — retry an individual relay the moment its frontier marker scrolls into view;
* the buffer keeps the frontier below the fold, so these stay quiet unless the buffer can't keep up.
*
* @param createdAtAt createdAt of the card at a LazyColumn index (null past the ends / non-card rows), so
* the hoisted sentinel can test which inter-card gap a relay's cursor sits in against the visible rows.
*/
@Composable
fun rememberNotificationHistoryPaging(
history: AccountNotificationsHistoryEoseManager,
listState: LazyListState,
drivesPaging: Boolean,
createdAtAt: (index: Int) -> Long?,
): List<RelayReachCursor> {
val historyStatus by history.status.collectAsStateWithLifecycle()
val exhausted = historyStatus.exhausted
val loadingMore by history.loadingMore.collectAsStateWithLifecycle()
// Keep a big runway of already-loaded rows below the fold so the user effectively never reaches the end.
val shouldLoadMore by remember {
derivedStateOf {
val lastVisibleIndex =
listState.layoutInfo.visibleItemsInfo
.lastOrNull()
?.index ?: 0
val totalItems = listState.layoutInfo.totalItemsCount
totalItems > 0 && lastVisibleIndex >= totalItems - NOTIFICATION_LOOKAHEAD_BUFFER
}
}
// Bound the eager fill. Pages are pulled in events but the buffer is counted in rows, and notifications
// collapse heavily into cards — so on a dense account a page can add very few rows, and an uncapped fill
// would keep pulling until it downloaded the whole history to reach the row target. Cap the consecutive
// pages pulled WITHOUT the user scrolling; scrolling (firstVisibleItemIndex moving) resets the budget so
// paging resumes as the buffer is consumed. From position 0 this still preloads the full look-ahead for a
// normal account (12 pages), yet a dense whale can't burst-download everything on open.
val firstVisibleIndex by remember { derivedStateOf { listState.firstVisibleItemIndex } }
var pagesThisBurst by remember { mutableIntStateOf(0) }
LaunchedEffect(firstVisibleIndex) { pagesThisBurst = 0 }
// Re-evaluated when the buffer runs low, a page settles (loadingMore falls), paging exhausts, this feed
// (de)activates, or the burst budget changes — so a page that doesn't refill the buffer keeps pulling the
// next (up to the burst cap) until the buffer is full or relays run dry.
LaunchedEffect(drivesPaging, shouldLoadMore, loadingMore, exhausted, pagesThisBurst) {
if (drivesPaging && shouldLoadMore && !loadingMore && !exhausted && pagesThisBurst < NOTIFICATION_MAX_PAGES_PER_BURST) {
history.advanceAll()
pagesThisBurst++
}
}
// Auto-retry faulty relays with backoff, only while this feed drives paging. A single non-restarting
// loop so the backoff survives the transient in-flight blips each retry causes.
LaunchedEffect(history, drivesPaging) {
if (!drivesPaging) return@LaunchedEffect
var backoffMs = STALLED_RETRY_MIN_MS
while (true) {
history.status.first { it.exhausted && it.stalledCount > 0 } // park until stuck on a stalled relay
while (true) {
delay(backoffMs)
val s = history.status.value
if (!(s.exhausted && s.stalledCount > 0)) break // recovered (a relay answered, or scroll retried)
history.advanceAll()
history.loadingMore.first { !it } // let the retry settle before escalating
backoffMs = (backoffMs * 2).coerceAtMost(STALLED_RETRY_MAX_MS)
}
backoffMs = STALLED_RETRY_MIN_MS // reset for the next stall
}
}
// One cursor per relay: its reached depth, state (reaching / stalled / done) and the advance() that pulls
// its next page. A done relay's marker sinks to the oldest end reading "fully loaded".
val limits =
remember(historyStatus) {
historyStatus.relayProgress.map { (relay, p) ->
RelayReachCursor(relay.url, relayShortName(relay), p.reachedUntil, reachState(p)) { history.advance(relay) }
}
}
// Per-relay retry driver: when a relay's frontier marker is on screen (the buffer couldn't keep the
// frontier ahead, i.e. that relay stalled or the feed is genuinely at its end), step that one relay.
if (drivesPaging && limits.isNotEmpty()) {
RelayReachSentinels(limits, listState, createdAtAt)
}
return limits
}
/**
* Bootstraps notification history while the feed is genuinely empty: steps every relay one page at a
* time, gated on its own loader, until notifications appear or every relay exhausts. Once cards load this
* stops and the look-ahead buffer driver takes over, keeping older pages loaded ahead of the viewport.
*
* Leads with a debounce so the brief Empty/Loading flash navigation passes through does NOT trigger a
* hunt; if [active] drops before it elapses (cards loaded) the effect cancels and nothing pages.
*/
@Composable
fun BootstrapNotificationHistoryWhenEmpty(
active: Boolean,
loadingMore: StateFlow<Boolean>,
status: StateFlow<PagingStatus>,
advanceAll: () -> Unit,
) {
LaunchedEffect(active, loadingMore, status) {
if (!active) return@LaunchedEffect
delay(BOOTSTRAP_DEBOUNCE_MS)
combine(loadingMore, status) { loading, s -> !loading && !s.exhausted }
.distinctUntilChanged()
.filter { it }
.collect { advanceAll() }
}
}
// Ignore the transient empty feed that navigation flashes through before notifications re-appear.
private const val BOOTSTRAP_DEBOUNCE_MS = 1200L
// How many already-loaded rows to keep below the last visible one before pulling the next older page.
// Large on purpose: the feed reads as infinite scroll, the user practically never reaches the bottom.
private const val NOTIFICATION_LOOKAHEAD_BUFFER = 100
// Cap on consecutive pages pulled to fill the buffer WITHOUT the user scrolling (the budget resets on
// scroll). Generous so a normal account preloads the full look-ahead from the top in 12 pages, while a
// dense account whose events collapse into few cards is bounded instead of burst-downloading everything.
private const val NOTIFICATION_MAX_PAGES_PER_BURST = 6
// Backoff bounds for auto-retrying stalled (slow/unreachable) relays: first retry ~3s after a stall,
// doubling up to ~30s, so a faulty relay is retried gently but keeps a chance to recover on its own.
private const val STALLED_RETRY_MIN_MS = 3_000L
private const val STALLED_RETRY_MAX_MS = 30_000L
private fun reachState(p: RelayPagingProgress): RelayReachState =
when {
p.done -> RelayReachState.DONE
p.stalled -> RelayReachState.STALLED
else -> RelayReachState.REACHING
}
private fun relayShortName(relay: NormalizedRelayUrl): String =
relay.url
.substringAfter("://")
.trimEnd('/')
.substringBefore('/')

View File

@@ -258,6 +258,9 @@ private fun SplitNotificationsBody(
nav: INav,
) {
HorizontalPager(state = pagerState) { page ->
// Only the settled, on-screen tab drives the shared account history pager, so the off-screen tab
// (composed during a swipe) doesn't page notifications the user isn't looking at.
val drivesPaging = page == pagerState.currentPage
when (page) {
0 -> {
NotificationPagerPage(
@@ -265,6 +268,7 @@ private fun SplitNotificationsBody(
pollContent = notifPolls,
scrollStateKey = ScrollStateKeys.NOTIFICATION_FOLLOWING,
scrollToEventId = scrollToEventId,
drivesPaging = drivesPaging,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -278,6 +282,7 @@ private fun SplitNotificationsBody(
// Only the Following tab honors the deep-link scroll target so users
// aren't bounced when they swipe across to Everyone.
scrollToEventId = null,
drivesPaging = drivesPaging,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -294,6 +299,7 @@ private fun NotificationPagerPage(
scrollToEventId: String?,
accountViewModel: AccountViewModel,
nav: INav,
drivesPaging: Boolean = true,
) {
RefresheableBox(state, true) {
val listState = rememberForeverLazyListState(scrollStateKey)
@@ -309,6 +315,7 @@ private fun NotificationPagerPage(
routeForLastRead = NOTIFICATION_LAST_READ_KEY,
scrollToEventId = scrollToEventId,
headerContent = { ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav) },
drivesPaging = drivesPaging,
)
}
}

View File

@@ -104,6 +104,7 @@ fun buildSettingsCatalog(
symEntry(R.string.ots_explorer_settings, MaterialSymbols.Search, R.string.ots_explorer_search_keywords, Route.OtsSettings),
symEntry(R.string.namecoin_settings, MaterialSymbols.Security, R.string.namecoin_search_keywords, Route.NamecoinSettings),
symEntry(R.string.resource_usage_title, MaterialSymbols.Bolt, R.string.resource_usage_search_keywords, Route.ResourceUsage),
symEntry(R.string.agent_console_title, MaterialSymbols.AutoAwesome, R.string.agent_console_search_keywords, Route.AgentConsole),
),
)

View File

@@ -36,6 +36,7 @@
<string name="channel_image">صورة القناة</string>
<string name="referenced_event_not_found">لم يتم العثور على الحدث المشار إليه</string>
<string name="could_not_decrypt_the_message">لا يمكن فك تشفير الرسالة</string>
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
<string name="group_picture">صورة المجموعة</string>
<string name="explicit_content">محتوى فاضح</string>
<string name="relay_notice">إشعار relay</string>
@@ -730,6 +731,7 @@
<string name="napplet_consent_query">يريد هذا nApplet قراءة الأحداث من relay الخاصة بك.</string>
<string name="napplet_consent_storage">يريد هذا nApplet استخدام مساحة التخزين الخاصة به.</string>
<string name="napplet_consent_pay">يريد هذا nApplet دفع فاتورة Lightning.</string>
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
<string name="napplet_consent_resource">يريد هذا nApplet جلب مورد ويب.</string>
<string name="napplet_consent_upload">يريد هذا nApplet تحميل ملف إلى خادم الوسائط الخاص بك.</string>
<string name="napplet_consent_notify">يريد هذا nApplet عرض إشعارات لك.</string>
@@ -742,11 +744,24 @@
<item quantity="many">يريد هذا nApplet دفع فاتورة Lightning بقيمة %1$d sats.</item>
<item quantity="other">يريد هذا nApplet دفع فاتورة Lightning بقيمة %1$d sats.</item>
</plurals>
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
kind-only summary would hide what is actually being signed. These replaceable lists are
already cached on the account, so the dialog diffs the proposed list against the current
one and reports what actually changes rather than a raw total. -->
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
the display name, shown next to their avatar. -->
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
<!-- No cached copy to compare against, so the whole list is what gets written. -->
<!-- Signer permissions: first-connect dialog -->
<!-- Signer trust levels -->
<!-- Signer per-op consent dialog -->
<!-- Signer op labels -->
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
<!-- Permissions management screen -->
<!-- NIP-46 remote signer (bunker) -->
<!-- Relay Authentication (NIP-42) settings -->

View File

@@ -25,6 +25,7 @@
<string name="channel_image">চ্যানেল ইমেজ</string>
<string name="referenced_event_not_found">উল্লেখিত ইভেন্টটি পাওয়া যায় নি</string>
<string name="could_not_decrypt_the_message">মেসেজটি ডিক্রিপ্ট করা যায় নি</string>
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
<string name="group_picture">দলগত ছবি</string>
<string name="explicit_content">খোলামেলা আধেয়</string>
<string name="relay_notice">রিলে নোটিশ</string>
@@ -703,6 +704,7 @@
<string name="napplet_consent_query">এই nApplet আপনার relay-গুলো থেকে ইভেন্ট পড়তে চায়।</string>
<string name="napplet_consent_storage">এই nApplet তার প্রাইভেট স্টোরেজ ব্যবহার করতে চায়।</string>
<string name="napplet_consent_pay">এই nApplet একটি Lightning ইনভয়েস পরিশোধ করতে চায়।</string>
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
<string name="napplet_consent_resource">এই nApplet একটি ওয়েব রিসোর্স আনতে চায়।</string>
<string name="napplet_consent_upload">এই nApplet আপনার মিডিয়া সার্ভারে একটি ফাইল আপলোড করতে চায়।</string>
<string name="napplet_consent_notify">এই nApplet আপনাকে বিজ্ঞপ্তি দেখাতে চায়।</string>
@@ -711,11 +713,24 @@
<item quantity="one">এই nApplet %1$d sat-এর জন্য একটি Lightning ইনভয়েস পরিশোধ করতে চায়।</item>
<item quantity="other">এই nApplet %1$d sats-এর জন্য একটি Lightning ইনভয়েস পরিশোধ করতে চায়।</item>
</plurals>
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
kind-only summary would hide what is actually being signed. These replaceable lists are
already cached on the account, so the dialog diffs the proposed list against the current
one and reports what actually changes rather than a raw total. -->
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
the display name, shown next to their avatar. -->
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
<!-- No cached copy to compare against, so the whole list is what gets written. -->
<!-- Signer permissions: first-connect dialog -->
<!-- Signer trust levels -->
<!-- Signer per-op consent dialog -->
<!-- Signer op labels -->
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
<!-- Permissions management screen -->
<!-- NIP-46 remote signer (bunker) -->
<!-- Relay Authentication (NIP-42) settings -->

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