Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
ac8bb87
docs(nostr): ADR-0003 + glossary for the tiered data layer
Kelbie Jun 20, 2026
880fff9
refactor(nostr): unify per-event engagement into one record (PR-5)
Kelbie Jun 20, 2026
8cf13a4
refactor(nostr): single profile cache — seed nagg feed profiles (PR-5)
Kelbie Jun 20, 2026
e5f0c90
build: link sibling @sovranbitcoin/schemas in dev
Kelbie Jun 20, 2026
3adb5a8
feat(dev): per-tier Nostr data-layer toggles + Primal cache URL config
Kelbie Jun 20, 2026
5bd9cb4
feat(settings): rename Relays → Network with per-tier sections
Kelbie Jun 20, 2026
23bc6b2
feat(log): log the resolved Nostr tier config
Kelbie Jun 20, 2026
8180435
feat(feed): route home feed through the tier facade (local-dev proof)
Kelbie Jun 20, 2026
b53decf
feat(notifications): route notifications through the tier facade (loc…
Kelbie Jun 20, 2026
207c393
feat(profiles): fetch kind-0 metadata through the tier facade (local-…
Kelbie Jun 20, 2026
47c5585
feat(dm): route DM inbox through the tier-selecting facade
Kelbie Jun 20, 2026
5605756
feat(social): seed the follow set from the tiered facade
Kelbie Jun 20, 2026
58fe609
feat(search): route profile search through the tiered facade
Kelbie Jun 21, 2026
6154e39
fix(notifications): tier-agnostic profiles + whole-page grouping
Kelbie Jun 21, 2026
513c485
feat(thread): route threads through the facade when nagg is off
Kelbie Jun 21, 2026
37ee688
feat(feed): roll engagement counts in instead of snapping
Kelbie Jun 21, 2026
5634be4
fix(thread): post-sort cache-mode replies + stop skeleton-forever
Kelbie Jun 21, 2026
1b0731a
fix(feed): lazy-load engagement counts from /nostr/notes/stats
Kelbie Jun 21, 2026
2cef0b6
Revert "fix(feed): lazy-load engagement counts from /nostr/notes/stats"
Kelbie Jun 21, 2026
cb22220
feat(nostr): default to nagg's bundled app-view, not GraphQL
Kelbie Jun 21, 2026
b82a7d0
fix(nostr): tier-respecting profile/notif/feed fixes across the facade
Kelbie Jun 21, 2026
1e31476
feat(thread): anchor the focused note via maintainVisibleContentPosition
Kelbie Jun 21, 2026
717516d
fix(thread): reserve tail space so the focused note can hold its posi…
Kelbie Jun 22, 2026
456d5fa
fix(deps): bump @legendapp/list 3.0.0 -> 3.0.6 for the thread anchori…
Kelbie Jun 22, 2026
7311cd3
fix(thread): own an explicit, logged focus reserve instead of anchore…
Kelbie Jun 22, 2026
b0f4229
fix(thread): reconcile the parent prepend's estimate->measured gap
Kelbie Jun 22, 2026
ded45ec
fix(thread): crossfade the parent reconcile off-screen to kill the ji…
Kelbie Jun 22, 2026
7ad1639
refactor(thread): instant two-list swap instead of a crossfade
Kelbie Jun 22, 2026
8fd6d30
fix(thread): hold the note through late media reshaping in parents
Kelbie Jun 22, 2026
8a7e117
docs(adr-0004): record the verified anchor + the reply-reflow residual
Kelbie Jun 22, 2026
41a3bdb
fix(thread): authoritative scrollToIndex landing for multi-parent chains
Kelbie Jun 22, 2026
938cf9d
fix(thread): withhold real reply content until the list is revealed
Kelbie Jun 22, 2026
79b0dc1
fix(thread): resolve replies off-screen + crossfade so they don't res…
Kelbie Jun 22, 2026
aa92bdb
spike(thread): FlashList v2 variant behind a dev toggle for A/B
Kelbie Jun 22, 2026
c6a6369
spike(thread): give the FlashList variant the focus reserve
Kelbie Jun 22, 2026
0977924
fix(thread): seed the focused note at index 0 so reply-tap focuses li…
Kelbie Jun 22, 2026
45174b4
spike(thread): drop reanimated row enter/exit on the FlashList path
Kelbie Jun 22, 2026
f679178
spike(thread): restore reply content fade-in on FlashList (keep skele…
Kelbie Jun 22, 2026
05123dc
fix(thread): match reply skeleton height to measured real text rows
Kelbie Jun 22, 2026
8f8474e
fix(thread): match reply skeleton footer to the real footer's line bo…
Kelbie Jun 22, 2026
72ba2d2
spike(thread): TEMP per-element height probes on reply skeleton vs real
Kelbie Jun 22, 2026
e5f5c4a
fix(thread): skeleton author row missing the more-button box (the rea…
Kelbie Jun 22, 2026
5cb5647
refactor(post): share the gutter header between real PostCard and its…
Kelbie Jun 22, 2026
74e949b
refactor(lists): replace @legendapp/list with FlashList v2 app-wide
Kelbie Jun 22, 2026
8ffdadf
fix(mint): match Add Mints search skeleton to real result rows
Kelbie Jun 22, 2026
72b1c7d
fix(mint): match accent skeleton height to the real pill line box
Kelbie Jun 22, 2026
1d4b57f
feat(ui): skeleton→content crossfade helper + app-wide adoption
Kelbie Jun 22, 2026
08aba84
fix(ui): clear out→in crossfade + visible wave
Kelbie Jun 22, 2026
1856044
fix(ui): wave highlight is always the background color
Kelbie Jun 22, 2026
623033b
fix(ui): wave matches the surface the skeleton sits on
Kelbie Jun 22, 2026
10850cf
fix(nostr): profile-scoped data-layer singleton + cache-first thread …
Kelbie Jun 23, 2026
4ac83c8
feat(nostr): bridge nagg GraphQL reads into the shared entity cache
Kelbie Jun 23, 2026
a5d5906
feat(nostr): make the entity cache the primary thread first-frame source
Kelbie Jun 23, 2026
76319bf
feat(nostr): render author identity reactively from the entity cache
Kelbie Jun 23, 2026
adf1d10
fix(thread): keep seeded reply order as a stable prefix on initial fetch
Kelbie Jun 23, 2026
5b83d10
feat(thread): render the ancestor chain from the facade thread parents
Kelbie Jun 23, 2026
2ccf44d
feat(log-doctor): error clustering, latency percentiles, redaction audit
Kelbie Jun 23, 2026
d3c5b1f
fix(logger): hide base58/base64/long-hex private-key material from logs
Kelbie Jun 23, 2026
f9b57fa
refactor(nagg): consume app-view REST only — strip client GraphQL
Kelbie Jun 23, 2026
d9c6338
chore(nagg): prune dead graphql helpers + lint
Kelbie Jun 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 72 additions & 3 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,10 @@ heuristics, unlike engagement state.
without a dedicated always-on subscription. Seam: `ingestOwnContent`.

**Viewer-state** — a viewer's relation to a post (did _I_ like / repost / quote /
reply; do I follow this profile). Authoritative source is the client-side
own-events relay sync (NOT nagg) — see **own-events sync** and ADR 0002 (which
supersedes ADR 0001's nagg approach).
reply; do I follow this profile). The local store stays authoritative (optimistic
LWW, ADR 0002), but its **seed source** is now the tiered facade, not a hardcoded
relay sub — see **tiered facade** and ADR 0003 (which supersedes ADR 0002's
relay-only seeding while keeping its store-authority mandate).

**Own-events sync** — `useOwnEventsSync` (`shared/lib/nostr/ownsync/`): one
long-lived app-level relay subscription for all our own events
Expand All @@ -91,3 +92,71 @@ toggles.
**Replied index** — `nostrSocialStore.repliedByEventId` (target id → our reply):
drives the "you replied" comment-icon highlight, populated from our own kind:1
reply e-tags by the own-events sync.

## Tiered Nostr data layer

**Tiered facade** — `@sovranbitcoin/nagg-ts`, the single opinionated entry point
for every Nostr read. Expressed in app domain terms (feed, thread, notifications,
conversations, social graph, own viewer-state, mint reviews); internally selects
the best available **tier** and hides fallback, bundling, ordering, validation,
dedupe, and cache-write. Callers never choose a tier or assemble events. See
ADR 0003.

**Tier** — one of three independently-operated read sources, tried in order:
**nagg** (we operate; gold, fully bundled + ranked) → **Primal cache** (Primal
operates, we build only a client adapter; almost as good) → **raw relays** (many
operators; the rough-but-functional floor). A tier that can't answer a given read
returns `unsupported` and the facade falls through to the next.

**Bundle** — one enriched response per read: notes + author profiles + aggregate
stats + (optionally) the per-viewer action overlay, joined client-side by id.
Replaces N round-trips with one batch.

**NoteStats vs NoteActions** — the load-bearing split. **NoteStats** = viewer-
INDEPENDENT aggregate counts (likes/reposts/replies/zaps), cacheable and shared
across viewers, held in the stats store. **NoteActions** = the per-VIEWER overlay
("which of THESE did I like/repost/…"), never cacheable across viewers, held in
the single authoritative viewer-state store. Adding a new engagement type is a
field on each, not three new maps.

**Ordering manifest** — a server-authoritative ordered id list (`FeedRange`
style) returned alongside the unordered bundle. The client renders strictly by it,
index by index; ids absent from the manifest aren't rendered. A structural defense
against the list reshuffling under your thumb. The relay floor synthesizes one
from a stable sort key (created_at).

**Live seam** — the ONE place a caller explicitly asks for relays: a listener that
surfaces a "Load new" pill for items newer than the current page (feeds-recent,
follow deltas, own-state deltas). nagg owns history/pagination; the listener only
cares about items newer than the page, never backfill.

**Own-history endpoints** — the paginated nagg own-events read family (authored,
replies, likes, reposts, zaps-sent, bookmarks, follows, mutes, relays), cursor =
`(created_at, id)`, lazy paging. Seeds the viewer-state store on cold start and
lets the user scroll back past the local cap. nagg is the only tier that covers
all action types; Primal lacks my-likes / my-reposts, so those fall through to
relays.

**Seen state** — one timestamp "seen-up-to" published as a NIP-78 kind-30078
app-data event so it syncs across devices via relays AND is readable on the
backend-free relay tier. Unread = `count(created_at > seenUntil)`.

## Thread reading

**Thread anchor** — the tapped/focused note a thread opens on. It stays visually
fixed while the parent chain prepends above it (the T1 full-thread update) and
replies append below. Achieved by `initialScrollIndex` (land on the note) +
`maintainVisibleContentPosition` (bare → `{ data: true, size: true }`: `data`
compensates the parent prepend, `size` absorbs measurement reconciliation) +
the **focus reserve**. The thread never auto-pins to the bottom — it omits the DM
`ChatScreen`'s `initialScrollAtEnd` / `alignItemsAtEnd` / `maintainScrollAtEnd`.
See ADR 0004.

**Focus reserve** — `focusReserve`: extra `paddingBottom` the thread adds so the
focused note can be scrolled to (and held at) the top. mVCP's prepend
compensation is clamped at the max scroll offset, so without room below, a short
thread drops the note when parents load, the scroll snaps, and the note can't be
refocused. The reserve = `viewport − (rows below the note × approx height)`,
shrinking to 0 as replies fill the screen. Owned in `ThreadView` (not the
library's opaque `anchoredEndSpace`) so it's deterministic and logged
(`thread.reserve`). See [[Thread anchor]] and ADR 0004.
78 changes: 4 additions & 74 deletions __tests__/apiClientBackendConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,92 +61,22 @@ describe('apiClient backend config routing', () => {
Reflect.deleteProperty(globalThis, 'fetch');
});

it('routes Nostr profile through app-view REST and search through GraphQL', async () => {
it('routes Nostr profile through app-view REST', async () => {
process.env.EXPO_PUBLIC_API_BASE_URL = 'https://api.example.test/api/';
process.env.EXPO_PUBLIC_NOSTR_APPVIEW_BASE_URL = 'http://localhost:8080/';

const { auditMint, fetchNostrProfile, searchUsers } =
const { auditMint, fetchNostrProfile } =
jest.requireActual<typeof import('@/shared/lib/apiClient')>('@/shared/lib/apiClient');

await fetchNostrProfile(PUBKEY);
await searchUsers({ query: 'jack' });
await auditMint({ mintUrl: 'https://mint.example.test' });

// Profile search moved to the tier-selecting facade (searchProfilesViaFacade),
// so apiClient no longer issues a GraphQL ProfileSearch here.
expect(mockFetch.mock.calls.map(([url]) => url)).toEqual([
`http://localhost:8080/nostr/profile?pubkey=${PUBKEY}`,
'http://localhost:8080/graphql',
'https://api.example.test/api/cashu/mint/audit?mintUrl=https%3A%2F%2Fmint.example.test',
]);
expect(JSON.parse(mockFetch.mock.calls[1]?.[1]?.body as string)).toMatchObject({
operationName: 'ProfileSearch',
variables: {
input: {
query: 'jack',
limit: 10,
sort: 'globalPagerank',
},
},
});
});

it('parses GraphQL profile search responses through the existing searchUsers schema', async () => {
process.env.EXPO_PUBLIC_NOSTR_APPVIEW_BASE_URL = 'http://localhost:8080/';
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({
data: {
profileSearch: {
query: 'jack',
limit: 1,
sort: 'globalPagerank',
fromCache: true,
nodes: [
{
pubkey: PUBKEY,
npub: 'npub1sg6p7p0ak80lh3ugjjvn9yshrmgr4wldxj547gh4t7dkxutj8mnqalaspq',
rank: 0.01,
score: null,
searchRank: 0.2,
searchScore: 35,
profileScore: null,
name: 'jack',
displayName: null,
picture: 'https://example.test/avatar.png',
image: null,
banner: null,
about: null,
nip05: null,
nip05Valid: null,
website: null,
lud16: null,
lud06: null,
followers: null,
follows: null,
createdAt: '2026-06-01T12:00:00Z',
},
],
},
},
}),
});

const { searchUsers } =
jest.requireActual<typeof import('@/shared/lib/apiClient')>('@/shared/lib/apiClient');

const result = await searchUsers({ query: 'jack', limit: 1 });

expect(result.isOk()).toBe(true);
if (result.isOk()) {
expect(result.value.results[0]).toMatchObject({
pubkey: PUBKEY,
score: null,
name: 'jack',
created_at: 1780315200,
});
expect(result.value.results[0]).not.toHaveProperty('displayName');
}
});

it('routes mint reviews through the Nostr GraphQL endpoint', async () => {
Expand Down
46 changes: 10 additions & 36 deletions __tests__/backendConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@ describe('backend config', () => {
apiBaseUrl: 'https://api.sovran.money/api',
scoreApiBaseUrl: 'https://nagg.up.railway.app',
nostrGraphqlEndpoint: 'https://nagg.up.railway.app/graphql',
nostrDmAppView: false,
nostrFeedAppView: false,
nostrNotificationsAppView: false,
primalCacheUrl: 'wss://cache2.primal.net/v1',
});
});

Expand All @@ -26,42 +24,10 @@ describe('backend config', () => {
apiBaseUrl: 'https://api.example.test/api',
scoreApiBaseUrl: 'http://localhost:8080',
nostrGraphqlEndpoint: 'http://localhost:8081/graphql',
nostrDmAppView: false,
nostrFeedAppView: false,
nostrNotificationsAppView: false,
primalCacheUrl: 'wss://cache2.primal.net/v1',
});
});

it('enables the DM app-view transport only when explicitly set to "true"', () => {
expect(parseBackendConfig({ EXPO_PUBLIC_NOSTR_DM_APPVIEW: 'true' }).nostrDmAppView).toBe(true);
expect(parseBackendConfig({ EXPO_PUBLIC_NOSTR_DM_APPVIEW: 'false' }).nostrDmAppView).toBe(
false
);
expect(parseBackendConfig({}).nostrDmAppView).toBe(false);
});

it('enables the feed app-view transport only when explicitly set to "true"', () => {
expect(parseBackendConfig({ EXPO_PUBLIC_NOSTR_FEED_APPVIEW: 'true' }).nostrFeedAppView).toBe(
true
);
expect(parseBackendConfig({ EXPO_PUBLIC_NOSTR_FEED_APPVIEW: 'false' }).nostrFeedAppView).toBe(
false
);
expect(parseBackendConfig({}).nostrFeedAppView).toBe(false);
});

it('enables the notifications app-view transport only when explicitly set to "true"', () => {
expect(
parseBackendConfig({ EXPO_PUBLIC_NOSTR_NOTIFICATIONS_APPVIEW: 'true' })
.nostrNotificationsAppView
).toBe(true);
expect(
parseBackendConfig({ EXPO_PUBLIC_NOSTR_NOTIFICATIONS_APPVIEW: 'false' })
.nostrNotificationsAppView
).toBe(false);
expect(parseBackendConfig({}).nostrNotificationsAppView).toBe(false);
});

it('keeps the legacy Nagg base URL env as an app-view fallback', () => {
expect(
parseBackendConfig({
Expand All @@ -83,6 +49,14 @@ describe('backend config', () => {
).toBe('https://nostr-index.example.test');
});

it('defaults the Primal cache URL and honors an override', () => {
expect(parseBackendConfig({}).primalCacheUrl).toBe('wss://cache2.primal.net/v1');
expect(
parseBackendConfig({ EXPO_PUBLIC_PRIMAL_CACHE_URL: 'wss://my-cache.example/v1' })
.primalCacheUrl
).toBe('wss://my-cache.example/v1');
});

it('rejects invalid Nostr app-view URLs', () => {
expect(() =>
parseBackendConfig({
Expand Down
Loading
Loading