Commit Graph
2410 Commits
Author SHA1 Message Date
Vitor PamplonaandGitHub 19d1167ccd Merge pull request #3441 from vitorpamplona/claude/podcast-event-kinds-merge-vv24gd
Add podcast authoring UI and NIP-XX Podcasting 2.0 support
2026-07-01 17:16:46 -04:00
Vitor PamplonaandGitHub 4a66435263 Merge pull request #3445 from vitorpamplona/claude/sqlite-event-store-no-fts-9k6y8a
Add optional full-text search indexing toggle to SQLiteEventStore
2026-07-01 17:14:22 -04:00
Claude 8b938396a0 refactor(quartz): move FTS toggle into IndexingStrategy
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
2026-07-01 21:11:26 +00:00
Claude cd4edefce0 feat(quartz): allow SQLite event store without FTS indexing
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
2026-07-01 21:00:06 +00:00
Claude d723e93e7a fix(quartz): drop commas from podcast test names for Kotlin/Native
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
2026-07-01 20:55:58 +00:00
Vitor PamplonaandGitHub 9c191137d5 Merge pull request #3443 from vitorpamplona/claude/nip11-document-builder-gq7eeq
Add type-safe DSL builder for NIP-11 relay information documents
2026-07-01 16:45:31 -04:00
Claude 266f59959e Merge remote-tracking branch 'origin/main' into claude/podcast-event-kinds-merge-vv24gd 2026-07-01 20:30:27 +00:00
Vitor PamplonaandGitHub 03c71d1d4e Merge pull request #3440 from davotoula/fix/sonar-encoding-nul-bytes
Escape raw NUL bytes to fix Sonar encoding warning
2026-07-01 16:27:23 -04:00
Claude c871597fc9 feat(quartz): add a type-safe NIP-11 relay info builder
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
2026-07-01 20:19:03 +00:00
Claude f7afc56752 feat(quartz): add optional NIP-42 AUTH relay policy
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
2026-07-01 19:30:19 +00:00
davotoula f7dd2c210b fix: escape raw NUL bytes to fix Sonar encoding warning
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.
2026-07-01 21:25:27 +02:00
Claude dc9579f994 Merge remote-tracking branch 'origin/main' into claude/podcast-event-kinds-merge-vv24gd 2026-07-01 18:39:59 +00:00
Claude f35d40cc00 style: drop redundant EOL comments flagged by ktlint in Hex.kt
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
2026-07-01 18:33:19 +00:00
Claude a98ec62004 feat(podcasts): render nostr-native podcast:person credits as real profiles
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
2026-07-01 18:15:24 +00:00
Claude 2e2dbcb16b docs(quartz): document core utilities (time, random, hashing, bech32, strings)
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
2026-07-01 18:00:45 +00:00
Claude 68a28faab2 docs(quartz): document Hex/HexKey utilities for discoverability
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
2026-07-01 17:17:23 +00:00
Claude 429ec9177e feat(podcasts): Podcasting-2.0 person credits and soundbites
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
2026-07-01 16:31:33 +00:00
Vitor PamplonaandGitHub 2cb058b323 Merge pull request #3434 from vitorpamplona/claude/quartz-negentropy-sync-accessory-r92bue
Add NIP-77 negentropy sync with streaming and windowing support
2026-07-01 10:24:12 -04:00
Claude 322e33678a perf(quartz): make the negentropy idle watchdog allocation-free
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
2026-07-01 14:18:31 +00:00
Claude 55e945a5ed feat(quartz): idle watchdog for negentropySync instead of a fixed per-round timeout
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
2026-07-01 14:02:57 +00:00
Claude 23722969fd refactor(quartz): reuse one subscription id across fetchAllPages pages
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
2026-07-01 13:01:12 +00:00
davotoula 385f0bd459 fix(quartz): rename gzip test corpus to .json.gz to clear Sonar encoding warning 2026-07-01 10:54:15 +02:00
Claude f357b445c3 fix(quartz): serialize PoolRequests state machine to kill shared-sub double-REQ race
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
2026-07-01 02:30:19 +00:00
Claude ed5c25e2b1 fix(quartz): fresh subId per page in fetchAllPages (was truncating large results)
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
2026-07-01 00:42:33 +00:00
Claude e5f43904f2 perf(quartz): stream negentropy sync in bounded memory for huge windows
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
2026-06-30 23:12:21 +00:00
Claude 496cda2f22 feat(podcasts): dedicated Podcast Bookmarks screen + detail-screen toggle
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
2026-06-30 23:05:29 +00:00
Claude 3fdf418177 feat(quartz): make negentropy paging fallback the caller's choice
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
2026-06-30 22:21:02 +00:00
Claude 21c714177c refactor(quartz): stream events from the negentropy flow variant
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
2026-06-30 22:03:29 +00:00
Claude abfea4e7b6 feat(quartz): add high-level negentropy sync-and-download accessory
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
2026-06-30 21:48:11 +00:00
Claude 821e9bafdd fix: drop the "Mock Podcast" kind:10154 spam flood before consuming
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
2026-06-30 20:50:33 +00:00
Claude 13d654f6be Merge remote-tracking branch 'origin/main' into claude/podcast-event-kinds-merge-vv24gd 2026-06-30 19:23:05 +00:00
Claude 6579b5f656 docs: audit, status-stamp, and index all module plans
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
2026-06-30 15:35:38 +00:00
nrobi144 de2c970e3e feat(desktop): Follow Packs (NIP-51 kind 39089) discovery and follow flow
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).
2026-06-30 12:05:16 +03:00
Vitor PamplonaandGitHub a0586a8613 Merge pull request #3422 from vitorpamplona/claude/event-tag-parsing-w7x43s
feat(nip89): parse and display app-handler t/i/a/client tags
2026-06-29 22:28:02 -04:00
Claude 250f918ebe feat(nip89): parse and display app-handler t/i/a/client tags
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
2026-06-29 23:57:40 +00:00
Claude 6355784c98 Merge remote-tracking branch 'origin/main' into claude/podcast-event-kinds-merge-vv24gd 2026-06-29 23:51:54 +00:00
Claude 2ad4af2479 feat(git): rich project-home dashboard (stats, languages, social pulse)
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
2026-06-29 15:32:47 +00:00
Claude 31f62a1787 Merge remote-tracking branch 'origin/main' into claude/podcast-event-kinds-merge-vv24gd 2026-06-28 22:29:42 +00:00
Claude 5c29c277c8 Merge remote-tracking branch 'origin/main' into claude/git-repo-readme-code-tabs-e4uf6c 2026-06-28 22:29:16 +00:00
Claude 402c2fd3b4 feat: commit history in the git repository code browser
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
2026-06-28 21:27:12 +00:00
Claude 9207209b0b feat: word-level intra-line highlighting in git diffs
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
2026-06-28 21:14:57 +00:00
Claude a44ee6b312 feat: finish git PR/patch review — computed diffs and status actions
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
2026-06-28 20:33:31 +00:00
Claude cd934fb0ea feat: git code browser branch/tag switch, file search, image preview; issue labels
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
2026-06-28 20:12:00 +00:00
Claude 1fe9bcec68 feat: per-minute V4V streaming payments, gated strictly to real playback
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
2026-06-28 16:03:56 +00:00
Claude 39727d819e feat: flagship git review — diff viewer, issue management, polished Issues tab
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
2026-06-28 16:00:35 +00:00
Claude a71ba61370 feat: execute Podcasting-2.0 value-for-value (V4V) Lightning splits
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
2026-06-28 15:43:22 +00:00
Claude 291fda5728 feat: add README and Code tabs to the git repository screen
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
2026-06-28 14:46:32 +00:00
Claude 5984d20042 feat: verify NIP-F4 podcast authors against their kind:10064 counter-claims
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
2026-06-28 13:48:47 +00:00
Claude 7b2399eb51 feat: inline Podcasting-2.0 chapter list (expand-to-load)
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
2026-06-28 01:38:00 +00:00
Claude d90165a4f6 feat: Podcasting-2.0 value-for-value (V4V) splits — parse, display, publish
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
2026-06-28 00:35:10 +00:00