Replace the removed per-episode comment chip with the full NoteCompose
ReactionsRow (comment / zap / react) on every episode row in a podcast's
screen — same engagement affordance as the show header and every other note.
addPadding = false so it aligns within the row's existing padding.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
AI agents were hand-rolling their own hex/npub/nprofile/NIP-05 resolvers
(e.g. a bespoke `resolveObserver` with its own well-known fetch and JSON
parse) instead of using `resolveUserHexOrNull` in
`quartz/nip05DnsIdentifiers/`, which already handles every identifier form.
Add discoverable explainers so the canonical functions surface before an
agent reaches for a hand-rolled version:
- New reference `references/nip05-identifiers.md`: full API surface
(`resolveUserHexOrNull`, `Nip05Client`, `Nip05Id`, `Nip05Parser`,
`KeyInfoSet`, Namecoin `.bit`, `OkHttpNip05Fetcher`) plus the
hand-rolled anti-pattern to avoid and the ✅ replacement.
- SKILL.md: new "Resolving User Input to a Pubkey" section, a
When-to-Use bullet, Bundled Resources + Quick Reference rows, and the
identifier-resolution trigger added to the skill description.
- nip-catalog.md: fix the stale NIP-05 entry (referenced a nonexistent
`Nip05Verifier.kt`) to point at `UserHexResolver.kt` / `Nip05Client.kt`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bb8txetygxSnLXHJhPZSwH
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.
Brings three of PodStr's engagement/reading widgets to Amethyst's podcast
screens, reusing existing infrastructure:
- Top Supporters — a sats-ranked zap leaderboard on the show header, top 3
flagged with gold/silver/bronze medals, tap-through to profiles. Aggregates
the show note's zaps through the same LiveActivityTopZappersAggregator the
live-stream leaderboard uses. Anonymous zaps collapse into one bucket.
- In-app Chapters — fetches the Podcasting-2.0 chapters.json referenced by the
episode's `chapters` tag and renders a collapsible, tappable list; tapping a
chapter seeks the live media controller (same seek path as soundbites).
- Transcript viewer — fetches the `transcript` file and shows it in a
collapsible scrollable panel, stripping VTT/SRT scaffolding into flowing text.
Adds PodcastRemoteContent (a bounded URL text fetcher) for the two off-event
side files. Also removes the now-redundant per-episode comment chip from the
episode list rows — the show ReactionsRow and the episode thread already cover
commenting.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
Add a PreToolUse hook (.claude/hooks/pre-push-spotless.sh, wired in
.claude/settings.json) that runs spotlessApply before a git-push subcommand
or the create_pull_request MCP tool. If spotless reformats tracked Kotlin,
or reports a non-autofixable lint, the call is blocked so the fix is
committed first -- turning CI's spotlessCheck failure into an in-session
block. Gradle infra/network failures warn and allow, leaving CI as the
backstop. Boundary detection tokenizes the command so the words appearing
in a commit message do not trip the gate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nWg1Wwq5xCE7kPFzWY95o
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
The download/save-to-local button handed the raw content URL straight to
OkHttp. For BUD-10 `blossom:` URIs this failed with "expected scheme http
or https but was blossom" because OkHttp only speaks http/https.
Resolve `blossom:` URIs to a concrete server URL via BlossomServerResolver
(the same resolver the Coil/ExoPlayer pipeline uses) before downloading, in
both save entry points (AccountViewModel.saveMediaToGallery and the zoomable
dialog's save action). When no hosting server can be found, the save reports
an error instead of crashing.
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
Give the podcast show itself the usual engagement affordance: the standard
NoteCompose ReactionsRow (comment / zap / react, with counts) now sits
between the show header and the episode list on the detail screen, bracketed
by the usual dividers like any other content detail. Acts on the resolved
show note and only renders once that event loads.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
Episode comments already work end to end — replying to an episode routes to
the NIP-22 GenericCommentPost composer (kind 1111 scoped to the episode
address), the thread datasource fetches them via #a/#A, and the thread view
renders them with a full reactions row. The only gap was discoverability on
the podcast-specific surfaces.
Add a comments chip (comment glyph + live reply count via
observeNoteReplyCount) to PodcastEpisodeListItem. It reads "N comments" (or
"Comment" when empty) and opens the episode's thread, where the discussion
lives and new comments are composed. Reuses the existing thread/composer
machinery — no new event plumbing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
The single-podcast screen's top bar now shows the standard MoreOptionsButton
(the NoteCompose 3-dot menu) to the right of the bookmark button, giving the
show/episode the usual note actions (share, report, mute, etc.). Both appear
only once the show metadata has resolved.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
- The bookmark toggle was a Material IconButton (48dp minimum touch
target), so it stood taller than the titleLarge line it sits beside and
broke the side-by-side alignment. Render it as a compact clickable glyph
sized to a single title line (iconSize, default 20dp) so title and button
align.
- Drop the add/remove toast — the icon flipping filled/outline is enough
feedback — and remove the now-unused strings.
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
PodcastBookmarkButton read `note in bookmarks.public` — an identity-based
List<Note> containment that is unreliable for addressable shows/episodes
(the rendered note instance may differ from the one rebuilt from the
a-tag), so the icon never flipped and there was no way to tell a podcast
was already bookmarked.
Switch to the public bookmark id/address sets (the same reactive pattern
the working git-repository bookmark button uses): match note.address for
addressable notes and note.idHex otherwise. The icon now reliably shows
bookmarked vs not (filled/primary vs outline/onSurfaceVariant), and each
tap fires a confirming toast so the action is never silent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
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
Damus-inspired content filter that collapses notes abusing `t` hashtag
tags into a compact reveal-on-click placeholder. Ships default ON with a
threshold of 5 (adjustable 1–20 in Settings → Content Filters, or off).
Scope
- Pure check (`HashtagSpamCheck`) + settings interface
(`HashtagSpamSettings`) live in `commons/moderation/`, callable by
Desktop, `amy` CLI, and (future) Android.
- JVM-backed `PreferencesHashtagSpamSettings` writes to the shared
`java.util.prefs` node `com/vitorpamplona/amethyst/filters`, so `amy`
and Desktop observe the same value automatically.
- `CollapsedSpamNote` placeholder in `commons/ui/note/` takes only
primitive scalars so Android can adopt it without touching commons.
- Desktop wraps every `NoteCard` call site (FeedNoteCard, QuotedNoteEmbed,
BookmarksScreen, 5 SearchResultsList sites) with a shared
`SpamCheckedNoteRender` helper. Thread root notes auto-expand via
`forceReveal=true`; replies still respect the filter.
Exemptions
- Long-form articles (kind 30023)
- Authors in the follow list plus self
- Repost wrappers check the inner event's tags via precomputed
`note.replyTo`, falling back to `containedPost()`
Search UX fixes bundled in
- Removed the `#hashtag` → "Direct lookup" card. `QueryParser` already
extracts `#xxx` into the query's hashtag filter, so typing `#bitcoin`
now goes straight to filtered results.
- Search-result rows now trigger metadata loading via
`subscriptionsCoordinator.loadMetadataBatched(authors)` and observe
each user's metadata flow via a new `rememberDisplayData` helper, so
display names + avatars refresh when kind-0 arrives from index
relays. Same helper reused in Bookmarks.
Tests + docs
- 19 unit tests (check × 10, displayed-event unwrap × 4, prefs × 5),
all green.
- Manual testing sheet with 16 scenarios at
`desktopApp/plans/2026-06-29-hashtag-spam-filter-manual-testing-sheet.md`.
- Plan at `docs/plans/2026-06-29-feat-desktop-hashtag-spam-filter-plan.md`.
- Cross-client desktop feature backlog reference at
`desktopApp/plans/_desktop-feature-backlog.md`.
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
The standard zap button now detects a Podcasting-2.0 value-for-value block
on a podcast note (episode or show) and pays that split instead of a plain
author zap — so V4V becomes a first-class zap rather than a separate, no-
receipt payment:
- AccountViewModel.zap() intercepts a note carrying a value block and routes
the chosen amount to the V4V split. Intercepting at zap() covers every
entry point (one-tap, amount popup, custom dialog, polls) with no UI churn.
- V4VPaymentHandler gains asZap/zapType: lnaddress shares now attach a NIP-57
zap request, so a Nostr-aware provider mints a zappable invoice and
publishes a receipt (driving the zap button's icon/counter). Node shares
stay keysend — no LNURL, so a receipt is impossible there by protocol.
- Per-minute streaming pays with asZap=false so it doesn't publish a receipt
every minute.
- The dedicated "Send value" button is now redundant and removed;
PodcastValueSplits becomes a pure recipient/percentage breakdown display
next to the zap button.
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
Make the single-podcast detail screen spec-neutral so a Podcasting-2.0
show (kind 30078, d=podcast-metadata, e.g. "Soapbox Sessions") renders
its real cover art and title instead of the default Amethyst banner and
"Podcasts" fallback:
- PodcastHeader/PodcastScreen now take a resolved PodcastShow? instead of
a NIP-F4-only PodcastMetadataEvent?, resolving from either the kind
10154 (F4) or kind 30078 (P2.0) metadata event.
- FilterOnePodcast adds a #d=podcast-metadata filter on kind 30078 so the
P2.0 show metadata is actually fetched on deep-links/fresh loads.
- NoteMaster (thread/full view) now dispatches Podcasting20EpisodeEvent,
Podcasting20TrailerEvent, and the kind 30078 podcast-metadata variant
to the shared podcast renderers, matching NoteCompose.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
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