- Add LocalOpenFullSearch CompositionLocal (navigates to Search column)
- Provided at Main.kt level alongside LocalFeedSearchActive
- FeedScreen reads it directly — no param threading needed
- "Open full search" link in expanded header now actually opens Search column
- Removed unused onOpenSearch param from DeckColumnContainer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- SearchPill: move hoverHighlight() inside Surface content Row so it's
clipped to the pill's 36dp height (was drawing on parent Row height)
- AccountSwitcherDropdown: reduce IconButton from 48dp to 40dp so ripple
circle fits within collapsed sidebar width
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- SidebarNavItem/SidebarFeedItem: .clip().clickable().background()
— ripple now clipped to RoundedCornerShape(8dp) bounds
- ColumnHeader: .padding() before .pointerInput() — gesture detection
respects horizontal padding
- SearchPill: .clip(pill shape) before .hoverHighlight() — hover
drawBehind rect clipped to pill shape, not parent rectangle
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sidebar onNavigate callback now sets searchActiveState.value = false,
clearing the feed search expansion and sidebar dim overlay when the
user clicks any nav item (Home, Messages, Settings, etc.)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Relay query debounce stays at 300ms (AdvancedSearchBarState default)
- Separate 1s debounce on searchText: when user stops typing for 1s,
save the query to SearchHistoryStore (assumes intent confirmed)
- LaunchedEffect(searchText.text) auto-cancels on each keystroke,
so only fires after 1s of inactivity
- No duplicates: addToHistory() deduplicates by serialized query
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Increase relay query debounce from 300ms to 1000ms for inline search
(reduces unnecessary relay load while typing)
- Save query to SearchHistoryStore when search collapses (if non-empty)
- SearchHistoryStore.addToHistory() already deduplicates by serialized query
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- LinearProgressIndicator at top of expanded card (animated in/out)
- Loading state: centered icon + "Searching N relays..."
- Empty state: "No results found" / "No search relays configured"
- Results stream in incrementally from relays
- 1s debounce for relay queries, save to search history (no dupes)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Show "Searching N relays..." with spinner while waiting for results
- Show "No search relays configured" if searchRelays is empty
- Observe isSearching, peopleResults, noteResults directly for state
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
FeedTabsHeader now creates actual NIP-50 relay subscriptions when
typing in the inline search, matching the full SearchScreen wiring:
- Collect debouncedQuery (300ms) from AdvancedSearchBarState
- Access searchRelays from LocalRelayCategories
- rememberSubscription for people search (MetadataEvent kind 0)
- rememberSubscription for note search (SearchFilterFactory filters)
- Results flow into SearchResultsList (reused from search/ package)
- 10s timeout for silent relays
- Subscriptions auto-cleanup on collapse via DisposableEffect
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. AnimatedVisibility for search card expand/collapse (expandVertically
+ fadeIn 200ms, shrinkVertically + fadeOut 150ms)
2. Feed tab clicks now collapse the search (onSearchExpandedChange(false)
before switching feed)
3. When typing in search: reuses SearchResultsList composable from
search/ package with AdvancedSearchBarState (same as full SearchScreen)
- Empty input shows search history (recent + saved)
- Typing shows live people + note results grouped by kind
- "Open full search" link at bottom for advanced filtering
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Header card pulled OUT of ReadingColumn into outer Box layer 3
(rendered after scrim, so it floats visually above it)
- Feed content in layer 1 with spacer for header height
- Scrim in layer 2 covers only feed content
- Sidebar gets its own scrim overlay via Box wrapper in Main.kt
- Search card stays sharp/visible while everything else dims
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- FeedTabsHeader expands to full-width search card when active
- Feed tabs hidden, search input fills entire header width
- History section (recent + saved) shown below input
- "Open full search" link at bottom
- Scrim overlay dims feed content when search is active
- Cmd+F toggles search via LocalFeedSearchActive CompositionLocal
- MutableState<Boolean> provided at Window level
- FeedScreen reads it directly, no param threading needed
- SearchPill simplified back to clickable-only (no inline expansion)
- Escape or click scrim dismisses search
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- SearchPill: fix collapsed state not rendering — removed nested
Box/Surface/matchParentSize, replaced with simple if/else on
expanded state within single Surface+Row
- Sidebar: add "+ Add Feed" item below custom feeds in FEEDS section
- Dropdown menu moves outside Surface to render properly
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- FeedTabsHeader wrapped in Surface card (border + rounded corners)
- Feed tabs compact with spacedBy(4dp), removed "+ More" (in sidebar)
- SearchPill centered with weight(1f), expands inline to BasicTextField
with DropdownMenu showing recent + saved searches
- Removed Dialog-based SearchSpotlight (wrong UX — created separate AWT window)
- Cmd+F now navigates to Search column via navigateToScreen callback
- All feed tab types (Following/Global/Custom) properly handled in onClick
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- SearchSpotlight: global overlay with scrim, auto-focused input, recent/saved
searches, hashtag quick-jump, and "Open full search" link
- SearchPill: compact reusable pill component with hover highlight and shortcut hint
- Cmd+F keyboard shortcut wired in MenuBar to open/close spotlight
- SearchPill added to FeedTabsHeader (Home/Feeds column headers)
- Result selection dispatches to deckState (deck mode) or singlePaneState (single-pane)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Rename DeckSidebar -> MainSidebar (it's not deck-specific anymore)
- Hoist MainSidebar from DECK-only to shared Row in Main.kt
- Remove inline 80dp NavigationRail from SinglePaneLayout
- SinglePaneLayout now only renders content pane (sidebar is external)
- onNavigate dispatches to singlePaneState.navigate() or deckState.addColumn()
based on active layoutMode
- Active item highlighting works in both modes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two parallel gaps to the cashu work, surfaced once the cashu side
was wired correctly:
1. The orange bolt highlight on the reaction row never lit up for
onchain zaps. Note.isZappedBy checked LN zaps, NWC payments,
and (since Phase 1) nutzaps — but never onchainZaps. And the
fast-path gate in ObserveZapIconState shared the same blind
spot. Add isOnchainZappedBy parallel to isNutzappedBy (same
shape: any onchainZaps entry whose source.author matches the
user and whose source event is newer than afterTimeInSeconds),
and extend the gate with onchainZaps?.isNotEmpty().
2. The reaction-row counter included CONFIRMED onchain amounts
via updateZapTotal (verifiedSats only, per NIP-BC) but not
the signed-in user's OWN pending/unverified outgoing zaps.
That created a UX mismatch: the gallery shows the user's own
UNVERIFIED entry with its claimed sat amount immediately
(the user knows what they sent), but the counter stays at 0
until the chain catches up. Add
Note.extraOwnPendingOnchainSats(loggedInPubKey) that sums
claimedSats from non-CONFIRMED onchainZaps whose source.author
matches the logged-in pubkey, and add it on top of zapsAmount
in both AccountViewModel.calculateZapAmount paths and
ObserveZapAmountText's no-zapPayments fast path. Other senders'
non-confirmed entries still contribute 0, preserving the
anti-spoof posture for incoming zaps.
StickToTopOnPrepend hardcoded `wasAtTop = true` on remember, so when the
user scrolled down, navigated to a post and came back,
rememberForeverLazyListState restored a non-zero offset but the helper
still believed the user was at the top. As soon as the head-of-feed key
emitted (first observation after recomposition), the LaunchedEffect
scrolled them back to 0.
Seed wasAtTop from the actual restored scroll position via an
initialAtTop lambda. The sampler's gesture-guarded false→true protection
against keyed-item shifts is unchanged.
Regression from #3088.
The module-level mutable SimpleDateFormat formatters in these files
were read concurrently — UI composition on the main thread, and
LocalCache.justVerify calling dateFormatter() from background event-
verification coroutines for failed-signature log lines. SimpleDateFormat
is not thread-safe (mutable internal Calendar), and updateFormattersIfNeeded
reassigned the field mid-format. Race produced corrupted timestamp
strings and occasionally NumberFormatException inside format().
Replace the shared-var pattern with a small LocaleAwareFormatter that
wraps a ThreadLocal<Pair<Locale, SimpleDateFormat>>. Each thread caches
its own instance and rebuilds lazily when Locale.getDefault() changes —
no locks, no contention, same allocation profile after warm-up.
Apply the same pattern to CalendarTimeFormat for consistency; today its
callers are all main-thread but the structure was identical.
The reaction-row icon turns orange when the logged-in user has
zapped a note. Phase 1 extended Note.isZappedBy to detect cashu
zaps, but ObserveZapIconState had a fast-path gate that skipped
the whole calculation when the note had no LN zap data:
val hasZapData =
zapsState?.note?.zapPayments?.isNotEmpty() == true ||
zapsState?.note?.zaps?.isNotEmpty() == true
A cashu-only zap leaves both `zaps` and `zapPayments` empty, so
hasZapData was false, wasZapped was hard-coded false, and the
new isZappedBy logic never ran. The amount counter still updated
because that path (Note.zapsAmount) was already extended in
Phase 1 — only the icon gate was missing the third source.
Add `nutzaps?.isNotEmpty()` to the gate.
Two UI follow-ups after seeing the rails in the running app:
1. ReactionDetailGallery cashu row was Size25dp, matching the
lightning-bolt convention from OnchainZapGallery. The
multi-tone cashu glyph reads bigger than a thin bolt at the
same nominal dp, so the row felt visually heavier than the
lightning row above it. Drop to Size20dp to match the cashu
chip in MultiSetCompose.RenderNutzapGallery and the boost /
like rows that sit below.
2. sendNutzap's success toast is now redundant. The earlier
Phase 1 work attaches the kind:9321 to the target Note via
addNutzap, so the reaction-row counter and the
you-already-zapped icon highlight both update on their own
the moment the kind:9321 round-trips through the cache. A
"Cashu zap sent" toast on top of that visible state change
is just noise. Drop the toast and the now-unused
nutzap_sent_title / nutzap_sent_amount strings.
The Phase 0 work was an artifact of the order I implemented the
phases — feedback first while the counter was still LN-only, then
the foundation made the toast moot. Removing it now keeps the
final UX clean.
The post-refactor `bestPattern(skeleton)` helper allocated a fresh
SimpleDateFormat — plus ran the ICU lookup in `getBestDateTimePattern`
— on every call. That's hot in the calendar grid: 7 weekday-header
calls per render and up to 42 `formatLongDate` cells per month render.
Restore the old caching pattern (module-level vars, rebuild on locale
change via `refreshFormattersIfLocaleChanged`), now keyed off the
locale-aware skeleton patterns. Time format still re-fetched per call
via `DateFormat.getTimeFormat(context)` since the user's 24-hour
override can change without a locale change.
Zulu's CDN (cdn.azul.com) has been returning HTTP 520 for several
hours, breaking JDK 21 setup across multiple PRs (#3075, #3095) on
every Linux runner. actions/setup-java's internal retry budget isn't
enough to ride through it.
Temurin (Adoptium) publishes its JDKs as GitHub Releases assets, so
the download path uses GitHub's own CDN — completely independent
infrastructure. Java 21 is the same upstream OpenJDK build either way,
so Gradle, Compose Multiplatform, and jpackage behave identically.
Applied to all setup-java steps in build.yml, smoke-test-desktop.yml,
and create-release.yml.
Phase 4 of the nutzap UX integration. The expanded reaction view
on a note now shows a cashu row between the lightning row and the
onchain row, matching the rail visual hierarchy used elsewhere
(zap chip popup → notifications card → reaction-detail gallery).
New `NutzapGallery.kt` modelled on `OnchainZapGallery.kt`:
- `WatchNutzapsAndRenderGallery` subscribes to the same per-note
zap flow the lightning + onchain galleries already use. Memoizes
on the nutzaps map reference (immutable per mutation) so a
lightning zap arrival on the same note doesn't recompose this
row, and renders only when at least one entry exists.
- Cashu icon (CustomHashTagIcons.Cashu, tint=Unspecified) followed
by a FlowRow of sender avatars with the claimed sat amount
overlay. Unlike onchain, there's no UNVERIFIED/PENDING gating —
every entry shows at full opacity because nutzap verification
is the recipient wallet's job at redeem time (the
reaction-detail gallery is just showing "who sent what to this
note").
- Tapping an entry navigates to the sender's profile, same as the
other galleries.
Wired into `ReactionsRow.ReactionDetailGallery` between
`WatchZapAndRenderGallery` and `WatchOnchainZapsAndRenderGallery`,
so the three zap rails read top-to-bottom in the order they tend
to surface for a given recipient (LN cheapest/easiest, cashu
mint-mediated, onchain final-settlement).
That closes the 4-phase nutzap UX work:
- Phase 0: success toast on send (sendNutzap previously silent)
- Phase 1: Note.nutzaps + LocalCache wiring; reaction-row counter
and icon highlight come along for free via zapsAmount and
isZappedBy extension.
- Phase 3: notifications — NotificationFeedFilter kind add,
NutzapUserSetCard for per-sender aggregates, cashu rail in
MultiSetCard, NutzapUserSetCompose card renderer.
- Phase 4 (this): dedicated cashu row in the expanded reaction
view.
Phase 3 of the nutzap UX integration. Inbound NIP-61 nutzaps
(kind 9321) now appear in the notifications feed alongside
lightning zaps, boosts, likes, and onchain zaps — but with the
cashu icon so the rail is visible at a glance.
Pieces:
- NotificationFeedFilter.NOTIFICATION_KINDS now includes
NutzapEvent.KIND. The existing tagsAnEventByUser logic falls
through to its `return true` default for nutzap (it's not a
BaseNoteEvent / ReactionEvent / Repost / Git / Highlight), and
the kind:9321's `p` tag carries the recipient so isTaggedUser
matches.
- CardFeedContentState gains a parallel nutzap grouping pass.
Nutzaps targeting a specific note feed into nutzapsPerEvent
and roll into the per-note MultiSetCard; nutzaps without an
e-tag target (recipient-only) feed into nutzapsPerUser and
surface as a NutzapUserSetCard per sender per day.
- MultiSetCard extended with `nutzapEvents: ImmutableList<Note>`,
and its min/max createdAt cover the cashu rail too. Adding a
new field at the end with a `persistentListOf()` default keeps
the existing constructor sites compatible.
- New NutzapUserSetCard for the per-sender aggregate. Wraps raw
kind:9321 Notes (no request/response pair like LN), keyed by
pubkey+createdAt with an "N" suffix so it never collides with
the LN ZapUserSetCard.
- MultiSetCompose: new RenderNutzapGallery row renders the cashu
icon (CustomHashTagIcons.Cashu, tint=Unspecified to preserve
the brand colour) followed by the same AuthorGalleryZaps the
lightning rail uses. Each kind:9321 is mapped to a
ZapAmountCommentNotification with the claimed sat total and
the event content as the comment.
- New NutzapUserSetCompose modelled on ZapUserSetCompose, also
wired into CardFeedView's card switch.
The user-facing result: the notifications screen now shows a
"X sent you Y sats via cashu" card with the cashu icon, exactly
the same shape as the lightning version, and the per-note
multi-card grows a cashu rail when the note has cashu zaps.
Phase 4 next: the dedicated cashu line in ReactionDetailGallery,
modelled on the existing onchain row.
Re-add the nostr-protocol/nips reference (dropped in the cleanup) as a
one-liner in the overview, tied to the /nip command so it points at the
exact spec file rather than the bare index.
Hardcoded date/time patterns ignored the user's Locale (date order:
dd/mm/yyyy vs mm/dd/yyyy vs yyyy-mm-dd) and the system 12/24-hour
override. Replace them with locale-aware formatters that resolve order
from the active Locale via DateFormat.getBestDateTimePattern() and pick
the time-of-day pattern via DateFormat.is24HourFormat(context).
- TimeAgoFormatter (amethyst + commons): build SimpleDateFormat from
Unicode LDML skeletons (yMMMd / MMMd / yMMM) so "May 28, 2026" in
en-US becomes "28 May 2026" in en-GB, "28.05.2026" in de-DE, etc.
- CalendarTimeFormat: same skeleton approach for date pieces; time
uses DateFormat.getTimeFormat(context) so a 24-hour Android user
sees 14:32 even on a 12-hour locale.
- New LocalizedDateTimeFormat helper with formatMonthDayTime,
formatMediumDate, formatMediumDateTime — used by wallet, vanish,
attestation, namecoin, eventsync screens to replace inline
SimpleDateFormat("MMM d, HH:mm") / ("MMM dd, yyyy hh:mm a") etc.
- Material3 TimePicker callers (calendar/nest/poll/zap-poll/expiration
date pickers + vanish request) now pass is24Hour from the system
setting instead of hardcoding false.
- Desktop article/reads/highlights screens use
java.text.DateFormat.getDateInstance(MEDIUM, locale).
- Drop dead formattedDateTime() in RelayCompose (was unused).
Intentionally left alone: notification feed bucket keys ("yyyy-MM-dd"
used as Map keys), TakePicture file naming (Locale.US), iCalendar
RFC 5545 stamps, NIP-52 ISO date storage, internal logging, and
ThreadLevelCalculator sort keys — none are user-facing.
Phase 0 (small fix): sendNutzap was async-launched with no success
callback, so after tapping the teal cashu chip in the zap picker
the popup vanished and the user saw no feedback for the 1-2 seconds
it took the swap + publish to complete. Add a "Cashu zap sent —
Sent N sat(s) via cashu" toast on success, matching the lightning
zap's progress feedback in spirit.
Phase 1 (foundation): NIP-61 nutzaps attach to their target note
the same way LN zaps and onchain zaps do, contributing to the
reaction-row total and the "you-already-zapped" icon highlight
without any UI-layer change.
Pieces:
- NutzapEvent.claimedSatsTotal() in quartz parses the sender-
claimed sat sum from the proof tags once, leniently (a single
malformed proof contributes 0 rather than throwing). The
recipient wallet still verifies proofs against the mint at redeem
time; this is the trusted-claim total for display.
- Note.nutzaps: Map<HexKey, NutzapEntry> on the canonical commons
Note, parallel to onchainZaps. NutzapEntry carries the source
kind:9321 note (sender = source.author) and the pre-parsed
claimedSats. Volatile because writes happen on applicationIOScope
and reads happen on the Compose main thread.
- updateZapTotal() now sums nutzap claimedSats into zapsAmount, so
the existing ObserveZapAmountText composable in ReactionsRow
picks up cashu without code change.
- hasZapped() and the suspend isZappedBy() extended to detect
nutzaps from a given user. ReactionsRow's calculateIfNoteWasZap-
pedByAccount path therefore highlights the bolt orange for cashu
zaps the same way it does for lightning.
- LocalCache previously routed NutzapEvent through
consumeRegularEvent, which would add it as a *reply* to the
e-tagged note via computeReplyTo. computeReplyTo gains a
NutzapEvent case returning the linked event ids, and a dedicated
consume(NutzapEvent) function attaches via addNutzap instead of
addReply.
The "list" merge across LN + cashu + onchain that the user floated
is deferred — three separate collections with different shapes
(zap pair, onchain entry, nutzap entry) are kept; only the
aggregates and queries are unified. That's enough for the
reaction-row UX and avoids touching every iteration site at the
call layer.
Coming next: notifications (NotificationFeedFilter + a cashu-icon
variant of ZapUserSetCard) and the dedicated cashu row in
ReactionDetailGallery modeled on OnchainZapGallery.
Addresses 10 audit findings from the post-build review:
CRITICAL — non-functional without this:
- LocalCache.justConsume had no branches for kind 54 / 10054 / 10064 / 10154,
so every podcast event fell through to "Event Not Supported" and was
silently dropped. Added the four explicit branches (regular event for
PodcastEpisode; replaceable for the other three).
HIGH — silent invisibility / broken tap-through:
- Home, profile (newthreads + mutual), hashtag, geohash, follow-pack, and
notification feed filters didn't recognize PodcastEpisodeEvent /
PodcastMetadataEvent. Episodes were invisible everywhere outside the
dedicated tab; reactions/zaps on episodes were dropped from the
notifications feed.
- ThreadFeedView's renderer dispatch had no podcast branch, so tapping a
feed card opened a plain text-note view. Added explicit cases that call
the new RenderPodcastEpisode / RenderPodcastMetadata composables.
- The hashtag / geohash / relay / search REQ kind lists didn't include
podcast kinds, so discovery surfaces returned nothing for them.
- RelayInformationScreen kind→label map gained podcast entries +
4 new string resources (Podcast Episode, Podcast Show, Authored
Podcasts, Favorite Podcasts).
- HomeNewThreadFeedFilter.ADDRESSABLE_KINDS gained PodcastMetadataEvent so
shows surface alongside music/wiki/long-form on the home feed.
HIGH — privacy leak in Quartz:
- FavoritePodcastsListEvent.add(isPrivate=true) was passing
earlierVersion.tags through untouched, so toggling a previously-public
favorite to private left the public p-tag intact. Made both branches
symmetric: each removes the entry from the other half before adding to
its own. Two regression tests cover the round-trip.
MEDIUM — data hygiene:
- AuthorTag.parse used to accept ANY non-empty slot-2 string as a role
(rendering a stray relay-hint URL as "Role: wss://relay…"). Now
validates against the spec-defined {host, cohost, editor} allowlist;
unknown values resolve to role=null, preserving the pubkey association.
PERF:
- PodcastEpisode renderer was allocating a fresh 96-element WaveformData
and rebuilding the cover Modifier chain per visible card. Hoisted both
to top-level constants (FLAT_WAVEFORM, COVER_IMAGE_MODIFIER,
PLAYER_BORDER_MODIFIER) so the whole feed shares one instance.
CODE QUALITY:
- Extracted PodcastCoverCard as a shared composable used by both renderers
(was duplicated byte-identical across PodcastEpisode + PodcastMetadata).
- Extracted PodcastFeedLoaded so the Episodes screen and Shows screen
share one feed body (was duplicated byte-identical).
- Dropped the misleading `group = listOf(singleAssembler)` wrapper in the
two FilterAssembler files.
- Replaced `mapNotNull { … }.flatten()` with `flatMap { … }` in the
Communities sub-assembly (the lambda never returns null).
DOCUMENTED:
- PODCAST_KINDS "Following" resolution still goes through kind:3 follows,
but per NIP-F4 podcasts use their own keypairs tracked via kind:10054.
Added an inline comment naming the deferred work — proper fix needs
Account-level 10054 integration which is a separate scope.
- Add a standing instruction to test hypotheses before diagnosing
(state guesses as guesses, reproduce-first, predict-then-run).
- Remove content duplicated by the harness-injected skill list (skills
tables, Commands section) and generic expect/actual examples.
- Condense the Feature Workflow (removed the duplicated share/keep-native
tables and the hardcoded grep block) and the skill-handshake example.
- Fix stale facts: drop pinned tool versions (now point to
libs.versions.toml) and correct the nestsClient tree comment to match
the overview (production runs on moq-lite).
The intraword `_` rule (CommonMark §6.2, implemented in the
previous commit) is sufficient on its own to keep base64url
cashu payloads out of the markdown renderer — every `_` inside
such a token is intraword and gets skipped. The upfront
`contains("cashuA"/"cashuB")` scan was a safety net for the
mixed-content case ("**enjoy** cashuB..."), but keeping it broke
markdown rendering for any post that legitimately had both
markdown formatting AND a cashu token. Drop it: mixed posts
render markdown correctly, cashu-only posts still skip markdown
via the intraword rule.
Convert both kind 30078 and the new kind 78 from the legacy
`suspend create(... signer)` shape to the now-standard
`build(...) -> EventTemplate` shape used across recent quartz events
(NIP-34, NIP-66, etc):
- Use `eventTemplate<T>(KIND, content, createdAt) { ... }` and lean
on the shared `alt()` and `dTag()` TagArrayBuilder extensions
instead of hand-rolling the `d`/`alt` injection.
- Callers now do `signer.sign(AppSpecificDataEvent.build(...))`.
For kind 78 the `d` tag is optional (it's a grouping key, not an
addressing key), so we keep it nullable and assemble it via
`DTag.assemble` — the typed `dTag()` extension is constrained to
addressable events, which is correct.
Update the lone caller (`AppSpecificState.saveNewAppSpecificData`).
Adds the audio-track imeta properties from NIP-71 PR #2255 so video
events can advertise external audio tracks (multi-language, alternate
bitrates) alongside video variants:
- New imeta properties: bitrate, duration (float seconds), waveform,
and l <code> <standard> [ov] for language with an original-version flag
- Extends VideoMeta with bitrate, duration, waveform, language fields
plus isAudio/isVideo helpers
- VideoEvent exposes audioTracks()/videoTracks() so players can prefer
separate audio tracks over in-video audio while switching resolution
- Round-trip test against the PR's spec example
The markdown detector now applies CommonMark §6.2 flanking rules:
- `_` skips intraword positions (preceded by a letter, digit, or
another `_`). This is the same "snake_case" carve-out that lets
identifiers like `foo_bar_baz` and `snake_case_identifier`
render literally — and it incidentally protects base64url
payloads from misclassifying, since every `_` inside a cashuB
token sits between word chars.
- `*` keeps intraword behavior (CommonMark allows
`foo*bar*baz`) but now requires non-whitespace on both sides of
the run, so `5 * 3 = 15` and `5 * 3 * 7 = 105` no longer
false-fire as italic.
The upfront `contains("cashuA"/"cashuB")` shortcut is kept as a
safety net for the mixed-content case (a chat that has BOTH a
cashu token AND real markdown like "**enjoy** cashuB..."), where
routing through the markdown renderer would lose the cashu card
because RenderContentAsMarkdown has no CashuSegment support. For
cashu-only messages, the intraword rule alone is sufficient — a
new test (`arbitraryBase64UrlBlobIsNotMarkdown`) confirms that
by feeding the detector a base64url blob without the cashuB
prefix.
New regression tests cover:
- snake_case_identifier
- foo__init__bar (intraword `__`)
- 0v______ trailing-underscore run
- foo*bar*baz (intraword `*`, expected markdown per spec)
- 5 * 3 * 7 = 105 (whitespace-flanked `*`)
- __init__ surrounded by whitespace (markdown per spec; Python
dunders collide here — users escape with backticks)
- user_name@example.com
- arbitrary base64url blob
NIP-78 was updated (nostr-protocol/nips#2292) to define a second
event kind alongside the existing addressable kind 30078:
- Kind 78: normal event, for apps that need to store and query
multiple events of the same type. Recommended to use unique tags
(including `d` tags) for grouping related events; the `d` tag here
is a grouping key only, not an addressing key.
Add `AppDataEvent` (kind 78) extending `Event`, mirroring the
ergonomics of `AppSpecificDataEvent` (kind 30078): optional `d` tag
hoisted into `tags`, NIP-31 `alt` tag injected when absent, and a
`signer.sign(...)` factory. Register it in `EventFactory` so
incoming kind-78 events deserialize into the typed class.
The existing kind-30078 implementation remains compliant with the
updated spec.
Swap computeIsMarkdown for a single-pass character scan gated by a
preallocated trigger-char table (BooleanArray[128]). Drops the
chain of `String.contains` calls in favor of one O(n) walk with
O(1) per-char dispatch. Also broadens coverage to ordered lists,
unordered lists (-/*/+), tables (|), strikethrough (~~), code
spans (single backtick), setext underlines (=== / ---), and
markdown links with URL validation.
Cashu exemption kept up front — the new algorithm would still
false-positive on cashuB tokens because `_` is in the trigger
table and `__` returns true immediately.
Two refinements on top of the proposed algorithm to pass the
new test suite:
- Setext heading now requires the underline line to be
homogeneous (only `=` or only `-` plus whitespace). Without
this, an ordinary sentence ending in `-` would be promoted
to a heading underline at the next newline.
- Markdown link detection now validates that the URL portion
(between `(` and `)`) doesn't contain a newline, so
`[link](url\nbroken)` no longer matches.
Test suite expanded to ~50 individual cases plus a parameterised
`testMarkdown()` that mirrors the user-provided case list with
pass/fail diagnostics to stdout. One case in the original list
(`"Standard divider line\n=========="`) was flipped from false
to true — the case name self-identified as "Valid Setext" and
CommonMark gives no length cap that would distinguish a long
underline from a heading.