feat: tiered Nostr data layer, thread anchoring, FlashList v2 & app-view REST (all changes since main)#232
Merged
Merged
Conversation
Record the locked decisions for the resilient three-tier Nostr data layer (nagg → Primal → raw relays) behind one opinionated nagg-ts facade: the NoteStats/NoteActions split, the ordering manifest, the live seam, the paginated own-history endpoints, and the NIP-78 seen primitive. ADR-0003 supersedes ADR-0002's relay-only seeding (own viewer-state now seeds from the tiered facade) while keeping its store-authority mandate.
Consolidate nostrSocialStore's three parallel viewer-state maps
(likesByEventId / repostsByEventId / repliedByEventId) into one
`engagementByEventId: Record<string, EngagementRecord>` where a record holds
`{ liked?, reposted?, replied? }` (each with our own event id). Adding a new
engagement type (zap, bookmark) is now a field, not a whole new map — the
NoteActions-style single viewer-state owner the data-layer design calls for.
- ingestOwn{Likes,Reposts,Replies} keep their public signatures (the
own-events sync is untouched); internally they merge their action into the
target's record additively and recency-cap by record.
- applyOwnDeletions clears only the deleted action, dropping the record only
when its last surviving action is gone.
- optimistic overlay maps stay separate (confirmed record + optimistic overlay,
matching the design); readers merge them as before.
- Persisted schema swaps the three map schemas for one engagement-record schema;
old persisted keys are stripped-and-defaulted on rehydrate (contacts/follows
preserved, engagement re-seeds from useOwnEventsSync) — no migration needed.
Migrated the only readers (useNostrEngagement) + the profile-switch reset.
Verified: whole-app tsc clean, lint clean, 17 store/own-state jest tests green
(incl. new multi-action-record + selective-deletion cases). Not run in the RN
app — optimistic-timing/highlight behavior should be spot-checked live.
De-duplicate profile metadata sourcing: nagg's inline feed profiles were an ephemeral map (profilesMap) that the relay layer then re-fetched as kind-0. Now every parsed feed page low-confidence-seeds its profiles into the one nostrMetadataCache, so that cache is the single profile store fed by BOTH nagg and relay kind-0. - Hoist seedFromSearchResults' merge into a generic seedManyProfilesLowConfidence (the name the in-file comment already anticipated); search results delegate to it — one low-confidence merge path, no duplication. - The seed fills gaps only, NEVER clobbers authoritative relay kind-0 fields, and marks new entries fetchedAt:0 so a real fetch still runs (a first-paint hint, not a substitute). nagg's minimal ProfileInfo can't degrade richer relay data. - Wired at the single feed-ingest chokepoint (naggFeedClient logResult), next to the existing passive ingestOwnContent seam — survives the eventual transport swap since it keys off the parsed page, not the transport. Verified: whole-app tsc clean, lint clean, jest seed tests (insert-stale, no-clobber, no-op, search-delegation) + the search suite green.
Add @sovranbitcoin/schemas to the postinstall sibling-link overlay, the same way @sovranbitcoin/colada and @sovranbitcoin/nagg-ts are linked: when ../sovran-schemas is checked out next to the app, node_modules resolves to its raw source for local dev; on EAS/CI (sibling absent) the link no-ops and the committed registry dependency (^2.0.6) is used as-is. Needed so local nagg-ts (itself symlinked) and the app resolve the schemas' new exports during dev while production keeps the published version.
Add Settings → Developer toggles to enable/disable each tier of the resilient Nostr data layer independently — "Nostr tier: nagg", "Nostr tier: Primal cache", "Nostr tier: raw relays" — so a developer can simulate any tier being down and watch the fallback degrade. Default all on; persisted in settingsStore. Config the Primal public cache server (tier 2): backendConfig.primalCacheUrl, default wss://cache2.primal.net/v1, override via EXPO_PUBLIC_PRIMAL_CACHE_URL (it was not configured anywhere before — only relay.primal.net / blossom). getNostrTierConfig() resolves the toggles + endpoints into a single tier-config seam the facade wiring will read (a disabled tier is omitted from the fallback chain). It imports NO facade code — only plain config — so it ships safely while the app still resolves @sovranbitcoin/nagg-ts to the published registry version that predates the facade; the toggles are inert until the read paths are routed through the facade (the transport swap). Whole-app type-check clean; backendConfig tests cover the new URL default + override.
Rename the Relays settings page to "Network" and reorganize it around the three tiers of the Nostr data layer, each with an enable/disable switch (a disabled tier drops out of the nagg → Primal → relays fallback chain): - Aggregator — nagg (shows the app-view base URL) - Caching — Primal cache (shows wss://cache2.primal.net/v1) - Relays — the raw-relay floor toggle, above the existing NIP-65 relay management (health, read/write markers, add/remove, restore, publish) Moves the three tier toggles out of Settings → Developer into this user-facing Network page (git-renamed the route + screen to preserve history). The toggles remain inert until the read paths route through the facade (transport swap); no facade code is imported, so prod is unaffected.
Emit nostr.tierConfig.resolved (enabled tiers + nagg/Primal URLs + relay count) whenever getNostrTierConfig() runs, so the active tier selection is visible alongside the existing per-toggle settings logs (store.settings.set_*_tier_*). Pairs with the nagg-ts data-layer instrumentation once the facade is wired.
Make the Settings → Network tier toggles actually take effect: the for-you / following-popular home feeds now fetch through the tier-selecting nagg-ts facade instead of the old naggFeedClient, so disabling nagg / Primal / relays changes which source serves the feed — and the bridged nostr.* logs show the selection (nostr.read.feed.request → nostr.tier.try/answered → tier). - buildNostrDataLayer: assembles the facade from getNostrTierConfig (tiers filtered by the toggles) with real nagg app-view / Primal WS / relay-pool connections, and bridges nagg-ts's logger into the app logger. - facadeFeedAdapter: pure ResolvedFeedPage → FeedParseResult bridge (unit-tested) + the spec→FeedSpec map (ranked home feeds only). - facadeFeedClient: wraps the existing client; every other read (threads, user feeds, following-replies, enrichment, notifications) delegates unchanged. LOCAL-DEV ONLY: this imports the facade from @sovranbitcoin/nagg-ts, which only exists in the symlinked local source. An EAS/release build will fail to resolve it until nagg-ts is published — don't cut a release build meanwhile. The 8 pre-existing naggFeedClient test failures are unchanged (baseline, not new).
…al-dev) Wire the notifications screen's getNotifications through the facade (nagg grouped → relay flat; Primal doesn't implement notifications, auto-skipped), so the Network tier toggles + fallback apply and the nostr.read.notifications.* trace logs. ALL/MENTIONS tabs route through the facade; the client-only APP tab falls back to the existing client. - facadeNotificationsAdapter: pure ResolvedNotifications → FeedNotificationsResult bridge (grouped/single, sample actors, target event, stats→metrics) + the request map (until→cursor). Unit-tested. - facadeFeedClient gains getNotifications. Known follow-up: the relay tier's #e backstop needs the viewer's own recent event ids (replies often omit #p) — not yet passed, so the relay floor catches #p-tagged engagement only for now. Local-dev only (see buildNostrDataLayer).
…dev) Convert useNostrProfileMetadata / …Many from an NDK relay subscription to a facade.getProfiles fetch (Primal user_infos → relay kind-0), so profiles everywhere (feed authors, contacts, pickers) become tier-resilient and obey the Network toggles. The nostrMetadataCache + SWR (only fetch missing/stale) stay; an attempted-set ref prevents a not-found profile from looping the effect. parseRawMetadata is kept/exported (colada reuses it). Trades live profile updates for the tiered fetch — acceptable for the cache's SWR model. Local-dev only (imports the facade; see buildNostrDataLayer).
fetchDmEnvelopes now resolves the conversation-list inbox through the nagg-ts facade (nagg DM index -> raw-relay floor) instead of a direct GraphQL/app-view call. Tier selection is gated by the Network settings toggles, and the relay floor (kind 1059/4 by #p, no since/limit) is now a real fallback when nagg has no DM index. Envelopes stay opaque; NIP-17 decryption is unchanged above the tiers in dmDecryptPipeline. - facadeDmAdapter: pure until<->cursor + ResolvedDmEnvelopes->DmEnvelopePage - hasNextPage derives from page non-emptiness (app re-derives until from envelope createdAt), so an exhausted chain stops loadMore - fetchDmConversation unchanged (per-conversation GraphQL resolver)
useOwnSocialGraphSeed fetches the viewer's social graph through the nagg-ts facade (nagg -> Primal -> raw relays) once per pubkey and seeds the read side of nostrSocialStore. Per ADR 0003 the facade is the seed/backfill source while useOwnEventsSync's relay sub stays the write-authoritative live-delta listener. - seedFollowsFromFacade sets followingPubkeys + bumps the LWW gate (contactsUpdatedAt) but never writes contactsTags/content: the raw kind-3 from the relay sub stays the re-publish source (it preserves relay hints + petnames the facade's parsed follows drop) - LWW-gated by the kind-3 created_at so a seed and a relay delta can't fight; the relay path's < guard still fills the raw tags at equal ts - gated by the Network settings toggles via buildNostrDataLayer
useContactSearch now resolves profile search via searchProfilesViaFacade (nagg /nostr/search -> raw-relay NIP-50), gated by the Network toggles, instead of apiClient's nagg-GraphQL searchUsers. The nagg tier preserves the Vertex pagerank ranking, so this is non-regressing while adding a relay floor for graceful degradation. - searchProfiles.ts: maps facade ProfileSearchHit -> NostrSearchResult, per-hit safeParse to bound untrusted relay kind-0 content, derives npub when the tier omits it; exhaustion is an empty answer, not an error - remove the now-dead searchUsers + its exclusive GraphQL helpers (nostrGraphqlClient, mapProfileSearchResult, parseSearchUsers, recipe imports) from apiClient; update the backend-config routing test
Two fixes for consistent notification display regardless of which tier served the data: - Profiles: only nagg bundles notification author profiles, so the relay/cache path left every row with a truncated-pubkey name + fallback avatar. The screen now warms + reads the shared metadata cache (filled by the facade getProfiles: Primal user_infos -> relay kind-0) for every actor and merges it into profilesMap as a fallback. - Grouping: the client-side fallback grouped only CONTIGUOUS runs, so interleaved follows/reactions each landed on their own row on ungrouped transports. It now groups across the whole page by reason+target (the same keying the server uses), demoting lone groups back to singles - matching the server-grouped path.
getThread previously always used naggFeedClient (GraphQL), so the thread page never honoured the Network toggles. Now: - nagg enabled: keep the GraphQL viewer-ranked path (authoredReplyChain + rankedReferencedBy) — the facade's REST nagg tier can't reproduce it. - nagg toggled off: route getThread through the facade (Primal thread_view -> raw-relay #e), assembling ThreadResult from the bundle via the pure buildThreadStructure; empty result on exhaustion rather than silently falling back to nagg behind the toggle. facadeThreadAdapter maps the app's 5-way reply sort onto the facade's relevant/new (engagement sorts degrade to relevance on cache/relay, which have no equivalent); the GraphQL path still serves all five when nagg is on.
MetricsFooter's AnimatedMetric was misnamed — the count text swapped abruptly. New AnimatedCountValue gives reply/repost/like counts and the zap-sats value a soft roll-in (opacity + a few-px translateY tween) when the value changes, so a lazily-loaded or updated count animates in rather than hard-cutting. Wraps the app Text so font/colour stay exact and only a parent Animated.View transforms (no clipping); the first render never animates, so counts don't all count up on mount/scroll.
Primal's thread_view has no server-side reply sort (its own clients post-sort locally), so the app's 5 sort tabs did nothing in cache mode. The facade thread adapter now post-sorts replies by the requested mode against the bundled per-note stats — new=created_at, likes/zaps/reposts= the matching count, relevant=weighted engagement — and the UI renders in that replyPageEventIds order. Also stop the perpetual thread skeletons in cache mode: facade getThread is now wrapped so it can never throw (useThread keeps isLoading=true on a throw when a seed is present, which pinned the skeleton), and a 12s per- tier thread timeout caps a Primal→relay fall-through instead of the 30s default.
Normal nagg feeds rendered posts with no like/repost/reply/zap counts. Root cause (confirmed in the nagg server): the inline feed noteStats join opens concurrent ClickHouse connections that reset under load — nagg's own code comments this as 'the feed noteStats break + the REST /nostr/feed/ ranked 500s'. So the counts embedded in the feed response are unreliable. nagg exposes a reliable single-query endpoint, POST /nostr/notes/stats, for exactly this. HomeFeed now lazily fetches authoritative counts for the visible notes (batched, once per id, max 100) and merges them into the metrics map, where they animate up via MetricsFooter. Gated on the nagg tier toggle: with nagg off the feed is served by Primal (bundles its own stats) or raw relays (no aggregate counts), so pulling nagg's counts there would break the decentralisation toggles.
This reverts commit 1b0731a.
Per the data-layer architecture, nagg is meant to return ALL of a page's data in one app-view payload (events + profiles + engagement stats), with Primal cache / relays as the lazy-loading fallback tiers. The app was instead defaulting to nagg's GraphQL transport, whose inline noteStats join is flaky (nagg documents it resetting concurrent ClickHouse connections) — so normal feeds rendered with no like/repost/reply/zap counts. Flip nostrFeedAppView / nostrNotificationsAppView / nostrDmAppView to default ON (opt out with EXPO_PUBLIC_NOSTR_*_APPVIEW=false for backend dev). The app-view feed/notification handlers bundle metrics via nagg's reliable single-query NoteStats, so counts (and bundled notification profiles) come through in one payload. GraphQL stays only as runNaggQuery's automatic fallback. This also reverts the interim /nostr/notes/stats lazy-load, which was the wrong mechanism for the bundled-nagg tier.
Feed: - Render aggregate metrics on first paint. getMetrics read metricsRef (synced in useInsertionEffect, after commit), so feedRows rebuilt with a stale map and counts only appeared after a scroll-refresh. Read the live metricsMap state instead. Notifications: - Tap a like/repost/zap row opens the target POST (via targetEventId), not the engagement event, in every tier — matching nagg mode. - Hydrate the target-post preview from the own-content cache (the target is always our own note) and resolve its author to our own profile, so the preview renders in relay/Primal mode too. Profile page: - Route the profile header through the facade so the "disable nagg" setting is actually honored: prefer nagg REST only when the nagg tier is enabled (keeps reputation + top followers), otherwise fall through to the facade profile-stats surface (Primal user_profile -> relay). - Hide the stats grid in relay-only mode rather than show "0 / 0 / N/A": follower counts need a server index relays lack, so the relay floor serves no profile stats and the grid renders nothing when nothing reliable resolved. - Bounded retry for kind-0 metadata: a transient empty facade fetch no longer marks a pubkey done forever, so profiles no longer stay blank.
Opening a thread on a reply landed on the note, then dropped it when the
parent chain prepended above (the T1 full-thread update). Root cause: the
thread LegendList omitted `maintainVisibleContentPosition`, so @legendapp/list
v3 resolved it to `{ data: false, size: true }` and the prepend offset-
compensation (gated on `.data`) never ran.
Add bare `maintainVisibleContentPosition` (normalizes to
`{ data: true, size: true }`): `data` compensates contentOffset for the parent
prepend so the focused note holds; `size` absorbs estimate->measured
reconciliation. Keep `initialScrollIndex` to land on the tapped note, and
deliberately omit the DM ChatScreen's `initialScrollAtEnd` / `alignItemsAtEnd`
/ `maintainScrollAtEnd` — the thread anchors on the note and never auto-pins
to the bottom.
Replaces the manual scrollToIndex re-pin approach (PR #225), which raced the
library's own anchoring and still shifted.
Docs: ADR 0004 + CONTEXT.md "Thread anchor" glossary term.
…tion
Adding maintainVisibleContentPosition alone wasn't enough: mVCP compensates a
prepend by raising the scroll offset, but that's clamped at the max scroll
offset. A thread opened on a reply with little content below it can't scroll far
enough for the compensation to hold the note — so the note still dropped when the
parent chain loaded above.
Add `anchoredEndSpace={{ anchorIndex: targetIndex }}`: it reserves tail space
sized to `viewport - (content from the note down) - footer - paddingBottom`,
giving mVCP the scroll room it needs to keep the note fixed. The reserve shrinks
to 0 once the replies below already fill the screen, so there's no dead gap on
long threads, and it waits for measured sizes so it won't thrash on estimates.
The prop is read by the @legendapp/list@3.0.0 RN runtime but omitted from the
exported RN prop type, so it's typed locally and passed via spread (no `any`).
ADR 0004 + CONTEXT.md updated with the end-reserve rationale.
…ng fixes The mVCP + anchoredEndSpace anchoring landed correctly but sat on the first v3 release (3.0.0), where the exact paths it relies on were buggy: - 3.0.1 — scrollToIndex landed at the wrong location on iOS (our initialScrollIndex) - 3.0.3 — MVCP was mis-batched, "making scroll worse" (our data-anchoring path) - 3.0.4 — anchoredEndSpace reported stale end-space sizes during load (our reserve) - 3.0.5 — on-screen rows could stay stuck in outdated positions No breaking changes across 3.0.1..3.0.6; peer dep is just react. Prop contracts re-verified against 3.0.6 (mVCP normalize identical; anchoredEndSpace still type-omitted from the RN entry, so the local-typed spread stays correct). ADR 0004 records the >= 3.0.4 requirement.
…dEndSpace The forensic log showed the focused reply drops by exactly the prepended parent's estimate->measured miss (200->523, +323) and the scroll then clamps to ~36: there is no room below the note, so mVCP can't raise the offset enough to hold it and the list bottoms out (snaps) — the note can't be kept or refocused. The library's anchoredEndSpace was meant to provide that room but never showed up in the trace (opaque, no RN public type, version-fragile across 3.0.x). Replace it with an explicit reserve owned in ThreadView: add `viewport - (rows below the note x approx height)` to paddingBottom. Generous on short threads (where the note needs the room), shrinks to 0 as replies fill the screen (no dead gap on long threads). Deterministic, hot-reloads with the component, and emits a `thread.reserve` log so a trace can confirm it's live. ADR 0004 + CONTEXT.md updated (Focus reserve replaces End reserve).
Live on-device trace (now that the build actually runs this code) showed the residual: mVCP scrolls to hold the focused note when the parent prepends, but it anchors against the parent's estimatedItemSize (200) and never reconciles the gap when the parent measures to its real height (145) — leaving the note off by exactly estimate-measured (here +55px up; an earlier media parent measured 523 -> -323px down). A single global estimate can't cover parents ranging ~145-523px, and v3 has no per-item estimate. Own that one reconciliation: when a row above the note changes size before the reader has scrolled, counter-scroll by the delta (scrollToOffset(scroll + size - previous)). One-time per parent measure (not a sustained re-pin, so it doesn't race mVCP like PR #225 did); onScrollBeginDrag hands control back to the user. ADR 0004 updated.
…tter The counter-scroll lands the focused note correctly but the prepend + re-anchor plays out over several frames, which reads as jitter on screen (it "ends in the right place" but jumps to get there). Hide that reconciliation entirely: - Withhold parents from the list (listData filters them) and show only the seed (tapped note + skeletons) at full opacity. - When the full thread is ready: fade the list out (120ms), inject the parents while hidden (showParents), let the scroll settle, then fade back in (180ms). Reveal is gated on the above-note size-changes going quiet (each correction pushes it out; a fallback covers the no-change case). - Reconcile each above-note row exactly once (corrected-keys guard) so the counter-scroll can't oscillate. The reader sees: tapped note -> brief crossfade -> settled thread, never the reconcile. Removes the older per-row "reveal hack" (it only masked rows below the note). All source-level, so it hot-reloads on the Metro build. ADR 0004 updated (decision 6).
The fade existed only because the one-list approach reconciled the parent prepend in place and had to hide it. Resolve it properly: render two lists — a seed list (tapped note + replies, parents filtered out) the reader sees immediately, and the real list (full thread) that resolves OFF-SCREEN (opacity 0, pointerEvents none). Once the above-note rows stop changing size, swap instantly (revealed flips; seed unmounts). No fade needed: the resolved view is a pixel match for the seed — same note at the top, same replies below, parents just scrolled off above — and the once-per-row counter-scroll makes the landing exact. If a residual ever showed at the swap it would mean the reconcile isn't exact, which we'd fix rather than mask. Removes the fade machinery (reanimated shared value / withTiming / runOnJS) and the showParents data-gating. ADR 0004 decision 6 updated.
Threads worked except when a parent contained an image: an image with no imeta
dims (and not cached) reserves a 16:9 box, then reshapes to its real aspect on
load — a second, larger above-note size change that our once-per-row guard was
blocking, so nothing compensated it and the note shifted. (Videos are locked to
imeta-or-16:9 and don't reshape.)
Make the counter-scroll the sole, continuous owner of the size axis:
- mVCP -> { data: true, size: false }: library compensates the prepend (data); we
own all above-note size compensation (no double-correction / oscillation).
- Drop the once-per-row guard: compensate EVERY above-note size change by its exact
delta while the reader hasn't scrolled — covers the prepend estimate->measured
jump AND late media reshaping. Off-screen parents growing keeps the note fixed.
- Bump the off-screen settle fallback 600ms -> 900ms so a fast/cached parent image
loads + reshapes before the swap (shown already settled); slow network images
reshape post-swap and are absorbed by the counter-scroll.
Remaining limit: a first-load image with no imeta dims that loads slower than the
settle window can still nudge once before the counter-scroll catches it — the
durable fix is imeta dims (server) or the warmed aspect cache. ADR 0004 updated.
The note anchor is confirmed holding (focused-note pageY constant across a 49-reply load). Document the known, out-of-scope residual: replies settling BELOW the stable note still reflow (fixed-height reply skeletons vs variable real heights; reply images without imeta reshaping on load). The two-list swap can't hide it because the seed renders the same replies — it's the feed-wide variable-height / media-reservation problem, not thread-anchor work.
…hift Gating reply content to post-reveal stopped the half-loaded flash but the replies then reshaped on-screen after mounting (heights vs skeletons + reply images loading) — a reshift after appearing. Resolve the whole thread (incl. replies) off-screen instead: the real list renders replies the entire time (hidden), so they measure and load images while off-screen and are already settled when shown. Reveal now waits for ALL on-screen rows (parents AND first replies) to go quiet, bounded by an absolute cap so a slow image can't stall the seed. Then land the note (scrollToIndex) and CROSSFADE the real list in over the seed (skeletons → settled replies) — seamless for the note (pixel match), and the fade IS the skeleton→real transition the reply flow wants. Seed shows skeletons only, so reply images aren't double-loaded. Residual: a reply image slower than the cap settles after the crossfade (below the focus, rare; imeta/aspect-cache covers the common case). ADR 0004 (6,7) updated.
Evaluate whether FlashList v2's synchronous Fabric layout + default
maintainVisibleContentPosition holds the focused note WITHOUT our legend-list
scaffolding (two-list crossfade, focusReserve, scrollToIndex landing,
counter-scroll, reply gating).
- add @shopify/flash-list@2 (pure JS, New-Arch only; loads via Metro, no native
rebuild). App is Expo SDK 55 / RN 0.83 / new arch default-on.
- settingsStore: persisted `flashListThread` dev toggle + Settings → Developer
switch ("FlashList thread (spike)").
- ThreadView: when the toggle is on, render ONE plain FlashList v2 (data, mVCP
default, initialScrollIndex on the target, getItemType, keyExtractor) with all
scaffolding stripped; otherwise the current legend-list path. A/B on device.
Spike only — not wired on by default; throwaway-able.
FlashList v2 holds the note via synchronous layout, but with little content below the note the anchored position sits past the real scroll bounds, so the first gesture snaps to top/bottom. focusReserve (the same bottom-padding fix as the legend-list path) gives real scroll room below — likely the ONLY scaffolding the FlashList path needs.
…ke notifications Tapping a reply seeds the full originating context (getThreadContext), so the note's ancestors come along and buildThreadItemsFromSeed lands the note at index >0 — focus then depends on a fragile estimated initialScrollIndex (and the seed->full structure shifting under it), so the note isn't focused. The notifications entry seeds no ancestors, so its note sits at index 0 and focuses correctly. Drop ancestors from the seed (thread.parents = []): the note mounts at the TOP and stays focused as the real parent chain loads above it, held by maintainVisibleContentPosition. Every entry point now focuses the note the same way. Benefits both the FlashList spike and the legend-list path.
FlashList recycles cells, which fights reanimated entering/exiting layout animations on rows — an exiting reply skeleton renders in a recycled cell's position for a frame (skeleton 'in the wrong place' during the skeleton->real transition). Skip REPLY_FADE_IN / SKELETON_FADE_OUT when flashListThread is on; keep them on the legend-list path where they crossfade cleanly.
…ton exit off) The skeleton's EXIT animation was the recycling glitch (a removed item lingering in a recycled cell — 'skeleton in the wrong place'). The reply ENTER fade is recycling-tolerant (plays at the cell's correct position, doesn't re-fire on scroll since recycled cells reuse the instance), so restore REPLY_FADE_IN on the content while leaving SKELETON_FADE_OUT off on the FlashList path.
Measured from the shift log: real text-only replies follow height = 84 + 24·lines (1-line 108px, 2-line 132px), but the reply skeleton rendered 101/125 — chrome ~7px short — so the row grew when real text replaced the skeleton. - bump REPLY_SKELETON_CHROME_HEIGHT 80 -> 84 (the measured real chrome); per-line stays NOTE_CONTENT_LINE_HEIGHT (24). - give the reply skeleton a minHeight = replySkeletonHeight(index) so it actually renders the computed height (FlashList measures it; legend already fixes it via getFixedItemSize) — one shared model, both paths match the real text ladder. Same-line-count skeleton↔real now occupy identical height (no 7px reflow). Replies with media stay off-ladder and are out of scope (separate imeta reservation).
…x (root cause)
Element-by-element comparison showed the skeleton↔real reply chrome was identical
EXCEPT the metrics footer inner row: the skeleton's count label was a hardcoded
14px rectangle, but the real footer's count is a Text size={11} (no lineHeight),
so the real footer row is as tall as that font's line box — ~7px taller. The
footer HStack (align center) sized to it, so every real reply was 7px taller than
its skeleton.
Fix the structure, not the reserve: render the skeleton's count label as a
Text loading placeholder at the SAME size (11 compact / 13 regular), so it
self-sizes to the real line box. Matching-line-count skeleton↔real rows now occupy
identical height with no minHeight. Keep REPLY_SKELETON_CHROME_HEIGHT=84 (the real
chrome) for the legend fixed-size hint; it now agrees with the rendered skeleton.
Add onLayout measurement to each reply chrome element (author row, content,
metrics footer) on both the real reply and the skeleton, logging thread.elem
{label,h}. Lets us compare element-for-element and find the residual ~7px height
mismatch (the footer line-box hypothesis was wrong — skeleton row unchanged). Real
probes gated to thread variant. To be removed after calibration.
…l 6px) Per-element onLayout measurement (real vs skeleton) found the mismatch: the real reply author row is 24px, the skeleton's was 18px. Cause: the real author row contains the '⋯ more' button (pcStyles.moreButton: 30×28, marginTop -4 → 24px tall) which the skeleton author row omitted, so it was only the 18px text line box. That's 6 of the 7px (footer is a trivial +1). Mirror the real structure in the skeleton: name/time in the flex headerTextRow plus an empty moreButton-sized box on the right, so the skeleton author row is 24px = real. Matching-line-count skeleton↔real rows now occupy the same height with no minHeight reserve. Removed the temporary per-element measurement probes.
… skeleton The real post and PostCardSkeleton hand-duplicated the author/header row — which is exactly how the skeleton silently dropped the '⋯ more' button and rendered 6px short. Extract a single PostCardGutterHeader that renders the name·time + more-button chrome for BOTH the real post (Pressables, real text) and the loading state (placeholders, empty more-button box). Now they cannot drift: the row's structure and height are defined once. General win beyond this bug: any future post skeleton (e.g. a feed variant) reuses the same header and stays in parity by construction. Real feed/thread rendering is unchanged (same elements/handlers/styles, just sourced from the shared component).
Remove @legendapp/list everywhere and migrate every virtualized list to @shopify/flash-list v2. Plain data lists render through a single shared <List> seam (shared/ui/composed/List.tsx) that owns the only flash-list import + app defaults; the four specialized surfaces use FlashList directly: - ThreadView: keep focusReserve + index-0 seed + initialScrollIndex + default mVCP; delete the legend two-list crossfade, onItemSizeChanged counter-scroll, reveal gating, getFixedItemSize and the flashListThread Settings toggle. - ChatScreen / AiChatScreen: chat-bottom via mVCP startRenderingFromBottom + autoscrollToBottomThreshold (replaces initialScrollAtEnd / alignItemsAtEnd / maintainScrollAtEnd). - SectionAnchorList: getItemType + scrollToIndex(viewOffset) + renderScrollComponent. - Transactions: AnimatedLegendList -> FlashList; row collapse keeps its reanimated layout transition (sibling reflow now immediate; itemLayoutAnimation has no v2 equivalent). Drop the content-shift list telemetry FlashList's synchronous layout makes obsolete (useVisualListLogger wiring, the five callbacks, getState, and the per-row VisualLayoutProbe in list renderItems). The shared contentShiftLog library stays for its element-level/composer/notification uses. Trim threadListLayout to NOTE_CONTENT_LINE_HEIGHT; delete the obsolete threadFixedItemSize and visualLayoutCoverage tests; drop @legendapp/list from package.json + bun.lock. See ADR 0005.
The Add Mints loading skeleton was a hand-rolled card layout that had drifted from the real result row (different card chrome, avatar size, padding, and missing the subtitle, stats accent, and checkbox), so content jumped when search data arrived. Render the skeleton through the SAME List + ContactRow path as real rows (in loading mode) so the container chrome is shared and can't drift, and close the one real height gap in ContactRow's loading state. - MintAddScreen: delete the hand-rolled LoadingMintsList; feed the result List skeleton placeholders during the initial search and render them via MintItem in loading mode (one List, shared header/padding). - ContactRow: reserve the inline stats-accent height while loading (gated to mint / explicit-stats rows so contact/ble/geohash skeletons are unaffected) and render an inert 24px box for the checkbox slot. - RowStatsAccent: add co-located RowStatsAccentSkeleton so accent geometry lives in one place and can't drift between loading and loaded states. - add ContactRow loading-reservation tests (accent + checkbox + gate).
The loading accent used a fixed 14px bar, which under-shot the real pill's text line box (Text size 12 ≈ 16px) by a couple of pixels, so skeleton rows sat slightly shorter than real rows and content still nudged on the swap. Mirror the real pill structure instead of guessing a height: a 12px icon box + a `Text loading` at the same size/bold, which self-sizes to the exact real line box. Accent line height is now identical loading vs loaded.
Add SkeletonContentCrossfade, the canonical region-level skeleton→content transition. While loading, a sizeable region shows the shared SkeletonLoadingShimmer wave; on the loading→loaded edge the skeleton fades out over the real content (220ms) so images fade in independently via expo-image and never block the swap. Leaf primitives stay dumb; reduced motion does an instant swap; exit="none" keeps FlashList-recycled cells safe. Adopt at Add Mints (results region), Mint info (stats + rating), Mint reviews (footer skeletons), Receive (QR hub), payment QR, referenced-post preview, history-refresh row, and mention search. Fix MintIcon, which was missing its expo-image transition. Add a Design System demo (/(settings-flow)/design-system-skeleton-crossfade). Thread + profile keep using the shared SkeletonLoadingShimmer directly: thread items are FlashList-recycled (an exiting skeleton renders in the wrong recycled slot) and the profile stats grid has a tuned 3-branch layout.
Address device feedback on the skeleton→content transition: - Make the fade a clear two-step crossfade — skeleton fades OUT, then content fades IN (300ms total) — instead of dissolving the skeleton over already- visible content. One centralized behavior, so every adopter (and the Design System demo) animates identically. - Default the region wave's highlight to the theme `surface` tint (matching the thread shimmer); the previous background-tinted fallback was nearly invisible, which read as "no wave" on Add Mints.
The shimmer wave must use the theme background, not surface: a background- colored band sweeping over the skeleton momentarily erases it, giving the illusion of the skeleton disappearing and reappearing rather than a brighter stripe on top. My previous surface default broke this (visible in the Design System). Hard-code the highlight to `background` and drop the override so it can't drift again.
The wave must paint the color revealed when the skeleton is "erased" — i.e. whatever is *behind* it — so it reads as the skeleton disappearing/reappearing. That's the screen background for full-screen lists, but the card/surface color when the skeleton is on one. Add a `surfaceColor` prop (defaults to background) and set it where skeletons sit on a surface: the Design System cards and MintInfo stats (surface-secondary), and the referenced-post preview (surface). Fixes the wrong-colored wave in the Design System.
…first-frame buildNostrDataLayer() now memoises ONE facade (and one entity cache) per active profile + tier config, so the cache accumulates across every caller instead of being rebuilt empty per request. An identity switch clears the prior cache before serving the new profile (no cross-account bleed). useThread paints its first frame from the shared cache (layer.readThread) for deep links and reply-taps that carry no nav snapshot, ordered after the richer feed nav seed so existing behaviour is unchanged.
The for-you and notifications facade paths already ingest, but the nagg GraphQL fast-paths (thread, user feed, posts-by-pubkey, and the non-mapped feed fallback) returned app-shaped results without populating the cache — so the cache was not authoritative for those surfaces. Write their notes/profiles/ metrics back into the singleton cache, tagged 'nagg', so opening a reply as its own thread or revisiting a profile serves instantly from cache.
useThread now seeds from the shared cache first (readThread: ancestors + reply previews + profiles/metrics), falling back to the transient nav snapshot only for anything not yet ingested, then own-content for a just-posted note. The cache is authoritative; the nav seed is a safety net pending on-device proof before its removal.
PostCard now resolves the author's pfp/name from the authoritative singleton cache via useProfile (per-key subscription) instead of a transient prop Map. A row re-renders in place when its author's profile arrives — so a reply's parent fills the instant it loads, relay-mode threads show authors the feed already saw, and nothing flashes a stale fallback. A loading status drives a real skeleton vs the npub fallback. Also seeds the viewer's own pfp/name from profileStore into the cache at build/switch, so notifications (whose targets are the viewer's own posts) render own identity with zero fetch. Adds shared/lib/nostr/useEntityCache.ts (useProfile/useNote/useNoteStats).
Opening a thread, the cache-seeded reply previews were replaced wholesale by the server's ranked order on the initial fetch, causing a visible reshuffle. Preserve the already-painted order as a stable prefix and append the ranked delta instead (reusing the load-more merge); with no seed, the server order is used as-is.
Put the new ResolvedThread.parents into allEvents (not the reply list) so buildThreadStructure renders the parent chain in relay/primal mode, matching nagg. With the parents now ingested into the cache, tapping a reply serves its parent from cache instead of flashing it in from the network.
Add three deterministic analysis features to the log-doctor dev CLI, all
confined to codereview/log-doctor (no production logger changes). Pure logic
lives in a new analysis.ts module so it is unit-testable without loading the
CLI/WDA stack.
- errors: cluster near-identical entries into exemplar + count + first/last,
collapsing the biggest token consumer. --all/--no-cluster restores the full
per-entry listing with context.
- perf: complete the existing p95 with p50/p99 and a per-event latency
sparkline (over params.ms / _perf ops). span-end duration_ms remains a
separate, documented gap rather than being silently conflated.
- redaction: new read-side audit mode (+ a stats line, + budget row) that
counts {_kind} brands and <REDACTED:..> markers and heuristically flags raw
values that look un-redacted, tiered high/low signal. Reports event names,
never values; respects the deliberate non-reversible {_kind,len} design.
Also reuse a shared percentile() in stats gaps, fix the stale MODES header
list, and document the new mode/flag in OVERVIEW.md + README.md.
The redaction pipeline already branded whole 64-hex (hex32), nsec, jwt, pem
and cashu tokens, but several private-key encodings still reached the log in
the clear because maxStringLength is 120:
- WIF private keys (base58, 51-52 chars) were logged in full as long_string.
- Extended private keys (xprv/yprv/zprv + testnet) are ~111 chars (<=120) and
were logged in full (or previewed via the base64 long pattern).
- base64-encoded 32-byte secrets (44 chars) were shown in full.
- The hex / base64 long patterns previewed their first 32 chars — enough to
leak half a key — even for opaque blobs that could be key material.
Hardening (value-based, field-name rules unchanged):
- New secret patterns: wif, xprv-family, base64_key (32/64-byte base64).
- hex / base64 / base58 long patterns are now no-preview: emit {_kind,len}
only, never bytes — even when short enough to otherwise print in full.
- Embedded xprv inside larger strings is scrubbed to <REDACTED:xprv>.
Deliberately conservative on the privkey/pubkey hex ambiguity (already
handled by hex32) and does NOT touch npub (public identity stays readable).
Embedded raw 64-hex is left alone so public image-hash URLs remain debuggable.
Covered by __tests__/loggerRedaction.test.ts (8 cases, incl. over-redaction
regressions).
naggFeedClient now serves every read (feed/user-feed/posts/thread/
notifications/enrich/profiles) from nagg's REST app-view via client.rest:
- gut runNaggQuery -> runNaggRest (binding + responseSchema, no graphql,
no appview->graphql fallback, no capability/timeout cascades).
- delete all 13 GraphQL query constants + selection fragments, the
graphqlToData distillers, the fallback wrappers, and the client-side
thread reply merge.
- thread now uses threadAppView({sort: relevant|ranked|new, viewer}),
with the viewer-specific relevance merge computed server-side; the
canonical NaggThread feeds buildThreadStructure + the server ordering
manifest (FeedEvent/NoteMetrics/ProfileInfo are the canonical shapes).
- enrich uses eventsAppView + profilesAppView.
- dmEnvelopeClient: dmConversation via /nostr/dm/conversation binding.
- backend.ts: drop the *_APPVIEW transport flags (app-view is the only
Nostr transport). nostrGraphqlEndpoint kept ONLY for coco-core-bound
integrations (mint enrichment, operator profiles) + recentPeopleProfiles,
which are not routed through nagg-ts (flagged for a separate migration).
- buildNostrDataLayer nagg tier is app-view-only.
Type-checks clean. WIP: the legacy naggFeedClient.test.ts (GraphQL-mock
based) + lint/knip still to update; on-device verification pending.
Structural health |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Single combined PR for all sovran-app changes since
main(60 commits). This is the full-stack view of work currently split across PRs #224, #226, #227, #228, #229, #231 — opened againstmainso it can be reviewed and merged as one unit. Those stacked PRs are left open and untouched.Themes
maintainVisibleContentPosition+ an explicit logged focus reserve; reconcile the estimate→measured parent-prepend gap; render the ancestor chain from the facade thread parents; stable seeded-reply ordering.@legendapp/listwith@shopify/flash-listv2 app-wide; shared<List>seam.SkeletonContentCrossfadehelper + app-wide adoption; share the gutter header betweenPostCardand its skeleton; match skeleton heights/line-boxes to measured real rows (thread reply, mint pills, Add-Mints search).naggFeedClient/dmEnvelopeClientserve every read from nagg's REST app-view; client GraphQL, fallbacks, and the client-side reply merge removed (server-side relevance merge). Requires the matching nagg + nagg-ts PRs.Verification
type-check + lint + knip clean; full test suite green at the tip. On-device verification pending.
🤖 Generated with Claude Code