Fifth batch of the note-ui-commons extraction, and the first to use the
platform-specific @Composable slot pattern the plan calls for (§3e/§4).
The PS1 save card (kind 38192) is pure value-in except for its animated
16×16 icon, which is decoded from raw memory-card pixels via Android Bitmap
APIs (createBitmap/setPixels/asImageBitmap) that can't move to commonMain.
- commons/ui/note/Ps1SaveCard.kt: the card takes decoded primitives (title,
filename, region, block number, blank flag, hex preview) plus an
`icon: (@Composable () -> Unit)?` slot; a null icon falls back to the
floppy-disk emoji prefix. Labels resolve via commons Res.
- amethyst keeps the thin RenderPs1Save(Note) entry, which decodes the event
and supplies the native Ps1SaveIconImage (the bitmap/frame-clock animation)
as the icon slot.
- Migrated ps1_save_title/ps1_save_block/ps1_save_empty_slot with all locale
translations into commons/composeResources; deleted the amethyst copies.
Verified: :commons:compileKotlinJvm and :amethyst:compileFdroidDebugKotlin
both pass; every touched strings.xml is well-formed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
Fourth batch of the note-ui-commons extraction. The two Birdstar cards
(Birdex kind 12473, bird detection kind 2473) are pure value-in renderers:
they only read decoded tag values and open one Wikidata link, with no
AccountViewModel/INav.
- commons/ui/note/BirdexCard.kt: BirdexCard(BirdexEvent) and
BirdDetectionCard(BirdDetectionEvent). The scientific-name link reuses the
commons ClickableUrl atom; labels resolve via commons Res.
- amethyst keeps thin RenderBirdex(Note) / RenderBirdDetection(Note) entries
that decode the event and call the shared cards.
- Migrated bird_detection_title (string) plus birdex_species_count and
birdex_species_preview_more (plurals) with all locale translations from
amethyst/res into commons/composeResources; deleted the amethyst copies.
Verified: :commons:compileKotlinJvm and :amethyst:compileFdroidDebugKotlin
both pass; every touched strings.xml is well-formed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
Third batch of the note-ui-commons extraction. GitDiffView is a pure
presentation composable — it takes a quartz ParsedPatch + a Modifier, with
no AccountViewModel/INav/Note — and its syntax-highlighting helper
(CodeHighlighter) already lives in commons, so it moves wholesale.
- commons/ui/note/GitDiffView.kt: the GitHub-style file-by-file diff view
(stat summary, per-file collapsible cards, +/- line coloring, intraline
emphasis, syntax highlighting). Labels resolve via commons Res.
- Migrated git_diff_binary (string) and git_diff_files_changed (plural,
all quantity forms across 11 locales) from amethyst/res into
commons/composeResources; deleted the amethyst copies. This exercises the
now-wired commons Crowdin pipeline for a plural resource.
- Callers (Git.kt, GitPullRequestChanges.kt, GitCommitLog.kt) re-point the
import to commons; the call sites are unchanged.
Verified: :commons:compileKotlinJvm (plural accessor generation) and
:amethyst:compileFdroidDebugKotlin both pass; every touched strings.xml is
well-formed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
Second batch of the note-ui-commons extraction, and the load-bearing
setup for every string-bearing renderer that follows.
Commons had no translation pipeline — crowdin.yml synced only the Android
app's strings.xml — so moving an already-translated string to commons
Res.string would drop its translations and remove it from future syncs.
This commit fixes that first:
- crowdin.yml: add commons/composeResources as a second Android source
with the same language mapping, so shared keys keep flowing through
Crowdin.
- Migrate the four git_status_* pill labels (open/merged/closed/draft)
and all 56 existing locale translations from amethyst/res into
commons/composeResources, deleting the amethyst copies. No language
regresses.
Then the renderer split (NIP-34 git status pill):
- commons/ui/note/GitStatusPill.kt: the StatusKind enum + the pure
GitStatusPill(kind, modifier) presentation composable, resolving labels
via commons Res.string. Shared by Android and Desktop.
- amethyst keeps the thin GitStatusPill(targetIdHex, …) entry that reads
the account-bound GitStatusIndex and delegates to the commons pill.
- Callers (GitItemListRow, Git.kt) re-point StatusKind to commons.
Verified: :commons:compileKotlinJvm (accessor generation + all 56 locale
qualifiers accepted) and :amethyst:compileFdroidDebugKotlin both pass;
every modified/added strings.xml is well-formed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
First pilot of the note-ui-commons extraction plan
(commons/plans/2026-07-16-note-ui-commons-extraction.md, Tier 0). These
two event-kind renderers take plain quartz events with no AccountViewModel,
INav, or R.string, so they move to commons/ui/note/ almost verbatim:
- EcashMintCard: RenderCashuMint / RenderFedimint / RenderMintRecommendation
move wholesale (already past the seam — the NoteCompose dispatcher hands
them a decoded event). NoteCompose + ThreadFeedView imports re-pointed.
- CodeSnippetCard: the feed preview and thread header layouts move to
commons; the Android app keeps a thin RenderCodeSnippetEvent(Note) entry
that decodes the Note and calls the shared card.
- NoteBorders: shared QuoteBorder/SmallBorder/StdHorzSpacer/StdVertSpacer/
subtleBorder/replyModifier theme primitives in commons so the migrated
cards share one visual vocabulary. Mirrors the Android ui/theme values;
the Android copies are deleted once every renderer that uses them moves.
Desktop now renders the identical cards, so the two front ends can't drift.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
Address gallery layout feedback for the "accepted by relays" line:
- Move the relay line to the bottom of the reaction detail gallery.
- Use NotificationIconModifier for the Dns icon box so it lines up
horizontally with the zap/like/nutzap category icons (which carry the
same 5dp end padding).
- Show the reaction-gallery expand button whenever the note has relay
URLs, not only when it has reactions/zaps/boosts, so the always-present
relay line is reachable. Gate it with a lightweight boolean relay
observer (no list allocation, no sampling) since it runs per feed note.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVPYTTnMbxwi5hUAagsKVc
The backward "load older" pager REQ'd only the current epoch's Chat Plane, so
deep scroll stopped at the last Refounding and showed "All caught up" while
older messages sat under prior-epoch planes.
Widen the history REQ authors to the union of the channel's plane pubkeys
across every held epoch (ConcordCommunitySession.channelPlaneAddressesAllEpochs).
The relay serves them interleaved by created_at, so one backward `until` sweep
walks the whole cross-Refounding timeline and `exhausted` (the "All caught up"
signal) now means every epoch is drained, not just the current one. The pager
is unchanged — it only tracks until/limit per relay and forwards createdAt; the
prior-epoch wraps decrypt on the normal ingest path (already epoch-aware).
Test: ConcordCommunitySessionTest asserts channelPlaneAddressesAllEpochs returns
current + prior planes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A CORD-06 Refounding rotates the community_root and bumps the epoch, so each
channel's pre-refounding messages live under a different derived Chat Plane
per epoch. The client only ever subscribed to the current epoch's plane, so
older history was invisible and the feed said "All caught up" while months of
messages sat on the same relays under prior-epoch stream keys.
The account already persists each rotated-out root in
ConcordCommunityListEntry.heldRoots; this consumes them on the read side:
- ConcordActions.historicalChannelPlanes() re-derives each folded channel's
plane at every held epoch (bounded by MAX_BACKFILL_EPOCHS = 8; 0 disables).
- ConcordCommunitySession keeps a historicalChannelKeysByAddress map (derived
in refold, since channels are known only after a fold) and folds it into
channelAddresses() (subscribe), streamKeys() (NIP-42 AUTH), and ingest()
(decrypt with the matching epoch, isBoundTo per epoch). Channel ids are
epoch-invariant, so historical messages merge into the same channel feed.
- ConcordSubscriptionPlanner.channelPlaneSubs appends the historical planes,
so the existing filter assembler subscribes to them unchanged.
Writes / moderation / rekey stay strictly on the current epoch. Cross-validated
against amy: the app now subscribes to the exact prior-epoch plane pubkeys
`amy concord read --epoch 0` proved hold the older Soapbox #nostrhub messages.
Tests: ConcordCommunitySessionTest.ingestsPriorEpochWrapsFromAHeldRoot,
ConcordSubscriptionPlannerTest.channelSubsAlsoCoverPriorEpochPlanesForHeldRoots.
Follow-up (plan step 3): BackwardRelayPager epoch-stepping so deep "load older"
scroll crosses epochs to the true start.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Web-based highlighter clients publish kind:9802 highlights with W3C Web
Annotation selectors (textquoteselector / textpositionselector /
rangeselector) instead of a NIP-84 `context` tag. These were previously
ignored, so the highlight rendered without its surrounding paragraph and
the "jump to page" link couldn't disambiguate repeated quotes.
- Parse the W3C textquoteselector into TextQuoteSelectorTag (exact/prefix/
suffix; a "-" or empty exact is treated as a placeholder since the quote
lives in .content).
- HighlightEvent.contextOrReconstructed() prefers an explicit `context`
tag and otherwise rebuilds the paragraph from prefix + content + suffix,
so the in-context bolding still works.
- Build a disambiguated Text Fragment URL (`#:~:text=prefix-,exact,-suffix`)
from the selector's prefix/suffix so the source link scrolls to the
correct occurrence.
The position/range selectors are left unparsed; they only matter for an
in-app live-page re-highlighter, which we don't have.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Di4UurD9SQGrpScX7uy2Kh
Replace the Jackson-based FHIR resource parser with kotlinx.serialization.
The `resourceType` polymorphism is now handled by a JsonContentPolymorphicSerializer
that dispatches to the modeled types (Practitioner, Patient, Bundle,
VisionPrescription) and falls back to a new UnknownResource for anything else,
so a Bundle mixing known and unknown resources still parses.
The reader is lenient (ignoreUnknownKeys, isLenient, explicitNulls=false,
coerceInputValues) so we parse what we can and tolerate the missing or extra
fields that many FHIR implementations add.
Adds MiniFhirTest covering the vision-prescription bundle, extra/unknown field
tolerance, unknown-resourceType fallback, mixed bundles, and garbage input.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUeR8YFNHvBELCaLRDTpCn
The relay favicons rendered at 17dp while the zap/boost/reaction gallery
rows use 35dp author pictures, so the "accepted by relays" line looked
out of scale. Parameterize RenderRelay with a box size and icon modifier
(defaults unchanged for the existing compact relay lists) and render the
gallery relays at 35dp with MediumRelayIconModifier to match.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVPYTTnMbxwi5hUAagsKVc
NostrSignerRemote extended NostrSigner(signer.pubKey), where `signer` is the
ephemeral NIP-46 transport keypair — so `pubKey` returned the transport key,
not the user's identity. Every self-encryption / self-authorship site keys off
`signer.pubKey`, so for bunker accounts this silently broke:
- private NIP-51 lists (private bookmarks / mute / follows / hashtags) and
NIP-37 drafts — an `if (signer.pubKey != event.pubKey)` guard short-circuits
(desktop: private bookmarks always empty);
- NIP-44 self-encrypted data (Concord list, Cashu) sealed to / read against
the wrong peer key.
Android is unaffected (no bunker path); desktop and CLI were affected.
Make `NostrSigner.pubKey` open and have `NostrSignerRemote` return the
bunker-resolved user key: `getPublicKey()` now caches it, and `bindUserPubkey()`
sets it eagerly for a reloaded account / stored identity. Internal transport
(the response-subscription `p` filter, request addressing) keeps using the
transport keypair explicitly, so it is unchanged. No-op for local/external
signers, where signer.pubKey already equals the account key.
Wired: desktop AccountManager.loadBunkerAccount binds the resolved pubkey; CLI
Context binds identity.pubKeyHex. amy's Concord-list decrypt workaround is
dropped — `newest.decrypt(ctx.signer)` now works for a bunker. Verified live:
`amy concord import` over a bunker account decrypts the kind-13302 list and
recovers Soapbox heldRoots [0,1].
Plan: quartz/plans/2026-07-17-nip46-remote-signer-self-pubkey.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
NostrSignerRemote.pubKey returns the ephemeral NIP-46 transport key, not
the verified user identity, so every self-encryption / self-authorship site
that uses signer.pubKey as "myself" breaks for bunker accounts. Verified
impact: Android unaffected (no bunker path); desktop private NIP-51 lists
(private bookmarks/mute/follows) and NIP-37 drafts silently empty, Cashu
self-encryption sealed to the wrong peer; CLI the same incl. `concord list`.
Records the two failure modes, the affected call sites, and the fix
direction (resolve pubKey to the user key via get_public_key while pinning
transport uses to the transport keypair).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A refounded Concord community (CORD-06 rotates community_root + bumps the
epoch) keeps its pre-refounding messages under the prior epoch's derived
Chat Plane stream key. The client only ever fetches the current epoch, so
older history is invisible and the feed says "All caught up".
Add the diagnostics to reach it:
- `amy concord import` — fetch this account's own kind-13302 list, decrypt
it, and upsert every community WITH its heldRoots (the prior-epoch access
roots Amethyst persists across Refoundings). Decrypts against the account
identity, not signer.pubKey (which for a bunker is the ephemeral transport
key, not the self-encryption peer).
- `amy concord read <community> <channel> --epoch <n> [--root <hex>]` — read
a prior epoch's Chat Plane; the root auto-resolves from the stored
heldRoots when --root is omitted. Output includes the epoch + derived plane.
- StoredCommunity.heldRoots persistence.
Verified live against Soapbox #nostrhub: epoch 0 (a held root) returns 7
messages the app never shows; epoch 2 (current) returns 2 — reproducing the
gap and confirming heldRoots-walking recovers the history.
Design for the in-app fix (walk heldRoots on the read side) lives in
commons/plans/2026-07-17-concord-epoch-walking-backfill.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`amy login bunker://<pubkey>?…` was persisting the pubkey embedded in the
bunker URI as the account identity. That pubkey is the remote-signer /
connection key (Amber, nsec.app, nak all mint a dedicated one), not the
user's identity key — so every "my events" fetch queried the wrong author
(no kind-10002/10050/13302 found, empty timelines).
After saving a provisional identity, connect the bunker and call the
NIP-46 `get_public_key` RPC (already implemented on NostrSignerRemote),
persisting the returned user pubkey as the account identity while keeping
the bunker's remote key in Identity.bunker for RPC addressing. Best-effort:
falls back to the URI pubkey if the bunker can't answer. Mirrors the app's
NostrConnectLoginUseCase, which already stores the verified pubkey.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Restore a visibility guard on the reaction detail gallery so a note with
neither relays nor reactions no longer paints an empty padded row when
the gallery is expanded. The relay flow is now collected once via
observeNoteRelays and reused both for the guard and to render the
"accepted by relays" line, avoiding a second subscription.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVPYTTnMbxwi5hUAagsKVc
ConcordInviteScreen auto-redeems and forwards to Route.ConcordServer, but it did
so with nav.newStack(). Since the community route isn't in the back stack yet,
newStack's popUpTo(target) matches nothing and the invite screen stays underneath.
Pressing Back from the community then reveals the invite screen, whose plain
remember(link) state resets to Working, re-runs joinConcordViaInvite, and forwards
right back — trapping the user in a Back→forward loop.
Forward with popUpTo(ConcordServer, ConcordInvite::class) instead, matching the
sibling group-creation screens (CreateGroupScreen, RelayGroupMetadataScreen), so
the invite screen is removed and Back returns to wherever the invite was opened.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeJJCDLdvi4KQEuozcMMBh
Replace the radio-row inline/grouped pickers with the same bordered PolicyCard
selection cards the Relay Authentication "When to authenticate" picker uses:
an accent-colored icon + label + description, a check when selected, and an 8dp
stack. Inline uses the list icon, grouped uses the folder icon (both already in
the Material Symbols subset).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RbzL9cZk1h88kE7ErgjG5
When redeeming a Concord invite link (`…/invite/<naddr>#<fragment>`) whose
resolved community is already in the joined list, return
`ConcordInviteResult.Joined` immediately instead of re-following and
re-announcing a Guestbook JOIN (kind 3306). The invite screen already forwards
to `Route.ConcordServer(communityId)` on `Joined`, so reopening an old invite
for a community you're already in simply takes you to it rather than spamming
the community relays with a fresh join every time.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeJJCDLdvi4KQEuozcMMBh
Complete UI Mode used to paint the "seen on" relay pills below the
author avatar (BadgeBox / RelayBadges), hidden from every other mode.
As part of retiring Complete UI Mode, surface those relay icons for
everyone instead: they now appear as an "Accepted by relays" line at
the top of the expanded reaction gallery.
- Add AcceptedByRelaysGallery: a reaction-gallery line (Dns icon +
FlowRow of relay favicons) mirroring the OnchainZap/Nutzap gallery
pattern, subscribing to the note's sampled relay flow.
- Wire it as the first line of ReactionDetailGallery and drop the
hasReactions gate so the line shows whenever the gallery is opened
(a note almost always has at least one relay).
- Remove the complete-mode-only BadgeBox and the now-dead RelayBadges
composable and the relayBadges slot in NoteComposeLayout (reindexing
the single-pass measure/place logic).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVPYTTnMbxwi5hUAagsKVc
Relays that can't serve a request (a NIP-50 search-only relay pulled into
the feed, a write-only relay, a relay whose filter shape is rejected) were
being hammered with the same doomed REQs. Observed on a 40s cold start:
search.nos.today CLOSED 13-14x ("error: search filter is required") across
6 subscriptions, plus repeated "restricted: does not accept REQs" and
"unsupported: too many filters". The reconnect path replayed refused REQs on
every reconnect, and many different subscriptions kept hitting the same
capability wall.
Two complementary, purely-quartz mechanisms (so the app and Amy both benefit
with zero wiring):
- Per-subscription refusal memory (RequestSubscriptionState + PoolRequests):
a filter a relay CLOSES is not replayed to that relay across reconnects
until it meaningfully changes or a REQ succeeds (EOSE/event). Never applies
to auth-required (the auth subsystem re-signs and replays) or rate-limited
(the adaptive limiter spaces it out).
- Per-relay capability block (RelayReqRefusals): after 2 refusals, classify a
relay SEARCH_ONLY (suppress only non-search filters; genuine search REQs
still flow) or NO_READS (suppress all), from narrow substring markers that
deliberately avoid auth-conditional messages. A fully-blocked relay is
dropped from PoolRequests.desiredRelays so the pool disconnects it, closing
the idle socket rather than keeping it open with every REQ suppressed. A
SEARCH_ONLY relay stays connected while any subscription carries a search
filter for it, so the separate search path is unaffected.
Adds UNSUPPORTED to MachineReadablePrefix (relays send "unsupported:"; parse()
returned null on it before).
Device-verified: search.nos.today now Connecting -> OnOpen -> 2x Closed ->
Disconnected; sendit.nosflare.com (NO_READS) -> 3x Closed -> Disconnected;
feed event volume unchanged (kind-1 from 17 relays) - no coverage loss.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a "Conversations to load" section to Settings › Messages that lets users
choose which chat protocols the inbox loads: NIP-04, NIP-17, NIP-28, NIP-29,
Marmot (MLS), Concord, Geolocation (geohash) and Ephemeral chats.
Disabling a type both hides its rows from the inbox and drops its kinds/
assemblers from the always-on downloading routes:
- New ChatFeedType enum (commons) with stable persisted codes.
- AccountSettings.enabledChatFeeds (defaults to all-on); persisted per-device
in LocalPreferences as the disabled set, so absence = everything on and any
future type defaults enabled.
- ChatroomListKnown/NewFeedFilter gate each section (NIP-04 vs NIP-17 split by
event type) in both the full build and the incremental update paths.
- Each downloading route (rooms-list NIP-04/28/ephemeral/geohash, account gift
wraps + Marmot, NIP-29 joined groups, Concord) returns no filters when its
type is off and re-arms via a shared launchChatFeedToggleObserver.
- AccountFeedContentStates rebuilds both tabs when a toggle flips.
- Modernized MessagesSettingsScreen: colorful accent switch cards per type; the
NIP-29/Concord display-mode options now only appear while their type is on.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RbzL9cZk1h88kE7ErgjG5
Document this session's CLI additions in the living NIP-46 device checklist
(no separate plan doc for two flags — they're covered by `amy --help`):
- a "CLI interop driver (amy)" section covering amy on both sides — client
(login bunker:// / --nostrconnect [--perms]) and signer (bunker --perms /
--interactive) — as the reproducible interop harness.
- a repro command on the NostrConnect informed-consent item: `amy login
--nostrconnect --perms sign_event:1,nip44_encrypt` emits a perms-carrying
offer that drives the app's consent sheet.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Until now the CLI bunker auto-approved every request (it hosts the
operator's own key, so the pairing secret was the only gate). That left
two NIP-46 signer behaviors the harness couldn't exercise: a signer that
*rejects* disallowed ops, and a signer that asks a human live.
Add two opt-in gates to both `amy bunker` and `amy bunker connect`:
- `--perms sign_event:1,nip44_encrypt,…` restricts the signer to the
listed ops; anything else is rejected. Fully scriptable/headless. This
is the server-side mirror of the client's `--nostrconnect --perms`, so
an interop run can now test a client against a rejecting signer.
- `--interactive` keeps the bunker listening and prompts `y/N` on the
terminal for any op the policy doesn't already allow, so the operator
approves/rejects each request live. TTY-guarded (errors on a piped
stdin), default-deny, prompts serialized by a mutex because the service
dispatches requests concurrently. Composes with `--perms` (auto-allow
the safe ops, prompt for the rest) — mirroring Amethyst's Reasonable
policy.
Neither flag → unchanged auto-approve behavior. Thin assembly: the perms
parsing and request→op mapping are reused from commons
(`Nip46PermissionAuthorizer.parsePerms` / `toSignerOp`), both already
unit-tested there.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
The Amethyst signer now honors a nostrconnect offer's `perms=` for
informed consent, but the CLI client had no way to emit one — so the new
consent flow couldn't be driven from `amy`, the project's interop-test
harness.
Add a `--perms` flag to `amy login --nostrconnect`, threaded into the
offer through quartz's NostrConnectURI (which already builds/parses the
param). Also keep the parsed perms on NostrConnect.Offer and surface a
client's requested perms in `amy bunker connect`, so an operator can see
what an app-side signer would be asked to pre-grant (the CLI bunker still
auto-approves the operator's own key — perms is not a gate there).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Closes the one real gap from the "first-class" review. The nostrconnect flow
treated the paste/scan as blanket consent and, having no dialog, had to DROP the
app's declared sensitive perms to avoid a silent grant.
Now first contact via nostrconnect shows a connect sheet with the app's identity,
the account it would act as, the exact permissions it declared (rendered
human-readably — "Sign notes (kind 1)", "Encrypt messages", …), and a trust
picker — before anything is published or granted. Approving connects and
pre-grants exactly those declared ops (including sensitive ones the user just
reviewed) unless they pick Paranoid; Cancel/Block declines and nothing is
registered. A re-pair of a known app skips the sheet and keeps the user's prior
trust/per-op decisions.
Adds SignerConnectInfo.requestedPermissions + rendering, a
Nip46ConsentBridge.requestNostrConnectConsent path, and a ConnectResult.Declined.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
The UI login path (AccountSessionManager) and the background preloaders
(RegisterAccounts, EventNotificationConsumer, loadAllWritableAccounts) race
loadAccount's check-then-create on cold start and after account switches.
Both saw an empty cache and both built an Account: the loser was never
cancelled, so its Nip46SignerState kept answering bunker requests with a
NostrSignerExternal no Activity ever registers an Amber launcher on — every
sign failed 'No activity to launch from' while the twin raced error replies
to NIP-46 clients and doubled every consent prompt. Reproduced on-device
(f8ff11c7 constructed twice 10ms apart); gone after the lock.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two follow-ups on the batched-consent sheet:
One account per sheet. The consent coordinator is process-wide, so a batch could
bundle requests for different logged-in accounts. Instead of mixing them, the
Activity now renders only the oldest-pending account's group; when that clears,
the next account's requests render as their own sheet. The account moves to a
header (avatar + "signing as <name>") since every row in a sheet now shares it.
Dismissing denies only that account's group.
Expandable per-item preview. Each batch row is collapsed to app · operation +
a one-line excerpt; tapping it expands the full detail so the user can inspect
exactly what they're signing/encrypting/decrypting — the unsigned event rendered
as a NoteCompose (with the JSON toggle), or the encrypt/decrypt plaintext.
Extracted that rich content block into a shared SignerConsentPreview reused by
the single-request dialog and each expanded row.
Removed the now-unused denyAllPending (dismissal is per-group / per-request).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF