Commit Graph
100 Commits
Author SHA1 Message Date
Claude 18c0c4291f chore(deps): update dependencies across all modules
Bump to latest stable (or latest within an existing pre-release track),
skipping stable→alpha/beta jumps for production safety.

- kotlin 2.4.0 → 2.4.10 (+ kotlinTest, compose compiler); ksp 2.3.9 → 2.3.10
- composeBom 2026.06.00 → 2026.06.01; composeRuntimeAnnotation 1.11.3 → 1.11.4
- firebaseBom 34.15.0 → 34.16.0; jacksonModuleKotlin 2.22.0 → 2.22.1
- kotlinxCollectionsImmutable 0.5.0 → 0.5.1; sqlite 2.6.2 → 2.7.0
- composemediaplayer 0.10.0 → 0.11.3; zoomable 2.12.0 → 2.13.0
- jlatexmath 1.4 → 1.5; jna 5.14.0 → 5.19.1 (nestsClient)
- benchmark 1.5.0-alpha06 → alpha07; genaiPrompt 1.0.0-beta2 → beta3
- bump DisableCacheInKotlinVersion guard 2_4_0 → 2_4_10 (iOS test workaround)

appfunctions held at alpha09 (appfunctions-service has no alpha10 published).
AGP 9.3.0, gms 4.5.0, kotlin stable, ktor, coil, media3, navigation, and other
androidx libs already at their newest stable; alpha-only newer releases skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TreeiMiHrhWce6PbnA5qWz
2026-07-19 14:06:37 +00:00
Claude b94303a77a fix(location): audit fixes — stuck GPS spinner, main-thread geocode
- "Use my location" could spin forever after a permission denial: the reset was
  in an isGranted-keyed effect that never re-ran on false→false. Use
  rememberPermissionState's result callback, which fires on grant AND denial, so
  the spinner always clears.
- Forward-geocode search ran the blocking Geocoder on the UI thread on Android
  < 13 (ANR risk). Run the search on Dispatchers.IO.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-19 02:11:59 +00:00
Claude a87d4688a8 fix(location): single location control on the geo-post screen
The geo-post composer showed two locations: the static external-id marker at the
top and the new "Posting to · Change" row below the message. Drop the duplicate
and, for the geohash case, render the interactive channel control (with retarget)
in the marker's place. Non-geohash external ids still use DisplayExternalId.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-19 01:50:02 +00:00
Claude fb9ccbaad0 feat(location): follow a teleported place; retarget geo-posts
1) Follow this location from the feed: when a top-nav Geohash filter is active
   (e.g. after teleporting), the filter header shows a bookmark toggle to
   follow/unfollow that place (kind 10081), so a teleported spot can be saved to
   your locations — the "add to my interests" step. Respects read-only accounts.

2) Retarget a geo-post: the geohash comment composer now shows "Posting to <place>"
   with a Change action that opens the map picker and re-scopes the post's NIP-73
   geohash channel (CommentPostViewModel.geohashScope/setGeohashScope), instead of
   being locked to the feed's channel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-19 01:25:24 +00:00
Claude 7c37a8691c feat(topnav): teleport to a place from the feed filter dialog
Add a "Teleport to a place…" entry to every top-nav feed filter that offers
"Around Me" (home, video, discover, notifications, products, music, git,
podcasts, relay-group discovery, …). Selecting it opens the shared map picker;
on confirm the chosen place becomes this screen's TopFilter.Geohash feed.

Implementation is centralized in FeedFilterSpinner (no per-top-bar changes): a
UI-only TopFilter.TeleportPicker sentinel is intercepted there to open the
picker, then forwarded through the normal onSelect path as a TopFilter.Geohash —
which each screen's existing changeDefault*FollowList already handles. The
spinner header falls back to the raw selection so a teleported (unfollowed)
geohash still shows its place name.

Follow-up (not in this commit): a "follow this location" action so a teleported
place can be saved to the kind-10081 geohash list from the feed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-19 00:55:43 +00:00
Claude 1445aea849 feat(location): open the picker at the user's location when permitted
If the picker opens with no seed and location permission is already granted, fly
to the user's current position instead of the neutral world view. Runs once and
never prompts — it only consumes already-granted permission; on denial or no fix
it stays at the world view as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-19 00:48:24 +00:00
Claude c6b2bad0a9 feat(location): calmer map, cell highlight, zoom on area size
- Mute the busy MAPNIK tiles in light mode (desaturate + gentle lighten), the
  light-mode counterpart to the existing dark tile filter. Applied to both the
  interactive picker and the preview thumbnail.
- Outline the selected geohash cell on the map so the user sees exactly which
  region a post/filter covers; the rectangle follows the pin and resizes with the
  area-size level.
- When the area-size level changes, animate the zoom so the new cell is framed.

LocationPickerMap gains zoomTo/highlight/highlightColor params (all guarded to
rebuild only on change, preserving the per-frame-cheap update path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-19 00:21:02 +00:00
Claude 4efb2cca98 fix(quartz,cli): audit fixes — bounded drains, no event loss, honest verdicts, false-reject traps
Adversarial audit of the PR's own changes (8 finder angles, verified
before fixing). Quartz core:

- fetchAll-family drains get a wall-clock ceiling (maxTotalMs, default
  10x the idle window, delay()-watchdog: cancellable and virtual-time
  testable). The pure idle window was unbounded when a relay trickled
  events forever — sandboxed napplet queries, set -e fetches, and
  marmot await stuck inside one drain. Streaming relays still finish.
- The suspending onEvent hook no longer runs inside a cancellable
  timeout scope (an expiring window could cancel verifyAndStore
  mid-write and silently drop a received event); the timeout is armed
  only when the channels are dry (no per-message timeout-job churn).
- fetchAll is a projection over fetchAllWithHooks: fixes its
  unsynchronized events/seenIds mutation from concurrent socket
  threads and deletes the duplicate loop + per-event activity channel.
- publishAndConfirmDetailed regains its only-responders contract
  (synthetic no-response entries no longer render as 'relay rejected
  your message' in app callers); results built by pure associateWith;
  shared failure-reason constants + PublishResult.isTransportFailure.
- NIP-65 mutations: split read+write r-tags for the same URL now merge
  to BOTH instead of last-wins dropping a facet (+ test).
- TcpProber's 128-thread pool drains after 60s idle.

CLI:

- publishGuard: all-transport failure exits 124 as timeout; rejected/1
  is reserved for an actual OK-false answer.
- --help anywhere in argv is hoisted centrally; 'amy notes post "x"
  --help' prints usage instead of publishing.
- rejectUnknown false-reject traps fixed: geochat --no-fetch behind an
  early return, and 13 elvis-alias short-circuit sites read eagerly.
- Aliases load once per Context and only match name-shaped inputs (no
  shadowing a real npub/NIP-05/hex); stderr color requires a
  positively-known terminal (TERM sniff polluted captured logs).
- Relay-CSV strictness unified on RawEventSupport.relayFlag (post,
  graperank publish/followers/register no longer silently drop
  malformed URLs); Args.timeoutMs(+OrNull) replaces 27 hand-rolled
  conversions, all strict; offer/debit --timeout > 3600 rejected with
  a 'looks like milliseconds' hint; NPub.create idiom; stale jq .id in
  the marmot reactions harness; printUsage drift (offer pay --with,
  profile --clink-offer, search --kind).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-19 00:17:43 +00:00
Claude 787fd99d1e fix(location): picker polish — open zoom, search pill, chip rows
- Open the seeded picker at a zoom that fits the geohash cell's precision
  (region opens wide, building opens tight) instead of a fixed zoom.
- Hide the search field's rectangular outline so its square corners no longer
  poke through the rounded pill (border colors → transparent).
- Drop the 48dp minimum touch-target on the area-size chips so wrapped rows sit
  8dp apart (matching the horizontal gap) instead of ~24dp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-18 23:57:06 +00:00
Claude b20efb3147 fix(location): map drag interception + wrap precision chips
- The interactive picker map didn't tell ancestor views to stop intercepting
  touches, so a horizontal drag on the Teleport map got stolen (opening the nav
  drawer) instead of panning. Add the same requestDisallowInterceptTouchEvent
  touch listener LocationPreviewMap already uses.
- The precision chips were a single horizontally-scrolling Row; make them a
  FlowRow so "Region · ~1250 km … Building · ~38 m" wraps onto multiple lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-18 23:45:44 +00:00
Claude 78e641b3b2 fix(location): audit fixes for the geohash picker
Bugs:
- "Use my location" could spin forever when a fix never arrives (location off,
  indoors, permission granted but unavailable). Wait with a 20s timeout and stop
  on any terminal outcome so the button always resets.
- With no seed, an initial osmdroid layout-scroll at the opening center could be
  mistaken for a pick, auto-selecting the mid-Atlantic and enabling Confirm before
  the user moved. Gate selection behind a real pan/search/GPS/tap (hasSelection).

Performance:
- The map's AndroidView update ran setColorFilter + overlays.removeAll +
  invalidate() on every scroll-driven recomposition. Move the dark-mode tile
  filter to a theme-keyed effect, and rebuild the marker + invalidate only when
  the marker point actually changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-18 23:43:02 +00:00
Claude 962d1706dc fix(quartz): fetchAll-family timeouts are idle windows, not absolute deadlines
The fetchAll/fetchAllWithHooks accessories wrapped their whole
collection loop in one withTimeoutOrNull, so a relay actively
streaming a large backlog was cropped mid-delivery the moment the
absolute deadline hit — even though the loop already has proper
terminal conditions (per-relay EOSE / CLOSED / cannot-connect) and the
timeout's only real job is stall detection.

timeoutMs now measures the delta since the LAST message: every event
or terminal signal resets the window (fetchAll gains a conflated
activity ping so event progress is visible to its wait loop), and only
a full window of silence ends the fetch early. fetchFirst/count keep
absolute waits (single-response — idle and absolute coincide), and
subscribe's duration timeout stays absolute by design (a live stream
has no terminal state).

Since the pages/pool helpers delegate to fetchAll, pagination inherits
the semantics. This also changes app-side callers of these accessories
— in their favor: the timeout only ever fired on slow relays, exactly
when cropping loses data.

New commonTest suite pins the behavior: a relay emitting every 200ms
under a 300ms window streams to completion (10/10 events); a stall
ends one window after the last message, not after the start; EOSE
still returns immediately. CLI docs reworded (--timeout = idle window).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 23:39:25 +00:00
Claude 620fc465df feat(cli): publish results carry each relay's rejection reason; converge output shapes
With no external consumers yet, converge the --json surface to its
ideal shape in one pass:

- quartz gains publishAndCollectResults: the NIP-01 OK message, connect
  errors, and silent timeouts now survive as PublishResult(accepted,
  message) per relay instead of dying in a debug log. The existing
  boolean APIs delegate unchanged. Silent relays are reported as
  'no response within timeout' rather than omitted.
- Context.publish returns the rich map; the new
  RawEventSupport.ackFields(ack) is the one canonical projection every
  publisher emits: published_to (urls) + rejected_by as
  [{relay, reason}] — 'why didn't it post' now answers itself, in
  partial failures and in the rejected error alike.
- author/pubkey rule enforced module-wide: 'author' is the key that
  signed an event (feed/search/dm/message list items), 'pubkey' an
  identity being described; profile show and outbox add the bech32
  npub beside the hex when the user is the primary subject.
- Event-list items converge on event_id/author/created_at/content
  (dm, feed, search, marmot message, geochat, concord).
- Byte counts standardize on *_bytes keys (blossom/nsite size ->
  size_bytes); the text renderer drops the fragile bare-'size'
  heuristic and colors stderr progress independently of a piped
  stdout.
- Error details are sentences everywhere (not bare gids); dead Result
  class removed from the quartz publish accessory.

Docs updated (DEVELOPMENT output conventions, README rejected example).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 23:30:06 +00:00
Claude fea9bcb8ea feat(composer): extend map location picker to all composers
Lift pickedGeoHash into the shared ILocationGrabber interface and add a shared
GeoHashPostSection composable (GPS default + "pick on map"), then wire it into
every remaining composer: long-form, classifieds, private DMs, public messages,
NIP-22 comments, channel messages, and nest messages. Each ViewModel now honors
the map-picked geohash over GPS at build time and round-trips it through drafts;
each screen renders the shared section in place of the GPS-only LocationAsHash.

The short-note composer is refactored onto the same shared section (its local
copy removed). The geohash-chat "New location channel" screen already reaches the
picker via its Teleport card, which now uses the shared, polished picker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-18 23:29:46 +00:00
Claude 8d3a47b8f8 refactor(cli): split GrapeRankCommand into graperank/ sub-files; TCP prober to quartz
The 1500-line GrapeRankCommand (15 sub-verbs in one object, 7.5x the
module's 200-line smell threshold) becomes a 165-line dispatch that
delegates to graperank/{Crawl,Score,Publish,Operator,Support}. The TCP
reachability pre-probe with its dedicated 128-thread dispatcher is
transport infrastructure, not command code — it moves to quartz
nip66RelayMonitor/reachability (TcpProber, jvmAndroid). Pure move, no
behavior change; cli tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 23:00:15 +00:00
Claude 82f041b1f5 docs(cli): reconcile README/ROADMAP/DEVELOPMENT/tests with reality
The docs had drifted badly behind the code: geochat was documented
nowhere, concord/zap/search/podcast20/nsite-publish were README-
invisible, the ROADMAP matrix contradicted its own nak table on six
shipped features, and DEVELOPMENT described the legacy FS event store
as the default when SQLite is.

- README: sections for search, zap (incl. --with auto-pay), podcast20,
  nsite/napplet (all four sub-verbs), concord (13 verbs), geochat, and
  a 'Which chat system?' comparison table; output section rewritten for
  the new contract (exit-code derivation, rejected, unknown-flag
  errors, -- terminator, per-command --help); layout diagram fixed for
  the SQLite default + operator/ + concord.json; the bunker nak-interop
  claim reworded honestly; RECIPES.md linked.
- ROADMAP: stale new-item rows flipped (follow, outbox, Blossom,
  bunker, search; zap partial), rows added for relaygroup/geochat/
  concord/nsite/napplet/podcast20/CLINK/fof, orphaned thread note
  fixed, test-suite section updated.
- DEVELOPMENT: canonical error-code list pinned, exit-code rule
  documented, no-prompts carve-outs, refreshed architecture tree +
  command template (USAGE/route(help=)/rejectUnknown/publishGuard),
  SQLite store section, testing table covers the new JVM suites.
- tests/README: all ten suite dirs listed, JVM contract suite noted,
  mis-spliced marmot row repaired.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 22:59:37 +00:00
Claude 7b32ca1da3 feat(composer): add "pick on map" location to the short-note composer
The composer could only attach the current GPS location (fixed ~5km), which
fails when GPS is denied/unavailable and can't tag a post with a different
place. Add a "pick a place on the map" action that opens the shared
GeohashLocationPickerDialog and stores the result in
ShortNotePostViewModel.pickedGeoHash, which overrides the GPS fix at build time
and round-trips through drafts. Picking a place also skips the GPS permission
prompt. The existing "use my location" GPS flow is unchanged; this is additive.

Scoped to the short-note composer for now; the other ILocationGrabber composers
(long-form, classifieds, DMs, comments, channel/nest messages) can adopt the
same pickedGeoHash override + section in a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-18 22:57:55 +00:00
Claude 6e664b2db6 refactor(cli): extract the drain loop to quartz accessories; split Context per domain
Context.drain/drainAllPages/requestResponse re-implemented the
subscription state machine quartz already ships in
relay/client/accessories — the CLI-specific needs (per-event
verify-and-store hook, dead-relay collection, pending-on-auth) now live
in an option-rich fetchAll variant there, and Context keeps thin
adapters. The per-domain sections bolted onto Context (Cashu seed
warming/snapshot/restore counters; the Concord stream-key AUTH
registry) move to CashuContext/ConcordAuth, with the NUT-09 restore
counter rule shared via commons CashuWalletOps so the CLI and Android
can't drift. Context.kt: 1246 -> 950 lines; behavior unchanged
(cli tests green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 22:55:56 +00:00
Claude 8658dcd512 refactor(geohash): reuse the shared picker in the Teleport screen
Extract the picker body into GeohashLocationPickerContent (chrome-less: map +
search + use-my-location + precision + readout + confirm), leaving
GeohashLocationPickerDialog as a thin dialog wrapper around it.

Rewrite GeohashTeleportScreen to host that shared content inside its own scaffold,
passing a teleport-specific confirm label and action (follow the cell + open the
chat with the teleport flag). This dedupes the map/precision/city-name code that
Teleport previously copied, and gives Teleport place search, "use my location",
dark-mode tiles, area-size chips, and the animated pin for free.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-18 22:36:47 +00:00
Claude 11590b7081 feat(location): polish the geohash picker and generalize it
Refinements after the initial map picker:

- Dark mode: apply the night tile filter to the interactive LocationPickerMap
  too, so the picker matches the app theme instead of showing a bright map.
- Search results: the place search now shows a tappable list of candidates
  (with human place names) instead of silently jumping to the first hit.
- Precision chips now show approximate area sizes (e.g. "City · ~5 km").
- The center pin lifts with an animated shadow while the target moves.

Generalization: the picker's own strings are renamed from relay_group_location_*
to neutral location_picker_* keys so any caller can reuse
GeohashLocationPickerDialog, not just the NIP-29 group form. The group-form card
strings stay namespaced. LocationPreviewMap gains a configurable aspectRatio.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-18 21:21:57 +00:00
Claude a4713c89c2 docs(cli): add RECIPES.md — task-shaped walkthroughs for the big subsystems
Seven end-to-end recipes (own relay + NIP-86 admin, NIP-46 bunker both
directions, Marmot group chat, NIP-60/61 Cashu wallet, NIP-5A nsite
publishing, GrapeRank provider pipeline, scripting patterns) so the
docs teach jobs, not just verbs. Reflects the new contract: per-command
--help, alias resolution in user slots, published_to/rejected errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 21:13:23 +00:00
Claude d8584a7935 feat: reorder App Settings entries and refine Home/Messages rows
Reorder the App Settings section in AllSettingsScreen to: Privacy Options,
UI Preferences, Home, Messages, Notifications, Compose Settings, Reaction
Row, Bottom Navigation Bar, Profile UI, then the remaining rows.

Rename the "Home Tabs" row to "Home" (reusing route_home) and switch the
Messages row icon to MaterialSymbols.Mail to match the Messages screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYck7FKoLzoaomnjjLFLzi
2026-07-18 21:12:04 +00:00
Claude c28c8af13f feat(nip29): map-based location picker for group creation
The NIP-29 group create/edit form asked for the group's location as a raw
geohash text field (placeholder "u0nd") — unusable, since nobody knows their
geohash, so the discovery geo-filter it feeds stayed empty.

Replace it with a first-class, map-first location experience, reusing the
pieces that already power the Geohash-chat Teleport screen:

- New GeohashLocationPickerDialog: a full-screen picker where the user pans a
  map under a fixed center pin, searches for a place by name, or taps "use my
  location" (device GPS). Precision chips (GeohashChannelLevel) control the
  area size; the resolved place name is shown via LoadCityName. Never surfaces
  a raw geohash.
- New ForwardGeolocation service (Geocoder.getFromLocationName) powering search,
  mirroring the existing ReverseGeolocation.
- Extend LocationPickerMap with backward-compatible recenter/recenterZoom and
  onCenterChanged hooks for the center-pin interaction; extend LocationPreviewMap
  with a configurable aspectRatio for the form's map thumbnail.
- Rework the form's location field: an inviting empty-state card, a filled card
  with a themed map thumbnail + place name + geohash + clear, and a collapsed
  "enter manually" field so power users can still paste a known geohash.

The ViewModel's geohash state stays the single source of truth, so the publish
path (parseGeohashes -> kind-9002 EditMetadataEvent) is unchanged. Adds the
MyLocation glyph and regenerates the Material Symbols subset font.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-18 21:07:44 +00:00
Claude 7ea6920679 refactor(nip65): move read/write-marker merge semantics from the CLI into quartz
The kind:10002 facet-merge rules (adding a write marker to a read-only
relay promotes it to BOTH; removing one facet of BOTH demotes to the
other; removing the last facet drops the relay) lived as private
helpers in the CLI's RelayCommands. Any frontend that edits a NIP-65
list needs them, so they now live in quartz nip65RelayList as
AdvertisedRelayListMutations (applyFacet/addFacet/removeFacet/setFacet)
with commonTest coverage. Behavior unchanged; the CLI rewires to the
shared functions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 21:03:56 +00:00
Claude 80be7b909b test(cli): bare 'amy' exits 2; key generate rejects unknown flags
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 20:59:34 +00:00
Claude 55884f81eb refactor(marmot): extract the incoming-sync policy from the CLI into commons
Context.syncIncoming carried real protocol policy — the NIP-59 gift-wrap
since-cursor rule (2-day lookback, advance only when events arrive),
per-group cursor bookkeeping, and the MIP-00 consumed-KeyPackage
rotation — that the Android app implements separately. Divergence there
silently drops DMs, so the policy now lives once in
commons/marmot/MarmotSyncPolicy with Cursors/Relays/drain/publish
injected, and the CLI Context wires itself in as a thin adapter.
Behavior is unchanged (the body moved verbatim, comments included).

Also resolves aliases from the per-account aliases.json in
Context.requireUserHex, so 'amy dm send bob ...' works with a local
alias — previously aliases.json was written but never read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 20:58:45 +00:00
Claude 7b5d9a0fe4 feat(cli): contract sweep for notes/nsite/offer/podcast/relay/search/zap verbs
Completes the module-wide contract sweep (N-Z families):
- rejectUnknown() everywhere Args is constructed
- publishGuard on single-event publishes (post, publish, profile edit,
  nsite/napplet publish, podcast + podcast20, relaygroup join/leave/
  message/invite/edit/put-user/remove-user)
- USAGE constants + route(help=...) / --help fast-paths; nsite/napplet
  publish/serve/list, offer discover/pay and zap --with are now
  documented in-binary; subscribe's USAGE pins the NDJSON stream shape
- notes post parses flags before positionals (flags may appear anywhere)
- search note: --kind canonical (--kinds kept as alias); search user
  default limit 20 -> 50
- --identifier accepted as alias of --d in nsite/napplet/podcast20
- relay dispatch gains a full USAGE (--help) documenting every noun

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 20:58:19 +00:00
Claude 11901df2c6 feat(cli): contract sweep for event/git/geochat/graperank/group/key/login verbs
Same contract as the A-D sweep, applied to the E-M command families:
- rejectUnknown() everywhere Args is constructed (typo'd flags -> bad_args)
- publishGuard on single-event publishes (total rejection -> non-zero)
- USAGE constants + route(help=...) / --help fast-paths; graperank's
  full sub-verb set (including the previously undocumented 'followers')
  and 'key validate' + the --pw alias are now documented in-binary
- geochat --relay is a strict comma-list (bare positional relays kept);
  geochat listen default limit 500 -> 50
- graperank update/probe aliases now print deprecation notes
- git --identifier accepted as alias of --d
- marmot message list defaults to --limit 50 (0 = everything)
- dm-style guards for marmot message send/react/delete and group edits

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 20:56:23 +00:00
Claude 471883a9f2 feat(cli): contract sweep for admin/await/blossom/bunker/concord/dm/cashu verbs
Applies the new CLI contract across the A-D command families:
- rejectUnknown() after flag parsing, so typo'd flags fail with bad_args
  instead of silently no-oping
- publishGuard on single-event publishes: total relay rejection now
  exits non-zero ('rejected') instead of 0
- per-group USAGE constants wired into route(help=...) and --help
  fast-paths on flat commands; previously invisible sub-verbs
  (blossom media/report, the concord moderation set, the whole cashu
  surface, NIP-86 method list) are now documented in-binary
- dm send/send-file parse flags before positionals (flags may appear
  anywhere); dm list/await accept a positional USER as alternative to
  --peer; dm list caps at 50 by default
- concord create: --relay is canonical (--relays kept as alias)
- cashu receive resume: deprecation warning pointing at 'complete'
- offer/debit: --timeout is seconds (was raw milliseconds)
- Identity.fromBunkerUri now delegates to quartz NostrConnectURI
  parseBunker instead of duplicating the URI parsing

BREAKING (--json): unknown flags and malformed numeric/relay/author
flag values now exit 2 where they were previously ignored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 20:56:09 +00:00
Claude 6bcae39b83 fix(cli): repair the exit-code and flag-parsing contract
- Output.error now derives the exit code from the error code
  (bad_args -> 2, timeout -> 124, else 1), so every
  'return Output.error(...)' site honours the documented contract.
  Previously ~225 bad_args sites exited 1 while the docs promised 2,
  and two timeout paths (nostrconnect wait, namecoin lookup) exited 1
  instead of 124.
- Args: literal '--' ends flag parsing (escape hatch for values that
  start with '--'); intFlag/longFlag reject non-numeric values instead
  of silently using the default; requireFlag/positional no longer
  double-print to stderr; new rejectUnknown() turns typo'd flags into
  bad_args failures; new 'help' detection.
- route() understands --help/-h/help (prints group usage, exit 0) and
  names the expected verbs on an unknown sub-verb.
- Unknown or missing top-level subcommand now emits a proper bad_args
  error (JSON-aware under --json) plus a one-screen verb list instead
  of dumping the full 400-line usage.
- RawEventSupport: --relay/--kind/--author/--id/--since/--until/--limit
  entries that do not parse are now bad_args errors; previously an
  unresolvable --author was silently DROPPED and the query ran with a
  weaker filter than requested. New shared publishGuard() reports
  'rejected' (exit 1) when every relay refuses an event.
- runCli() seam extracted from main() plus an 'amy.home' system-property
  override of DEFAULT_ROOT so the new JVM test suite can drive the CLI
  in-process; first contract tests: ArgsTest, ExitCodeContractTest,
  JsonContractTest (NIP-19 vector goldens).

BREAKING (--json): error code pow_timeout is now timeout; exit codes
for bad-argument failures move from 1 to 2 as documented.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 20:54:14 +00:00
Claude b852a163c7 feat: paginate the NIP-29 group Threads tab with a backward history pager
The Threads tab loaded kind-11/1111 with a single #h since-filter and no
limit, so a group with more threads than the relay's default result cap
silently lost the older ones. Mirror the chat history stack for threads:

- RelayGroupChannel.threadsHistory: separate RelayLoadingCursors so paging
  the forum doesn't move the chat's cursor.
- buildRelayGroupThreadsHistoryFilters: per-armed-relay #h + kind-11/1111
  until+limit page, the forum analog of buildRelayGroupHistoryFilters.
- RelayGroupOpenThreadsHistoryFilterAssembler: the on-demand BackwardRelayPager
  ("relayGroup.threads.history"), bound to the open group's threadsHistory
  cursors, landing on the normal ingest path (kind-11 -> addThread).
- Threads screen: mount the history subscription, eagerly backfill to a
  window on open, page older content as the list nears its end, and show a
  quiet loading/caught-up footer.

Tests: RelayGroupFilterBuildersTest gains the threads-history filter shape;
RelayGroupFilterServingRelayTest gains a geode backward #h + thread-kinds
walk proving every thread is covered exactly once and the walk terminates.

The screen wiring is device-untested (flagged with the other Tier-D items).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
2026-07-18 18:54:18 +00:00
Claude 3214095b99 fix: route stray NIP-29 group content to the host, not a phantom channel
A group-scoped content event (kind-9 chat, poll, kind-11 thread) is keyed
to its RelayGroupChannel by the relay that served it, because a NIP-29
event doesn't carry its host relay. That's correct for the group's own
host-pinned subscriptions, but a message resolved from a NON-host relay --
e.g. a quoted kind-9 fetched by id during missing-event resolution -- was
filed under GroupId(groupId, strangerRelay), a channel the group's screens
never read, so the message silently vanished (the serving-relay hazard).

LocalCache.attachToRelayGroupIfScoped / attachThreadToRelayGroupIfScoped
now, when no channel is keyed to the serving relay, redirect the stray to
the group's single confirmed host channel via redirectStrayRelayGroupContent,
keyed off RelayGroupChannel.hasRelaySignedState(). A phantom channel never
has relay-signed state, so the redirect can only ever land on a real host,
never on another phantom -- the fix is strictly safe and the common
host-pinned arrival stays an untouched O(1) fast path (the scan runs only
on the rare no-channel-for-serving-relay miss).

Also add the cache-prune gap-fill can't-miss test: drive the production
RelayLoadingCursors down a real relay, rewindTo below the window, and
confirm the pruned band re-loads with no gap.

Tests: RelayGroupContentRoutingTest (pure router + the channel signal);
RelayGroupHistoryPagingRelayTest gains the rewind reload case. The
LocalCache wiring is unit-covered at the router level but still device-
untested end-to-end (flagged in the test plan).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
2026-07-18 18:13:31 +00:00
Claude db5170a82e test: extend NIP-29 group-chat coverage to every assembler + relay integration
Round out the branch's test plan across the headless-runnable tiers.

Tier B (filter shapes) — now every assembler:
- reconnect stability: a since-only bump on the state/tail filters is not a
  resend (no full replay on reconnect), while a history until step is; via
  FiltersChanged.needsToResendRequest.
- directory: extract buildRelayGroupDirectoryFilter into RelayGroupFilterBuilders
  (kinds 39000-39003, no d/h scope, limit 500) and point the RelayGroupsOnRelay
  assembler at it, with a shape test.
- ChannelPublic relay-group branch (filterRelayGroupState): state + pinned-id
  backfill and crucially NO message window.
- group notifications (filterGroupNotificationsToPubkey): #p+#h scope, kind set,
  empty-guards.
- discovery #p roster augmentation (filterRelayGroupsByAuthors): the author,
  #p-roster and #d-backfill filter shapes.

Tier C (serves-the-shape, against the in-process geode relay): state #d,
batched preview tail, threads, a pinned message reachable by id below the tail
window, notification #p+#h scoping, and the relay directory.

Tier C3/E1 (can't-miss + resilience): drive the production RelayLoadingCursors
backward over the wire to the bottom, and pin that a short page is not
exhaustion (only an empty page + EOSE ends the walk).

The remaining hostile-relay faults (echo-newest, no-EOSE, auth/stall) are
already covered generically by RelayLoadingCursorsTest, WindowLoadTrackerIdleTest
and BackwardRelayPagerTest; Tier D (device) and E2 (real third-party relay) are
not headless-runnable and stay flagged for a human in the test plan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
2026-07-18 17:54:38 +00:00
Claude 4653951b54 fix: recover media type from file extension when imeta MIME is malformed
Primal iOS writes a bare subtype in the imeta MIME tag (`m jpeg`) instead of a
full type (`m image/jpeg`). `createMediaContent` classified media only by the
MIME `startsWith` prefixes when a MIME was present, so a bare subtype matched
neither image/video/pdf and the whole imeta was dropped: the URL rendered as a
plain link. That path is doubly bad — it discards the imeta `dim`/blurhash, so
the loading placeholder cannot reserve the image's height and the feed jumps
when the bitmap finally arrives, and it forces a URL-preview network round-trip
just to rediscover the type the imeta already declared.

Fall back to file-extension detection whenever the type is still unknown after
the MIME/data: checks. This recovers the `.jpg` (or `.mp4`, …) classification
and keeps the imeta metadata, so the image renders through the fast media path
with its dimensions reserved up front. `data:` URIs keep their type in the
prefix and are left unprobed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQ9F7xnRvRgBmVNbo8hRKc
2026-07-18 17:40:31 +00:00
Claude 399bef6bf7 test: cover NIP-29 group-chat filter shapes and can't-miss history paging
Extract the pure REQ-filter construction out of the NIP-29 group-chat
assemblers into RelayGroupFilterBuilders so the exact filter each screen
puts on the wire can be unit-tested without an Account or relay client,
and point every assembler at the shared builders (dropping the duplicated
per-file kind lists).

Add two test suites from the branch's test plan:

- RelayGroupFilterBuildersTest (Tier B): pins the kinds, #d/#h scope,
  per-host-relay batching, since/until/limit and all-authors shape of the
  state, joined chat-tail, open chat-tail, history-pager, threads and
  card-warmup joined-skip filters.

- RelayGroupHistoryPagingRelayTest (Tier C3/E1): the can't-miss-messages
  property against the in-process geode relay -- a backward #h + kind-9
  walk delivers every group message exactly once and stops on an empty
  page, and an #h-scoped walk isolates one group from another on the same
  relay even when their createdAt ranges fully overlap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
2026-07-18 17:13:56 +00:00
Claude 4cf86ca030 docs(nip29): add Tier E — relay-behavior resilience + third-party conformance
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
2026-07-18 16:04:07 +00:00
Claude c8362fbc18 docs(nip29): per-screen × per-assembler test plan for the group-chat refactor
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
2026-07-18 15:16:36 +00:00
Claude 20e86b8998 fix: move BootCompletedReceiver disk read off the main thread
isEnabled() reads plain SharedPreferences (a synchronous disk read, by
design) and scans the accounts cache. Since onReceive runs on the main
thread, this triggered a StrictMode DiskReadViolation at boot. Wrap the
work in goAsync() + a background thread so it runs off the main thread
while keeping the receiver alive until the check completes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P4tx1QSE27uoDpnxZZ7msT
2026-07-18 14:39:03 +00:00
Claude ee5dc25414 refactor(nip29): finish the datasource rename — Open threads + consistent filter-builder names
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
2026-07-18 14:33:56 +00:00
Claude 54b2b42d87 refactor(nip29): scope Card warmup to non-joined, rename the group-chat assemblers by scope+role
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
2026-07-18 13:57:44 +00:00
Claude a25c5f9518 fix: harden Blossom sync concurrency and cancellation
Audit fixes for the Blossom client:

- BlossomBlobManagerViewModel: use StateFlow.update{} for the presence
  matrix so the Main-thread sync collector and IO-thread delete/mirror
  actions can't lose each other's writes; add refreshJob de-dup so two
  quick refreshes can't interleave; rethrow CancellationException; bound
  the /list HEAD-probe backfill with a Semaphore(8).
- BlossomClient.has(): rethrow CancellationException instead of
  swallowing it as 'not found'.
- BlossomSyncForegroundService: drop the stale 'running' de-dup guard so
  a fresh sweep always gets foreground protection.
- CLI mirror: strip query/fragment before extracting the sha256.
- DisplayBlossomSyncProgress: retain the last state so the slide-out exit
  animation still has content to draw after the state clears.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
2026-07-18 13:46:06 +00:00
Claude 84640a5890 perf(blossom): parallelize "sync all" across servers
The sweep was strictly sequential (one mirror at a time). Now it parallelizes
ACROSS servers but stays serial WITHIN each server, so every server gets a steady
one-at-a-time stream (no rate-limit storms) while all of the user's servers work at
once — wall-clock drops to roughly the slowest single server's queue instead of the
sum of everything.

- BlossomMirrorQueue: group work by target server, run one coroutine per server, and
  update the shared progress atomically via MutableStateFlow.update from the parallel
  workers. Extracted mirrorOne() for the per-task call.
- Dropped the now-ambiguous currentHost from BlossomSyncState (and the banner /
  notification text) since multiple servers are in flight at once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
2026-07-18 13:31:36 +00:00
Claude 5d54a0ff90 refactor(fgs): extract FlowProgressForegroundService; add dataSync Blossom sync service
Generalizes the shared foreground-service scaffolding (channel setup, start/stop-
when-idle over a StateFlow, the ProgressStyle notification skeleton, tap + cancel
intents, onTimeout) into an abstract FlowProgressForegroundService<T>. Kept as a
base — NOT one mega-service — because Android 14+ binds foregroundServiceType to
behavior, so each workload keeps its own subclass with the correct type.

- FlowProgressForegroundService: the shared base.
- PowMiningForegroundService: refactored onto the base (still shortService); mining
  state maps to the card via render(); behavior preserved.
- BlossomSyncForegroundService: new, typed dataSync (the right type for a multi-file
  sync that exceeds shortService's ~3-min budget), bound to blossomMirrorQueue.state.
  Started (from the foreground, as dataSync requires) when a "Sync all" sweep begins,
  so it survives backgrounding; stops itself when done.
- BlossomMirrorQueue: publishes the active state and fires onActive() synchronously so
  the FGS starts with live state; wired in AppModules.
- Manifest: FOREGROUND_SERVICE_DATA_SYNC permission + the service declaration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
2026-07-18 04:33:10 +00:00
Claude 050ecaaf9f feat(nip29): split group chat into always-on state + live tail + history pager
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
2026-07-18 04:19:50 +00:00
Claude af9d5a7278 docs(nip29): plan to split group chat into always-on state + paginated content
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
2026-07-18 04:19:25 +00:00
Claude 4845e95c9f chore(blossom): rename "My Blossom Data" to "My Blossom Files"
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
2026-07-18 03:59:37 +00:00
Claude ecbe2c6704 feat(blossom): app-level "Sync all" with floating progress; rename to "My Blossom Data"
Sync-all now runs like the PoW miner — an app-level job with a floating progress
banner — instead of a screen-bound loop, so it keeps going as the user navigates:

- BlossomMirrorQueue (app-level, on applicationIOScope via Amethyst.instance): runs
  the BUD-04 fan-out, exposes an aggregate BlossomSyncState for the banner and a
  results SharedFlow so an open manager flips each pill green/grey live. Servers that
  need payment are skipped (pay those per-row).
- DisplayBlossomSyncProgress: a bottom floating banner mounted at the navigation root
  (sibling of the mining/broadcast banners) with a determinate bar, "x / N · host",
  failed count, and cancel/dismiss.
- Manager: a "Sync all" banner appears when any file has gaps; tapping it enqueues the
  sweep and optimistically spins the affected pills.

Also, per request: the screen is renamed "My Blossom Data" and moved into the left
drawer's "You" section (just before My Emoji Packs); the standalone Settings-catalog
entry is removed and the Media Servers shortcut relabeled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
2026-07-18 03:57:58 +00:00
Claude da633342bc feat(blossom): faster, more legible blob manager (load, progress, refresh)
Addresses three UX gaps on the "Manage stored files" screen:

- Loading was a full-screen spinner until every server × every blob HEAD probe
  finished, sequentially — and it even HEAD-probed servers that had already
  returned a /list. Now: /list runs across all servers in parallel and renders
  immediately; only servers that don't implement /list are HEAD-probed, and those
  run in parallel too. A server that listed its blobs needs no probe. Result is
  shown as soon as the lists land.

- Mirror-to-missing gave no feedback. Presence is now a per-server state
  (present/missing/pending); the target pill shows a spinner while mirroring and
  turns green on success or back to grey on failure — updated in place, no full
  reload. Delete spins the pill then greys it (and drops the row when it's gone
  everywhere).

- The refresh control was a FAB mislabeled "Retry". It's now a proper refresh
  action in the top bar (a spinner while loading); the contextual "Retry" stays
  only on the error state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
2026-07-18 03:34:42 +00:00
Claude 6920dc6e82 fix(blossom): don't let mirroring corrupt upload progress or fail the upload
Two regressions from the mirror-on-upload change:

1. Progress state stuck. verifyHeader() ends by calling finish() (state=Finished,
   progress=1.0), but mirrorToOtherServers then ran updateState(0.95,
   ServerProcessing) and never restored Finished — so the on-screen progress ended
   pinned at "processing / 95%" and isUploading never cleared. Mirroring is now
   fire-and-forget on account.scope AFTER the upload finishes: it never touches the
   progress state, never delays the post, and can never turn a completed upload into
   a failure.

2. Upload could fail outright. The upload token was BUD-11 server-scoped; some
   servers reject an upload whose auth carries a `server` tag, surfacing as a bare
   "Uploading error:" with no detail. Upload-token replay isn't the threat scoping
   guards against (delete tokens are — those stay scoped), so upload/media tokens
   are no longer scoped. Also: blank exception messages now fall back to the class
   name so the dialog is never empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
2026-07-18 03:05:37 +00:00
Claude 22396cf583 feat(blossom): redesign blob manager to match the app's modern UI
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
2026-07-18 02:32:51 +00:00
Claude e87309e038 test(blossom): live interop smoke test for amy blossom (BUD-01/02/04/09)
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
2026-07-18 02:16:17 +00:00
Claude 7dcf2afa0f perf(chats): make ChannelNewMessageViewModel.init idempotent across recompositions
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
2026-07-18 01:25:52 +00:00
Claude 9d8e07fbee perf(nip29): prefetch a joined group's history with a per-group since
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
2026-07-18 01:25:28 +00:00
Claude 9bc436b0c2 feat(blossom): BUD-07 confirm-then-pay for paid-server mirroring
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
2026-07-18 01:18:46 +00:00
Claude 4f661a501f feat(blossom): blob-manager per-file controls + clear 402 on upload
- 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
2026-07-18 01:04:35 +00:00
Claude 5ad3e143e4 fix: keep Concord community unread flow alive across Refounding + dedupe emissions
Audit follow-ups on the Messages unread dot:

- concordCommunityHasUnreadFlow captured the community session once, but a
  Refounding rebuilds a still-joined community's session in place (same id, new
  object). Because the row's flow is remember(communityId)-scoped it was never
  recreated, so the fan-in kept watching the dead session and the dot froze.
  Re-resolve the session on every concordSessions.revision tick instead — the
  same signal ConcordServerRoomCompose already uses to refresh the row's
  name/icon — which also covers channel-set changes from folds.
- distinctUntilChanged() on both aggregated server flows so a new message that
  doesn't flip the has-unread boolean no longer pushes a redundant value into
  composition.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ZkjdHANMKyu2EbM7eEM8C
2026-07-18 00:55:30 +00:00
Claude 6c01ad2b10 feat(blossom): mobile upload controls — mirror + /media toggles, manager refresh
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
2026-07-18 00:07:30 +00:00
Claude d0f2f08e19 fix(blossom): mirror and blob-manager use only the user's configured servers
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
2026-07-17 23:50:53 +00:00
Claude ed4b851518 feat(settings): host the settings filter in the app bar
Move the All Settings search field from a separate row below the top bar
into the app bar itself, replacing the static "Settings" title. The back
arrow is still shown only when the back stack can pop (deep-link entry),
so bottom-nav entries stay chromeless.

Adds SettingsSearchTopBar with a compact pill search field sized to fit
the app bar and leaves the shared TopBarWithBackButton untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBSLyPYssVu8QdBrvCM7wk
2026-07-17 23:50:25 +00:00
Claude 91765c56aa fix(ci): reclaim working-tree ownership after Crowdin Docker action
The crowdin/github-action runs inside a Docker container as root, so files
and directories it downloads are root-owned. When Crowdin creates a brand-new
locale folder (e.g. values-en-rGB/ under commons), the unprivileged runner
user in the peter-evans/create-pull-request step can't unlink files inside it,
so the branch checkout aborts with 'unable to unlink ... Permission denied'.

Add a chown step that reclaims ownership of the whole working tree after the
Crowdin action and before any git manipulation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0179E3AfsnB1NP87YmtAMZNX
2026-07-17 23:39:44 +00:00
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 26b24c9125 feat: show unread dot for NIP-29 groups and Concord communities in Messages
The Messages list showed the purple "new messages" dot (NewItemsBubble) for
DMs, NIP-28 public chats, ephemeral chats, geohash chats and Marmot groups,
but hardcoded hasNewMessages = false for the four NIP-29 / Concord rows, so
those never indicated unread activity.

Wire real unread detection into all four, reusing the last-read markers their
open-chat screens already advance:

- Individual NIP-29 group row and Concord channel row compare the newest
  message against the persisted last-read timestamp, exactly like the Marmot /
  public-chat rows do.
- The collapsed "grouped by relay" (RelayGroupServerRoomNote) and "grouped by
  community" (ConcordServerRoomNote) rows light the dot when ANY child group /
  channel has unread, via new reactive fan-in flows that follow the joined list
  / community session so joins and folds re-subscribe.

Extract the relay-group last-read route into relayGroupChannelLastReadRoute so
the read side (row) and write side (RelayGroupChannelView) can't drift, mirroring
marmotGroupLastReadRoute / concordChannelLastReadRoute.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ZkjdHANMKyu2EbM7eEM8C
2026-07-17 23:33:18 +00:00
Claude 699c2dc7e5 fix(graperank): import kotlinx.coroutines.IO for Kotlin/Native compile
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
2026-07-17 23:26:46 +00:00
Claude d2e18cb18d fix(media-servers): trim the gap above the first section header
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
2026-07-17 23:26:34 +00:00
Claude a99fc59de8 feat(media-servers): auto-save on every change; polish cache card & add flow
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
2026-07-17 23:21:02 +00:00
Claude 7f99cbc3d6 fix(ci): include commons translations in the Crowdin PR
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.
2026-07-17 23:14:36 +00:00
Claude 4d199ab28a fix(media-servers): probe /upload not root; polish rows into cards
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
2026-07-17 23:03:05 +00:00
Claude 33f4c648e9 fix(graperank): don't create the operator master for a read-only follower crawl
`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
2026-07-17 22:57:25 +00:00
Claude 0feb0072a1 feat(media-servers): redesign whole screen as one canvas, drop tabs
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
2026-07-17 22:53:13 +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 60a226891c build(amethyst): read commons Compose resources; drop a duplicated string
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
2026-07-17 22:50:07 +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
Claude 331dca79cd feat(graperank): run crawl/score without a personal account
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
2026-07-17 22:41:26 +00:00
Claude ed9e02ac7f fix: reorder relay line last, align its icon, show expand button for relays
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
2026-07-17 22:20:49 +00:00
Claude db586e30d3 feat: render NIP-84 highlights from web highlighter clients
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
2026-07-17 21:40:21 +00:00
Claude 40acd69781 refactor(media-servers): cache health probes, prune stale status, save tab
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
2026-07-17 21:33:26 +00:00
Claude dc157e32d2 fix(graperank): page through ALL followers, not just the first limit
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
2026-07-17 21:32:16 +00:00
Claude 1daa871d95 refactor: move MiniFhir/MedicalData parsing from Jackson to Kotlin Serialization
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
2026-07-17 21:27:31 +00:00
Claude 76dd0a0f16 fix: size accepted-by-relays icons to match gallery author pictures
The relay favicons rendered at 17dp while the zap/boost/reaction gallery
rows use 35dp author pictures, so the "accepted by relays" line looked
out of scale. Parameterize RenderRelay with a box size and icon modifier
(defaults unchanged for the existing compact relay lists) and render the
gallery relays at 35dp with MediumRelayIconModifier to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVPYTTnMbxwi5hUAagsKVc
2026-07-17 21:11:34 +00:00
Claude ede4fea1cd fix: don't render empty reaction gallery row without relays or reactions
Restore a visibility guard on the reaction detail gallery so a note with
neither relays nor reactions no longer paints an empty padded row when
the gallery is expanded. The relay flow is now collected once via
observeNoteRelays and reused both for the guard and to render the
"accepted by relays" line, avoiding a second subscription.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVPYTTnMbxwi5hUAagsKVc
2026-07-17 20:28:16 +00:00
Claude e3e85994f2 fix: drop Concord invite screen from back stack after forwarding to the community
ConcordInviteScreen auto-redeems and forwards to Route.ConcordServer, but it did
so with nav.newStack(). Since the community route isn't in the back stack yet,
newStack's popUpTo(target) matches nothing and the invite screen stays underneath.
Pressing Back from the community then reveals the invite screen, whose plain
remember(link) state resets to Working, re-runs joinConcordViaInvite, and forwards
right back — trapping the user in a Back→forward loop.

Forward with popUpTo(ConcordServer, ConcordInvite::class) instead, matching the
sibling group-creation screens (CreateGroupScreen, RelayGroupMetadataScreen), so
the invite screen is removed and Back returns to wherever the invite was opened.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeJJCDLdvi4KQEuozcMMBh
2026-07-17 19:47:17 +00:00
Claude 7649c7f94d feat(messages): use PolicyCard selection cards for NIP-29/Concord display modes
Replace the radio-row inline/grouped pickers with the same bordered PolicyCard
selection cards the Relay Authentication "When to authenticate" picker uses:
an accent-colored icon + label + description, a check when selected, and an 8dp
stack. Inline uses the list icon, grouped uses the folder icon (both already in
the Material Symbols subset).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RbzL9cZk1h88kE7ErgjG5
2026-07-17 19:41:18 +00:00
Claude 5f65405b08 feat(graperank): add reverse follower crawl (amy graperank followers)
The outbox model can't find an observer's followers — you don't know a
follower exists until you've seen their kind:3, so you can't route to their
outbox first. FollowerCrawler casts a wide net instead: it asks as many relays
as possible for kind:3 lists that #p-tag the observer, paging each relay past
its per-REQ cap via fetchAllPagesFromPool, verifies with ParallelEventVerifier,
keeps only lists that genuinely tag the observer, dedups by id, and
group-commits to the store.

Each follower's list is a full contact list, so persisting it also enriches the
graph a later `graperank score` builds — every follower becomes a FOLLOW edge
into the observer.

CLI: `amy graperank followers [OBSERVER]` assembles "all possible relays" from
the reachability-cache live set + every kind:10002/30166 relay in the store +
the index/aggregator relays, skipping proven-dead relays. Runs anonymously (no
signing) when given an explicit observer. Tunable via
--relay/--page-limit/--timeout/--relay-concurrency/--insert-batch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4
2026-07-17 19:34:06 +00:00
Claude c8ff1bb18f feat(graperank): persist follower count and hop distance on trust cards
Each kind:30382 GrapeRank card now carries two more public tags alongside
`rank`:

- `followers` — the number of the target's followers whose own score clears a
  threshold (`--followers-threshold`, default 0.02), mirroring Brainstorm's
  trusted-follower cutoff.
- `hops` — the shortest follow-graph distance from the observer (1 = a direct
  follow), matching the `hops` field on Brainstorm's ScoreCard.

New `HopsTag` (the `followers`/`FollowerCountTag` already existed) is wired
through the ContactCardEvent tag accessors/builders. TrustGraph gains
`hopsFrom` (a follow-only BFS over the compact int-CSR) and
`trustedFollowerCounts`; the out-CSR now packs the relation code so a forward
walk can filter FOLLOW edges. The publisher's `reconcileLocal` takes a richer
`ScoredCard` and diffs the full (rank, followers, hops) triple, so a card
re-signs when any of them moves and older cards migrate onto the new tags once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4
2026-07-17 19:34:06 +00:00
Claude 9717af28ee Merge remote-tracking branch 'origin/main' into claude/messages-settings-toggles-s1qo9e 2026-07-17 19:34:04 +00:00
Claude f759ef6ca7 feat: modernize Media Servers settings with reorderable priority list
Redesigns the Media Servers screen around the fact that Blossom uploads
mirror in list order, plus a lightweight reachability check per server.

- Split the screen into "Servers" / "Local cache" segmented tabs, moving
  the local-cache switches into their own tab to declutter the list.
- Make the server list drag-to-reorder using the shared RelayDragState
  utility; list order is the upload/fallback priority (row #1 first).
- Add a rank badge per row (accent-filled for the primary target) and a
  reorder hint.
- Add a per-server health dot (Online / Slow / Offline / Checking) backed
  by a one-shot HEAD probe through the account's Tor-aware preview client.
- Add moveServer() + health StateFlow to BlossomServersViewModel; probe on
  load and when a server is added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ77Hm5L7fXEbPWbUds1iA
2026-07-17 19:33:43 +00:00
Claude 2ac9cc81f7 feat: skip re-join when redeeming a Concord invite for a community already joined
When redeeming a Concord invite link (`…/invite/<naddr>#<fragment>`) whose
resolved community is already in the joined list, return
`ConcordInviteResult.Joined` immediately instead of re-following and
re-announcing a Guestbook JOIN (kind 3306). The invite screen already forwards
to `Route.ConcordServer(communityId)` on `Joined`, so reopening an old invite
for a community you're already in simply takes you to it rather than spamming
the community relays with a fresh join every time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeJJCDLdvi4KQEuozcMMBh
2026-07-17 19:33:43 +00:00
Claude 2f3c09c41d feat: move relay icons from author badge to reaction gallery
Complete UI Mode used to paint the "seen on" relay pills below the
author avatar (BadgeBox / RelayBadges), hidden from every other mode.
As part of retiring Complete UI Mode, surface those relay icons for
everyone instead: they now appear as an "Accepted by relays" line at
the top of the expanded reaction gallery.

- Add AcceptedByRelaysGallery: a reaction-gallery line (Dns icon +
  FlowRow of relay favicons) mirroring the OnchainZap/Nutzap gallery
  pattern, subscribing to the note's sampled relay flow.
- Wire it as the first line of ReactionDetailGallery and drop the
  hasReactions gate so the line shows whenever the gallery is opened
  (a note almost always has at least one relay).
- Remove the complete-mode-only BadgeBox and the now-dead RelayBadges
  composable and the relayBadges slot in NoteComposeLayout (reindexing
  the single-pass measure/place logic).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVPYTTnMbxwi5hUAagsKVc
2026-07-17 19:33:04 +00:00