Skip to content

feat: tiered Nostr data layer, thread anchoring, FlashList v2 & app-view REST (all changes since main)#232

Merged
Kelbie merged 60 commits into
mainfrom
feat/appview-data-layer
Jun 23, 2026
Merged

feat: tiered Nostr data layer, thread anchoring, FlashList v2 & app-view REST (all changes since main)#232
Kelbie merged 60 commits into
mainfrom
feat/appview-data-layer

Conversation

@Kelbie

@Kelbie Kelbie commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

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 against main so it can be reviewed and merged as one unit. Those stacked PRs are left open and untouched.

Excludes the two branches not folded into this line: #225 (thread-reverse-scroll, the superseded auto-pin approach) and #221 (blossom-url-extension, independent).

Themes

  • Tiered Nostr data layer (ADR-0003) — profile-scoped data-layer singleton, a shared write-through entity cache as the thread first-frame source, reactive author identity, and nagg reads bridged into the cache. Engagement-store consolidation.
  • Thread anchoring (ADR-0004) — anchor the focused/tapped note via 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.
  • FlashList v2 migration (ADR-0005) — replace @legendapp/list with @shopify/flash-list v2 app-wide; shared <List> seam.
  • Skeleton ↔ content paritySkeletonContentCrossfade helper + app-wide adoption; share the gutter header between PostCard and its skeleton; match skeleton heights/line-boxes to measured real rows (thread reply, mint pills, Add-Mints search).
  • App-view REST onlynaggFeedClient/dmEnvelopeClient serve 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.
  • Observability & safety — log-doctor error clustering, latency percentiles, redaction audit; logger hides base58/base64/long-hex private-key material.

Verification

type-check + lint + knip clean; full test suite green at the tip. On-device verification pending.

🤖 Generated with Claude Code

Kelbie added 30 commits June 20, 2026 12:29
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.
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.
Kelbie added 28 commits June 22, 2026 05:26
…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.
@github-actions

Copy link
Copy Markdown

Structural health

# Structural Health Score

Overall: **56/100**
- Architecture: 40/100 (weight 20)
- Module Design: 58/100 (weight 15)
- Code Complexity: 41/100 (weight 15)
- Type Safety: 86/100 (weight 10)
- Component Health: 88/100 (weight 10)
- Hygiene: 69/100 (weight 15)
- Testability: 8/100 (weight 10)
- Conceptual Cohesion: 92/100 (weight 5)

# Repo Analysis: .

Files: 1123  Code: 149037  Cycles: 2  Orphans: 313  Shallow: 48  Pass-through: 43  Complexity hotspots: 313  Component smells: 89  Type-safety hotspots: 342  Hub-spoke: 139  Test gaps: 713  Unused-export files: 60

## Top complexity hotspots
- features/mint/hooks/useMintRebalanceOrchestrator.ts | cognitive=697 cyclomatic=178 nesting=12 code=1146
- features/send/lib/sovranPaymentConfig.ts | cognitive=631 cyclomatic=276 nesting=9 code=1570
- shared/lib/loggerCore.ts | cognitive=588 cyclomatic=267 nesting=7 code=898
- shared/lib/contentShiftLog.ts | cognitive=579 cyclomatic=405 nesting=6 code=1401
- features/nostrSigner/lib/nip46Engine.ts | cognitive=393 cyclomatic=185 nesting=6 code=840
- features/nearPay/screens/NearPayScreen.tsx | cognitive=352 cyclomatic=207 nesting=5 code=2149
- features/feed/components/nostr/image-overlay/AnimatedImageOverlay.tsx | cognitive=346 cyclomatic=185 nesting=7 code=1371
- features/feed/components/HomeFeed.tsx | cognitive=335 cyclomatic=134 nesting=8 code=1047
- shared/ui/composed/ContactRow.tsx | cognitive=319 cyclomatic=253 nesting=6 code=842
- shared/lib/cashu/manager.ts | cognitive=298 cyclomatic=82 nesting=9 code=688
- …and 303 more

## Top shallow modules
- shared/lib/brandColors.ts | depth=1 exports=14 code=14
- features/feed/components/nostr/image-overlay/config.ts | depth=1.2 exports=52 code=62
- features/feed/components/thread-embed/embedConstants.ts | depth=1.2 exports=9 code=11
- scripts/release.config.mjs | depth=1.8 exports=25 code=46
- shared/lib/nav/routeSchemas.ts | depth=1.9 exports=7 code=13
- features/nostrSigner/lib/boundedDisplay.ts | depth=2.2 exports=5 code=11
- features/nostrSigner/lib/nip46Types.ts | depth=2.5 exports=44 code=109
- shared/ui/composed/ScreenFooterContext.tsx | depth=2.5 exports=4 code=10
- shared/ui/composed/CircleActionButton/CircleActionButton.types.ts | depth=3 exports=6 code=18
- shared/lib/cashu/npc.ts | depth=4.2 exports=6 code=25
- …and 38 more

## Pass-through suspects
- features/feed/components/nostr/image-overlay/config.ts | ratio=1 exports=52 fanin=5 fanout=0
- features/feed/components/thread-embed/ThreadEmbedProvider.tsx | ratio=1 exports=2 fanin=2 fanout=6
- features/feed/components/HomeFeed.tsx | ratio=1 exports=5 fanin=1 fanout=25
- features/nostrSigner/components/display.tsx | ratio=1 exports=2 fanin=5 fanout=2
- features/nostrSigner/components/SummaryPreviewCards.tsx | ratio=1 exports=7 fanin=2 fanout=18
- features/nostrSigner/hooks/signerApprovalCoordination.ts | ratio=1 exports=2 fanin=3 fanout=0
- features/nostrSigner/lib/boundedDisplay.ts | ratio=1 exports=5 fanin=10 fanout=0
- features/nostrSigner/lib/pairingPreset.ts | ratio=1 exports=2 fanin=4 fanout=1
- features/nostrSigner/lib/rateLimiter.ts | ratio=1 exports=7 fanin=2 fanout=1
- features/send/screens/AmountFlowScreen.tsx | ratio=1 exports=2 fanin=3 fanout=13
- …and 33 more

## Hub-spoke coordinators
- shared/ui/primitives/Avatar.tsx | fanin=39 fanout=11 ×=429
- shared/ui/composed/Screen.tsx | fanin=52 fanout=6 ×=312
- shared/ui/primitives/Pressable.tsx | fanin=87 fanout=3 ×=261
- shared/providers/NostrKeysProvider.tsx | fanin=37 fanout=6 ×=222
- features/nostrSigner/data/nip46ConnectionsStore.ts | fanin=26 fanout=6 ×=156
- shared/ui/composed/ButtonHandler.tsx | fanin=22 fanout=7 ×=154
- features/nostrSigner/lib/nip46Engine.ts | fanin=9 fanout=17 ×=153
- shared/ui/composed/AmountFormatter.tsx | fanin=17 fanout=9 ×=153
- shared/ui/composed/ContactRow.tsx | fanin=9 fanout=17 ×=153
- shared/stores/global/settingsStore.ts | fanin=43 fanout=3 ×=129
- …and 129 more

## Type-safety hotspots
- redux/store/store.deprecated.ts | any=29 !=0 as=16 ts-ignore=0
- __tests__/nip46Engine.test.ts | any=0 !=31 as=1 ts-ignore=0
- __tests__/avatarFallback.test.tsx | any=0 !=16 as=0 ts-ignore=0
- shared/blocks/popup/PopupHost.tsx | any=3 !=1 as=20 ts-ignore=0
- __tests__/sovranPaymentConfig.profile.test.ts | any=0 !=0 as=6 ts-ignore=6
- __tests__/receiveScreenLayout.test.tsx | any=0 !=13 as=0 ts-ignore=0
- features/ai/hooks/useAiSend.ts | any=6 !=0 as=7 ts-ignore=0
- __tests__/loggerChild.test.ts | any=0 !=10 as=3 ts-ignore=0
- shared/lib/typedUpdate.ts | any=7 !=0 as=0 ts-ignore=0
- __tests__/androidUiRegressions.test.tsx | any=0 !=10 as=0 ts-ignore=0
- …and 332 more

## Component smells
- features/nearPay/screens/NearPayScreen.tsx:NearPayScreen | large(804L) hooks(65) effect-deps(19)
- features/feed/screens/NotificationsScreen.tsx:NotificationsScreen | large(604L) hooks(40) bool-state(3)
- features/mint/screens/MintAddScreen.tsx:MintAddScreen | large(505L) hooks(35) effect-deps(7)
- features/mint/screens/MintRebalancePlanScreen.tsx:MintRebalancePlanScreen | large(531L) hooks(20) effect-deps(16)
- features/contacts/screens/ContactsScreen.tsx:ContactsScreen | large(449L) hooks(27) inline-subcomp(2)
- features/nostrSigner/components/PermissionGestureDemo.tsx:PermissionGestureDemo | large(322L) hooks(46)
- shared/blocks/popup/PopupHost.tsx:SheetPopup | large(432L) hooks(34)
- shared/blocks/popup/ActionMenuHost.tsx:ActionMenuHost | large(544L) hooks(19)
- features/feed/screens/NotificationFollowersScreen.tsx:NotificationFollowersScreen | large(441L) hooks(28)
- features/onboarding/screens/ClaimUsernameScreen.tsx:ClaimUsernameScreen | large(398L) hooks(22) inline-subcomp(1)
- …and 79 more

## Duplicate export names
- ShareRoute in 2 files: app/share.tsx, features/user/screens/ShareRoute.tsx
- NOTE_CONTENT_LINE_HEIGHT in 2 files: features/feed/components/nostr/NoteContent.tsx, features/feed/lib/threadListLayout.ts
- PUBLISH_TIMEOUT_MS in 2 files: features/nostrSigner/lib/nip46Transport.ts, shared/lib/nostr/publish/constants.ts
- isSupported in 2 files: modules/liquid-glass-menu/src/LiquidGlassMenu.tsx, modules/liquid-glass-text/src/LiquidGlassText.tsx
- Avatar in 2 files: shared/ui/primitives/Avatar.tsx, mealection-react-native-boring-avatars.d.ts

## Default+named export clash
- LoadingIndicator in shared/blocks/status/LoadingIndicator.tsx

## Unused export sets
- features/feed/data/naggSchemas.ts | unused: NaggNoteMetrics, NaggFeedEvent, NaggProfileInfo, NaggFeedItem, …
- shared/styles/tokens.ts | unused: minTouchTarget, Spacing, Radius, Alpha, …
- features/mint/hooks/useMintRebalanceOrchestrator.ts | unused: RebalanceRunStatus, MintLite, UseMintRebalanceOrchestratorArgs, UseMintRebalanceOrchestratorResult
- features/nostrSigner/data/nip46ConnectionsStore.ts | unused: ConnectionOrigin, ConnectionEncryption, Nip46Grant, PeerDecryptGrant
- shared/lib/nostr/nip11.ts | unused: fetchRelayInformation, RelayInfoError, RelayLimitation, RelayInformation
- features/feed/components/nostr/feedParse.ts | unused: getFirstTagValue, IMAGE_EXT, ImetaInfo
- features/feed/data/recentPeopleProfiles.ts | unused: buildRecentPeopleProfilesGraphqlBody, mapRecentPeopleProfileEvents, RECENT_PEOPLE_PROFILES_QUERY
- features/feed/lib/feedRows.ts | unused: getFeedItemKey, feedRowsAreEqual, BuildFeedRowsOptions
- features/nostrSigner/lib/nip46Engine.ts | unused: OnUserVerdictNeeded, Nip46EngineStartConfig, Nip46EngineTransport
- features/nostrSigner/lib/permissionPolicy.ts | unused: PolicyAllowReason, PolicyDenyReason, PolicyAskReason
- …and 50 more

## Test gaps
- features/nearPay/screens/NearPayScreen.tsx | exports=1 code=2149
- features/send/lib/sovranPaymentConfig.ts | exports=6 code=1570
- shared/lib/popup/popups/emojiData.ts | exports=4 code=1440
- features/feed/components/nostr/image-overlay/AnimatedImageOverlay.tsx | exports=1 code=1371
- features/feed/screens/NotificationsScreen.tsx | exports=1 code=1293
- features/user/screens/UserProfileScreen.tsx | exports=1 code=1182
- features/mint/hooks/useMintRebalanceOrchestrator.ts | exports=5 code=1146
- features/feed/components/HomeFeed.tsx | exports=5 code=1047
- features/feed/components/nostr/image-overlay/provider.tsx | exports=6 code=983
- features/feed/components/UserFeed.tsx | exports=2 code=983
- …and 703 more

## Cycles
- (2 files) features/payments/data/facadeDmAdapter.ts → features/payments/data/dmEnvelopeClient.ts
- (2 files) shared/stores/runtime/mockDataStore.ts → features/payments/hooks/useNip17RecentContacts.ts

## Instability per folder
- __tests__ | I=1.00 Ce=245 Ca=0
- scripts | I=1.00 Ce=1 Ca=0
- app | I=1.00 Ce=290 Ca=1
- features | I=0.86 Ce=1770 Ca=288
- navigation | I=0.50 Ce=6 Ca=6
- redux | I=0.42 Ce=5 Ca=7
- assets | I=0.27 Ce=3 Ca=8
- config | I=0.15 Ce=3 Ca=17
- shared | I=0.03 Ce=55 Ca=2044
- codereview | I=0.00 Ce=0 Ca=1
- …and 2 more

## Re-export depth (barrel hops)
- __tests__/nip46PairingEntry.test.ts | depth=2 exports=0
- __tests__/nip46SignerService.test.ts | depth=2 exports=0
- __tests__/nip46SwitchProfileAndPair.test.ts | depth=2 exports=0
- __tests__/sovranPaymentConfig.profile.test.ts | depth=2 exports=0
- app/(drawer)/(tabs)/index/index.tsx | depth=2 exports=1
- __tests__/contactRowLoadingReservation.test.tsx | depth=1 exports=0
- __tests__/loggerChild.test.ts | depth=1 exports=0
- __tests__/loggerFile.test.ts | depth=1 exports=0
- __tests__/loggerRedaction.test.ts | depth=1 exports=0
- __tests__/mintRebalanceRunState.test.ts | depth=1 exports=0
- …and 18 more

## Concept locality
- size | folders=8 files=251
- data | folders=7 files=113
- not | folders=4 files=69
- seed source | folders=4 files=61
- raw relays | folders=4 files=52
- pending | folders=4 files=39
- tier | folders=3 files=20
- resolveOn modes | folders=3 files=8
- resolveOn | folders=3 files=8
- confirmed | folders=3 files=6
- …and 64 more

## Churn × cognitive (history)
- features/send/lib/sovranPaymentConfig.ts | commits=32 cognitive=631 ×=20192
- features/nearPay/screens/NearPayScreen.tsx | commits=41 cognitive=352 ×=14432
- app/_layout.tsx | commits=75 cognitive=104 ×=7800
- features/feed/components/HomeFeed.tsx | commits=19 cognitive=335 ×=6365
- shared/lib/loggerCore.ts | commits=9 cognitive=588 ×=5292
- features/mint/hooks/useMintRebalanceOrchestrator.ts | commits=7 cognitive=697 ×=4879
- shared/ui/composed/ContactRow.tsx | commits=15 cognitive=319 ×=4785
- features/feed/data/naggFeedClient.ts | commits=19 cognitive=247 ×=4693
- features/transactions/components/Transactions.tsx | commits=24 cognitive=175 ×=4200
- shared/lib/cashu/manager.ts | commits=14 cognitive=298 ×=4172
- …and 806 more

## Temporal coupling (history)
- package.json ↔ yarn.lock | co=38
- package-lock.json ↔ package.json | co=35
- bun.lock ↔ package.json | co=22
- package-lock.json ↔ yarn.lock | co=16
- metro.config.js ↔ package.json | co=15
- app/currency.tsx ↔ helper/payment-handler/handlers.ts | co=11
- app/ecashSendConfirmation.tsx ↔ app/lightningReceiveConfirmation.tsx | co=11
- docs/adr/0004-thread-list-anchoring.md ↔ features/feed/components/ThreadView.tsx | co=10
- features/nearPay/lib/peerLayout.ts ↔ features/nearPay/screens/NearPayScreen.tsx | co=10
- __tests__/nearPayPeerLayout.test.ts ↔ features/nearPay/screens/NearPayScreen.tsx | co=10
- …and 226 more

## Stale files (history)
- prettier.config.js | last=2025-04-16 code=9

14 |     "moduleSuffixes": [".ios", ".android", ""],
         ^
warn: moduleSuffixes is not supported yet
   at /home/runner/work/Sovran/Sovran/sovran-app/tsconfig.json:14:5

@Kelbie
Kelbie merged commit d5be2d2 into main Jun 23, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant