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
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
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
Verification of kind:1111 comments on podcast episodes: they already work
end-to-end (parse, build, route to the thread screen, thread assembly, composer,
and the reply/reaction subscriptions all treat any kind as a valid root — nothing
gates on the RootScope marker). Added a quartz test that drives the real
CommentEvent.replyBuilder path and asserts:
- a comment on a Podcasting-2.0 episode (30054) roots on its `a`/`A` address,
- a comment on a NIP-F4 episode (54) roots on its `e`/`E` event id,
- both are kind:1111 and carry the root-kind tag.
Also closes a small consistency gap: every other commentable content type
(articles, all video kinds, pictures, highlights, wiki, polls, …) implements the
RootScope marker, but the podcast events did not. Add it to PodcastEpisodeEvent,
Podcasting20EpisodeEvent and Podcasting20TrailerEvent. Harmless today (no code
does `is RootScope`), but it documents intent and future-proofs any such check.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
Add a separate command group for authoring the Podcasting-2.0 (podstr) kinds,
kept distinct from the NIP-F4 `podcast` commands because the models differ —
here the logged-in account is the creator and signs everything with its own key,
and episodes/trailers are addressable (d-tag) events.
amy podcast20 metadata --title T [...] kind:30078 show metadata (JSON body)
amy podcast20 episode --title T --audio URL[,URL] [...] kind:30054 episode
amy podcast20 trailer --title T --url URL [...] kind:30055 trailer
amy podcast20 list [USER] [--limit N] metadata + episodes + trailers
Episodes accept the full rich tag set (video, episode/season, transcript,
chapters, topics, duration); d-tags and the RFC2822 pubdate are auto-generated
when omitted. Thin assembly only — added Podcasting20PodcastMetadata.build() in
quartz so JSON-body construction stays out of cli (covered by a round-trip test).
Verified end-to-end against the running CLI: all three commands build, sign and
emit the expected kinds (30078/30054/30055) with correct d-tags and the --json
single-line contract.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
- Make all four consent actions (Always Allow / Allow Once / Deny Once / Always Deny)
full-width OutlinedButton/Button so they are visually consistent; remove TextButton
with left-aligned text that clashed with the centered primary buttons
- Move domain/coordinate label into the header Column directly below the subtitle so
the URL is contextually grouped with the app identity rather than floating near buttons
- Fix DataStore multiple-instances crash: add nappletPermissionStore and signerPermissionStore
as lazy singletons in AppModules; warm them up on IO thread to avoid StrictMode
DiskReadViolation; update NappletBrokerService and connected-apps screens to use the
shared singletons instead of creating independent instances
- Propagate iconUrl through NappletConsentInfo / NappletSignerConsentInfo / NappletConnectInfo
data classes; resolve napplet metadata (title + icon) in the broker-side builders
(NappletConsentSummary, NostrSignerOpLabels) so dialogs receive it ready to display
- Replace manual buildEventJson (org.json) with JacksonMapper.toJsonPretty(EventTemplate)
in NostrSignerOpLabels; add toJsonPretty(EventTemplate<*>) overload to JacksonMapper
- Delete dead NappletPermissionsScreen.kt and NappletSignerPermissionsScreen.kt (never
navigated to); remove Route.NappletPermissions and its composable registration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
- Add AllowForSession and AllowUntil(expiresAt) signer grant types so users
can grant temporary access (session, 24h, 30d) from the consent dialog
- Track per-app lastUsed timestamp in NostrSignerPermissionStore and update
it on every granted signing operation
- Auto-expire timed grants: decide() clears expired op decisions before
returning, so no background sweep is needed
- Add NappletBroker.sessionAllows in-memory set for session grants (cleared
on broker destroy, never persisted)
- Implement full NIP-42 relay auth policy system:
- RelayAuthPolicy enum (ALWAYS / NEVER / IF_IN_MY_LIST) stored in
AccountSettings and persisted in LocalPreferences
- RelayAuthDecision (ALLOW / DENY) per-relay overrides in DataStore
- RelayAuthPermissionLedger combining global policy + per-relay overrides
- DataStoreRelayAuthPermissionStore writing to relay_auth.preferences_pb
- Wire relay auth into AuthCoordinator: subscribeLedger/unsubscribeLedger
lets each logged-in account contribute its own policy; signWithAllLoggedInUsers
now receives the relay URL so it can check the ledger before signing
- Update RelayAuthenticator (quartz) to pass relay URL in the signing lambda
- Add RelayAuthSubscription composable that subscribes both the account and
its ledger when a screen is active
- Add RelayAuthSettingsScreen: global policy radio picker + per-relay
override list with toggle and remove; reachable from Settings
- Add Route.RelayAuthSettings, AppNavigation wiring, and SettingsCatalog entry
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
Parse the episode tags podstr emits beyond the basics — video, episode
number, season, transcript and chapters — and surface them in the UI.
quartz:
- Add VideoTag, EpisodeNumberTag, SeasonTag, TranscriptTag, ChaptersTag plus
accessors and builder DSL on Podcasting20EpisodeEvent.
- Extend PodcastEpisode with episodeVideo / episodeNumber / episodeSeason /
episodeTranscriptUrl / episodeChaptersUrl as interface defaults, so NIP-F4
needs no change. PodcastAudio is now documented as audio-or-video media.
- Tests for round-trip + interface access and an all-absent default case.
amethyst:
- Extract shared PodcastBadge / PodcastLinkChip (PodcastChips.kt) and reuse
them in the show card for visual consistency.
- Episode card: a season/episode badge, a "Video" badge, and Transcript /
Chapters link chips that open the off-event documents; the player now falls
back to the video source when an episode ships no audio.
- Compact show-page row: an "S2 · E5" prefix on the date line and the same
audio→video media fallback.
Read-only. New strings for season/episode, video, transcript and chapters.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
Parse and render the Podcasting-2.0 show fields the kind:30078 metadata carries
beyond the basics:
quartz:
- Extend PodcastShow with author, categories, funding URLs, explicit, complete
and copyright as interface defaults — so NIP-F4 (kind:10154) needs no change
and just returns empties, while Podcasting-2.0 overrides them.
- Podcasting20PodcastMetadata now parses categories, funding[], copyright,
type, complete, locked, email and guid from the JSON content (value/V4V is
still skipped). Covered by expanded tests incl. an all-absent default case.
amethyst:
- Rebuild the podcast metadata card: an author byline, tinted pills for
Completed / Explicit / genre categories, a prominent filled "Support the show"
button opening the funding URL, clickable website chips with a globe icon, and
a subtle copyright footer — all Material3, no new icons/font subset needed.
Read-only. New strings: podcast_explicit/completed/premium/support_show/by_author.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
Companion to the episode merge: unify show-level metadata across both drafts.
quartz:
- Add a spec-neutral PodcastShow interface (title/image/description/websites).
- NIP-F4 PodcastMetadataEvent (kind:10154) implements it directly.
- Add Podcasting20PodcastMetadata, a read-only view over a kind:30078 NIP-78
app-data event with d="podcast-metadata" whose channel fields live in a JSON
content blob (lenient parse; unknown keys like value/funding/categories are
ignored). resolvePodcastShow()/isPodcastShowEvent() adapt either kind.
amethyst:
- PodcastsFeedFilter merges kind:10154 with kind:30078 (d="podcast-metadata"),
both from LocalCache.addressables, gated by isPodcastShowEvent so the 30078
scan ignores unrelated app-data.
- RenderPodcastMetadata renders via PodcastShow; NoteCompose dispatches a
podcast-metadata app-data note to it and keeps the text fallback otherwise.
Read support only. The kind:30078 metadata still needs a d-constrained relay
subscription to arrive proactively — that touches the topNav subassembly
plumbing and is left as the remaining step; shows already in cache merge and
render today.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
Amethyst's podcast support (NIP-F4) and derekross/podstr use incompatible
identity models: NIP-F4 makes each podcast its own keypair with regular
kind:54 episodes, while the Podcasting-2.0 draft signs editable, addressable
kind:30054 episodes with the human creator's key. The two cannot share a wire
kind, but a client can still render them in one list.
Add the Podcasting-2.0 episode (kind:30054) and trailer (kind:30055) event
classes plus their tags, and introduce a spec-neutral `PodcastEpisode`
abstraction (with `PodcastAudio`) that both kind:54 and kind:30054 implement.
Feeds and UI can now depend on the shared interface and surface both kind sets
in a single, ordered podcast/episode list. NIP-F4 remains Amethyst's publish
format; this only adds read/parse support for the Podcasting-2.0 kinds.
Register both new kinds in EventFactory and KindNames. Covered by round-trip,
spec-example-JSON, and a unified-list test proving both kinds flow through the
shared abstraction.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
Render the repository screen's Issues and Patches & PRs tabs with a
lightweight one-line-per-item row (author picture, name, NIP-05, subject,
time, status pill and the shared 3-dot options) instead of the full
NoteCompose renderer, which is tuned for items shown inside a regular
feed. Injected via RefresheableFeedView's existing onLoaded slot, so the
feed filters and status-split view models are untouched.
The rows reuse and re-layer the same gating NoteCompose applies — event
loading (WatchNoteEvent), mute/block/report hiding
(CheckHiddenFeedWatchBlockAndReport) and the long-press quick-action menu
— so blocked authors and reported items are hidden here exactly as
elsewhere. No body or media is rendered, so sensitive content never
reaches this list.
Adds GitPatchEvent.subject(), which parses the patch title from the
git-format-patch Subject header (stripping the [PATCH n/m] prefix and
unfolding continuation lines), with unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013gs6pxiq58X18Fkz9wdZhU
NappletIconPath.choose scanned the whole path list once per conventional name
(O(PRIORITY x N)), allocating a filter list and recomputing each basename up to
20 times per path. A manifest is a whole static site, so N can be large. Collapse
to one O(N) pass with a name->rank map: basename computed once per path, no
intermediate lists, and the loose-raster fallback is skipped entirely once an
exact match is in hand. Behavior is unchanged (the 11 selection tests still pass).
Also key the favorite-icon resolution on the manifest event rather than the whole
NoteState, so paths()/choose() don't re-run on unrelated metadata bumps (a
reaction or zap tracked on the note).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaPdt8EnSrSBuzdudrxzTH
Favorited web apps replace the bottom-nav glyph with the site's favicon, but
nsites (NIP-5A) and napplets (NIP-5D) fell back to the generic grid glyph.
The webapp trick (capturing onReceivedIcon from the WebView) can't work here:
nsites/napplets render inside a cross-origin sandboxed iframe under a trusted
shell, so onReceivedIcon only ever reports the shell's main-frame icon, never
the applet's. Instead, derive the icon from the manifest's own bundled blobs —
content-addressed, sha256-verified, and Tor-routed like the rest of the site.
- quartz: NappletIconPath picks the best conventional icon path (favicon/icon/
apple-touch-icon, raster over ico/svg, shallower path wins) from a manifest's
path tags; exposed as iconBlob() on the four nsite/napplet event kinds. Unit
tested.
- amethyst: rememberNappletIconModel resolves the live manifest from LocalCache,
prefetches the icon blob into the shared verified cache off the composition
thread, and returns a file:// model. AppBottomBar and FavoriteAppsScreen feed
it into FavoriteAppIcon for NostrApp favorites, mirroring the webapp path.
Priority: bundled blob > manifest icon URL tag > type glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaPdt8EnSrSBuzdudrxzTH
Per NIP-46 (nostr-protocol/nips#2381), client metadata is the 4th
positional connect param. When it is present but the optional secret or
permissions are not, those slots are now back-filled with empty strings
so the metadata always lands at index 3. Parsing maps empty placeholders
back to null. When no metadata is sent, the array stays as short as
possible for backward compatibility.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PYpupiVAq4VyHDdjrYyPdi
Implements nostr-protocol/nips#2381: a client MAY attach an optional
4th positional parameter to the NIP-46 `connect` request carrying a
JSON-stringified `{name, url, image}` object, mirroring the fields
already present in `nostrconnect://` URIs. This lets a bunker:// paired
signer show who is asking to connect.
- quartz: add BunkerClientMetadata and a clientMetadata field on
BunkerRequestConnect; serialize it as the 4th param (omitted when
empty) and parse it back, degrading malformed/empty JSON to null.
- quartz: NostrSignerRemote carries and sends clientMetadata on
connect() and threads it through fromBunkerUri().
- commons: BunkerLoginUseCase.execute() accepts optional clientMetadata.
- desktopApp: advertise Amethyst's metadata on bunker login.
- cli: the receiving bunker logs the connecting client's identity
(display-only; never gates the ACK on it, since the client pubkey is
unauthenticated in bunker:// pairing).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PYpupiVAq4VyHDdjrYyPdi
Redesign the shared StaticWebsiteCard (used by the feed AND the napplets browse
screen) to look like an app entry instead of a manifest dump: square app icon
(with a colored monogram fallback), name, a NAPPLET/WEBSITE type label, a short
description, and an Open button. The technical details users don't care about —
declared capabilities, Blossom servers, source URL — move behind a tap-to-expand
"What it can access" disclosure; capabilities are still re-confirmed at the
consent prompt when actually used and remain fully manageable in the permissions
screen.
Add an `icon` tag (NIP-5A/5D) end-to-end:
- quartz: IconTag + siteIcon() accessor/builder, NappletManifest.icon(), and an
icon param on all four site/napplet build() factories (+ round-trip test).
- amy: `--icon URL` on `nsite/napplet publish`, surfaced in the publish output.
- card: renders the icon via Coil, monogram fallback when absent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Fill the KindNames registry from 145 to 280 entries so every event kind
quartz defines a class for has a canonical English label + NIP. Adds the
whole NIP-90 DVM request/response set, NIP-29 relay groups, NIP-60/61
Cashu wallet + nutzaps, NIP-43 relay members, WebRTC calls (NIP-AC),
marketplace (NIP-15), git PRs/state (NIP-34), CLINK, NIP-51 curation
sets, NIP-85 assertions, Marmot MLS events, and more.
For the handful of kind numbers shared by multiple classes, the registry
keeps one canonical entry (e.g. CashuToken over the deprecated nip61
TokenEvent, ExternalIdentities over GalleryList, ReleaseArtifactSet over
SoftwareRelease). Kind 1 stays "Notes" rather than the bounty value-add
helper that reuses it.
Also point `amy nip`'s Nostr fallback at quartz's canonical NipText kind
30817 (NipTextEvent) alongside the wiki and long-form kinds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Introduce a canonical, i18n-free event-kind registry in quartz:
`com.vitorpamplona.quartz.kinds.KindNames` maps each of the 145 known
kinds to an English label + the defining NIP. The data is ported from
Amethyst's relay-view `kindDisplayName` mapping (the NIP derived from
each event class's package), so the "what is kind N" knowledge now lives
once in quartz instead of only in the Android UI.
`amy kind <N|NAME>` looks a kind up by number (label + NIP) or searches
labels by name — a thin wrapper over KindNames, dispatched statelessly
(no account/network).
i18n split: quartz holds the canonical English (quartz is intentionally
translation-free); localized front ends overlay their own strings and can
fall back to KindNames.nameFor() for kinds they don't translate. amy
prints the English directly.
Verified: kind 1/0/30023/1059/24133 labels+NIPs, name search ("podcast"
→ 4), unknown → known:false, text + JSON output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Adds NIP-46 `auth_url` (web-authorization) support to quartz's remote
signer, so amy (and Amethyst) can use auth-requiring bunkers like
nsec.app / nsecbunker:
- Both BunkerResponse deserializers (kotlinx + the JVM/Android Jackson
one actually used by OptimizedJsonMapper) now special-case
`{result:"auth_url", error:<url>}` BEFORE the generic error branch,
which previously flattened it to a plain error and dropped the marker.
- RemoteSignerManager gained an `onAuthUrl` callback and a wait loop: on
an auth_url response it surfaces the URL once and keeps waiting for the
real response under the same request id (UNLIMITED channel so both are
buffered). newResponse peeks the pending entry (get, not remove) so the
follow-up isn't dropped; the waiter still removes it in finally.
- NostrSignerRemote forwards `onAuthUrl`; amy's Context prints the URL to
stderr and the pending command keeps waiting.
Tests: a deserializer test (auth_url keeps result+error) and a manager
test (auth_url surfaced via callback, then the real response resumes the
request) — both green; existing duplicate/late-response manager tests
still pass after the get-vs-remove change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Closes the remaining nsite/napplet runtime gaps, all keeping content
integrity (every blob is sha256-verified) and the sandbox intact:
- resource.bytes blossom: scheme — blossom:<sha256> fetches from the
user's kind:10063 Blossom servers and verifies the hash before
returning. nostr: stays deferred (bytes semantics unspecified).
- Content-type byte-sniffing in the resolver: when a manifest path has
no/unknown extension, sniff magic bytes (png/jpeg/webp/gif/pdf/wasm/...).
Text/markup is never sniffed so HTML detection stays extension-driven.
Unit-tested in quartz.
- kind:10063 server fallback: the launcher augments the manifest's servers
with the author's published Blossom list (best-effort, when cached).
- Blob caching: the host OkHttp client caches blobs on disk with a forced
immutable policy (content-addressed). The resolver re-verifies every
served blob's sha256, so a stale/poisoned cache entry can't be served.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Render real exercise names in POWR workout cards instead of slug-derived
labels by fetching the referenced kind-33401 exercise templates.
- ExerciseTemplateEvent (kind 33401, addressable) with title/format/
format_units/equipment/difficulty accessors; registered in EventFactory.
- WorkoutRecordEvent now implements AddressHintProvider: linkedAddressIds
(deduped 33401 + 33402 coordinates) seed the gatherer so the card
re-renders when a template arrives, and addressHints feed the relay-hint
index with the relay.powr.build hints so the fetch reaches where POWR
published the templates.
- WorkoutDisplay resolves each exercise via LoadAddressableNote +
observeNoteEvent<ExerciseTemplateEvent>, showing the template title once
fetched and the slug until then.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJwXz6CWez8r7trgHXT545
The blanket NIP-31 alt removal also dropped genuine accessibility
descriptions (image descriptions for the blind) on a few media paths where
the user's caption was only stored in the event-level alt tag. Restore them
via the proper, non-deprecated fields:
- NIP-94 FileHeaderEvent (kind 1063) and NIP-17 encrypted file headers
(kind 15): write the `alt` tag only when the user actually provided a
caption (the NIP-94 accessibility description), never the old boilerplate
fallback.
- MIP-04 encrypted group media (kind 9): route the caption into the imeta
`alt` field via buildMip04IMetaTag instead of an event-level alt tag.
Re-adds the narrowly-scoped TagArrayBuilder.alt() / AltTag.assemble() write
helpers (documented as accessibility-only, not for deprecated NIP-31
boilerplate). Boilerplate alt tags on all other event kinds remain removed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014xAESAz1H1VNjmQpMVqBXj
POWR and RUNSTR both publish kind 1301 but with incompatible tag schemas.
A POWR event previously rendered with the raw "33401:...:back-squat-bb"
coordinate as its activity label, no duration, and none of the set data.
Parse the POWR / NIP-101e dialect in quartz and render it in Amethyst:
- type tag for the activity (strength/circuit/emom/amrap), preferred over
the RUNSTR exercise verb; coordinate-form exercise tags no longer leak as
a verb.
- start/end session timestamps -> derived duration; completed flag.
- structured per-set exercise tags (kg weights, reps, rpe, set_type),
grouped per exercise template with volume/top-weight aggregates.
- WorkoutDisplay now shows Exercises/Sets/Volume stats and a per-exercise
breakdown (e.g. "Back Squat Bb -> 3 x 8 x 84 kg") in the viewer's unit.
Rendering interop only; Amethyst still publishes the RUNSTR-canonical form.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJwXz6CWez8r7trgHXT545
The NIP-31 event-level "alt" tag is deprecated, so Amethyst no longer
emits it on any event it builds. Removed all `alt(...)` builder calls and
`AltTag.assemble(...)` insertions across every event kind in quartz (and
the few app-side builders), along with the now-unused `ALT`/`ALT_DESCRIPTION`
companion constants and the `TagArrayBuilder.alt()` / `AltTag.assemble()`
write helpers.
Reading alt tags from incoming events is kept (AltTag.parse/match,
TagArray.alt(), Event.alt()) for interop with clients that still send them,
and the imeta media accessibility `alt` field (NIP-92/94) is untouched.
Updated/removed tests that asserted alt-tag presence and refreshed the
deterministic event-id/sig golden masters in UpdateMetadataTest.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014xAESAz1H1VNjmQpMVqBXj
When replying to a note that is a kind 1 TextNoteEvent, is the root of a
new thread (no e-tags), and was itself posted from Amethyst (NIP-89
client tag), build a NIP-22 kind 1111 CommentEvent instead of a kind 1
reply. Forks keep using kind 1.
Applies across all kind-1 reply paths: the Android composer
(ShortNotePostViewModel), the notification quick-reply
(NotificationReplyReceiver), and the desktop composer (ComposeNoteDialog).
Adds Event.isClient / TagArray.isClient helpers (NIP-89, case-insensitive)
with unit coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V7RyevA6jL1NuY7uev2agS