Commit Graph
1247 Commits
Author SHA1 Message Date
Claude bb03cd2a3c feat(blossom): full-client protocol support across quartz, commons, CLI and Android
Extends Blossom support toward a full client on both the CLI and the mobile app.

Quartz (protocol):
- BlossomAuthorizationEvent: add t=media auth (BUD-05) and optional BUD-11
  `server` domain scoping on every factory (stops replayable upload/delete tokens)
- BlossomServerUrl: mirror/media/list/report path builders, BUD-06 preflight and
  BUD-07 payment header constants, and a lowercase bare-domain helper
- BlossomUploadResult: parse `ox` (BUD-05 original hash) and `nip94` (BUD-08)
- BlossomPaymentRequired: BUD-07 402 challenge model (Cashu/Lightning)
- BlossomReport: BUD-09 kind-1984 blob report reusing NIP-56 tag builders

Commons (shared JVM client, now in jvmAndroid so Android shares it too):
- BlossomClient gains mirror (BUD-04), list/delete (BUD-02), media (BUD-05),
  preflight/has (BUD-06/01), report (BUD-09) and typed 402 handling
- BlossomAuth: media/list/delete passthroughs with server scoping

CLI (first-class):
- amy blossom now routes all HTTP through the shared client and adds `media`
  and `report` verbs; auth tokens are scoped to --server

Android (first-class):
- uploads mirror to the user's other Blossom servers (BUD-04) best-effort
- new "Manage stored files" screen: per-server presence matrix (BUD-02 list +
  BUD-01 HEAD), delete, mirror-to-missing, and report actions

Tests: quartz URL/auth/descriptor/payment parsing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
2026-07-17 23:36:41 +00:00
Claude ee217a42dc perf(commons): remember derived values in the moved note cards
Audit follow-up. These renderers sit in scrolling feeds; each recomputed
event parses / string builds directly in the composable body instead of
caching them against the (immutable) event — the CLAUDE.md rule-#4 pattern.
The cards are already skippable (immutable event params + strong skipping),
so this is work redone per composition (each item scrolling into view), not
per frame; still worth removing. These patterns pre-existed in the Android
originals and were carried over faithfully — this cleans them up now that the
code is shared.

- RelayDiscoveryCard: the heaviest — 6 `joinToString` + a `.sorted()` ran in
  the body. Now each display string (network / relay-type / requirements /
  supported-NIPs / accepted-kinds / geohashes, the requirements lock flag, and
  the relay-URL displayUrl()) is `remember`ed off its parsed list; the row
  visibility checks still key off the original lists so behavior is identical.
- CalendarRsvpCard: `status` / `calendarEventAddress` / `freebusy` parses now
  `remember(event)` instead of re-scanning tags every composition.
- CalendarCollectionCard: `title()` and `calendarEventAddresses().size` (which
  allocated a whole List just to read size) now `remember(event)`.
- PodcastValueSplits: the `recipients.filter{}` + `totalSplit()` now
  `remember(value)`.

Behavior is unchanged (same keys, conditions, and outputs). Verified:
:commons:compileKotlinJvm + :amethyst:compileFdroidDebugKotlin pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
2026-07-17 22:50:09 +00:00
Claude c4957ee8c0 fix(commons): base ColorScheme.isLight on background, not primary luminance
commons ColorScheme.isLight tested `primary.luminance() < 0.5f`. With the
default purple accent the primary is a deep purple in the light theme (lum
0.09) AND a light purple in the dark theme (lum 0.35) — both < 0.5 — so it
reported "light" in BOTH modes. The Android app decides the same thing from
the background (`background != Color.Black`); `background.luminance() > 0.5f`
is the multiplatform-safe equivalent (light bg ≈ 0.98, dark bg = 0.0).

Surfaced while reviewing the note-ui extraction: the new commons theme helpers
that branch on isLight — subtleBorder / replyModifier (card hairline borders)
and allGoodColor / warningColor (RelayDiscovery latency chips) — were rendering
their light-theme variant in dark mode, a regression vs the Android originals
which use the app's background-based isLight. This restores parity.

Also corrects two pre-existing consumers that had the same latent bug and now
behave correctly in dark mode (worth a dark-mode glance in review):
- UserAvatar → CachedRobohash light/dark variant selection;
- ChatTheme.chatBubbleBackground alpha.

Verified: :commons:compileKotlinJvm passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
2026-07-17 22:50:09 +00:00
Claude c18256a175 docs(plan): record note-ui-commons extraction progress + findings
Amends commons/plans/2026-07-16-note-ui-commons-extraction.md with a §0.1
"Progress & findings" section after landing 13 event kinds:

- a batch table (what moved, which seam mechanic each proved) and the shared
  commons theme now in place;
- Finding A: commons i18n was an unscoped prerequisite — wired commons into
  Crowdin so migrated strings/plurals keep every locale;
- Finding B: gate #1 (amethyst reads commons Res) is the real Tier-1 unblock,
  since most renderers share strings with a still-native screen;
- Finding C: "unused seam" (declares accountViewModel/nav but never calls
  them) is the cleanest Tier-1 signal;
- Finding D: commonMain bans Jackson (blocks MedicalData/MiniFhir);
- Finding E: platform leaves become opaque or typed @Composable slots (PS1
  bitmap icon, Roadstr map) and it works cleanly.

Also flips front-matter status to in-progress and annotates the Tier 0 / Tier
1 lists in §5 with what's done and what's left. The design (§1–§8) is
unchanged; §0.1 is the amendment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
2026-07-17 22:50:09 +00:00
Claude 14c9f99bac refactor(commons): extract NIP-52 calendar collection + RSVP cards to commons
Twelfth batch of the note-ui-commons extraction — the calendar family, and
a fuller exercise of gate #1: strings shared with three native call sites
move to commons with no duplication.

- commons/ui/note/CalendarCollectionCard.kt: the calendar collection card
  (kind 31924) — title, description, event count.
- commons/ui/note/CalendarRsvpCard.kt: the RSVP card (kind 31925) — the
  going/maybe/not-going status, note, and target address.
  Both are pure value-in; the entries' unused accountViewModel/nav are dropped.
- Strings/plural migrated to commons: calendar_rsvp_going/maybe/not_going and
  the calendar_collection_count plural. Their native co-users now read them
  from commons Res instead of the Android res tree — CalendarRsvpRow (the
  interactive RSVP row), CalendarEventDetailScreen, and CalendarCollectionsView
  — so each key lives in exactly one place.
- amethyst keeps the thin RenderCalendarCollectionEvent / RenderCalendarRSVPEvent
  dispatcher entries.

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
2026-07-17 22:50:09 +00:00
Claude fd4d9c10f7 refactor(commons): extract RelayDiscovery card to commons
Eleventh batch of the note-ui-commons extraction, and the first to exercise
gate #1 (app-side reads of commons Res) for a string shared with a native
screen — no duplication.

- commons/ui/note/RelayDiscoveryCard.kt: the NIP-66 relay discovery/monitor
  card (relay URL, latency health chips, network/relay-type/requirements/
  NIPs/kinds/topics/geohashes). Pure value-in — takes the quartz event; the
  entry's unused accountViewModel/nav are dropped.
- commons/ui/theme: adds ColorScheme.allGoodColor / warningColor (the
  green/amber status colors), mirroring the Android values.
- Migrated the 10 relay_monitor_* / relay_discovery_* strings the card uses
  into commons. Seven of them are shared with the native RelayInformationScreen,
  which now reads them from commons Res instead — so the keys live in exactly
  one place. relay_monitor_reports (screen-only) stays app-side.
- amethyst keeps the thin RenderRelayDiscovery(Note, …) dispatcher entry.

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
2026-07-17 22:50:09 +00:00
Claude 62c2d7755d refactor(commons): extract PodcastValueSplits to commons
Ninth batch of the note-ui-commons extraction, completing the podcast
display family (badge/link/soundbite atoms landed earlier).

- commons/ui/note/PodcastValueSplits.kt: the Podcasting-2.0 value-for-value
  split breakdown card (header, split hint, one row per recipient with its
  percentage). Pure value-in — takes a quartz PodcastValue.
- commons/ui/theme: adds ColorScheme.grayText (onSurface @52%); reuses the
  Size5dp added with the ActivityCard batch.
- Consumers (PodcastEpisode, PodcastMetadata) re-point to commons.

Strings: podcast_value_zap_split_hint is renderer-only and moves fully to
commons. podcast_value_for_value and podcast_value_split_percent are also
used by the native V4V split editor — for_value via an Android-int
ResourceToastMsg toast that has no Compose-resource equivalent, and the
editor can't reference commons' generated Res until amethyst gains the
compose-resources dependency. So those two keys are intentionally duplicated
(kept in amethyst for the editor, copied to commons for the shared card).
The duplication collapses once amethyst can read commons Res or the editor
itself moves; both sources stay in Crowdin meanwhile.

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
2026-07-17 22:50:06 +00:00
Claude fb471a2e6b refactor(commons): extract Roadstr road-event card to commons
Eighth batch of the note-ui-commons extraction. The two Roadstr renderers
(report kind 1315, confirmation kind 1316) are self-contained except for the
map hero, which is a platform tile view.

- commons/ui/note/RoadEventCard.kt: RoadEventReportCard(event, map) and
  RoadEventConfirmationCard(event, map). Commons owns all the logic — the
  category emoji/color/label palette, the geohash→point resolution, the
  freshness-based pin opacity, the floating category pill, and the layout.
  The map arrives as a typed RoadEventMap slot
  (lat, lon, pinColor, pinEmoji, pinAlpha) so the native LocationPreviewMap
  stays in the app. Labels resolve via commons Res.
- amethyst keeps thin RenderRoadEventReport(Note) /
  RenderRoadEventConfirmation(Note) entries that decode the event and pass
  LocationPreviewMap as the slot.
- Migrated all 18 road_event_* strings with their 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
2026-07-17 22:49:36 +00:00
Claude 05f7da6814 refactor(commons): extract activity-card building blocks to commons
Seventh batch of the note-ui-commons extraction. The activity-card slot
composables — the gradient frame, kind badge, header row, amount row, and
pill shared by the reaction / zap / nutzap renderings — are pure
value/slot-in with no strings, so they move as-is.

- commons/ui/note/ActivityCard.kt: ActivityCardFrame, ActivityBadge,
  ActivityPill, ActivityHeaderRow, ActivityAmountRow, and the LikeTint color.
- commons/ui/theme/Sizes.kt: shared Size16Modifier (and Size5dp for the next
  batch), mirroring the Android ui/theme values.
- Consumers (Reaction, ZapEvent, Nutzap) re-point their imports to commons.

Verified: :commons:compileKotlinJvm and :amethyst:compileFdroidDebugKotlin
both pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
2026-07-17 22:49:35 +00:00
Claude 71d15bf916 refactor(commons): extract podcast badge/link/soundbite atoms to commons
Sixth batch of the note-ui-commons extraction. These are the shared
Podcasting-2.0 display atoms the show/episode renderers compose from; they
already take plain values + onClick/onPlayFrom lambdas, so they move as-is.

- commons/ui/note/PodcastChips.kt: PodcastBadge + PodcastLinkChip (now
  public), pure value/lambda-in pills.
- commons/ui/note/PodcastSoundbites.kt: the soundbite "jump to the good
  part" chip row; the play label resolves via commons Res, playback stays
  controller-agnostic through the onPlayFrom callback.
- Migrated podcast_play_soundbite with its locale translations into
  commons/composeResources; deleted the amethyst copy.
- Consumers (PodcastEpisode, PodcastMetadata, PodcastEpisodeAudioPlayer,
  PodcastChaptersView) re-point their imports to commons.

PodcastValueSplits is intentionally left for a follow-up: two of its strings
are shared with the native V4V split editor, so it needs the editor
re-pointed in the same change.

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
2026-07-17 22:49:35 +00:00
Claude 3b96dc2f82 refactor(commons): extract PS1 memory-card save card to commons
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
2026-07-17 22:49:34 +00:00
Claude a9ff6f5fbb refactor(commons): extract Birdstar Birdex/detection cards to commons
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
2026-07-17 22:48:33 +00:00
Claude b2c78d6b76 refactor(commons): extract GitDiffView renderer to commons
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
2026-07-17 22:48:31 +00:00
Claude a839ef226a refactor(commons): extract GitStatusPill to commons; wire commons i18n
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
2026-07-17 22:47:39 +00:00
Claude 870aeb4b99 refactor(commons): extract CodeSnippet + EcashMint renderers to commons
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
2026-07-17 22:47:37 +00:00
Vitor PamplonaandGitHub 23597ab883 Merge pull request #3623 from vitorpamplona/claude/messages-settings-toggles-s1qo9e
Add per-chat-type load toggles to Messages settings
2026-07-17 18:30:57 -04:00
Vitor PamplonaandClaude Opus 4.8 1d5b08a128 docs(concord): mark epoch-walking backfill steps 1-3 done
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:48:45 -04:00
Vitor PamplonaandClaude Opus 4.8 602c6a90b2 feat(concord): page channel history across epochs to the true start
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>
2026-07-17 17:48:45 -04:00
Vitor PamplonaandClaude Opus 4.8 891e6ced91 feat(concord): backfill prior-epoch channel history from held roots
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>
2026-07-17 17:48:45 -04:00
Vitor PamplonaandGitHub 3254b90b19 Merge pull request #3618 from vitorpamplona/claude/amy-nip46-bunker-concord-epoch-diag
fix(nip46): remote-signer pubKey is the user identity + amy Concord epoch tooling
2026-07-17 17:25:39 -04:00
Vitor PamplonaandClaude Opus 4.8 ac1ca888c7 feat(cli): amy concord import + prior-epoch history read
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>
2026-07-17 16:56:25 -04:00
Claude ae6e1e9c2d feat(messages): per-type load toggles for the Messages inbox
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
2026-07-17 16:22:20 +00:00
Claude 1d8b7d7c8f feat(nip46): batched consent via concurrent request dispatch
Third refinement from the Primal comparison — and the one that needed an
architecture change, not just UI.

Quartz: NostrConnectSignerService now fans each request into a child coroutine
under a Semaphore(maxConcurrentHandles=16) instead of handling them inline, so a
request awaiting a consent prompt no longer blocks other clients' auto-allowed
traffic and several prompts can be pending at once. Intake (dedup, staleness,
rate-limit, seen-id persistence) stays on the single consumer. Two guards keep
it safe: BunkerRequestProcessor serializes the actual crypto with a Mutex
(authorization — the prompt — runs unlocked, only sign/encrypt/decrypt holds the
lock) so an external NIP-55 signer never sees concurrent IPC ops; and
Nip46PermissionAuthorizer serializes first-connect consent so two connects can't
stack dialogs. Covered by BunkerRequestProcessorConcurrencyTest (crypto never
overlaps; a blocked prompt doesn't stall another client's signing).

Amethyst: SignerConsentCoordinator is now a shared pending StateFlow; one
SignerConsentActivity observes it and shows the rich single-request dialog (1
pending) or a batched checkbox list with select-all + a Remember toggle +
Allow/Deny selected (>1). Dismissing the sheet denies every still-open request
(fail closed).

Needs on-device validation (burst batching, no concurrent external-signer IPC,
fail-closed on dismiss) — see the device checklist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:35 +00:00
Claude 98c18a9fcf feat(nip46): honor nostrconnect perms + per-app live relay status
Two gaps found comparing against Primal's NIP-46 signer:

Honor the offer's `perms`: we already parsed the `nostrconnect://?perms=` list
but ignored it. Now the declared ops are pre-granted at pairing (the deliberate
pair is the user's consent for what the app openly asked for), so a client that
declares its needs runs without prompting on first use. The two highest-risk
classes stay gated even when declared — decryption (private content) and
deletion (kind 5) still prompt on first use with full context. Adds
Nip46PermissionAuthorizer.parsePerms + tests.

Per-app live relay status: the connected-apps list shows a Connected/Offline dot
per app (judged on its own nostrconnect relays, or the inbox relays for a
bunker-flow app), and the detail Relays section shows a live dot per relay — so
"which relays is this costing me and are they up right now" is answerable at a
glance. Shared Nip46StatusDot/Nip46LiveStatus/nip46AppOnline helpers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:35 +00:00
Claude 565342d7bf feat(nip46): drawer entry, dedicated apps screen, foreground surfacing, idle prune
Move the Nostr Signer out of Settings into the left drawer's "You" section,
directly under Wallet (and available as a bottom-bar favorite). Removed the
Settings catalog entry.

Give NIP-46 remote-signer clients their own management screen, separate from
the napplet/nsite/browser Connected Apps screen — unlike those, each NIP-46 app
can carry its own relays that the signer keeps subscribed in the background, so
they need distinct visibility (name, npub, relay count, last-used, trust level)
and pruning. The shared Connected Apps screen no longer lists NIP-46 apps.

Auto-forget apps idle for 7+ days on signer start (Nip46PermissionAuthorizer.
pruneIdle), so an app paired once and abandoned stops leaking a background relay
subscription forever. last-used is stamped on connect and every serviced op, so
an app still in use is never pruned.

Surface the consent dialog when Amethyst is backgrounded: a bare startActivity
from the app context is silently dropped by Android 12+ background-activity-launch
restrictions, so the dialog never appeared and the request timed out. Add a
full-screen-intent notification fallback (the same mechanism CallNotifier uses
for incoming calls) on a high-importance channel; it no-ops when the app is
already in the foreground so there's no redundant heads-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:34 +00:00
Claude 4fa41f1a67 feat(nip46): show which account is acting on the consent dialogs
Both signer dialogs now render the account's avatar + display name instead of
a raw pubkey / coordinate hex, so it's clear which logged-in identity is
approving, signing, encrypting, or decrypting:

- Connect dialog: replaces the client transport-pubkey line with the account
  being connected to (avatar + name).
- Per-op dialog: replaces the meaningless coordinate hex with the account that
  would sign/encrypt/decrypt.

The account is resolved from the coordinate's signer pubkey
(Nip46PermissionAuthorizer.signerPubKeyOf) via LocalCache, and rendered with a
shared ConnectedAccountRow (RobohashFallbackAsyncImage + name, robohash
fallback). SignerConnectInfo/SignerConsentInfo carry the account
name/picture/pubkey; the napplet/browser paths leave them null and keep their
existing domain line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:32 +00:00
Claude e69b59a255 test(nip46): verify a real-world Ditto kind-1 signs through the bunker
Runs the exact payload (kind 1 + `client` tag + fixed created_at) through the
processor/authorizer with a REASONABLE policy and asserts: it signs with no
prompt (kind 1 is auto-allowed), created_at/content/tags are preserved, the
event is authored by the identity key (not the transport key), and the
signature + id verify.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:31 +00:00
Claude ac6697330e test(nip46): consent integration test + device verification checklist (Tier 4)
- Nip46ConsentIntegrationTest: end-to-end through the real dispatch path
  (BunkerRequestProcessor → Nip46PermissionAuthorizer → opConsent/connectConsent)
  with a real NostrSignerInternal — proves an ASK sign prompts and returns a
  signed event on allow, "unauthorized" on deny, and that a FULL_TRUST app
  signs even a dangerous kind (0) without prompting.
- Device checklist (amethyst/plans/) for the interactive/background/interop
  behavior JVM tests can't cover: pairing paths, consent variants, rotation,
  activity feed, relay health, boot restart, and the reference-client matrix.

Notification polish was deliberately skipped: the always-on notification is
shared with the relay/DM service, and consent uses its own dialog Activity, so
neither retitling nor notification actions are warranted. Documented in the
checklist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:29 +00:00
Claude 4676d176ec feat(nip46): live per-request + first-connect consent (Tier 1)
Wire the NIP-46 remote signer into the same interactive consent surface the
napplet/browser signer path uses, so requests that aren't pre-granted prompt
instead of silently failing.

The ledger already returns ASK for the risky operations (profile 0, contacts
3, deletion 5, decryption, DMs are excluded from REASONABLE_SIGN_KINDS; a
PARANOID app asks for everything) — the only reason it didn't work was that
authorize() treated ASK as "unauthorized". Now:

- authorize(): ALLOW proceeds, DENY refused, ASK consults an in-memory session
  grant then calls opConsent (the shared per-op dialog). The returned
  SignerOpGrant is recorded via a new NostrSignerPermissionLedger.record()
  helper (allow-for-op / until / all / deny-for-op persisted; once/session not),
  mirroring the broker. No opConsent wired → ASK fails closed (CLI/tests).
- onConnect(): first contact asks connectConsent for the trust level
  (AppConnectResult) instead of silently granting REASONABLE; Blocked/Cancelled
  reject the connection. Falls back to defaultPolicyOnConnect when no prompt.
- forget() also clears the client's in-memory session grants.

Nip46ConsentBridge (amethyst) implements the two prompts by reusing the
existing NappletConnect/NappletSignerConsent coordinators + dialogs + ledger,
building the render info from the bunker request (op label, event JSON
preview, client metadata/icon). A 120s timeout fails a stuck per-op prompt
closed so it can't wedge the signer's single-consumer loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:28 +00:00
Claude 922a5841d0 fix(nip46): make forgetting a client complete and immediate
Clearing a connected client on logout had two gaps:

- The user-facing "Forget this app" button only revoked the permission ledger;
  it never cleared the NIP-46 client store, so a forgotten app's metadata and
  relays lingered and were re-recovered on the next restart. Route NIP-46
  coordinates through the host's new forgetClient() so the store is cleared too.
- Neither logout path stopped the RUNNING session from listening on the app's
  relays — only the next restart picked up the change. extraRelays is now a live
  projection of the client store (recomputed on connect, on start, and on
  disconnect via a new onDisconnected hook), so a forgotten app's relays are
  dropped immediately.

onLogout and the UI Forget now share one authorizer.forget() path (revoke grant
+ clear store + clear throttle entry + signal the host), so client-initiated and
user-initiated disconnects behave identically. Adds tests for both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:26 +00:00
Claude cd3e1353e7 docs(nip46): correct stale coordinate format in KDoc
Two doc comments still described the pre-namespacing coordinate
`nip46:<clientPubKey>`; the actual key is `nip46:<signerPubKey>:<clientPubKey>`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:26 +00:00
Claude c20cc2b514 refactor: move the generic signer-permission layer out of napplet/
The per-app signing-authorization plumbing was named/located under `napplet/`
for historical reasons, but it is not napplet-specific — it already gates
napplets, the sandboxed browser, and (now) NIP-46 remote clients through one
shared ledger. The package name mislabelled what the code is, so:

- commons: `napplet/signers/` (generic) → `connectedApps/signers/`
  (AppSignerPolicy, NostrOpDecision, NostrSignerOp, NostrSignerConsentPrompt,
  NostrSignerPermissionLedger/Store). The NIP-46-specific bridge moves to
  `connectedApps/nip46/` (Nip46PermissionAuthorizer, Nip46ClientStore), so the
  feature is no longer split across unrelated packages.
- The `NappletRequest.toSignerOp()` extension — napplet protocol leaking into
  the generic layer — moves back to `napplet/protocol/`.
- amethyst: `napplet/DataStoreNostrSignerPermissionStore` → `connectedApps/`,
  `napplet/DataStoreNip46ClientStore` → `connectedApps/nip46/`.

Pure move + repackage: all 27 import sites updated, no behaviour change.
Napplet-specific code (broker, capabilities, consent, :nappletHost) and the
Connected Apps UI folder are untouched — those really are napplet/UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:26 +00:00
Claude d06b1c7f2c feat(nip46): rate-limit flooding clients + clean up on logout
Follow-ups to the audit:

- Abuse protection: the signer service now bounds its event queue
  (DROP_LATEST) and rate-limits per author BEFORE decrypting — decryption can
  be an external-signer (NIP-55) IPC round-trip, so a looping or hostile client
  can no longer force one per event or grow the queue without limit. Fixed
  window (default 40 requests / 10s per author, oldest authors evicted). The
  limiter is touched only by the single consumer coroutine, so it needs no
  locking. Covered by a headless test.
- logout now clears the client's persisted metadata/relays too (not just the
  ledger grant), so a disconnected app stops being listened for after restart.

Not changed: get_public_key/ping stay ungated — gating them behind a prior
connect risks breaking clients that discover the pubkey at connect time, and
the pubkey is already public, so the enumeration leak is negligible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:25 +00:00
Claude fe4e881df6 fix(nip46): audit fixes — data race, cancellation, write amplification
Findings from an audit of the signer, all verified against the code:

- Data race: NostrConnectSignerService deduped request ids inside onEvent,
  which the relay pool invokes CONCURRENTLY from each relay's socket thread
  (PoolRequests dispatches listeners outside its lock). Two relays delivering
  the same subscription could mutate the LinkedHashSet at once → race / CME.
  Move dedup into the single consumer coroutine; onEvent now only does the
  thread-safe channel send.
- Swallowed cancellation: broad `catch (Exception)` around suspend calls in the
  processor, the service's decrypt + publish, and connectViaNostrConnect caught
  CancellationException too, breaking structured cancellation when the service
  restarts. Rethrow it first (matching the AccountCacheState convention).
- Write amplification: the ledger wrote last-used to that client's DataStore
  file on EVERY authorized request (unthrottled, unlike the relay-auth store).
  Coalesce to at most one write per client per 60s in the authorizer.
- Redundant resubscribe: the enable/relays collector lacked distinctUntilChanged,
  so a duplicate inbox-relay emission tore the subscription down and re-opened
  it on every relay for nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:25 +00:00
Claude 9667168a6a feat: persist connected NIP-46 client metadata + relays
Add a Nip46ClientStore (commons interface + InMemory + a single-file Android
DataStore) keyed by the same signer-namespaced coordinate as the permission
ledger, holding each connected client's self-declared name/url/image and the
relays it reaches us on.

- The host persists metadata on connect (bunker + nostrconnect) and, for the
  nostrconnect flow, the app's own relays. On startup it re-adds those relays
  to the listen set, so a nostrconnect-paired app stays reachable across app
  restarts instead of silently going dark until it re-pairs.
- Connected Apps now shows the app's real name (falling back to the generic
  label + npub) for remote-signer clients.
- Wired the store through AppModules → AccountCacheState → Account → host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:24 +00:00
Claude dea711596e feat: namespace NIP-46 grants by signer + revoke on logout
The Connected Apps signer store is app-global, so a remote client keyed only by
its own pubkey would share one trust level across every local account. Namespace
the coordinate as `nip46:<signerPubKey>:<clientPubKey>` so the same client paired
with two accounts on one device gets independent grants.

- Nip46PermissionAuthorizer takes the user's signerPubKey; coordinateFor/belongsTo
  encode + match the namespace; clientPubKeyOf reads the trailing segment.
- onLogout now revokes the client's grant (wired through the new quartz hook).
- Connected Apps lists only the active account's remote clients (napplet/browser
  grants stay app-global); the signer screen counts the same way.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:24 +00:00
Claude 81aea57ddb feat(commons): ledger-backed NIP-46 authorizer for Connected Apps
Nip46PermissionAuthorizer implements the quartz Nip46RequestAuthorizer by
routing every remote-signer request through the shared Connected Apps
permission ledger (NostrSignerPermissionLedger). A NIP-46 client becomes a
connected app under the coordinate `nip46:<clientPubKey>`, so it reuses the
same per-app trust levels and per-op overrides as napplets and web origins:

- sign/encrypt/decrypt requests map to NostrSignerOp and are allowed only when
  the ledger's standing decision is ALLOW (ASK/DENY are refused — a background
  signer cannot prompt, so access is granted ahead of time in the UI).
- connect validates the pairing secret, then registers the app at a default
  REASONABLE policy (never downgrading a level the user already set) and echoes
  the secret back.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:20 +00:00
Claude e2743ed0b6 fix: address pre-merge audit findings for geohash chat
Correctness:
- GeoRelayDirectory.relays is now @Volatile; on the process-wide `shared`
  directory the CSV refresh was written by one thread and read by others with
  no memory barrier, so readers could keep using the FALLBACK list forever and
  never route to the correct rendezvous relays.
- sendPostSync bails before cancel() when a geohash cell has no resolvable
  relays, so the composer text + draft are preserved instead of the message
  being silently dropped with its draft deleted.
- Teleport detection compares on the common geohash prefix; a cell finer than
  the fixed 8-char device fix could never be a startsWith prefix, so the user
  was wrongly marked teleported even when physically present.

Performance:
- GeoRelayDirectory.closest precomputes each relay's great-circle distance
  once instead of recomputing the trig inside the sort comparator (was
  O(n log n) haversine calls over the ~370-relay directory).
- GeohashChatChannel.relays() memoizes the derived set, invalidated by a new
  directory version token, instead of re-sorting the whole directory (and
  allocating a fresh Set) on every call.
- filterFollowingGeohashChats groups cells by relay into one filter each
  (g = [cells]) rather than one REQ per (cell, relay).

Leak/thread-safety:
- FollowingGeohashChatSubAssembler.userJobMap is a ConcurrentHashMap and
  endSub now removes the entry (it previously cancelled the jobs but left the
  stale entry behind).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
2026-07-17 02:10:18 +00:00
Claude 4c9f599a79 Merge remote-tracking branch 'origin/main' into claude/bitchat-ephemeral-interop-8epkek 2026-07-17 00:24:33 +00:00
Vitor PamplonaandGitHub c2b2be322d Merge branch 'main' into claude/amethyst-mobile-colors-kgdfhn 2026-07-16 20:15:27 -04:00
Claude 22a3bb31d5 Merge remote-tracking branch 'origin/main' into claude/bitchat-ephemeral-interop-8epkek 2026-07-17 00:10:58 +00:00
Claude 420fdfea53 Merge remote-tracking branch 'origin/main' into claude/bitchat-ephemeral-interop-8epkek
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
2026-07-16 23:52:28 +00:00
Claude 8891e3a1a7 feat: match the Following badge to the FilledTonalButton palette
Per review, the follow badge now uses the same two colours as the tonal
buttons (Show more, profile actions): the shield is secondaryContainer and the
inner figure is onSecondaryContainer. The vector is a following(shield, figure)
builder cached per colour at the call site, so the badge follows the accent and
stays visually consistent with the tonal buttons in both themes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1F9jXNwRGTP8qmsV3Wd69
2026-07-16 23:33:01 +00:00
Claude dd03307ac3 fix: solid accent container tones + keep Following its brand purple
Two more on-device fixes:

- Container roles were a faint 0.12/0.16 tint over the surface, so filled
  shapes like the Settings icon boxes nearly vanished (accent icon on almost no
  background) and tonal buttons looked washed. Retune the container tones to a
  moderately saturated fill (mirroring Material's baseline containers, recoloured
  to the accent) with near-white content in dark mode, so the settings icon
  boxes and profile-header tonal buttons read as white-on-accent again.

- The Following badge keeps its original deep purple (#7F2EFF) instead of
  following the accent — it reads as a brand identity mark, and the accent-tinted
  version looked flat. Reverted to the fixed two-tone vector.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1F9jXNwRGTP8qmsV3Wd69
2026-07-16 22:59:40 +00:00
Claude 853d5e1b25 fix: address accent regressions — teal tonal buttons, FAB glyph, follow badge
Three fixes from on-device review:

- Secondary/tertiary CONTAINER roles now derive from the accent (primary)
  instead of the purple theme's teal secondary. FilledTonalButton (profile
  follow/edit/message, "Show more"/"Show anyway", tonal chips) was neutral
  before and had turned teal-on-teal; it now reads as an accent tonal button.
  The solid secondary/tertiary roles stay teal (unchanged, as before).

- onPrimary is white again. Deriving it by max contrast made it black on the
  light accents used in dark mode, flipping FAB glyphs from white to black;
  white reads better on the accent.

- The Following badge is two-tone again: the shield follows the accent while
  the inner figure stays white, instead of a flat single-colour shield. The
  vector is now a following(accent) builder cached per accent at the call site.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1F9jXNwRGTP8qmsV3Wd69
2026-07-16 22:45:20 +00:00
Claude 19f5065af4 refactor(commons): shared ClickableUrl/ClickableEmail; Desktop drops its duplicate
Desktop's ClickableLink and Amethyst's ClickableUrl were near-identical: the only
real differences are mouse-first styling (Desktop underlines + shows a hand
cursor) and the open mechanism. But LocalUriHandler.openUri opens the browser on
Android AND Desktop (and the mail client for mailto:), so the "open a link"
logic never needed to be platform-specific.

Add ClickableUrl/ClickableEmail to commons/ui/components on LocalUriHandler, with
an `underline` flag so each front end keeps its exact look. Desktop's rich-text
renderer now reuses them (underline = true) for url/email/link-preview/withdraw
and its bespoke ClickableLink is deleted. Amethyst keeps its own blossom-intent-
aware ClickableUrl (blossom never applies to plain links, so this path is
equivalent); Phone stays platform-specific (Android dials, Desktop has no dialer).

Verified: :commons JVM and :desktopApp compile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
2026-07-16 22:24:27 +00:00
Claude 9eb827fed6 feat(commons): route url/email/phone through the platform strategy
Closes the last rich-text fidelity residual. url/email/phone were rendered
generically by the shared core via RichTextInteractions callbacks, losing each
front end's per-type styling and open behavior. They now go through the
RichTextSegmentRenderer strategy:
- Add Url(url, displayText)/Email(address)/Phone(number) to the contract (with
  plain-text defaults).
- Core routes LinkSegment(no-preview)/SchemelessUrl -> Url, Email -> Email,
  Phone -> Phone; drop the in-core ClickableSpan.
- Amethyst renders them with ClickableUrl/ClickableEmail/ClickablePhone (blossom
  intent + dial preserved); Desktop with ClickableLink / underlined mailto / plain
  phone text.
- RichTextInteractions now carries only onClickHashtag (the one segment the core
  draws itself, with shared icons); the onOpen* callbacks are gone.

Verified: :commons JVM, :desktopApp, :amethyst play debug compile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
2026-07-16 22:12:28 +00:00
Claude 51f005d22f refactor(commons): unify the rich-text parser cache across Android + Desktop
Replaces the two forked cached parsers (amethyst CachedRichTextParser on
android.util.LruCache + desktop DesktopCachedRichTextParser on ConcurrentLruCache
with a naive isMarkdown) with one shared object in commons/jvmAndroid/richtext,
built on quartz ConcurrentLruCache and keeping amethyst's CommonMark-aware
computeIsMarkdown and content-addressed key (content+tags+callbackUri+authorPubKey).

- Add ConcurrentLruCache.trimToSize(maxItems) (+ tests) for the onTrimMemory path.
- Repoint all amethyst callers (incl. the markdown unit test) and both desktop
  callers; delete both forks.

Verified: :commons JVM, :desktopApp, :amethyst play debug + unit tests compile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
2026-07-16 21:56:12 +00:00
Claude 2725a93569 fix: keep the liked heart and reposted check their semantic colours
The previous change made every commons action icon follow the accent, but the
liked heart (red) and reposted check (green) are semantic status colours, not
brand purple. Restore their original baked colours and Color.Unspecified
rendering. Only the Following badge — which was brand purple — keeps following
the accent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1F9jXNwRGTP8qmsV3Wd69
2026-07-16 21:50:01 +00:00
Claude 269ae95b4b feat(desktop): render rich text through the shared core; delete the Desktop fork
Implements the cross-platform RichTextSegmentRenderer contract on Desktop
(mouse-first) and converges Desktop onto the shared commons RichTextViewer:
- DesktopRichTextSegmentRenderer draws each divergent segment with the existing
  Desktop leaves (AsyncImage media + onImageClick, RenderInvoiceCard/RenderCashuCard,
  QuotedNoteEmbed, RenderBechSegment, RenderPdfCard/RenderNowhereLinkCard,
  RenderSecretEmoji, relay copy).
- DesktopRichText replaces the hand-rolled DesktopRichTextViewer switchboard: it
  parses with DesktopCachedRichTextParser, keeps markdown on RenderMarkdown, and
  drives the shared core via the two CompositionLocals. NoteCard repointed.
- Delete the old DesktopRichTextViewer + RenderSegment + the duplicate
  RenderCustomEmojiSegment (the core now renders emoji); rename the file.
- Add a NowhereLink method to the contract so both platforms keep their
  nowhere.ink card (the core no longer flattens it to a plain link); Android
  adapter implements it via NowhereLinkCard.

Verified: :commons JVM, :desktopApp, and :amethyst play debug all compile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
2026-07-16 21:43:56 +00:00