Fold the full-text-search on/off switch into `IndexingStrategy` as
`indexFullTextSearch` (default `true`) instead of a separate top-level
`enableFullTextSearch` constructor param on `EventStore`/`SQLiteEventStore`.
`IndexingStrategy` is already the single place that decides which indexes
the store builds — every field is a per-index toggle with a size/speed
tradeoff, and `QueryBuilder` already receives it — so FTS, being just
another index, belongs there rather than split across two config surfaces.
Behaviour is unchanged: the module's no-op path and the QueryBuilder
"search matches nothing" guards now read the flag via the strategy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjzUpY8H31c7ux669zytWg
Add an `enableFullTextSearch` flag (default `true`) to `EventStore` and
`SQLiteEventStore` so deployments that never serve NIP-50 search from
SQLite — e.g. a relay that offloads search to an external engine like
Vespa — can skip the full-text-search write cost.
When disabled:
- `FullTextSearchModule` becomes an inert no-op: the `event_fts` virtual
table and its `fts_foreign_key` delete trigger are never created,
inserts skip `indexableContent()` + tokenization, and both reindex
entry points return immediately.
- `QueryBuilder` short-circuits any query/count/delete filter carrying a
non-empty `search` term to a "matches nothing" result (an empty-string
search still imposes no constraint), so no SQL ever references the
absent `event_fts` table. In a multi-filter union the search branch
contributes nothing while the other filters resolve normally.
Everything else (replaceable/addressable handling, deletions,
expirations, right-to-vanish, negentropy) is unchanged, and the default
keeps FTS on for existing callers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjzUpY8H31c7ux669zytWg
Kotlin/Native (the iOS test target) rejects commas in backtick function
names, so test-quartz-ios failed to compile even though jvmTest — which
allows them — passed. Rename the two offending tests. No behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
Relay operators wiring up a Quartz-based relay had to hand-write the
NIP-11 document as a large JSON string, which is error-prone and drifts
from the model. Add a DSL builder so they can describe the document in
Kotlin instead:
val info = relayInformation {
name = "sot"
description = "NIP-50 profile search ranked by Nostr web-of-trust"
software = "https://github.com/vitorpamplona/sot"
version = "0.1"
supports(1, 11, 42, 50)
}
call.respondText(info.toJson(), ContentType.parse(Nip11RelayInformation.CONTENT_TYPE))
The builder covers every field, with nested `limitation { }` / `fees { }`
DSLs, repeatable list helpers, a `retention(...)` entry adder, and a
`limitation(RelayLimits)` overload that advertises exactly the limits the
relay enforces so the two can't drift.
Also fix FlexibleIntListSerializer to emit numeric `supported_nips` as
JSON integers ([1,11,42,50]) instead of quoted strings, matching the
NIP-11 spec; non-numeric ids still fall back to strings. Geode's default
document now builds via the DSL.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C64Yy2d3na7Y28u7GRrHMX
Introduce OptionalAuthPolicy, a relay-server policy that runs the full
NIP-42 challenge/verify handshake — emitting the AUTH challenge on connect
and recording verified pubkeys into the connection scope — but never
requires it: EVENT, REQ, and COUNT are always accepted, so clients that
ignore the challenge keep working.
It subclasses FullAuthPolicy and only relaxes the EVENT/REQ/COUNT gates, so
the authorize() hook and per-connection authenticatedUsers set behave
identically; downstream policies can still gate or rewrite on caller
identity. Wire it into geode via an optional_auth config option and a
--optional-auth CLI flag (ignored when require_auth/--auth is set, since
mandatory AUTH already sends the challenge).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJWy4dBBYLbqthhLvbcZh7
Four Kotlin sources embedded literal NUL (0x00) bytes, used as string
separators and as literal characters in KDoc comments. A NUL is valid
UTF-8 (U+0000) so decoders accept it, but SonarScanner flags an embedded
NUL in a text source as a file-encoding problem, and git tracked these
files as binary.
New KDoc blocks on isHex/isHex64 already state the "~47ns" and
"~30% faster" perf notes, so the trailing EOL comments between the
KDoc and the function now trip ktlint's standard:no-consecutive-comments
rule ("an EOL comment may not be preceded by a KDoc"). Remove them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BawgXifvcPidMMqJ719Ka2
When a Podcasting-2.0 person's href points at an npub/nprofile (bare,
nostr: URI, or an njump-style link), upgrade the free-text credit to a real
Nostr profile: the standard ClickableUserPicture + UsernameDisplay, tappable
through to the profile. Plain web links keep the free-text card with the
default profile-image loader.
- quartz: PodcastPerson.nostrPubKey() resolves href → pubkey via Nip19Parser
(npub/nprofile only). Covered by PodcastPersonSoundbiteTest.
- UI: PodcastPeople branches per person — LoadUser + standard profile
components for nostr identities, free-text card otherwise — sharing one
card scaffold so both look identical in the Hosts & Guests strip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
Same discoverability treatment as the Hex helpers: KDoc on the reusable,
heavily-used primitives external integrators and AIs kept missing, plus
skill coverage.
- TimeUtils: object + key-fn KDoc emphasizing Unix *seconds* (created_at)
vs the millisecond nowMillis() exception.
- RandomInstance: object + per-fn KDoc (secure random; use over kotlin.random).
- sha256(): KDoc pointing event-id work to EventHasher.
- EventHasher: object + fn KDoc on canonical id serialization / verification.
- StringUtils: KDoc on the allocation-free case-insensitive matchers + DualCase.
- Bech32: "use NIP-19 helpers unless you need a custom prefix" pointer; KDoc
on bechToBytes.
- quartz-integration skill: new "Everyday utilities" section (time/random/
hashing/bech32/base64) + quick-reference rows.
- nostr-expert skill: "Core Utilities" section.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HM5uueF4a17umGpjC8wcz
AI agents integrating Quartz were re-implementing hex encoding or pulling
in third-party codecs because the built-in helpers weren't discoverable.
- Add KDoc to the `HexKey` typealias, its extension functions
(`toHexKey`/`hexToByteArray`/`hexToByteArrayOrNull`/`isValid`) and the
`PUBKEY_LENGTH`/`EVENT_ID_LENGTH` constants.
- Add KDoc to the `Hex` object and its public API
(`isHex`/`isHex64`/`decode`/`encode`/`isEqual`).
- Add a dedicated "Hex utilities" section + quick-reference rows to the
quartz-integration skill, and fix the wrong `HexKey.decodeHex(hex)`
snippet (no such API) to the real `hex.hexToByteArray()`.
- Add a "Hex Encoding" section to the nostr-expert skill.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HM5uueF4a17umGpjC8wcz
Adds two Podcasting-2.0 features to the podcast stack:
Persons (podcast:person) — hosts/guests as free-text credits with role,
avatar, and link (not necessarily Nostr users):
- quartz: PodcastPerson model; PersonTag ["person", name, role, img, href]
on kind 30054 episodes; a persons[] array in the kind 30078 show JSON.
Exposed via PodcastEpisode.episodePersons() / PodcastShow.showPersons().
- UI: PodcastPeople — a "Hosts & Guests" avatar strip (robohash fallback,
tap opens href), shown on the episode card and the show header.
Soundbites (podcast:soundbite) — highlight clips:
- quartz: PodcastSoundbite model; SoundbiteTag ["soundbite", start, dur,
title?] on episodes; PodcastEpisode.episodeSoundbites().
- UI: PodcastSoundbites — "jump to the good part" chips under the audio
player that seek the live media controller to the clip's start.
Both parse leniently, round-trip through build(), and are covered by
PodcastPersonSoundbiteTest. NIP-F4 returns empty for both (no such tags),
so nothing renders there.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
IdleClock.bump() is called for every message the relay sends — the connection
listener bumps it per event, so a multi-million-event download bumped it millions
of times. It stored a ValueTimeMark into an AtomicReference, and since the value
class boxes when used as a generic type argument, every bump allocated a heap
object. That is needless GC pressure on the hottest path (and battery/jank on
Android).
Replace it with a single monotonic base mark taken once (stored unboxed) plus a
@Volatile Long of nanos-since-start updated on each bump — zero allocation per
bump, and only visibility (not atomicity) is needed since each relay's bumps come
from its single reader thread and the driver only reads. Behavior is unchanged;
negentropy + concurrency suites pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
The fixed `timeoutMs` (default 30s) applied to every reconcile round, but the
FIRST round on a large relay is a legitimate long silence while the relay builds
its whole negentropy fingerprint — observed at ~63-68s for a 3.5M-event kind:0
set on a real relay. So the old default spuriously failed big syncs with
UNAVAILABLE ("reconcile round timed out"), even though the relay was working fine
and would have answered seconds later.
Replace it with `idleTimeoutMs` (default 120s): the maximum time the relay may go
COMPLETELY SILENT before giving up. It resets on every message the relay sends —
each NIP-77 round and every download EOSE/event — and on connect, so a genuinely
slow but progressing sync runs for as long as it needs; only true silence trips
it. Because the watchdog is fed by a connection-level listener that sees all of
the relay's traffic, download activity extends the reconcile deadline and vice
versa. `idleTimeoutMs = 0` disables it entirely (run until the socket drops); the
initial connect and each download batch keep their own finite bounds so an
unreachable relay or a single stuck batch still can't hang the pipeline.
Liveness of a dead/half-open socket does not depend on this: the WebSocket
keep-alive (ping/pong) detects it and the disconnect is already turned into a
clean NEG-ERR abort.
Verified against wss://wot.grapevine.network: the first round took 68.4s (the old
30s default would have thrown UNAVAILABLE) and the sync sailed through it under
the 120s idle window. Negentropy unit suite passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
Revert the fresh-subId-per-page workaround (ed5c25e2) now that the underlying
double-REQ race is fixed at the root in PoolRequests. Relays cap the number of
concurrent subscriptions per connection, so a single reused id — opened per page
with the page's `until`, closed before the next page — keeps the whole download
to one subscription slot instead of churning through a distinct id each page.
Safe because the pool now serializes the "send a REQ" decision: after a page's
EOSE, the auto-resend and the loop's unsubscribe+resubscribe can no longer both
fire a REQ for the same id (guarded by PoolRequestsConcurrencyTest). Unit suites
(negentropy paging scenarios, subscriptions) pass; full-scale real-relay
verification to follow before push.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
A subscription id is driven from two threads at once: the app thread (the
subscribe/unsubscribe path) and every relay's socket-reader thread (an EOSE
that triggers an auto-resend). Both read the subscription state and both can
decide "the filters changed, send a REQ", but the decision (read state) and the
send (mark state SENT in onSent) were not atomic. So the reader could observe
the pre-send state — filters still on the previous value — while the app had
already moved the desired filters forward, and both would send a REQ for the
same sub id.
Two REQs on one id race on the wire: the relay answers with duplicate EOSEs and
events, or — if a CLOSE interleaves — an empty result that silently truncates a
paged download. This is what intermittently broke fetchAllPages on large sets
(fixed at the call site in ed5c25e2 by using a fresh sub id per page); this
commit fixes the underlying race in the relay-client layer, which could equally
corrupt any subscription that spans multiple relays (several reader threads
mutate the same RequestSubscriptionState maps concurrently).
The fix:
- Add a tiny non-reentrant spin lock (withStateLock, same AtomicBoolean
primitive BasicRelayClient uses) guarding every access to the subscription
state machine. Listener callbacks and socket sends stay OUTSIDE the lock —
they re-enter this class via onSent, so holding it across them would deadlock.
- Fold the send decision into decideCommandLocked, which runs under the lock and
pre-marks the state SENT (+ filters) the moment it decides to send a REQ. A
concurrent decider then sees SENT/updated filters and declines, so exactly one
REQ is ever produced.
Verified with a deterministic A/B repro that pins the exact interleaving open:
pre-fix 300/300 episodes produced a duplicate REQ; post-fix 0/300 (max one REQ
per episode). Kept as PoolRequestsConcurrencyTest. Full relay test suite passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
fetchAllPages reused a single subscription id across all pages
(unsubscribe + immediately re-subscribe the same id). On a real relay that
caps REQ results, the rapid same-id CLOSE→REQ races on the wire: in-flight
events from the previous page's REQ bleed into the next page's listener.
Those stale events carry a created_at above the freshly-lowered `until`, so
`match()` rejects them, the page ends with pageCount == 0, and the whole
loop breaks — silently truncating the download.
Observed against wss://wot.grapevine.network: a full kind:0 download (~3.55M
events, per a concurrent negentropy sync) stopped at 89,500. A controlled
diagnosis paging the same data with a fresh subId per page vs a shared subId
reproduced it exactly: shared stalled at ~95k with in-page duplicates and
events above `until`; fresh advanced cleanly with no duplicates. After the
fix, the real-relay fetchAllPages sails past the old stall (100k+ and
counting).
Fix: allocate the subId inside the paging loop so each page is an
independent subscription.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
Rework the negentropy download path from "reconcile fully → then download"
into a single back-pressured streaming pipeline so peak memory is independent
of the window size — built for multi-million-event syncs.
- reconcileStreaming drives the NIP-77 rounds directly (instead of via
NegentropyManager) and hands each round's ids to a bounded id-queue *before*
acking the next round, so the relay's id stream is paced to the downloader.
- Ids flow id-queue → bounded download worker pool → bounded delivery channel;
a slow consumer back-pressures the whole chain. The full id list is never
materialised.
- Drop the global event-dedup set (was O(set) ~ hundreds of MB at 4M): NIP-77
yields a distinct id set, so each event is requested once. Keep only a tiny
per-batch dedup (bounded by fetchBatch) to absorb a relay replaying a REQ.
- Pin the relay with a never-matching keep-alive subscription for the sync's
duration: a NEG-OPEN isn't a REQ, so during a reconcile round the pool would
otherwise see the relay as unwanted and disconnect it mid-sync.
- Document that timeoutMs must accommodate the relay's first-frame snapshot
latency on huge sets (a real strfry took ~73s for an unbounded kind:0 set).
Validated against wss://wot.grapevine.network: streamed 30k kind:0 events with
heap bounded at ~30-90 MB (not growing with the set) and zero duplicates. New
unit test forces many small reconcile frames to exercise the multi-round /
back-pressure path; existing windowing/cap/fallback tests still green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
Adds a Podcasts row to the Bookmark lists screen (mirroring Git
Repositories), opening a feed of just the bookmarked podcasts:
- quartz: isPodcastEvent() — a reusable predicate matching shows,
episodes (NIP-F4 + Podcasting-2.0) and trailers, used to pull the
podcast subset out of the mixed NIP-51 kind:10003 bookmark list.
- BookmarkPodcastsFeedFilter / ...FeedViewModel / BookmarkedPodcastsScreen
filter the bookmark list (public + private) down to podcast notes,
newest first, and render them with the standard podcast cards.
- Route.BookmarkedPodcasts + AppNavigation wiring; a "Podcasts" row with a
live count in ListOfBookmarkGroupsFeedView.
- The single-podcast detail screen (PodcastScreen) gains a bookmark action
in its top bar that reflects and toggles bookmarked state (reusing the
stateful PodcastBookmarkButton). TopBarExtensibleWithBackButton now
accepts an actions slot to host it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
Per review: negentropySync should not silently switch transports. Plain
created_at paging is heavier and non-delta, and a caller who reached for
negentropy may prefer to know it failed (try another relay, narrow the
filter, abort) rather than get a surprise paged download.
So:
- negentropySync is now negentropy-only. created_at windowing on the
relay's max_sync_events cap stays automatic (it's still negentropy), but
a window that genuinely can't be reconciled — minimal window still over
the cap, or a relay with no NIP-77 support / disconnect / timeout — now
throws the typed NegentropySyncException (reason OVER_MAX_SYNC_EVENTS or
UNAVAILABLE, carrying the failing window) instead of paging. Dropped
NegentropySyncResult.fellBackToPaging.
- Added negentropySyncOrFetch (+ negentropySyncOrFetchEvents Flow form) as
the ergonomic "try negentropy, else page" combinator: runs negentropySync
and, on NegentropySyncException, falls back to fetchAllPages over the same
filter, deduping by id across both phases and honoring maxEvents. Returns
NegentropyOrFetchResult so callers can see which path ran and why.
Callers now choose explicitly: negentropySync to handle failure themselves,
negentropySyncOrFetch for automatic paging fallback.
Tests: over-cap relay with spread timestamps succeeds via windowing alone;
over-cap minimal window throws (and the caller can page); orFetch pages on
failure and uses negentropy when it works.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
Replace the cumulative `negentropySyncAsFlow(): Flow<List<Event>>` with
`negentropySyncEvents(): Flow<Event>`, which emits each event individually
as it arrives. Rebuilding an ever-growing list per event was O(events²) in
both CPU and memory and pointless for a bulk download; the stream stays
O(1) in memory and hands the caller raw events to collect however they
like.
Events are buffered with Channel.UNLIMITED because negentropySync delivers
through a non-suspending callback — a bounded buffer would drop events when
the collector lags. Callers can apply their own buffer/conflate/
collectLatest downstream.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
Add `INostrClient.negentropySync` / `negentropySyncAsFlow`, a high-level
NIP-77 accessory that downloads every event a relay holds matching a
`Filter` and delivers each (deduped) through `onEvent` — mirroring the
existing `fetchAllPages` accessory so downstream apps stop hand-rolling
the `NegentropyManager` dance.
It encapsulates the parts that make raw negentropy painful:
- reconciles the relay's matched set (empty local set) via NegentropyManager
- downloads the resulting ids through a bounded pool of concurrent REQs
(`maxConcurrentReqs` subs of `fetchBatch` ids each, refilled on EOSE)
- handles the relay-side cap (strfry `max_sync_events`,
`NEG-ERR "blocked: too many query results"`) by splitting the filter
into adaptive created_at windows; a minimal window that still can't
reconcile (or a relay that doesn't speak NIP-77) falls back to
`fetchAllPages` and reports it via `NegentropySyncResult.fellBackToPaging`
- caps delivery at `maxEvents`, dedupes through a single consumer, and
tears down all subscriptions + the neg session on completion/cancel
Scope is controlled entirely by the `Filter` (per maintainer guidance the
caller-supplied local-id delta interface is dropped in favour of a custom
Filter), so the common call is one line.
To drive NEG-OPEN on a single connection, add
`INostrClient.getOrCreateRelay(url)` (default throws; NostrClient delegates
to the pool). Because NEG-OPEN is a one-shot command that — unlike a REQ —
is never replayed on reconnect, the accessory connects and waits for the
relay to be ready before opening the session.
Tests (quartz jvmAndroidTest, in-process relay): full download, maxEvents
cap, clean teardown / no leaked subs, the Flow variant, and window-split +
paging fallback against a relay that rejects the full reconcile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
Someone is flooding thousands of identical mock NIP-F4 show-metadata events.
They share an exact fingerprint — title "Mock Podcast", description and content
both "Headless test feed" — so match that and refuse to cache them.
- PodcastMetadataEvent.isMockSpam() encodes the fingerprint (all three fields
must match, so a real show sharing one field is never flagged); unit-tested.
- LocalCache's PodcastMetadataEvent consume branch returns without storing when
it matches, so the spam never reaches the cache, feeds, search, or the merged
podcast list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
Audited all 143 plan files across the 10 plans/ folders. Each plan now
carries a Status header (shipped | in-progress | queued | abandoned)
backed by codebase evidence, and every folder has a README.md index
grouping plans by status.
Shipped plans were moved into a per-folder plans/archive/ (via git mv,
history preserved) so each plans/ folder surfaces only live work:
shipped (archived): 122 in-progress: 8 queued: 7 abandoned: 4
docs/plans/ is the frozen legacy folder; its plans were stamped and
indexed in place (48 of 52 archived) but it remains closed to new plans.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016hpUivtmq4pgzqRbY6MYrA
Adds a Follow Packs experience to Amethyst Desktop:
- New "Discover" sidebar destination with featured-pack hero, hashtag chips
driven by NIP-12 `t` tags, a 3-up "From the pack" notes feed, and a
right-rail of mini pack thumbnails.
- New "Follow Packs" launchable column (App Drawer + Discover "Browse all")
with multi-field search across title, description, creator name/npub,
and `t` tags.
- Pack detail overlay (read-only) with per-member Follow/Unfollow buttons
that reflect the live kind-3 state, plus pack-level Follow all /
Unfollow all with a dedupe-aware confirm dialog ("Follow N new (M
already followed)").
- Bulk follow / unfollow batched into a single kind-3 publish via new
`FollowActions.buildUnfollowBatch` and `Kind3FollowListState.follow/
unfollow(users: List<User>)`. The mutating call sites are Mutex-
protected against concurrent races.
- naddr → 39089 references in notes render as a rich inline card with
avatar stack + Follow all CTA. Cache miss triggers a one-shot
subscription; empty / deleted packs render minimal states.
- Shuffle button rotates both the featured pack and the gallery,
excluding the last 5 shown.
- Pack image fields render via Coil `AsyncImage` with a deterministic
gradient fallback.
Protocol additions:
- Quartz: `FollowListEvent.hashtags()` convenience accessor.
Bug fixes wrapped into the feature:
- `DesktopLocalCache.consumeContactList` now also loads the event into
`addressableNotes` so `Kind3FollowListState.getFollowListEvent()`
returns the user's actual kind-3. Without this, every bulk follow
silently replaced (rather than appended to) the contact list.
- Added Material Symbols `Shuffle` codepoint and regenerated the
bundled subset font (still 432 KB).
NIP-89 handler cards (kind 31990) previously only surfaced the supported
kinds and platform links, and even the platform links were silently dropped
when the link tag had no entity type. This widens both parsing and display:
- Fix PlatformLinkTag.match to accept 2-element link tags (entity type is
optional per NIP-89), so e.g. NostrHub's `["android", "intent:..."]` links
are no longer discarded.
- Parse the `i` supported-NIP tags (NostrHub points them at the NIP spec
markdown files) into a new SupportedNipTag, plus accessors for `t`
categories, `a` related addresses, and the `client` tag.
- Extend AppDefinitionEvent.build() with categories/supportedNips/
relatedAddresses/client so creation stays symmetric with parsing.
- Render the new data in RenderAppDefinition: category chips, compact
tappable rows for related addressable events (source repo, store listing,
...), a "via <client>" line, and NIP chips with a "+N" overflow that opens
a bottom sheet listing every supported NIP linking to its spec.
The deprecated `alt` tag is intentionally not surfaced on the handler card.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FLfM7JUTr2vNiNUi4Ukmqn
Turns the repository home into a data-rich dashboard combining git facts
with the Nostr social layer:
- Social row: live zap / reaction / comment counts on the repo announcement
(via observeNoteZaps/Reactions/ReplyCount).
- Stat tiles: branches, tags, file count, and last-updated relative time.
- Language breakdown bar: proportional colored segments computed from the
snapshot's file tree by extension (new GitRepoSnapshot.walkFileNames()).
- Last-commit strip: tip commit summary, author, time and short SHA, exposed
up-front via the new GitRepoSnapshot.tipCommit (no extra fetch).
- Nav cards now show open issue / PR counts.
- Recent-activity pulse: newest issues/patches merged from the feeds.
quartz: GitRepoSnapshot gains tipCommit (parsed in open()) and
walkFileNames() to enumerate blob paths for the language bar.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
Add a History action to the Code tab that shows a git-log-style list of the
branch's recent commits, and opens the diff a commit introduced (vs its first
parent) with the shared diff viewer.
quartz: GitCommit + a commit-object parser (skips multi-line headers like a
signed gpgsig), and GitHttpClient.loadHistory() — a shallow tree:0 fetch
(commits only) walked most-recent-first. The fetch filter is generalized from
a blob:none boolean to a filter spec so tree:0 can be requested. Unit-tested
against a real SSH-signed commit object.
amethyst: GitCommitLog (log list + per-commit diff), a History button on the
repo header, and ViewModel hooks (loadHistory / commitDiff).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
Emphasize what changed inside a modified line, not just the whole line.
A new pure IntralineDiff helper pairs each delete with its corresponding
add within a hunk and trims the common prefix/suffix to the differing
middle; GitDiffView layers a stronger add/delete background over just that
character span, on top of the existing syntax highlighting.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
Pull requests reference a clone URL + commit rather than embedding a patch,
so their changes were invisible. Now the app computes and renders them.
- quartz: a pure Myers O(ND) line-diff (LineDiff) producing the same
GitDiffHunk model the embedded-patch parser uses, unit-tested against git
-U3 output and with a reconstruction property check.
- quartz: GitHttpClient.computeDiff(cloneUrl, head, base?) — fetches both
commit trees, finds changed files by oid, batch-fetches the differing
blobs and line-diffs each into a ParsedPatch (base = merge base, or HEAD).
- amethyst: a "View changes" section on the PR card that loads the diff over
the git client and renders it with the shared GitDiffView.
- amethyst: GitStatusActions generalizes the issue open/close controls to
issues, patches and PRs — patches/PRs additionally get "Mark merged"
(GitStatusAppliedEvent). Visible to the author and repo owner/maintainers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
Code browser:
- Branch & tag switching: the client now exposes all refs from ls-refs;
a branch/tag picker on the repo header reloads the tree at the chosen
ref (GitRepositoryBrowserViewModel.switchRef).
- In-tree filename search: a search field filters the whole tree
(GitRepoSnapshot.searchFiles) and shows matching paths.
- Image preview: png/jpg/gif/webp/bmp blobs render via Coil instead of the
binary notice.
Issues:
- Labels at creation: the new-issue composer takes a comma/space separated
label list, written as NIP-34 `t` tags.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
Adds the streaming half of Podcasting-2.0 value-for-value: a "Stream sats"
toggle on the episode player that, while on, pays the value split once per full
minute of playback at a chosen sats/minute rate (boostagram action "stream").
The hard requirement is that it must never pay while the user isn't listening,
so accrual is bound tightly to genuine playback rather than a free-running timer:
- The control lives inside the player composable, so navigating away, scrolling
it out of a feed, or tearing down the screen disposes it and stops streaming.
- Each second the engine re-reads the live MediaController.isPlaying and only
accrues when audio is actually playing and there's no playback error. The
player already pauses itself on background / off-screen / audio-focus loss /
error, so every one of those halts accrual for free. A released controller
reads as not-playing (guarded).
- Only whole, actually-played minutes are billed; a partial minute is dropped
when the session ends (never rounded up). This rule is a pure, unit-tested
unit (PodcastStreamingAccrual).
- The toggle defaults OFF and uses plain remember (not rememberSaveable), so it
never silently resumes after a rotation or process death — the user re-opts in.
- Streaming is gated to an in-app wallet (NWC / CLINK debit); we never auto-fire
an external wallet intent every minute. Per-minute errors are swallowed (no
toast spam) while one-off boosts still surface errors.
The selected rate is always shown on the toggle and a live "streamed N sats this
session" counter makes the spend visible.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
Build out the repository screen into a project-review hub.
PR/patch diff viewer:
- quartz: UnifiedDiffParser turns a NIP-34 patch (kind 1617) `git
format-patch` body into a commit message + structured per-file diffs,
bounding each hunk by its header counts so the mbox "-- " signature is
never miscounted. Unit-tested against a real git-generated patch.
- amethyst: GitDiffView renders a GitHub-style file-by-file diff —
stat summary, collapsible file cards, +/- line coloring, old/new line
numbers, and per-line syntax highlighting reused from the code browser
(size-guarded). Wired into the patch card; feeds keep the compact preview.
Issue management:
- New-issue composer (subject + body) that builds, signs and broadcasts a
GitIssueEvent via the account signer; reachable from the Issues tab.
- Author/maintainer Open/Close controls on the issue card, publishing
GitStatusOpen/Closed events that flow back through GitStatusIndex.
Issues tab polish:
- Open/Closed filter chips, label (#topic) chips on each row.
Adds git_diff_files_changed plural and issue/diff strings. No new icons.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
Adds payment execution to the V4V value blocks that were previously
display-only. A "Send value" button on the value card opens the account's
zap-amount picker; choosing an amount fans the weighted shares out to every
recipient, mirroring how a NIP-57 zap-split is paid.
quartz (pure, tested):
- PodcastValue.computeShares() — splits a total across recipients by relative
weight, honoring `fee` recipients that take their split as a percent off the
top. Returns PodcastValueShare (recipient + millisats).
- PodcastBoostagram — the satoshis.stream keysend metadata blob carried in TLV
record 7629169, with the registered field names and unset fields omitted.
- PODCAST_TLV_RECORD / TYPE_NODE / TYPE_LNADDRESS constants.
amethyst:
- V4VPaymentHandler — the execution engine. lnaddress recipients resolve to a
BOLT-11 via LNURL-pay and pay through the user's default source (NWC, CLINK
debit, or external wallet intent), same rails as a zap. node recipients pay
by NWC keysend (pay_keysend) carrying the boostagram TLV plus any per-recipient
custom TLV; keysend is NWC-only, so node recipients are skipped with a clear
error when no NWC wallet is configured.
- AccountViewModel.payV4V() wrapper + the "Send value" amount picker on the
value card, wired for both episode and show value blocks.
V4V recipients are raw Lightning destinations, not Nostr users, so there is no
zap request and no zap receipt — just the payment.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
Render the repository README in the first tab and add a Code tab that
browses the repo's file tree and renders source files (syntax-highlighted),
reading directly from the NIP-34 clone URL over the git smart-HTTP v2
protocol (works with GRASP/ngit bare servers as well as GitHub/GitLab).
quartz (jvmAndroid): a from-scratch git smart-HTTP v2 client — pkt-line
codec, packfile parser with OFS/REF delta resolution and SHA-1 oids,
tree/commit parsers, and a high-level browser that fetches a shallow
filter=blob:none snapshot (one request for the whole tree) and lazily
pulls file blobs on demand. Offline tests run against real captured
GitHub wire bytes plus a git-generated OFS-delta pack.
amethyst: README tab (rich markdown), Code tab (folders-first browser
with breadcrumb navigation + a file viewer that renders markdown or
syntax-highlighted source), a browser ViewModel, new UI strings, and a
Folder material symbol (font subset regenerated). Syntax highlighting
uses dev.snipme:highlights (Apache-2.0, permissive).
Tabs are now: README, Code, Overview, Issues, Patches & PRs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
A show's kind:10154 metadata can name any pubkey as an author (host, co-host,
editor) via `p` tags, but those claims are unverified — the show can list
anyone. NIP-F4 lets the named author publish their own kind:10064
AuthoredPodcastsEvent listing the podcasts they actually author, which closes
the loop.
On the single-podcast header, render each claimed author as a row (avatar,
name, role) and cross-check it: the author's 10064 is fetched + observed
lazily via observeNoteEvent, and a "verified" check badge appears only when
that 10064 lists this podcast's pubkey.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
Turn the episode "Chapters" affordance from a link-out into an inline,
timestamped chapter list.
quartz:
- PodcastChapters / PodcastChapter (@Serializable) parse the off-event
podcast-namespace chapters.json (version + startTime/title/img/url/toc).
Lenient parse with a malformed-input test.
amethyst:
- PodcastChaptersSection fetches the chapters document with the app's
preview HTTP client (Tor/proxy aware) off the main thread and renders
`timestamp — title` rows in a tinted card; empty/failed renders nothing.
- The episode card's "Chapters" chip now toggles this section instead of
opening the URL. Fetch is lazy — gated behind the toggle — so scrolling a
feed never triggers network.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
Make the Podcasting-2.0 `value` block first-class across read and publish. Actual
Lightning execution (keysend to node recipients, LNURL fan-out to lnaddress
recipients, weighted by split) is a separate wallet/NWC effort and is NOT done
here — this lands the data model, display, and authoring.
quartz:
- PodcastValue / PodcastValueRecipient (@Serializable): amount, currency,
recipients[] (name, type node|lnaddress, address, split weight, fee, custom*).
- Episode `["value", "<json>"]` tag (ValueTag) + accessor/builder; show value is
parsed from the kind:30078 JSON. Exposed via the shared abstraction as
PodcastEpisode.episodeValue() and PodcastShow.showValue() (interface defaults,
so NIP-F4 returns null). Round-trip + JSON-parse tests.
amethyst:
- PodcastValueSplits: a tinted "Value-for-Value" card listing each recipient
with its address and computed share, rendered on both the episode and show
cards when a value block is present.
cli:
- `podcast20 episode`/`metadata` gain `--value-json` to publish the block;
malformed JSON is rejected as bad_args. Verified end-to-end against the CLI.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18