The friendly-geode tiers test the client against a compliant relay; they neither
harden it against buggy relays nor surface bugs in the third-party NIP-29 relays
users connect to. Tier E adds both:
- E1: a deterministic fault-injecting mock relay (ignore since/until, short pages,
echo-newest, out-of-order, EOSE-early/none, AUTH-CLOSE, mid-stream drop, caps)
asserting the tail/pager still converge with no miss / loop / hang. Notes that
UntilLimitPagingRelayTest + BackwardRelayPagerTest already cover several cases
for the generic pager, to be extended to the NIP-29 #h shapes.
- E2: a conformance run against a real containerized NIP-29 relay via relayBench's
RelayUnderTest adapter (AUTH gating, relay-signed 39xxx, previous-tag rejection,
since/until semantics + caps) — a failure is a relay bug, doubling as a
conformance report for the operator.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
A validation plan an AI (or human) on this branch runs before trusting the
state-vs-content refactor. Organized by screen and covering every NIP-29
assembler (JoinedState, JoinedChatTail, OpenChatTail, OpenChatHistory,
OpenThreads, CardWarmup, RelayGroupsOnRelay, RelayGroupsDiscovery, the
ChannelPublic relay-group metadata branch, and the notifications path).
Tiers: (A) build + JVM suite; (B) new amethyst unit tests for each assembler's
filter shape + reconnect stability; (C) amy + geode/`amy serve` integration for
the framework, ingest and the can't-miss-messages paging property (C3); (D)
Android per-screen scenarios with logcat pass criteria, including the original
join-mid-session bug (D1) and reconnect no-replay (D8). Flags what is verifiable
headless vs. needs a device, and the known-failing serving-relay hazard row.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
Continues the scope+role naming pass over the group-chat datasource layer:
- RelayGroupThreadFeed -> RelayGroupOpenThreads: it's the open group's threads
tab, so it now sits in the "Open" family beside RelayGroupOpenChatTail/History.
- filterMetadataToRelayGroup -> filterRelayGroupState: matches the "state"
concept (39000-39005 + pinned-id back-fill) the always-on state sub is named for.
- makeRelayGroupsDiscoveryFilter -> filterRelayGroupsDiscovery: consistent
`filter…` verb with the rest of the relay-group filter builders.
The RelayGroupsOnRelay / RelayGroupsDiscovery assemblers keep their PLURAL prefix
on purpose: it marks the "browse/discover many groups" surfaces apart from the
singular in-a-specific-group ones (RelayGroupJoined*/Open*/Card*). The already-
consistent filterRelayGroupsBy* discovery builders are left as-is.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
Two clarity cleanups on the group-chat subscription split:
1. RelayGroupCardWarmup (ex-Warmup) now skips a group the user has already joined.
Joined groups are kept fully warm app-wide by the always-on state + chat-tail
subs; warmup only needs to cover groups shown as cards that those don't — above
all NON-joined groups (discovery, a relay's channel list, member/metadata/parent
screens). Removes the last joined-group double-fetch. (A joined group appearing
in the discovery "My Groups" tab now draws its card activity from the recent-tail
cache instead of a fixed newest-50 fetch.)
2. Rename the assemblers so their scope and role read at a glance, side by side:
RelayGroupState -> RelayGroupJoinedState (joined · state, always-on)
RelayGroupPreviewTail -> RelayGroupJoinedChatTail (joined · recent chat, always-on)
RelayGroupChatTail -> RelayGroupOpenChatTail (open group · recent chat)
RelayGroupChatHistory -> RelayGroupOpenChatHistory (open group · older chat)
RelayGroupWarmup -> RelayGroupCardWarmup (on-screen card, non-joined)
The family now reads Joined{State,ChatTail} · Open{ChatTail,ChatHistory} · CardWarmup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
Consolidates the six overlapping NIP-29 group-chat REQ assemblers into the
state-vs-content shape the DM and Concord chat stacks already use, reusing the
BackwardRelayPager / RelayLoadingCursors / WindowLoadTracker framework. See
amethyst/plans/2026-07-18-nip29-group-chat-subscriptions.md.
State (always-on, account-keyed), mounted at LoggedInPage:
- RelayGroupStateFilterAssembler — one #d filter per host relay carrying every
joined group's 39000/39001/39002/39003/39005. Keeps name/roster/roles/pins
current app-wide, so no screen re-queries metadata. Promotes (and replaces) the
old RelayGroupMyJoinedGroups roster path from "while a groups screen is up" to
genuinely always-on.
- RelayGroupPreviewTailFilterAssembler — one #h filter per host relay across all
joined group ids, since=recentBoundary(), NO per-group limit (a time floor
batches it and makes it reconnect-safe). Drives the Messages-list previews.
Replaces the old fixed-window content path whose shared per-relay `since` gated
a newly-joined group's backfill (the reported slow-first-load bug).
Content (per open group), mounted on the chat screen:
- RelayGroupChatTailFilterAssembler — the open group's recent chat live (covers a
non-joined group opened by link, which the joined-only preview tail misses).
- RelayGroupChatHistoryFilterAssembler — on-demand backward pager (until+limit on
the host relay, all authors), cursors on RelayGroupChannel.history, driven by
the feed's viewport markers with an eager backfill-to-window on open. Gap-proof
via RelayLoadingCursors.rewindTo, so deep scroll never misses older messages —
and being all-authors it re-materializes my own history too.
Retires the duplicated paths:
- RelayGroupMyJoinedGroups{FilterAssembler,Subscription} deleted (state + preview
subs cover it); unmounted from Messages panes, discovery, bottom-bar preloader.
- ChannelPublicFilterSubAssembler RelayGroupChannel branch drops the content
window, keeps only metadata + pinned-id backfill (for a non-joined open group).
- ChannelFromUserFilterSubAssembler RelayGroupChannel branch removed (redundant).
- FilterMessagesToRelayGroup / FilterMyMessagesToRelayGroup deleted (orphaned).
Warmup (non-joined cards), OnRelay (directory), Discovery, ThreadFeed and the
always-on notifications path are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
Design doc for consolidating the six overlapping NIP-29 group-chat REQ assemblers
into the same state-vs-content shape the DM and Concord chat stacks already use:
an always-on account subscription for the small replaceable state (metadata /
roster / roles / pins), and a live tail + on-demand backward history pager
(reusing BackwardRelayPager / RelayLoadingCursors / WindowLoadTracker) for the
high-volume chat content. Includes the full message-delivery coverage proof
(can't-miss-messages), the file-by-file change list, and the additive-first
rollout order.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
Every channel composer screen (NIP-28 public chat, NIP-29 relay group, NIP-53
live activity, ephemeral and geohash chats) calls `init(accountViewModel)`
directly from its composable body, so it ran on the main thread on every
recomposition of that body — re-allocating UserSuggestionState,
EmojiSuggestionState and a fresh ChatFileUploadState each time. Besides the
wasted main-thread allocations, blindly re-initializing `uploadState` would
reset an in-progress upload.
Guard the setup so it only (re)runs when the account actually changes, matching
the pattern the sibling ConcordNewMessageViewModel already uses. Repeated calls
with the same account are now cheap no-ops, so leaving the call in the composable
body stays correct while dropping the per-recomposition work.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
The Messages-list warmup (RelayGroupMyJoinedGroupsSubAssembler) is keyed by
account, so its `since` is one per-relay EOSE map shared across every joined
group on that relay — not per-group. Feeding that shared `since` into the
bounded per-group content backfill gated any group joined (or first surfaced)
after that relay's `since` advanced — or after a live message on a sibling group
pushed it forward: the group only ever fetched events newer than that timestamp,
so its history never landed in the Channel's (strong-ref) notes cache. The
Messages row showed the newest message while opening the group waited on a full
`limit=200` relay round-trip — "the last message is there but the group loads
slowly even though it should be cached."
Dropping `since` entirely would be just as wrong: the pool re-sends every REQ on
each reconnect (FiltersChanged treats a brand-new connection's empty prior state
as a change), and reconnects happen constantly, so a since-less filter would
replay the whole newest-50 page for every group on every reconnect.
Instead, gate the shared `since` per group on whether we already hold a full page
(>= LIMIT) of that group's preview content in the notes cache:
- < LIMIT cached -> cold / newly joined / thinly scattered: fetch the full
page with no `since`; once it lands it flips to incremental
on its own.
- >= LIMIT cached -> already backfilled: apply the shared `since` so reconnects
fetch only the tail (the group already has its history, so
sharing the relay `since` only bounds incremental top-ups).
A group with genuinely fewer than LIMIT total events re-pulls its (sub-page,
cheap) content on reconnect — an acceptable cost for guaranteeing the backfill.
Roster stays on the shared per-relay `since`: those are a few small replaceable
events per group, so a reconnect just re-confirms them rather than replaying chat.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
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