Brings the "Manage stored files" screen up to the design language main just
applied to the Media Servers screen:
- Rounded surfaceContainer cards instead of flat Cards.
- Image thumbnail, or a file-type glyph in a rounded secondaryContainer box
for non-images.
- Monospace, truncated-both-ends hash; type · size subtitle.
- Per-server presence as pills with a status dot — allGoodColor tint for
present, muted for missing — replacing the ambiguous same-shaped chips.
- A single MoreVert overflow (copy link / open / report / per-server delete)
with leading icons, plus a full-width tonal "Mirror to missing" CTA.
- Icon-in-rounded-box empty/error states and an ExtendedFloatingActionButton
refresh.
- BUD-07 pay dialog restored with a Bolt icon and tonal confirm; payment
message now names the server.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
Adds cli/tests/blossom/blossom-live.sh, an end-to-end harness that drives the
full amy blossom lifecycle against a REAL public Blossom server: upload → HEAD
check → download-and-verify-hash → list → cross-server mirror → delete. Verified
green (6/6) against files.sovbit.host with a mirror to blossom.primal.net, which
exercises the whole quartz/commons/CLI Blossom stack over the wire.
Server-side write rejections (whitelists, rate limits, payment) record as SKIP,
not FAIL — only a broken client contract (bad descriptor, hash mismatch,
unparseable list) fails — since public servers gate anonymous writes very
differently. Documented in cli/tests/README.md; state dir gitignored.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
Adds a confirm-then-pay flow so a 402 from a paid Blossom server can be settled
from the app instead of only being reported.
- quartz: BlossomPaymentProof (settled Cashu token / lightning preimage) with the
X-Cashu / X-Lightning retry headers; BlossomClient.mirror accepts a proof.
- BlossomPaymentHandler (Android): pays the challenge's BOLT-11 invoice via the
account's existing NIP-47 (NWC) wallet and returns the preimage — it never
handles keys or funds itself, only drives the connected wallet. Decodes the
invoice amount for display.
- Blob manager: a mirror that hits 402 now raises a payment prompt; a dialog shows
the amount and, on confirm, pays and retries the mirror, then continues with the
remaining servers. Cancel leaves the blob unmirrored.
Cashu-only servers and the composer upload path still surface a clear message;
auto-settlement there can reuse this handler next. Not yet validated against a
live paid server.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
- Blob manager rows now show an image thumbnail (image/* blobs) and Copy-link /
Open controls, alongside the existing mirror-to-missing / delete / report.
- BUD-07: BlossomUploader now raises a typed BlossomPaymentException on a 402, and
UploadOrchestrator surfaces a specific "server requires payment" message instead
of a generic upload failure (auto-settlement via the NIP-60 wallet is still TBD).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
Adds first-class Blossom upload controls to the Media Servers screen and rounds
out the blob manager:
- New persisted account settings (LocalPreferences + AccountSettings):
`mirrorUploadsToAllServers` (BUD-04, default on) and `optimizeMediaOnUpload`
(BUD-05, default off), each with a change fn.
- UploadOrchestrator honours both: uploads via /media with a t=media token when
optimize is on, and only mirrors when the mirror toggle is on.
- BlossomUploader gains a useMediaEndpoint flag to target /media vs /upload.
- "Upload behaviour" section on the Media Servers screen: mirror toggle,
optimize-via-/media toggle, and a shortcut into the blob manager.
- Blob manager gains a refresh FAB.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
Both the mirror-on-upload fan-out and the blob-manager matrix read the raw
kind-10063 list (BlossomServerListState.flow) instead of hostNameFlow, which
injects the 10 public DEFAULT_MEDIA_SERVERS when the user has none set. This
avoids fanning uploads out to — and listing/HEAD-probing across — public
servers the user never opted into.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
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
FollowerCrawler is in commonMain but used `Dispatchers.IO` without importing
the multiplatform `kotlinx.coroutines.IO` extension. On JVM `Dispatchers.IO`
resolves to the JVM member (compiled fine locally), but on Kotlin/Native that
member is internal, so `:quartz:compileKotlinIosSimulatorArm64` failed with
"Cannot access 'val IO': it is internal". Add the explicit import — the same
idiom the sibling GrapeRankCrawler already uses across all targets.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4
The first "Upload priority" header stacked FeedPadding's top inset, an
extra 8dp, and SectionLabel's 20dp inter-section top padding — ~38dp of
dead space under the top bar. Make SectionLabel's top padding a parameter
and shrink it for the first section.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ77Hm5L7fXEbPWbUds1iA
Addresses review feedback on the redesign:
- Auto-save: drop the Save button and replace the cancel "X" with a
standard back arrow (TopBarWithBackButton). Every add/remove persists
immediately; a reorder persists once the drag gesture ends, so dragging
no longer publishes a kind-10063 event on every intermediate swap.
- On-device cache card now uses the app's filled surfaceContainer card
style (no outline), matching the main Settings screens.
- Drop the redundant intro sentence at the top of the screen.
- Rework the "Add a server" block: recommended servers come first as
one-tap chips, followed by the "or paste a server address" field.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ77Hm5L7fXEbPWbUds1iA
The Crowdin action now downloads the commons module's Compose-resource
translations too, but the create-pull-request step's add-paths still only
covered amethyst/ and docs/. New untracked commons files created by the
Crowdin action (run as root in its container) were therefore left out of
the commit and shelved by git stash --include-untracked, which failed with
'Permission denied' when trying to remove the root-owned files.
Add commons/src/commonMain/composeResources/**/strings.xml to add-paths so
those translations are committed into the PR instead of being stashed.
Health probe was hitting the bare server root with HEAD. CDN-fronted hosts
like cdn.satellite.earth never answer `/` (verified: HEAD/GET `/` time out,
while HEAD `/upload` returns 404 in ~0.5s), so a fast, working server was
wrongly shown Offline. Probe the BUD-01 `/upload` endpoint instead — the
path that actually matters for an upload target.
Visual polish:
- Server rows are now outlined cards; the primary target (#1) gets an accent
border, a faint accent tint, and a "Primary" badge.
- Rank and monogram merge into one avatar (rank as a small corner badge),
removing the separate rank tile and decluttering the row.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ77Hm5L7fXEbPWbUds1iA
`amy graperank followers <observer>` (no --relay) assembles the relay universe
via allKnownRelays, which read the reachability cache through ctx.reachability —
and that lazily derives the monitor key from the operator master, forcing a
passphrase prompt (or failing on a fresh machine) purely to READ kind:30166
records. The follower crawl signs nothing, so this defeats its anonymous path.
Read the reachability snapshot with a throwaway signer instead (snapshot() only
reads; the signer is used solely for writes) — the same trick `graperank status`
already uses to stay side-effect-free.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4
Replaces the Servers/Local-cache segmented tabs with a single cohesive
scroll, and redesigns every zone of the screen:
- Upload priority: draggable, ranked server rows now carry a colored
monogram (identity) and a "Primary" badge on #1, alongside the health dot.
- Add a server: the old inline URL field + a second 10-row recommended
list are replaced by one inline add area — the URL field plus the
recommended servers as a horizontal strip of add-chips that read as
"done" once added (matched by host so URL normalization can't hide it).
- On-device cache: the old bare switch rows become a self-contained card
with a storage icon, a detection-status dot, and the profile-pics-only
sub-toggle nested under it.
Removes the segmented tabs and their now-dead strings. Same staged
Save/Cancel model, kind-10063 publish, drag utility, and health probe.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ77Hm5L7fXEbPWbUds1iA
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
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
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
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
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
Enables app-side (still-native) screens to reference commons' generated
`Res` directly, so a string shared between a commons renderer and an
Android screen no longer has to be duplicated across the two resource
trees. commons already exposes `Res` publicly (publicResClass = true); the
only missing piece was the Compose-resources runtime on amethyst's
classpath — the JetBrains compose artifacts are already there transitively
via :commons, this just adds components-resources so
`org.jetbrains.compose.resources.*` resolves.
Proof + cleanup: the V4V split editor now reads
podcast_value_split_percent from commons `Res` instead of the Android res
tree, and that key is removed from amethyst — collapsing the duplication
introduced when PodcastValueSplits moved to commons. (podcast_value_for_value
stays app-side: it feeds an Android-int ResourceToastMsg, which has no
Compose-resource form.)
This unblocks the next wave of renderer extractions whose strings are
shared with native screens (relay discovery, calendar, …) — those screens
can now switch to commons `Res` rather than forcing a duplicate.
Verified: :amethyst:compileFdroidDebugKotlin and :commons:compileKotlinJvm
both pass; touched strings.xml well-formed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
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
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
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
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
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
crawl and score used Context.open, which requires an account solely to default
the observer to the logged-in user — but neither uses account keys: crawl never
signs, and score signs cards with the machine-level operator key (~/.amy/operator/,
independent of any account). Switch both to Context.openOrAnonymous and require
an explicit OBSERVER when running anonymously, matching `graperank followers`.
A machine acting as a GrapeRank provider for arbitrary observers no longer needs
a personal account (the operator key still needs its SecretStore passphrase, as
before — that's the reachability monitor + card signer, a real secret).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4
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
Follow-up on the audit of the Media Servers redesign:
- Add a process-wide TTL cache (60s) to MediaServerHealthProbe so probe
results survive the screen's ViewModel being recreated on each open;
the ViewModel reuses fresh cached status instead of re-hitting the
network, and skips launching a probe when one is already in flight.
- Prune the _health map on refresh/remove so it can't grow unbounded as
servers are added and removed within a session.
- Persist the selected tab across configuration changes (rememberSaveable).
- Restore the screen's intro caption, reusing the previously orphaned
set_preferred_media_servers string.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ77Hm5L7fXEbPWbUds1iA
FollowerCrawler set the reverse-lookup filter's `limit` to the page size, but
fetchAllPages treats a filter `limit` as the TOTAL cap across all pages and
stops paging once it's reached — so the crawl silently capped at ~500 followers
per relay (verified live: relay.damus.io returned exactly 500 for a
many-thousand-follower observer).
Leave the filter limit null by default so pagination walks the whole result set
(the same observer now returns 12,823 followers from damus alone); Config gains
`maxPerRelay` and the CLI a `--max N` flag to opt back into a bounded spot check.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4
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