diff --git a/CONTEXT.md b/CONTEXT.md index e2a2e18c6..afc55e6e7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 @@ -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. diff --git a/__tests__/apiClientBackendConfig.test.ts b/__tests__/apiClientBackendConfig.test.ts index ee9df19f9..430608cba 100644 --- a/__tests__/apiClientBackendConfig.test.ts +++ b/__tests__/apiClientBackendConfig.test.ts @@ -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('@/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('@/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 () => { diff --git a/__tests__/backendConfig.test.ts b/__tests__/backendConfig.test.ts index 8477efc52..28f63f209 100644 --- a/__tests__/backendConfig.test.ts +++ b/__tests__/backendConfig.test.ts @@ -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', }); }); @@ -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({ @@ -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({ diff --git a/__tests__/contactRowLoadingReservation.test.tsx b/__tests__/contactRowLoadingReservation.test.tsx new file mode 100644 index 000000000..e6599dd92 --- /dev/null +++ b/__tests__/contactRowLoadingReservation.test.tsx @@ -0,0 +1,254 @@ +/** + * @jest-environment node + * + * ContactRow loading-state height reservation. A loading row must reserve every + * layout box the loaded row will show, or the row grows when data arrives + * (content shift). These tests pin the two reservations the Add Mints skeleton + * relies on — the inline stats accent and the selection checkbox — and the gate + * that keeps the accent reservation off plain (non-mint) skeletons. + */ + +import React from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; + +import { ContactRow, mintIdentity, nostrIdentity } from '@/shared/ui/composed/ContactRow'; +import { ListRow } from '@/shared/ui/composed/ListRow'; +import { RowStatsAccent, RowStatsAccentSkeleton } from '@/shared/ui/composed/RowStatsAccent'; +import { SelectableCheck } from '@/shared/ui/primitives/SelectableCheck'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +jest.mock('@/shared/hooks/useThemeColor', () => ({ + useThemeColor: (tokens: string | readonly string[]) => + Array.isArray(tokens) ? tokens.map((token) => `theme-${token}`) : 'theme-single', +})); + +jest.mock('hex-color-opacity', () => jest.fn(() => 'rgba(0,0,0,0.12)')); + +jest.mock('@/shared/lib/date', () => ({ + formatRelative: jest.fn(() => 'recently'), +})); + +jest.mock('@/shared/stores/global/settingsStore', () => { + const state = { + language: 'en', + avatarFallbackVariant: 'beam', + getDisplayBtc: () => 'sats', + }; + const useSettingsStore = Object.assign( + jest.fn((selector?: (value: typeof state) => unknown) => (selector ? selector(state) : state)), + { + getState: () => state, + subscribe: jest.fn(() => jest.fn()), + } + ); + + return { useSettingsStore }; +}); + +jest.mock('@/shared/ui/composed/ListRow', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + + return { + __esModule: true, + ListRow: jest.fn((props: Record) => + ReactActual.createElement(View, { testID: 'list-row', ...props }) + ), + }; +}); + +jest.mock('@/shared/ui/composed/MintIcon', () => ({ + MintIcon: () => null, +})); + +jest.mock('@/shared/ui/primitives/Avatar', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + + return { + __esModule: true, + Avatar: (props: Record) => + ReactActual.createElement(View, { testID: 'avatar', ...props }), + }; +}); + +jest.mock('@/shared/ui/primitives/Text', () => { + const ReactActual = jest.requireActual('react'); + const { Text } = jest.requireActual('react-native'); + + return { + __esModule: true, + Text: ({ children, ...props }: { children?: React.ReactNode }) => + ReactActual.createElement(Text, props, children), + }; +}); + +jest.mock('@/shared/ui/primitives/Pressable', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + + return { + __esModule: true, + Pressable: ({ children, ...props }: { children?: React.ReactNode }) => + ReactActual.createElement(View, props, children), + }; +}); + +jest.mock('@/shared/ui/primitives/Spinner', () => ({ + Spinner: () => null, +})); + +jest.mock('@/shared/ui/primitives/View/HStack', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + + return { + __esModule: true, + HStack: ({ children, ...props }: { children?: React.ReactNode }) => + ReactActual.createElement(View, props, children), + }; +}); + +jest.mock('@/shared/ui/primitives/View/View', () => { + const { View } = jest.requireActual('react-native'); + + return { + __esModule: true, + View, + }; +}); + +// Identifiable stand-ins so we can assert which selection/accent node ContactRow +// hands to ListRow without rendering their internals. +jest.mock('@/shared/ui/primitives/SelectableCheck', () => ({ + SelectableCheck: function SelectableCheck() { + return null; + }, +})); + +jest.mock('@/shared/ui/composed/AmountFormatter', () => ({ + AmountFormatter: () => null, +})); + +jest.mock('@/shared/ui/composed/RowStatsAccent', () => ({ + RowStatsAccent: function RowStatsAccent() { + return null; + }, + RowStatsAccentSkeleton: function RowStatsAccentSkeleton() { + return null; + }, + STAT_ICONS: { + score: 'score', + audit: 'audit', + reputation: 'reputation', + followers: 'followers', + offline: 'offline', + }, + STAT_COLOR_SOCIAL: 'social', + STAT_COLOR_ERROR: 'error', +})); + +jest.mock( + 'assets/icons', + () => ({ + __esModule: true, + default: ({ name, ...props }: { name: string }) => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return ReactActual.createElement(View, { testID: `icon-${name}`, ...props }); + }, + }), + { virtual: true } +); + +let consoleErrorSpy: jest.SpyInstance; +let consoleWarnSpy: jest.SpyInstance; + +function renderRow(element: React.ReactElement) { + let renderer: TestRenderer.ReactTestRenderer; + act(() => { + renderer = TestRenderer.create(element); + }); + const listRowProps = jest.mocked(ListRow).mock.calls[0]?.[0]; + act(() => { + renderer.unmount(); + }); + return listRowProps; +} + +describe('ContactRow loading reservation', () => { + beforeEach(() => { + jest.mocked(ListRow).mockClear(); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + if (String(args[0]).includes('react-test-renderer is deprecated')) return; + throw new Error(`Unexpected console.error: ${args.map(String).join(' ')}`); + }); + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation((...args: unknown[]) => { + if (String(args[0]).includes('props.pointerEvents is deprecated')) return; + throw new Error(`Unexpected console.warn: ${args.map(String).join(' ')}`); + }); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + }); + + it('reserves the stats accent and an inert 24px checkbox while a mint row loads', () => { + const listRowProps = renderRow( + + ); + + // Accent reserved: the skeleton placeholder, not the real (null) accent. + expect(listRowProps?.accent).toBeTruthy(); + expect((listRowProps?.accent as React.ReactElement).type).toBe(RowStatsAccentSkeleton); + + // Checkbox reserved as an inert 24x24 box (no live SelectableCheck flash). + const trailing = listRowProps?.trailing as React.ReactElement<{ style?: unknown }>; + expect(trailing.type).not.toBe(SelectableCheck); + expect(trailing.props.style).toEqual({ width: 24, height: 24 }); + + expect(listRowProps?.loading).toBe(true); + }); + + it('does NOT reserve the accent for a loading nostr (non-mint) row', () => { + const listRowProps = renderRow( + + ); + + // Gate proof: nostr rows route to the real RowStatsAccent (which renders + // nothing when there are no stats), so contact skeletons keep their height. + expect((listRowProps?.accent as React.ReactElement).type).toBe(RowStatsAccent); + expect(listRowProps?.loading).toBe(true); + }); + + it('renders the live checkbox and real accent once a selectable mint row is loaded', () => { + const listRowProps = renderRow( + + ); + + expect((listRowProps?.accent as React.ReactElement).type).toBe(RowStatsAccent); + expect((listRowProps?.trailing as React.ReactElement).type).toBe(SelectableCheck); + expect(listRowProps?.loading).toBeFalsy(); + }); +}); diff --git a/__tests__/facadeFeedAdapter.test.ts b/__tests__/facadeFeedAdapter.test.ts new file mode 100644 index 000000000..b89069e5f --- /dev/null +++ b/__tests__/facadeFeedAdapter.test.ts @@ -0,0 +1,66 @@ +/** + * Validates the facade ResolvedFeedPage → app FeedParseResult adapter: the shape + * bridge that lets the tier-selecting facade drive the existing feed UI. + */ +import { resolvedFeedPageToParseResult } from '@/features/feed/data/facadeFeedAdapter'; + +const NOTE = 'a'.repeat(64); +const REPOST = 'b'.repeat(64); +const ORIG = 'c'.repeat(64); +const PUB = 'd'.repeat(64); +const QUOTED = 'e'.repeat(64); + +function event(id: string, created_at: number, tags: string[][] = []) { + return { id, kind: 1, pubkey: PUB, content: `e ${id}`, tags, created_at }; +} + +describe('resolvedFeedPageToParseResult', () => { + it('maps notes + reposts (adding timestamp), stats→metrics, and cursor', () => { + const page = { + tier: 'relay' as const, + items: [ + { type: 'note' as const, event: event(NOTE, 200, [['q', QUOTED]]) }, + { type: 'repost' as const, repostEvent: event(REPOST, 150), originalEvent: event(ORIG, 100), originalEventId: ORIG }, + ], + stats: { [NOTE]: { likes: 5, reposts: 2, replies: 1, zaps: 3, satsZapped: 2100 } }, + profiles: { [PUB]: { name: 'alice', picture: 'http://x/a.png' } }, + quoted: {}, + cursor: { createdAt: 100, id: ORIG }, + missingIds: [], + }; + + const result = resolvedFeedPageToParseResult(page); + + // notes carry a timestamp from created_at + const note = result.orderedFeedItems[0]; + expect(note.type === 'note' && note.event.id).toBe(NOTE); + expect(note.timestamp).toBe(200); + // repost normalizes originalEvent/originalEventId + timestamp + const repost = result.orderedFeedItems[1]; + expect(repost.type === 'repost' && repost.originalEventId).toBe(ORIG); + expect(repost.timestamp).toBe(150); + // stats → NoteMetrics field rename + expect(result.metricsMap.get(NOTE)).toEqual({ likeCount: 5, repostCount: 2, replyCount: 1, satsZapped: 2100 }); + // profiles + cursor + expect(result.profilesMap.get(PUB)).toEqual({ name: 'alice', picture: 'http://x/a.png' }); + expect(result.paginationUntil).toBe(100); + expect(result.paginationOffset).toBe(2); + // gaps for the enrichment path: the q-tagged quote + the author profile when absent + expect(result.missingQuotedIds).toContain(QUOTED); + }); + + it('produces an empty-but-valid result for an empty page', () => { + const result = resolvedFeedPageToParseResult({ + tier: 'nagg' as const, + items: [], + stats: {}, + profiles: {}, + quoted: {}, + cursor: null, + missingIds: [], + }); + expect(result.orderedFeedItems).toEqual([]); + expect(result.paginationUntil).toBe(0); + expect(result.missingProfilePubkeys).toEqual([]); + }); +}); diff --git a/__tests__/facadeNotificationsAdapter.test.ts b/__tests__/facadeNotificationsAdapter.test.ts new file mode 100644 index 000000000..0571e5ded --- /dev/null +++ b/__tests__/facadeNotificationsAdapter.test.ts @@ -0,0 +1,79 @@ +import { + resolvedNotificationsToResult, + toFacadeNotificationsRequest, +} from '@/features/feed/data/facadeNotificationsAdapter'; + +const TARGET = 'a'.repeat(64); +const PUB = 'c'.repeat(64); +const ACTOR = 'd'.repeat(64); + +function event(id: string, created_at: number) { + return { id, kind: 1, pubkey: PUB, content: `e ${id}`, tags: [], created_at }; +} + +describe('toFacadeNotificationsRequest', () => { + it('maps ALL/MENTIONS tabs + until→cursor; falls back (null) for APP', () => { + const req = toFacadeNotificationsRequest({ + viewerPubkey: PUB, + tab: 'MENTIONS', + until: 500, + limit: 30, + }); + expect(req?.tab).toBe('MENTIONS'); + expect(req?.cursor).toEqual({ createdAt: 500, id: '' }); + expect(toFacadeNotificationsRequest({ viewerPubkey: PUB, tab: 'APP' as never })).toBeNull(); + }); +}); + +describe('resolvedNotificationsToResult', () => { + it('maps grouped + single notifications, stats→metrics, and paging', () => { + const result = resolvedNotificationsToResult({ + tier: 'nagg', + grouped: true, + notifications: [ + { + type: 'group', + event: event(TARGET, 200), + reason: 'reaction', + actorVertexScore: 0, + total: 12, + totalCapped: false, + sampleActors: [{ pubkey: ACTOR, eventId: 'f'.repeat(64), createdAt: 200 }], + targetEventId: TARGET, + }, + { type: 'single', event: event('b'.repeat(64), 100), reason: 'reply', actorVertexScore: 0 }, + ], + stats: { [TARGET]: { likes: 12, reposts: 0, replies: 1, zaps: 0, satsZapped: 0 } }, + profiles: { [PUB]: { name: 'alice' } }, + quoted: {}, + cursor: { createdAt: 100, id: 'b'.repeat(64) }, + missingIds: [], + }); + + expect(result.notifications).toHaveLength(2); + const grouped = result.notifications[0]; + expect(grouped.type).toBe('group'); + expect(grouped.total).toBe(12); + expect(grouped.targetEventId).toBe(TARGET); + expect(grouped.sampleActors?.[0]?.pubkey).toBe(ACTOR); + expect(result.metricsMap.get(TARGET)?.likeCount).toBe(12); + expect(result.profilesMap.get(PUB)?.name).toBe('alice'); + expect(result.paginationUntil).toBe(100); + expect(result.hasNextPage).toBe(true); + }); + + it('an empty page reports no next page', () => { + const result = resolvedNotificationsToResult({ + tier: 'relay', + grouped: false, + notifications: [], + stats: {}, + profiles: {}, + quoted: {}, + cursor: null, + missingIds: [], + }); + expect(result.notifications).toEqual([]); + expect(result.hasNextPage).toBe(false); + }); +}); diff --git a/__tests__/facadeThreadAdapter.test.ts b/__tests__/facadeThreadAdapter.test.ts new file mode 100644 index 000000000..55c0fb65c --- /dev/null +++ b/__tests__/facadeThreadAdapter.test.ts @@ -0,0 +1,64 @@ +import type { facade } from '@sovranbitcoin/nagg-ts'; +import { resolvedThreadToResult } from '@/features/feed/data/facadeThreadAdapter'; +import type { ThreadReplySort } from '@/features/feed/data/feedClient'; + +const ROOT = 'r'.repeat(64); +const A = 'a'.repeat(64); // newest, most reposts +const B = 'b'.repeat(64); // most likes +const C = 'c'.repeat(64); // most zaps, oldest + +function note(id: string, createdAt: number): facade.FeedItem { + return { + type: 'note', + event: { id, pubkey: 'p'.repeat(64), kind: 1, content: '', tags: [], created_at: createdAt }, + } as unknown as facade.FeedItem; +} + +// Stats engineered so each sort yields a distinct top reply. +const STATS = { + [A]: { likes: 1, reposts: 9, replies: 0, zaps: 0, satsZapped: 0 }, + [B]: { likes: 9, reposts: 1, replies: 0, zaps: 0, satsZapped: 100 }, + [C]: { likes: 0, reposts: 0, replies: 0, zaps: 5, satsZapped: 9000 }, +}; + +function buildThread(): facade.ResolvedThread { + return { + tier: 'primal', + root: note(ROOT, 1000), + parents: [], + replies: [note(A, 300), note(B, 200), note(C, 100)], + stats: STATS, + profiles: {}, + quoted: {}, + cursor: null, + missingIds: [], + } as unknown as facade.ResolvedThread; +} + +function order(sort: ThreadReplySort): string[] { + return resolvedThreadToResult(buildThread(), { eventId: ROOT, sort }).replyPageEventIds; +} + +describe('resolvedThreadToResult reply post-sorting', () => { + test('new → newest created_at first', () => { + expect(order('new')).toEqual([A, B, C]); + }); + test('likes → most likes first', () => { + expect(order('likes')[0]).toBe(B); + }); + test('zaps → most sats-zapped first', () => { + expect(order('zaps')[0]).toBe(C); + }); + test('reposts → most reposts first', () => { + expect(order('reposts')[0]).toBe(A); + }); + test('relevant → weighted engagement (reposts*2 + likes + replies) first', () => { + // A: 1 + 18 = 19; B: 9 + 2 = 11; C: 0 → A wins. + expect(order('relevant')[0]).toBe(A); + }); + test('the target is rendered + all replies kept', () => { + const result = resolvedThreadToResult(buildThread(), { eventId: ROOT, sort: 'new' }); + expect(result.thread.target?.id).toBe(ROOT); + expect(result.loadedReplyCount).toBe(3); + }); +}); diff --git a/__tests__/loggerRedaction.test.ts b/__tests__/loggerRedaction.test.ts new file mode 100644 index 000000000..8b434a8a6 --- /dev/null +++ b/__tests__/loggerRedaction.test.ts @@ -0,0 +1,70 @@ +import { createLogger } from '@/shared/lib/logger'; + +// Exercise the real compaction/redaction pipeline end to end: log a value under +// a NEUTRAL field name (so field-name rules don't fire) and inspect what landed +// in the ring buffer. Anything a private key could be encoded as — hex, base58 +// (WIF / xprv), base64 — must never appear in the stored entry. +function logValue(value: unknown): { brand: unknown; json: string } { + const log = createLogger({ level: 'debug', async: false, transports: [], pretty: false }); + log.info('redaction.test', { blob: value }); + const entry = log.getRecentLogs().find((e) => e.event === 'redaction.test'); + const brand = (entry?.params as Record | undefined)?.blob; + return { brand, json: JSON.stringify(brand) }; +} + +describe('logger redaction — private key material is never shown', () => { + it('brands a WIF private key (base58) as a secret, no value', () => { + const wif = '5' + 'K'.repeat(50); // 51 chars, mainnet-shaped + const { brand, json } = logValue(wif); + expect(brand).toEqual({ _kind: 'wif', len: wif.length }); + expect(json).not.toContain(wif); + }); + + it('brands an extended private key (xprv) — was logged in full at ≤120 chars', () => { + const xprv = 'xprv' + '9'.repeat(107); // 111 chars + const { brand, json } = logValue(xprv); + expect(brand).toEqual({ _kind: 'xprv', len: xprv.length }); + expect(json).not.toContain('9'.repeat(20)); + }); + + it('brands a 32-byte base64 secret (44 chars) — was shown in full', () => { + const b64 = 'A'.repeat(43) + '='; // 44 chars => 32 bytes + const { brand, json } = logValue(b64); + expect(brand).toEqual({ _kind: 'base64_key', len: 44 }); + expect(json).not.toContain('AAAA'); + }); + + it('never previews opaque hex / base64 / base58 long values', () => { + // 50-char hex (below base64's 60 floor, so it lands on the hex pattern). + expect(logValue('a'.repeat(50)).brand).toEqual({ _kind: 'hex', len: 50 }); + expect(logValue('A'.repeat(70)).brand).toEqual({ _kind: 'base64', len: 70 }); + // 45-char base58 blob (e.g. an opaque key encoding) — no bytes shown. + const b58 = 'z'.repeat(40); + expect(logValue(b58).brand).toEqual({ _kind: 'base58', len: 40 }); + }); + + it('still brands a 32-byte hex value (privkey length) with no preview', () => { + const hex64 = 'a'.repeat(64); + expect(logValue(hex64).brand).toEqual({ _kind: 'hex32', len: 64 }); + }); + + it('redacts an embedded xprv inside a larger string', () => { + const xprv = 'xprv' + 'z'.repeat(107); + const { brand } = logValue(`restored from ${xprv} ok`); + expect(String(brand)).toContain(''); + expect(String(brand)).not.toContain('z'.repeat(20)); + }); + + it('does NOT over-redact a public npub (stays readable)', () => { + const npub = 'npub1' + 'q'.repeat(58); + const { brand } = logValue(npub); + // Public identity: kept as a readable string, not branded away. + expect(typeof brand).toBe('string'); + expect(brand).toBe(npub); + }); + + it('does NOT over-redact ordinary short strings', () => { + expect(logValue('hello world').brand).toBe('hello world'); + expect(logValue('feed.note.inline').brand).toBe('feed.note.inline'); + }); +}); diff --git a/__tests__/naggFeedClient.test.ts b/__tests__/naggFeedClient.test.ts index e8d82a995..40ef32ca0 100644 --- a/__tests__/naggFeedClient.test.ts +++ b/__tests__/naggFeedClient.test.ts @@ -17,6 +17,10 @@ jest.mock('@/shared/lib/cashu/profileScopedStorage', () => ({ }), })); +// The nagg client is app-view (REST) only — these tests mock fetch returning the +// canonical REST bodies nagg emits, and assert the client maps them and hits the +// right `/v1/nostr/*` route. There is no GraphQL transport. + const ENV_KEYS = [ 'EXPO_PUBLIC_NOSTR_APPVIEW_BASE_URL', 'EXPO_PUBLIC_NAGG_BASE_URL', @@ -29,55 +33,37 @@ const originalFetchDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'fet const originalEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); const mockFetch = jest.fn(); -/** App-view 404 — notifications prefer the REST app-view, so GraphQL-mapping - * tests miss it first and ride the documented fallback. */ -const appViewMiss = () => ({ - ok: false, - status: 404, - statusText: 'Not Found', - json: async () => ({}), +const restResponse = (body: unknown) => ({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => body, }); -const root = { +const note = (overrides: Record = {}) => ({ id: 'root', kind: 1, pubkey: 'alice', - content: 'root', - tags: [], + content: 'hello', + tags: [] as string[][], created_at: 100, -}; -const rootGql = { - id: 'root', - kind: 1, - pubkey: 'alice', - content: 'root', - tags: [], - createdAt: 100, - authorMetadata: [ - { - id: 'profile-alice', - kind: 0, - pubkey: 'alice', - content: JSON.stringify({ name: 'Alice' }), - tags: [], - createdAt: 99, - }, - ], - likes: { rows: [] }, - reposts: { rows: [] }, - replyStats: { rows: [] }, - zaps: { rows: [] }, -}; + ...overrides, +}); -const AUTHORED_REPLY_CHAIN_INPUT = { - kinds: [1, 1111], - via: { key: 'e' }, - target: 'EVENT_ID', - maxDepth: 8, - maxBranchFanout: 32, -}; +const emptyStats = { likeCount: 0, repostCount: 0, replyCount: 0, satsZapped: 0 }; -describe('createNaggFeedClient', () => { +function loadClient() { + const { createNaggFeedClient } = jest.requireActual< + typeof import('@/features/feed/data/naggFeedClient') + >('@/features/feed/data/naggFeedClient'); + return createNaggFeedClient(); +} + +function lastUrl(call = 0): string { + return String(mockFetch.mock.calls[call][0]); +} + +describe('createNaggFeedClient (app-view REST)', () => { beforeEach(() => { jest.resetModules(); mockFetch.mockReset(); @@ -106,2017 +92,171 @@ describe('createNaggFeedClient', () => { Reflect.deleteProperty(globalThis, 'fetch'); }); - it('posts hydrated feed specs to nagg and maps the response', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ - data: { - events: { - nodes: [rootGql], - pageInfo: { hasNextPage: false, endCursor: null }, - }, - }, - }), - }); - - const { createNaggFeedClient } = jest.requireActual< - typeof import('@/features/feed/data/naggFeedClient') - >('@/features/feed/data/naggFeedClient'); - - const result = await createNaggFeedClient().getFeed({ - spec: JSON.stringify({ id: 'feed' }), - userPubkey: 'viewer', - limit: 12, - }); - - expect(result.orderedFeedItems).toHaveLength(1); - expect(result.profilesMap.get('alice')).toEqual({ name: 'Alice' }); - expect(mockFetch).toHaveBeenCalledWith( - 'http://nagg.test/graphql', - expect.objectContaining({ - method: 'POST', - body: expect.any(String), + it('maps a generic feed page from GET /v1/nostr/feed', async () => { + mockFetch.mockResolvedValueOnce( + restResponse({ + items: [{ type: 'note', event: note() }], + ordering: { orderBy: 'created_at', elements: ['root'] }, + metrics: { root: emptyStats }, + profiles: { alice: { name: 'Alice' } }, + quoted: {}, + paginationUntil: 100, + paginationOffset: 1, }) ); - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.variables.input).toEqual({ - pubkeys: ['viewer'], - kinds: [1, 6, 16], - limit: 12, - }); - }); - - it('applies content exclusions to GraphQL input and filters ignored local events', async () => { - const blockedPubkey = 'b'.repeat(64); - const blockedEventId = 'c'.repeat(64); - const blockedPubkeyNode = { - ...rootGql, - id: 'd'.repeat(64), - pubkey: blockedPubkey, - content: 'blocked pubkey', - authorMetadata: [], - }; - const blockedEventNode = { - ...rootGql, - id: blockedEventId, - pubkey: 'e'.repeat(64), - content: 'blocked event', - authorMetadata: [], - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ - data: { - events: { - nodes: [rootGql, blockedPubkeyNode, blockedEventNode], - pageInfo: { hasNextPage: false, endCursor: null }, - }, - }, - }), - }); - - const { useFeedIgnoreStore } = jest.requireActual< - typeof import('@/features/feed/stores/ignoreStore') - >('@/features/feed/stores/ignoreStore'); - useFeedIgnoreStore.setState({ - ignoredPubkeys: [blockedPubkey], - ignoredEventIds: [blockedEventId], - }); - - const { createNaggFeedClient } = jest.requireActual< - typeof import('@/features/feed/data/naggFeedClient') - >('@/features/feed/data/naggFeedClient'); - const result = await createNaggFeedClient().getFeed({ + const result = await loadClient().getFeed({ spec: JSON.stringify({ id: 'feed' }), - limit: 3, - }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.variables.input).toMatchObject({ - kinds: [1], - limit: 3, - excludeIds: [blockedEventId], - excludePubkeys: [blockedPubkey], - }); - expect(result.orderedFeedItems).toHaveLength(1); - expect(result.orderedFeedItems[0]).toMatchObject({ - type: 'note', - event: expect.objectContaining({ id: 'root' }), - }); - }); - - it('loads notifications with policy and maps hydrated events', async () => { - // Notifications prefer the REST app-view; miss it (404) so the documented - // GraphQL fallback carries the mapping this test pins. - mockFetch.mockResolvedValueOnce(appViewMiss()); - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ - data: { - notifications: { - nodes: [ - { - reason: 'mention', - actorVertexScore: 77, - event: rootGql, - }, - ], - pageInfo: { hasNextPage: false, endCursor: null }, - }, - }, - }), - }); - - const { createNaggFeedClient } = jest.requireActual< - typeof import('@/features/feed/data/naggFeedClient') - >('@/features/feed/data/naggFeedClient'); - - const result = await createNaggFeedClient().getNotifications({ - viewerPubkey: 'viewer'.padEnd(64, '0'), - tab: 'MENTIONS', - policy: 'STRICT', - limit: 12, - }); - - // calls[0] is the app-view miss (GET, no body); the GraphQL POST is second. - const body = JSON.parse(mockFetch.mock.calls[1][1].body); - expect(body.query).toContain('notifications(input: $input)'); - expect(body.variables.input).toEqual({ - pubkey: 'viewer'.padEnd(64, '0'), - tab: 'MENTIONS', - policy: 'STRICT', - replyScope: 'THREAD', - limit: 12, - }); - expect(result.notifications).toEqual([ - { - event: expect.objectContaining({ id: 'root' }), - reason: 'mention', - actorVertexScore: 77, - }, - ]); - expect(result.profilesMap.get('alice')).toEqual({ name: 'Alice' }); - expect(result.paginationUntil).toBe(100); - }); - - it('orders notifications newest first after GraphQL mapping', async () => { - // Notifications prefer the REST app-view; miss it (404) so the documented - // GraphQL fallback carries the mapping this test pins. - mockFetch.mockResolvedValueOnce(appViewMiss()); - const older = { - ...rootGql, - id: 'older', - content: 'older', - createdAt: 90, - }; - const newer = { - ...rootGql, - id: 'newer', - content: 'newer', - createdAt: 110, - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ - data: { - notifications: { - nodes: [ - { - reason: 'mention', - actorVertexScore: 1, - event: older, - }, - { - reason: 'mention', - actorVertexScore: 2, - event: newer, - }, - ], - pageInfo: { hasNextPage: false, endCursor: null }, - }, - }, - }), - }); - - const { createNaggFeedClient } = jest.requireActual< - typeof import('@/features/feed/data/naggFeedClient') - >('@/features/feed/data/naggFeedClient'); - - const result = await createNaggFeedClient().getNotifications({ - viewerPubkey: 'viewer'.padEnd(64, '0'), - limit: 12, - }); - - expect(result.notifications.map((notification) => notification.event.id)).toEqual([ - 'newer', - 'older', - ]); - expect(result.paginationUntil).toBe(90); - }); - - it('maps notification target posts from selected references', async () => { - // Notifications prefer the REST app-view; miss it (404) so the documented - // GraphQL fallback carries the mapping this test pins. - mockFetch.mockResolvedValueOnce(appViewMiss()); - const reactionGql = { - id: 'reaction', - kind: 7, - pubkey: 'bob', - content: '+', - tags: [ - ['e', 'root'], - ['p', 'alice'], - ], - createdAt: 101, - authorMetadata: [ - { - id: 'profile-bob', - kind: 0, - pubkey: 'bob', - content: JSON.stringify({ name: 'Bob' }), - tags: [], - createdAt: 100, - }, - ], - eventRefs: { nodes: [rootGql] }, - rootContext: { nodes: [] }, - quotedContent: { nodes: [] }, - likes: { rows: [] }, - reposts: { rows: [] }, - replyStats: { rows: [] }, - zaps: { rows: [] }, - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ - data: { - notifications: { - nodes: [ - { - reason: 'reaction', - actorVertexScore: 12, - event: reactionGql, - }, - ], - pageInfo: { hasNextPage: false, endCursor: null }, - }, - }, - }), - }); - - const { createNaggFeedClient } = jest.requireActual< - typeof import('@/features/feed/data/naggFeedClient') - >('@/features/feed/data/naggFeedClient'); - - const result = await createNaggFeedClient().getNotifications({ - viewerPubkey: 'alice'.padEnd(64, '0'), - tab: 'ALL', - policy: 'RELAXED', - replyScope: 'DIRECT', - limit: 12, - }); - - // calls[0] is the app-view miss (GET, no body); the GraphQL POST is second. - const body = JSON.parse(mockFetch.mock.calls[1][1].body); - expect(body.query).toContain('eventRefs: selectedReferences'); - expect(body.variables.input.replyScope).toBe('DIRECT'); - expect(result.notifications[0]).toMatchObject({ - event: expect.objectContaining({ id: 'reaction' }), - targetEvent: expect.objectContaining({ id: 'root', content: 'root' }), - targetEventId: 'root', - reason: 'reaction', - actorVertexScore: 12, - }); - expect(result.profilesMap.get('alice')).toEqual({ name: 'Alice' }); - expect(result.profilesMap.get('bob')).toEqual({ name: 'Bob' }); - }); - - it('uses the direct NIP-10 parent as the reply notification target', async () => { - // Notifications prefer the REST app-view; miss it (404) so the documented - // GraphQL fallback carries the mapping this test pins. - mockFetch.mockResolvedValueOnce(appViewMiss()); - const parentGql = { - ...rootGql, - id: 'parent'.padEnd(64, '0'), - content: 'direct parent', - pubkey: 'alice', - }; - const replyGql = { - id: 'reply'.padEnd(64, '0'), - kind: 1, - pubkey: 'bob', - content: 'reply body', - tags: [ - ['e', rootGql.id.padEnd(64, '0'), '', 'root'], - ['e', parentGql.id, '', 'reply'], - ['p', 'alice'.padEnd(64, '0')], - ], - createdAt: 102, - authorMetadata: [ - { - id: 'profile-bob', - kind: 0, - pubkey: 'bob', - content: JSON.stringify({ name: 'Bob' }), - tags: [], - createdAt: 100, - }, - ], - eventRefs: { nodes: [{ ...rootGql, id: rootGql.id.padEnd(64, '0') }, parentGql] }, - rootContext: { nodes: [{ ...rootGql, id: rootGql.id.padEnd(64, '0') }] }, - quotedContent: { nodes: [] }, - likes: { rows: [] }, - reposts: { rows: [] }, - replyStats: { rows: [] }, - zaps: { rows: [] }, - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ - data: { - notifications: { - nodes: [ - { - reason: 'reply', - actorVertexScore: 12, - event: replyGql, - }, - ], - pageInfo: { hasNextPage: false, endCursor: null }, - }, - }, - }), - }); - - const { createNaggFeedClient } = jest.requireActual< - typeof import('@/features/feed/data/naggFeedClient') - >('@/features/feed/data/naggFeedClient'); - - const result = await createNaggFeedClient().getNotifications({ - viewerPubkey: 'alice'.padEnd(64, '0'), - tab: 'MENTIONS', - policy: 'RELAXED', - limit: 12, - }); - - expect(result.notifications[0]).toMatchObject({ - event: expect.objectContaining({ id: replyGql.id, content: 'reply body' }), - targetEvent: expect.objectContaining({ id: parentGql.id, content: 'direct parent' }), - targetEventId: parentGql.id, - reason: 'reply', - }); - }); - - it('maps root context for mixed root and reply feed nodes', async () => { - const replyGql = { - id: 'reply', - kind: 1, - pubkey: 'bob', - content: 'reply', - tags: [['e', 'root', '', 'root']], - createdAt: 101, - authorMetadata: [ - { - id: 'profile-bob', - kind: 0, - pubkey: 'bob', - content: JSON.stringify({ name: 'Bob' }), - tags: [], - createdAt: 100, - }, - ], - rootContext: { - nodes: [ - { - ...rootGql, - likes: { rows: [{ metrics: { pubkeys: 9 } }] }, - reposts: { rows: [{ metrics: { pubkeys: 1 } }] }, - replyStats: { rows: [{ metrics: { events: 2 } }] }, - zaps: { rows: [{ metrics: { amountSats: 3 } }] }, - }, - ], - }, - eventRefs: { nodes: [] }, - quotedContent: { nodes: [] }, - likes: { rows: [{ metrics: { pubkeys: 4 } }] }, - reposts: { rows: [] }, - replyStats: { rows: [] }, - zaps: { rows: [] }, - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ - data: { - events: { - nodes: [rootGql, replyGql], - pageInfo: { hasNextPage: false, endCursor: null }, - }, - }, - }), - }); - - const { createNaggFeedClient } = jest.requireActual< - typeof import('@/features/feed/data/naggFeedClient') - >('@/features/feed/data/naggFeedClient'); - - const result = await createNaggFeedClient().getFeed({ - spec: JSON.stringify({ id: 'feed' }), - limit: 2, - }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.query).toContain('rootContext: selectedReferences'); - expect(body.query).toContain('excludeMarkers: ["mention"]'); - expect(result.orderedFeedItems).toHaveLength(2); - expect(result.orderedFeedItems[1]).toMatchObject({ - type: 'note', - event: expect.objectContaining({ id: 'reply' }), - rootEvent: expect.objectContaining({ id: 'root' }), - rootEventId: 'root', - }); - expect(result.metricsMap.get('root')).toEqual({ - likeCount: 9, - repostCount: 1, - replyCount: 2, - satsZapped: 3, - }); - expect(result.metricsMap.get('reply')?.likeCount).toBe(4); - expect(result.profilesMap.get('bob')).toEqual({ name: 'Bob' }); - }); - - it('collapses multiple reposts of the same original into one feed item', async () => { - const repostA = { - id: 'repost-a', - kind: 6, - pubkey: 'alice', - content: '', - tags: [['e', 'root']], - createdAt: 110, - authorMetadata: rootGql.authorMetadata, - eventRefs: { nodes: [rootGql] }, - quotedContent: { nodes: [] }, - likes: { rows: [] }, - reposts: { rows: [] }, - replyStats: { rows: [] }, - zaps: { rows: [] }, - }; - const repostB = { - id: 'repost-b', - kind: 6, - pubkey: 'bob', - content: '', - tags: [['e', 'root']], - createdAt: 109, - authorMetadata: [ - { - id: 'profile-bob', - kind: 0, - pubkey: 'bob', - content: JSON.stringify({ name: 'Bob' }), - tags: [], - createdAt: 108, - }, - ], - eventRefs: { nodes: [rootGql] }, - quotedContent: { nodes: [] }, - likes: { rows: [] }, - reposts: { rows: [] }, - replyStats: { rows: [] }, - zaps: { rows: [] }, - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ - data: { - events: { - nodes: [repostA, repostB], - pageInfo: { hasNextPage: false, endCursor: null }, - }, - }, - }), - }); - - const { createNaggFeedClient } = jest.requireActual< - typeof import('@/features/feed/data/naggFeedClient') - >('@/features/feed/data/naggFeedClient'); - - const result = await createNaggFeedClient().getFeed({ - spec: JSON.stringify({ id: 'feed' }), - limit: 2, - }); - - expect(result.orderedFeedItems).toHaveLength(1); - expect(result.orderedFeedItems[0]).toMatchObject({ - type: 'repost', - originalEventId: 'root', - reposters: [ - expect.objectContaining({ - pubkey: 'alice', - event: expect.objectContaining({ id: 'repost-a' }), - }), - expect.objectContaining({ - pubkey: 'bob', - event: expect.objectContaining({ id: 'repost-b' }), - }), - ], - }); - }); - - it('sends explicit refresh feed requests as uncached network reads', async () => { - const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1234567890); - const rankedRootGql = { - ...rootGql, - likes: { rows: [{ metrics: { pubkeys: 42 } }] }, - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ - data: { - rankedEvents: { - nodes: [rankedRootGql], - pageInfo: { hasNextPage: false, endCursor: null }, - }, - }, - }), - }); - - try { - const { createNaggFeedClient } = jest.requireActual< - typeof import('@/features/feed/data/naggFeedClient') - >('@/features/feed/data/naggFeedClient'); - - const result = await createNaggFeedClient().getFeed({ - spec: JSON.stringify({ id: 'for-you', kind: 'notes', hours: 24 }), - userPubkey: 'viewer', - refresh: true, - }); - expect(result.metricsMap.get('root')?.likeCount).toBe(42); - - const requestUrl = String(mockFetch.mock.calls[0][0]); - const parsedRequestUrl = new URL(requestUrl); - expect(`${parsedRequestUrl.origin}${parsedRequestUrl.pathname}`).toBe( - 'http://nagg.test/graphql' - ); - expect(parsedRequestUrl.searchParams.get('refresh')).toBe('1'); - // nagg-ts deliberately appends NO volatile `_refresh` timestamp: the - // GraphQL cache key is the POST body, and a per-request timestamp would - // bust nagg's shared response cache. `refresh=1` + no-store carry it. - expect(parsedRequestUrl.searchParams.get('_refresh')).toBeNull(); - expect(mockFetch).toHaveBeenCalledWith( - requestUrl, - expect.objectContaining({ - method: 'POST', - cache: 'no-store', - }) - ); - - const init = mockFetch.mock.calls[0][1] as RequestInit; - const headers = new Headers(init.headers); - expect(headers.get('Cache-Control')).toBe('no-cache'); - expect(headers.get('Pragma')).toBe('no-cache'); - const body = JSON.parse(init.body as string); - expect(body.query).toContain('rankedEvents'); - expect(body.variables.input).toMatchObject({ - references: { - kinds: [7, 9735, 6, 16, 1, 1111], - since: Math.floor(1234567890 / 1000) - 86_400, - limit: 1000, - pubkeyScore: { source: 'vertex' }, - }, - target: { kinds: [1, 1111], limit: 30, offset: 0 }, - metric: { name: 'actors', op: 'COUNT_DISTINCT', distinctField: 'PUBKEY' }, - limit: 30, - offset: 0, - }); - } finally { - nowSpy.mockRestore(); - } - }); - - it('requests For You through the composite nagg-ts recipe', async () => { - const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1234567890); - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ - data: { - rankedEvents: { - nodes: [rootGql], - pageInfo: { hasNextPage: false, endCursor: null }, - }, - }, - }), - }); - - try { - const { createNaggFeedClient } = jest.requireActual< - typeof import('@/features/feed/data/naggFeedClient') - >('@/features/feed/data/naggFeedClient'); - - await createNaggFeedClient().getFeed({ - spec: JSON.stringify({ id: 'for-you', kind: 'notes', hours: 24 }), - userPubkey: 'viewer', - limit: 12, - }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.query).toContain('authoredReplyChain'); - expect(body.variables.authorChain).toEqual(AUTHORED_REPLY_CHAIN_INPUT); - expect(body.variables.input).toMatchObject({ - references: { - kinds: [7, 9735, 6, 16, 1, 1111], - since: Math.floor(1234567890 / 1000) - 86_400, - limit: 1000, - pubkeyScore: { source: 'vertex' }, - }, - target: { kinds: [1, 1111], limit: 12, offset: 0 }, - metric: { name: 'actors', op: 'COUNT_DISTINCT', distinctField: 'PUBKEY' }, - limit: 12, - offset: 0, - }); - expect(body.variables.input.terms).toEqual( - expect.arrayContaining([ - expect.objectContaining({ pubkeyScore: { source: 'vertex', target: 'AUTHOR' } }), - expect.objectContaining({ - references: expect.objectContaining({ pubkeyScore: { source: 'vertex' } }), - }), - expect.objectContaining({ - candidateField: 'CREATED_AT', - transform: 'RECENCY_HALFLIFE', - }), - ]) - ); - expect(body.variables.input.candidatePubkeyBoosts).toHaveLength(1); - } finally { - nowSpy.mockRestore(); - } - }); - - it('requests and maps a viewer-derived ranked reply for For You feeds', async () => { - const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1234567890); - const replyGql = { - id: 'reply', - kind: 1, - pubkey: 'bob', - content: 'reply from a derived pubkey source', - tags: [['e', 'root']], - createdAt: 99, - authorMetadata: [ - { - id: 'profile-bob', - kind: 0, - pubkey: 'bob', - content: JSON.stringify({ name: 'Bob' }), - tags: [], - createdAt: 98, - }, - ], - quotedContent: { nodes: [] }, - }; - const rankedRootGql = { - ...rootGql, - likes: { rows: [{ metrics: { pubkeys: 42 } }] }, - followedReply: { nodes: [replyGql] }, - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ - data: { - rankedEvents: { - nodes: [rankedRootGql], - pageInfo: { hasNextPage: false, endCursor: null }, - }, - }, - }), - }); - - try { - const { createNaggFeedClient } = jest.requireActual< - typeof import('@/features/feed/data/naggFeedClient') - >('@/features/feed/data/naggFeedClient'); - - const result = await createNaggFeedClient().getFeed({ - spec: JSON.stringify({ id: 'for-you', kind: 'notes', hours: 24 }), - userPubkey: 'viewer', - }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.query).toContain('rankedReferencedBy'); - expect(body.query).toContain('latestEventTags'); - expect(body.query).toContain('authoredReplyChain'); - expect(body.query).not.toContain('sourceEventAuthor'); - expect(body.variables).toMatchObject({ - input: { - references: { - kinds: [7, 9735, 6, 16, 1, 1111], - since: Math.floor(1234567890 / 1000) - 86_400, - limit: 1000, - pubkeyScore: { source: 'vertex' }, - }, - target: { kinds: [1, 1111], limit: 30, offset: 0 }, - metric: { name: 'actors', op: 'COUNT_DISTINCT', distinctField: 'PUBKEY' }, - limit: 30, - offset: 0, - }, - viewerPubkey: 'viewer', - authorChain: AUTHORED_REPLY_CHAIN_INPUT, - }); - expect(result.orderedFeedItems).toHaveLength(1); - expect(result.orderedFeedItems[0]).toMatchObject({ - type: 'note', - event: expect.objectContaining({ id: 'root' }), - replyPreviewEvents: [expect.objectContaining({ id: 'reply' })], - }); - expect(result.metricsMap.get('root')?.likeCount).toBe(42); - expect(result.metricsMap.get('reply')).toEqual({ - likeCount: 0, - repostCount: 0, - replyCount: 0, - satsZapped: 0, - }); - expect(result.profilesMap.get('bob')).toEqual({ name: 'Bob' }); - } finally { - nowSpy.mockRestore(); - } - }); - - it('falls back to followed reply previews when source author pubkeys are not deployed', async () => { - const replyGql = { - id: 'reply', - kind: 1, - pubkey: 'bob', - content: 'legacy followed reply', - tags: [['e', 'root']], - createdAt: 99, - authorMetadata: [], - quotedContent: { nodes: [] }, - likes: { rows: [] }, - reposts: { rows: [] }, - replyStats: { rows: [] }, - zaps: { rows: [] }, - }; - mockFetch - .mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ - errors: [{ message: 'Cannot query field "authoredReplyChain" on type "NostrEvent"' }], - }), - }) - .mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ - data: { - rankedEvents: { - nodes: [{ ...rootGql, followedReply: { nodes: [replyGql] } }], - pageInfo: { hasNextPage: false, endCursor: null }, - }, - }, - }), - }); - - const { createNaggFeedClient } = jest.requireActual< - typeof import('@/features/feed/data/naggFeedClient') - >('@/features/feed/data/naggFeedClient'); - - const result = await createNaggFeedClient().getFeed({ - spec: JSON.stringify({ id: 'for-you', kind: 'notes', hours: 24 }), - userPubkey: 'viewer', - }); - - expect(mockFetch).toHaveBeenCalledTimes(2); - const firstBody = JSON.parse(mockFetch.mock.calls[0][1].body); - const secondBody = JSON.parse(mockFetch.mock.calls[1][1].body); - expect(firstBody.query).toContain('authoredReplyChain'); - expect(firstBody.query).not.toContain('sourceEventAuthor'); - expect(secondBody.query).not.toContain('authoredReplyChain'); - expect(secondBody.query).not.toContain('sourceEventAuthor'); - expect(result.orderedFeedItems[0]).toMatchObject({ - type: 'note', - event: expect.objectContaining({ id: 'root' }), - replyPreviewEvents: [expect.objectContaining({ id: 'reply' })], - }); - }); - - it('maps only the selected author thread chain plus a followed tail preview', async () => { - const secondAuthorReply: any = { - id: 'author-second', - kind: 1, - pubkey: 'alice', - content: 'second self reply', - tags: [ - ['e', 'root', '', 'root'], - ['e', 'author-direct', '', 'reply'], - ], - createdAt: 102, - authorMetadata: [], - }; - const directAuthorReply = { - id: 'author-direct', - kind: 1, - pubkey: 'alice', - content: 'first self reply', - tags: [['e', 'root', '', 'reply']], - createdAt: 101, - authorMetadata: [], - childAuthorReplies: { nodes: [secondAuthorReply] }, - }; - const authorReplyToSomeoneElse = { - id: 'author-branch', - kind: 1, - pubkey: 'alice', - content: 'author reply on another branch', - tags: [ - ['e', 'root', '', 'root'], - ['e', 'bob-reply', '', 'reply'], - ], - createdAt: 103, - authorMetadata: [], - }; - const followedTail = { - id: 'followed-tail', - kind: 1, - pubkey: 'carol', - content: 'followed tail reply', - tags: [ - ['e', 'root', '', 'root'], - ['e', 'author-second', '', 'reply'], - ], - createdAt: 104, - authorMetadata: [], - }; - secondAuthorReply.childFollowedReply = { nodes: [followedTail] }; - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ - data: { - rankedEvents: { - nodes: [ - { - ...rootGql, - authorReplies: { - nodes: [authorReplyToSomeoneElse, directAuthorReply], - }, - followedReply: { nodes: [] }, - }, - ], - pageInfo: { hasNextPage: false, endCursor: null }, - }, - }, - }), - }); - - const { createNaggFeedClient } = jest.requireActual< - typeof import('@/features/feed/data/naggFeedClient') - >('@/features/feed/data/naggFeedClient'); - - const result = await createNaggFeedClient().getFeed({ - spec: JSON.stringify({ id: 'for-you', kind: 'notes', hours: 24 }), - userPubkey: 'viewer', - }); - - expect(result.orderedFeedItems[0]).toMatchObject({ - type: 'note', - event: expect.objectContaining({ id: 'root' }), - }); - expect( - result.orderedFeedItems[0].type === 'note' - ? result.orderedFeedItems[0].replyPreviewEvents?.map((event) => event.id) - : [] - ).toEqual(['author-direct', 'author-second', 'followed-tail']); - }); - - it('requests only followed replies and maps their parent context', async () => { - const replyGql = { - id: 'reply', - kind: 1, - pubkey: 'bob', - content: 'reply from someone followed', - tags: [['e', 'root', '', 'reply']], - createdAt: 99, - authorMetadata: [ - { - id: 'profile-bob', - kind: 0, - pubkey: 'bob', - content: JSON.stringify({ name: 'Bob' }), - tags: [], - createdAt: 98, - }, - ], - rootContext: { - nodes: [ - { - ...rootGql, - likes: { rows: [{ metrics: { pubkeys: 11 } }] }, - reposts: { rows: [{ metrics: { pubkeys: 1 } }] }, - replyStats: { rows: [{ metrics: { events: 4 } }] }, - zaps: { rows: [{ metrics: { amountSats: 8 } }] }, - }, - ], - }, - eventRefs: { nodes: [rootGql] }, - quotedContent: { nodes: [] }, - likes: { rows: [{ metrics: { pubkeys: 5 } }] }, - reposts: { rows: [{ metrics: { pubkeys: 2 } }] }, - replyStats: { rows: [{ metrics: { events: 3 } }] }, - zaps: { rows: [{ metrics: { amountSats: 4 } }] }, - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ - data: { - events: { - nodes: [replyGql], - pageInfo: { hasNextPage: false, endCursor: null }, - }, - }, - }), - }); - - const { createNaggFeedClient } = jest.requireActual< - typeof import('@/features/feed/data/naggFeedClient') - >('@/features/feed/data/naggFeedClient'); - - const result = await createNaggFeedClient().getFeed({ - spec: JSON.stringify({ id: 'following-replies', kind: 'notes' }), userPubkey: 'viewer', limit: 12, }); - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.query).toContain('query NaggGraphqlFollowingReplies'); - expect(body.query).toContain('rootContext: selectedReferences'); - expect(body.query).not.toContain('parentRootRefs'); - expect(body.query).not.toContain('parentReplyRefs'); - expect(body.variables.input).toEqual({ - kinds: [1, 1111], - tags: [{ key: 'e' }], - pubkeysFrom: [ - { - latestEventTags: { - pubkey: 'viewer', - kinds: [3], - tag: { key: 'p' }, - limit: 1, - maxValues: 2000, - }, - }, - ], - limit: 12, - }); expect(result.orderedFeedItems).toHaveLength(1); - expect(result.orderedFeedItems[0]).toMatchObject({ - type: 'note', - event: expect.objectContaining({ id: 'reply' }), - rootEvent: expect.objectContaining({ id: 'root' }), - rootEventId: 'root', - }); - expect(result.metricsMap.get('reply')).toEqual({ - likeCount: 5, - repostCount: 2, - replyCount: 3, - satsZapped: 4, - }); - expect(result.metricsMap.get('root')).toEqual({ - likeCount: 11, - repostCount: 1, - replyCount: 4, - satsZapped: 8, - }); - expect(result.profilesMap.get('bob')).toEqual({ name: 'Bob' }); expect(result.profilesMap.get('alice')).toEqual({ name: 'Alice' }); + const url = lastUrl(); + expect(url).toContain('/v1/nostr/feed'); + expect(url).toContain('limit=12'); + expect(mockFetch.mock.calls[0][1].method).toBe('GET'); }); - it('requests popular followed posts and replies through rankedEvents', async () => { - const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1234567890); - const replyGql = { - id: 'reply', - kind: 1, - pubkey: 'bob', - content: 'popular reply from someone followed', - tags: [['e', 'root', '', 'reply']], - createdAt: 99, - authorMetadata: [ - { - id: 'profile-bob', - kind: 0, - pubkey: 'bob', - content: JSON.stringify({ name: 'Bob' }), - tags: [], - createdAt: 98, - }, - ], - rootContext: { - nodes: [ - { - ...rootGql, - likes: { rows: [{ metrics: { pubkeys: 11 } }] }, - reposts: { rows: [{ metrics: { pubkeys: 1 } }] }, - replyStats: { rows: [{ metrics: { events: 4 } }] }, - zaps: { rows: [{ metrics: { amountSats: 8 } }] }, - }, - ], - }, - eventRefs: { nodes: [rootGql] }, - quotedContent: { nodes: [] }, - likes: { rows: [{ metrics: { pubkeys: 5 } }] }, - reposts: { rows: [{ metrics: { pubkeys: 2 } }] }, - replyStats: { rows: [{ metrics: { events: 3 } }] }, - zaps: { rows: [{ metrics: { amountSats: 4 } }] }, - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ - data: { - rankedEvents: { - nodes: [replyGql], - pageInfo: { hasNextPage: false, endCursor: null }, - }, - }, - }), - }); - - try { - const { createNaggFeedClient } = jest.requireActual< - typeof import('@/features/feed/data/naggFeedClient') - >('@/features/feed/data/naggFeedClient'); - - const result = await createNaggFeedClient().getFeed({ - spec: JSON.stringify({ id: 'following-popular', kind: 'notes', hours: 24 }), - userPubkey: 'viewer', - limit: 12, - }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.query).toContain('query NaggGraphqlFollowingPopular'); - expect(body.query).toContain('rankedEvents'); - expect(body.variables.input).toMatchObject({ - references: { - kinds: [7, 9735, 6, 16, 1, 1111], - since: Math.floor(1234567890 / 1000) - 86_400, - limit: 1000, - }, - via: { key: 'e' }, - target: { - kinds: [1, 1111], - pubkeysFrom: [ - { - latestEventTags: { - pubkey: 'viewer', - kinds: [3], - tag: { key: 'p' }, - limit: 1, - maxValues: 2000, - }, - }, - ], - limit: 12, - }, - metric: { name: 'actors', op: 'COUNT_DISTINCT', distinctField: 'PUBKEY' }, - limit: 12, - offset: 0, - }); - expect(body.variables.input.terms).toEqual( - expect.arrayContaining([ - expect.objectContaining({ pubkeyScore: { source: 'vertex', target: 'AUTHOR' } }), - expect.objectContaining({ - candidateField: 'CREATED_AT', - transform: 'RECENCY_HALFLIFE', - }), - ]) - ); - expect(result.orderedFeedItems).toHaveLength(1); - expect(result.orderedFeedItems[0]).toMatchObject({ - type: 'note', - event: expect.objectContaining({ id: 'reply' }), - rootEvent: expect.objectContaining({ id: 'root' }), - rootEventId: 'root', - }); - expect(result.metricsMap.get('reply')).toEqual({ - likeCount: 5, - repostCount: 2, - replyCount: 3, - satsZapped: 4, - }); - expect(result.metricsMap.get('root')).toEqual({ - likeCount: 11, - repostCount: 1, - replyCount: 4, - satsZapped: 8, - }); - } finally { - nowSpy.mockRestore(); - } - }); - - it('maps followed reply previews for popular following root posts', async () => { - const replyGql = { - id: 'reply', - kind: 1, - pubkey: 'bob', - content: 'popular followed reply', - tags: [['e', 'root', '', 'reply']], - createdAt: 101, - authorMetadata: [], - quotedContent: { nodes: [] }, - likes: { rows: [] }, - reposts: { rows: [] }, - replyStats: { rows: [] }, - zaps: { rows: [] }, - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ - data: { - rankedEvents: { - nodes: [{ ...rootGql, followedReply: { nodes: [replyGql] } }], - pageInfo: { hasNextPage: false, endCursor: null }, - }, - }, - }), - }); - - const { createNaggFeedClient } = jest.requireActual< - typeof import('@/features/feed/data/naggFeedClient') - >('@/features/feed/data/naggFeedClient'); - - const result = await createNaggFeedClient().getFeed({ - spec: JSON.stringify({ id: 'following-popular', kind: 'notes', hours: 24 }), - userPubkey: 'viewer', - }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.query).toContain('followedReply'); - expect(body.query).toContain('authoredReplyChain'); - expect(body.query).not.toContain('sourceEventAuthor'); - expect(body.variables.authorChain).toEqual(AUTHORED_REPLY_CHAIN_INPUT); - expect(result.orderedFeedItems).toHaveLength(1); - expect(result.orderedFeedItems[0]).toMatchObject({ - type: 'note', - event: expect.objectContaining({ id: 'root' }), - replyPreviewEvents: [expect.objectContaining({ id: 'reply' })], - }); - }); - - it('falls back to a lightweight For You query after a GraphQL deadline', async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ - errors: [{ message: 'context deadline exceeded' }], - }), + it('routes a for-you spec to POST /v1/nostr/feed/ranked', async () => { + mockFetch.mockResolvedValueOnce( + restResponse({ + items: [{ type: 'note', event: note() }], + ordering: { orderBy: 'rank', elements: ['root'] }, + metrics: { root: emptyStats }, + profiles: {}, + quoted: {}, + paginationUntil: 0, + paginationOffset: 1, }) - .mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ - data: { - events: { - nodes: [rootGql], - pageInfo: { hasNextPage: false, endCursor: null }, - }, - }, - }), - }); - - const { createNaggFeedClient } = jest.requireActual< - typeof import('@/features/feed/data/naggFeedClient') - >('@/features/feed/data/naggFeedClient'); - - const result = await createNaggFeedClient().getFeed({ - spec: JSON.stringify({ id: 'for-you', kind: 'notes', hours: 24 }), - userPubkey: 'viewer', - }); - - expect(mockFetch).toHaveBeenCalledTimes(2); - const firstBody = JSON.parse(mockFetch.mock.calls[0][1].body); - const secondBody = JSON.parse(mockFetch.mock.calls[1][1].body); - expect(firstBody.query).toContain('authoredReplyChain'); - expect(firstBody.query).not.toContain('sourceEventAuthor'); - expect(secondBody.query).toContain('query NaggGraphqlFeed'); - expect(secondBody.query).not.toContain('authoredReplyChain'); - expect(secondBody.query).not.toContain('sourceEventAuthor'); - expect(secondBody.query).not.toContain('followedReply'); - expect(secondBody.variables).toEqual({ - input: { - kinds: [1, 1111], - since: expect.any(Number), - limit: 30, - }, - }); - expect(result.orderedFeedItems).toHaveLength(1); - expect(result.orderedFeedItems[0]).toMatchObject({ - type: 'note', - event: expect.objectContaining({ id: 'root' }), - }); - }); - - it('falls back to a lightweight popular following query after a GraphQL deadline', async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ - errors: [{ message: 'context deadline exceeded' }], - }), - }) - .mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ - data: { - events: { - nodes: [rootGql], - pageInfo: { hasNextPage: false, endCursor: null }, - }, - }, - }), - }); - - const { createNaggFeedClient } = jest.requireActual< - typeof import('@/features/feed/data/naggFeedClient') - >('@/features/feed/data/naggFeedClient'); + ); - const result = await createNaggFeedClient().getFeed({ - spec: JSON.stringify({ id: 'following-popular', kind: 'notes', hours: 24 }), + const result = await loadClient().getFeed({ + spec: JSON.stringify({ id: 'for-you', kind: 'notes' }), userPubkey: 'viewer', + limit: 20, }); - expect(mockFetch).toHaveBeenCalledTimes(2); - const firstBody = JSON.parse(mockFetch.mock.calls[0][1].body); - const secondBody = JSON.parse(mockFetch.mock.calls[1][1].body); - expect(firstBody.query).toContain('authoredReplyChain'); - expect(firstBody.query).not.toContain('sourceEventAuthor'); - expect(secondBody.query).toContain('query NaggGraphqlFeed'); - expect(secondBody.query).not.toContain('authoredReplyChain'); - expect(secondBody.query).not.toContain('sourceEventAuthor'); - expect(secondBody.query).not.toContain('followedReply'); - expect(secondBody.variables).toEqual({ - input: { - kinds: [1, 1111], - pubkeysFrom: [ - { - latestEventTags: { - pubkey: 'viewer', - kinds: [3], - tag: { key: 'p' }, - limit: 1, - maxValues: 2000, - }, - }, - ], - limit: 30, - }, - }); expect(result.orderedFeedItems).toHaveLength(1); - expect(result.orderedFeedItems[0]).toMatchObject({ - type: 'note', - event: expect.objectContaining({ id: 'root' }), - }); + expect(lastUrl()).toContain('/v1/nostr/feed/ranked'); + expect(mockFetch.mock.calls[0][1].method).toBe('POST'); }); - it('requests recent followed posts and replies through derived pubkeys', async () => { - const replyGql = { - id: 'reply', - kind: 1, - pubkey: 'bob', - content: 'recent reply from someone followed', - tags: [['e', 'root', '', 'reply']], - createdAt: 99, - authorMetadata: [ - { - id: 'profile-bob', - kind: 0, - pubkey: 'bob', - content: JSON.stringify({ name: 'Bob' }), - tags: [], - createdAt: 98, - }, - ], - rootContext: { - nodes: [ - { - ...rootGql, - likes: { rows: [{ metrics: { pubkeys: 11 } }] }, - reposts: { rows: [{ metrics: { pubkeys: 1 } }] }, - replyStats: { rows: [{ metrics: { events: 4 } }] }, - zaps: { rows: [{ metrics: { amountSats: 8 } }] }, - }, + it('getUserFeed filters to the author root notes from /v1/nostr/feed/user', async () => { + mockFetch.mockResolvedValueOnce( + restResponse({ + items: [ + { type: 'note', event: note({ id: 'own', pubkey: 'bob' }) }, + { type: 'note', event: note({ id: 'other', pubkey: 'alice' }) }, ], - }, - eventRefs: { nodes: [rootGql] }, - quotedContent: { nodes: [] }, - likes: { rows: [{ metrics: { pubkeys: 5 } }] }, - reposts: { rows: [{ metrics: { pubkeys: 2 } }] }, - replyStats: { rows: [{ metrics: { events: 3 } }] }, - zaps: { rows: [{ metrics: { amountSats: 4 } }] }, - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ - data: { - events: { - nodes: [replyGql], - pageInfo: { hasNextPage: false, endCursor: null }, - }, - }, - }), - }); - - const { createNaggFeedClient } = jest.requireActual< - typeof import('@/features/feed/data/naggFeedClient') - >('@/features/feed/data/naggFeedClient'); + ordering: { orderBy: 'created_at', elements: ['own', 'other'] }, + metrics: {}, + profiles: {}, + quoted: {}, + paginationUntil: 100, + paginationOffset: 2, + }) + ); - const result = await createNaggFeedClient().getFeed({ - spec: JSON.stringify({ id: 'following-recent', kind: 'notes' }), - userPubkey: 'viewer', - limit: 12, - }); + const result = await loadClient().getUserFeed({ pubkey: 'bob', limit: 50 }); - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.query).toContain('query NaggGraphqlFeed'); - expect(body.variables.input).toEqual({ - kinds: [1, 1111], - pubkeysFrom: [ - { - latestEventTags: { - pubkey: 'viewer', - kinds: [3], - tag: { key: 'p' }, - limit: 1, - maxValues: 2000, - }, - }, - ], - limit: 12, - }); + expect(lastUrl()).toContain('/v1/nostr/feed/user'); expect(result.orderedFeedItems).toHaveLength(1); - expect(result.orderedFeedItems[0]).toMatchObject({ - type: 'note', - event: expect.objectContaining({ id: 'reply' }), - rootEvent: expect.objectContaining({ id: 'root' }), - rootEventId: 'root', - }); - expect(result.profilesMap.get('bob')).toEqual({ name: 'Bob' }); - expect(result.profilesMap.get('alice')).toEqual({ name: 'Alice' }); - }); - - it('does not request followed replies without a viewer pubkey', async () => { - const { createNaggFeedClient } = jest.requireActual< - typeof import('@/features/feed/data/naggFeedClient') - >('@/features/feed/data/naggFeedClient'); - - const result = await createNaggFeedClient().getFeed({ - spec: JSON.stringify({ id: 'following-replies', kind: 'notes' }), - limit: 12, - }); - - expect(mockFetch).not.toHaveBeenCalled(); - expect(result.orderedFeedItems).toEqual([]); - }); - - it('keeps user feeds filtered to root notes and reposts', async () => { - const reply = { - id: 'reply', - kind: 1, - pubkey: 'alice', - content: 'reply', - tags: [['e', 'root', '', 'reply']], - createdAt: 99, - authorMetadata: [], - likes: { rows: [] }, - reposts: { rows: [] }, - replyStats: { rows: [] }, - zaps: { rows: [] }, - }; - const repost = { - id: 'repost', - kind: 6, - pubkey: 'alice', - content: '', - tags: [['e', root.id]], - createdAt: 98, - authorMetadata: [], - eventRefs: { nodes: [rootGql] }, - likes: { rows: [] }, - reposts: { rows: [] }, - replyStats: { rows: [] }, - zaps: { rows: [] }, - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ - data: { - events: { - nodes: [rootGql, reply, repost], - pageInfo: { hasNextPage: false, endCursor: null }, - }, - }, - }), - }); - - const { createNaggFeedClient } = jest.requireActual< - typeof import('@/features/feed/data/naggFeedClient') - >('@/features/feed/data/naggFeedClient'); - - const result = await createNaggFeedClient().getUserFeed({ - pubkey: 'alice', - authorName: 'Alice', - }); - - expect(mockFetch.mock.calls[0][0]).toBe('http://nagg.test/graphql'); - expect(JSON.parse(mockFetch.mock.calls[0][1].body).variables.input).toEqual({ - pubkeys: ['alice'], - kinds: [1, 6, 16], - limit: 50, - }); - expect(result.orderedFeedItems.map((item) => item.type)).toEqual(['note', 'repost']); - expect( - result.orderedFeedItems.map((item) => - item.type === 'note' ? item.event.id : item.repostEvent.id - ) - ).toEqual(['root', 'repost']); - expect(result.profilesMap.get('alice')).toEqual({ name: 'Alice' }); - }); - - it('maps default ranked thread responses into thread buckets', async () => { - const reply = { - id: 'reply', - kind: 1, - pubkey: 'bob', - content: 'reply', - tags: [['e', 'root', '', 'reply']], - createdAt: 101, - authorMetadata: [ - { - id: 'profile-bob', - kind: 0, - pubkey: 'bob', - content: JSON.stringify({ name: 'Bob' }), - tags: [], - createdAt: 100, - }, - ], - likes: { rows: [] }, - reposts: { rows: [] }, - replyStats: { rows: [] }, - zaps: { rows: [] }, - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ - data: { - event: { - ...rootGql, - replyStats: { rows: [{ metrics: { events: 1 } }] }, - replies: { nodes: [reply] }, - parentRefs: { nodes: [] }, - quotedContent: { nodes: [] }, - }, - }, - }), - }); - - const { createNaggFeedClient } = jest.requireActual< - typeof import('@/features/feed/data/naggFeedClient') - >('@/features/feed/data/naggFeedClient'); - - const result = await createNaggFeedClient().getThread({ eventId: 'root' }); - - expect(mockFetch.mock.calls[0][0]).toBe('http://nagg.test/graphql'); - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.variables.id).toBe('root'); - expect(body.variables.candidateLimit).toBe(100); - expect(body.variables.rankedLimit).toBe(50); - expect(body.variables.viewerPubkey).toBe(''); - expect(body.variables.rank).toMatchObject({ - references: { kinds: [7], limit: 500 }, - via: { key: 'e' }, - metric: { name: 'likes', op: 'COUNT_DISTINCT', distinctField: 'PUBKEY' }, - }); - expect(body.variables.rank.terms).toHaveLength(7); - expect(body.variables.rank.terms).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - derivedMetric: 'contribution_quality', - weight: 3, - }), - expect.objectContaining({ - references: { kinds: [7], limit: 500 }, - via: { key: 'e' }, - metric: { name: 'likes', op: 'COUNT_DISTINCT', distinctField: 'PUBKEY' }, - weight: 3, - transform: 'LOG1P', - }), - expect.objectContaining({ - references: { kinds: [1, 1111], limit: 500 }, - via: { key: 'e' }, - metric: { name: 'replies', op: 'COUNT' }, - weight: 2.5, - transform: 'LOG1P', - }), - expect.objectContaining({ - references: { kinds: [6, 16], limit: 500 }, - via: { key: 'e' }, - metric: { name: 'reposts', op: 'COUNT_DISTINCT', distinctField: 'PUBKEY' }, - weight: 2, - transform: 'LOG1P', - }), - expect.objectContaining({ - references: { kinds: [9735], limit: 500 }, - via: { key: 'e' }, - metric: { name: 'zapSats', op: 'SUM', derived: 'nip57.amount_sats' }, - weight: 1.5, - transform: 'LOG1P', - }), - expect.objectContaining({ - pubkeyScore: { source: 'vertex', target: 'AUTHOR' }, - weight: 0.25, - }), - expect.objectContaining({ - candidateField: 'CREATED_AT', - weight: 0.8, - transform: 'RECENCY_HALFLIFE', - halfLifeSeconds: 86_400, - }), - ]) - ); - expect(body.query).toContain('rankedReferencedBy'); - expect(body.query).toContain('rank: $rank'); - expect(body.query).toContain('allReplies: referencedBy'); - expect(body.query).toContain('authoredReplyChain'); - expect(body.query).not.toContain('sourceEventAuthor'); - expect(body.query).toContain('followedReply'); - expect(body.variables.authorChain).toEqual(AUTHORED_REPLY_CHAIN_INPUT); - expect(body.query).not.toContain('offset: $offset'); - expect(body.query).not.toContain('childReplies'); - expect(result.thread.target?.id).toBe('root'); - expect(result.thread.replies.map((event) => event.id)).toEqual(['reply']); - expect(result.replyPageEventIds).toEqual(['reply']); - expect(result.metrics.get('root')?.replyCount).toBe(1); - expect(result.profiles.get('bob')).toEqual({ name: 'Bob' }); - expect(result.replyPageSize).toBe(10); - expect(result.loadedReplyCount).toBe(1); - expect(result.hasMoreReplies).toBe(false); + expect(result.orderedFeedItems[0]).toMatchObject({ type: 'note' }); }); - it('orders relevant thread replies as the author thread chain, followed tail, then ranked remainder', async () => { - const authorSecond: any = { - id: 'author-second', - kind: 1, - pubkey: 'alice', - content: 'author second self reply', - tags: [ - ['e', 'root', '', 'root'], - ['e', 'author-direct', '', 'reply'], - ], - createdAt: 103, - likes: { rows: [] }, - reposts: { rows: [] }, - replyStats: { rows: [] }, - zaps: { rows: [] }, - }; - const authorDirect = { - id: 'author-direct', - kind: 1, - pubkey: 'alice', - content: 'author direct self reply', - tags: [['e', 'root', '', 'reply']], - createdAt: 102, - likes: { rows: [] }, - reposts: { rows: [] }, - replyStats: { rows: [] }, - zaps: { rows: [] }, - childAuthorReplies: { nodes: [authorSecond] }, - }; - const authorBranch = { - id: 'author-branch', - kind: 1, - pubkey: 'alice', - content: 'author reply on someone else branch', - tags: [ - ['e', 'root', '', 'root'], - ['e', 'bob-reply', '', 'reply'], - ], - createdAt: 101, - likes: { rows: [] }, - reposts: { rows: [] }, - replyStats: { rows: [] }, - zaps: { rows: [] }, - }; - const followed = { - id: 'followed', - kind: 1, - pubkey: 'bob', - content: 'followed reply', - tags: [ - ['e', 'root', '', 'root'], - ['e', 'author-second', '', 'reply'], - ], - createdAt: 104, - likes: { rows: [] }, - reposts: { rows: [] }, - replyStats: { rows: [] }, - zaps: { rows: [] }, - }; - authorSecond.childFollowedReply = { nodes: [followed] }; - const ranked = { - id: 'ranked', - kind: 1, - pubkey: 'carol', - content: 'ranked reply', - tags: [['e', 'root', '', 'reply']], - createdAt: 104, - likes: { rows: [] }, - reposts: { rows: [] }, - replyStats: { rows: [] }, - zaps: { rows: [] }, - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ - data: { - event: { - ...rootGql, - replyStats: { rows: [{ metrics: { events: 4 } }] }, - authorReplies: { nodes: [authorBranch, authorDirect] }, - followedReply: { nodes: [] }, - replies: { nodes: [followed, ranked, authorBranch] }, - parentRefs: { nodes: [] }, - quotedContent: { nodes: [] }, - }, + it('getNotifications maps the canonical connection from /v1/nostr/notifications', async () => { + mockFetch.mockResolvedValueOnce( + restResponse({ + notifications: { + nodes: [{ event: note({ id: 'n1', pubkey: 'carol' }), reason: 'mention', actorVertexScore: 0.5 }], + pageInfo: { hasNextPage: false, endCursor: null }, }, - }), - }); - - const { createNaggFeedClient } = jest.requireActual< - typeof import('@/features/feed/data/naggFeedClient') - >('@/features/feed/data/naggFeedClient'); - - const result = await createNaggFeedClient().getThread({ - eventId: 'root', - viewerPubkey: 'viewer', - limit: 3, - }); - - expect(result.replyPageEventIds).toEqual(['author-direct', 'author-second', 'followed']); - expect(result.loadedReplyCount).toBe(3); - expect(result.hasMoreReplies).toBe(true); - expect(result.thread.replies.map((event) => event.id).sort()).toEqual( - ['author-direct', 'author-second', 'followed', 'ranked'].sort() + metrics: { n1: emptyStats }, + profiles: { carol: { name: 'Carol' } }, + quoted: {}, + }) ); - }); - - it('fills relevant thread pages from plain replies when ranked replies under-return', async () => { - const rankedTop = { - id: 'ranked-top', - kind: 1, - pubkey: 'bob', - content: 'ranked reply', - tags: [['e', 'root', '', 'reply']], - createdAt: 104, - likes: { rows: [{ metrics: { pubkeys: 4 } }] }, - reposts: { rows: [] }, - replyStats: { rows: [] }, - zaps: { rows: [] }, - }; - const plainA = { - id: 'plain-a', - kind: 1, - pubkey: 'carol', - content: 'plain reply a', - tags: [['e', 'root', '', 'reply']], - createdAt: 103, - likes: { rows: [] }, - reposts: { rows: [] }, - replyStats: { rows: [] }, - zaps: { rows: [] }, - }; - const plainB = { - id: 'plain-b', - kind: 1, - pubkey: 'dave', - content: 'plain reply b', - tags: [['e', 'root', '', 'reply']], - createdAt: 102, - likes: { rows: [] }, - reposts: { rows: [] }, - replyStats: { rows: [] }, - zaps: { rows: [] }, - }; - const plainC = { - id: 'plain-c', - kind: 1, - pubkey: 'erin', - content: 'plain reply c', - tags: [['e', 'root', '', 'reply']], - createdAt: 101, - likes: { rows: [] }, - reposts: { rows: [] }, - replyStats: { rows: [] }, - zaps: { rows: [] }, - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ - data: { - event: { - ...rootGql, - replyStats: { rows: [{ metrics: { events: 4 } }] }, - authorReplies: { nodes: [] }, - followedReply: { nodes: [] }, - replies: { nodes: [rankedTop] }, - allReplies: { nodes: [rankedTop, plainA, plainB, plainC] }, - parentRefs: { nodes: [] }, - quotedContent: { nodes: [] }, - }, - }, - }), - }); - const { createNaggFeedClient } = jest.requireActual< - typeof import('@/features/feed/data/naggFeedClient') - >('@/features/feed/data/naggFeedClient'); - - const result = await createNaggFeedClient().getThread({ - eventId: 'root', - viewerPubkey: 'viewer', - limit: 3, - }); + const result = await loadClient().getNotifications({ viewerPubkey: 'viewer' }); - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.variables.rankedLimit).toBe(50); - expect(body.query).toContain('allReplies: referencedBy'); - expect(result.replyPageEventIds).toEqual(['ranked-top', 'plain-a', 'plain-b']); - expect(result.loadedReplyCount).toBe(3); - expect(result.hasMoreReplies).toBe(true); - expect(result.thread.replies.map((event) => event.id).sort()).toEqual( - ['ranked-top', 'plain-a', 'plain-b', 'plain-c'].sort() - ); + expect(lastUrl()).toContain('/v1/nostr/notifications'); + expect(result.notifications).toHaveLength(1); + expect(result.profilesMap.get('carol')).toEqual({ name: 'Carol' }); }); - it('falls back to ranked thread replies when source author pubkeys are not deployed', async () => { - const reply = { - id: 'reply', - kind: 1, - pubkey: 'bob', - content: 'legacy ranked reply', - tags: [['e', 'root', '', 'reply']], - createdAt: 101, - authorMetadata: [], - likes: { rows: [] }, - reposts: { rows: [] }, - replyStats: { rows: [] }, - zaps: { rows: [] }, - }; - mockFetch - .mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ - errors: [{ message: 'Cannot query field "authoredReplyChain" on type "NostrEvent"' }], - }), + it('getThread renders the server reply manifest from /v1/nostr/thread', async () => { + const reply1 = note({ id: 'reply1', pubkey: 'bob', tags: [['e', 'root', '', 'reply']] }); + const reply2 = note({ id: 'reply2', pubkey: 'carol', tags: [['e', 'root', '', 'reply']] }); + mockFetch.mockResolvedValueOnce( + restResponse({ + root: note(), + events: [reply1, reply2], + ordering: { orderBy: 'rank', elements: ['reply2', 'reply1'] }, + metrics: { root: emptyStats }, + profiles: {}, + quoted: {}, }) - .mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ - data: { - event: { - ...rootGql, - replies: { nodes: [reply] }, - parentRefs: { nodes: [] }, - quotedContent: { nodes: [] }, - }, - }, - }), - }); - - const { createNaggFeedClient } = jest.requireActual< - typeof import('@/features/feed/data/naggFeedClient') - >('@/features/feed/data/naggFeedClient'); - - const result = await createNaggFeedClient().getThread({ - eventId: 'root', - viewerPubkey: 'viewer', - }); - - expect(mockFetch).toHaveBeenCalledTimes(2); - const firstBody = JSON.parse(mockFetch.mock.calls[0][1].body); - const secondBody = JSON.parse(mockFetch.mock.calls[1][1].body); - expect(firstBody.query).toContain('authoredReplyChain'); - expect(firstBody.query).not.toContain('sourceEventAuthor'); - expect(secondBody.query).not.toContain('authoredReplyChain'); - expect(secondBody.query).not.toContain('sourceEventAuthor'); - expect(secondBody.query).toContain('offset: $offset'); - expect(result.replyPageEventIds).toEqual(['reply']); - expect(result.thread.replies.map((event) => event.id)).toEqual(['reply']); - }); - - it('adds followed pubkey boosts to relevant thread reply ranking', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ - data: { - event: { - ...rootGql, - replies: { nodes: [] }, - parentRefs: { nodes: [] }, - quotedContent: { nodes: [] }, - }, - }, - }), - }); - - const { createNaggFeedClient } = jest.requireActual< - typeof import('@/features/feed/data/naggFeedClient') - >('@/features/feed/data/naggFeedClient'); + ); - await createNaggFeedClient().getThread({ + const result = await loadClient().getThread({ eventId: 'root', + sort: 'relevant', viewerPubkey: 'viewer', + limit: 10, }); - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.variables.rank.candidatePubkeyBoosts).toEqual([ - { - pubkeysFrom: [ - { - latestEventTags: { - pubkey: 'viewer', - kinds: [3], - tag: { key: 'p' }, - limit: 1, - maxValues: 2000, - }, - }, - ], - weight: 6, - }, - ]); + const url = lastUrl(); + expect(url).toContain('/v1/nostr/thread'); + expect(url).toContain('sort=relevant'); + expect(url).toContain('viewer=viewer'); + expect(result.allEvents.has('root')).toBe(true); + expect(result.replyPageEventIds).toEqual(['reply2', 'reply1']); }); - it('requests new thread reply pages with offset and reports more pages', async () => { - const replies = Array.from({ length: 10 }, (_, index) => ({ - id: `reply-${index}`, - kind: 1, - pubkey: 'bob', - content: `reply ${index}`, - tags: [['e', 'root', '', 'reply']], - createdAt: 200 - index, - authorMetadata: [ - { - id: `profile-bob-${index}`, - kind: 0, - pubkey: 'bob', - content: JSON.stringify({ name: 'Bob' }), - tags: [], - createdAt: 100, - }, - ], - likes: { rows: [] }, - reposts: { rows: [] }, - replyStats: { rows: [] }, - zaps: { rows: [] }, - })); - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ - data: { - event: { - ...rootGql, - replyStats: { rows: [{ metrics: { events: 25 } }] }, - replies: { nodes: replies }, - parentRefs: { nodes: [] }, - quotedContent: { nodes: [] }, - }, - }, - }), - }); - - const { createNaggFeedClient } = jest.requireActual< - typeof import('@/features/feed/data/naggFeedClient') - >('@/features/feed/data/naggFeedClient'); + it('enrich fetches quoted events and profiles from the REST endpoints', async () => { + mockFetch + .mockResolvedValueOnce( + restResponse({ + metrics: { q1: emptyStats }, + profiles: {}, + quoted: { q1: note({ id: 'q1' }) }, + }) + ) + .mockResolvedValueOnce( + restResponse({ metrics: {}, profiles: { dave: { name: 'Dave' } }, quoted: {} }) + ); - const result = await createNaggFeedClient().getThread({ - eventId: 'root', - limit: 10, - offset: 10, - sort: 'new', + const updates = await loadClient().enrich({ + missingQuotedIds: ['q1'], + missingProfilePubkeys: ['dave'], }); - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.variables).toEqual({ - id: 'root', - limit: 10, - offset: 10, - }); - expect(body.query).toContain('referencedBy'); - expect(body.query).not.toContain('rankedReferencedBy'); - expect(body.query).toContain('offset: $offset'); - expect(body.query).not.toContain('childReplies'); - expect(result.thread.target?.id).toBe('root'); - expect(result.replyPageEventIds).toEqual(replies.map((reply) => reply.id)); - expect(result.loadedReplyCount).toBe(10); - expect(result.replyPageSize).toBe(10); - expect(result.hasMoreReplies).toBe(true); + expect(updates.quotedEvents?.get('q1')).toMatchObject({ id: 'q1' }); + expect(updates.profiles?.get('dave')).toEqual({ name: 'Dave' }); + const urls = mockFetch.mock.calls.map((call) => String(call[0])); + expect(urls.some((u) => u.includes('/v1/nostr/events?'))).toBe(true); + expect(urls.some((u) => u.includes('/v1/nostr/profiles?'))).toBe(true); }); - it('builds metric-specific thread reply sort rank inputs', async () => { - mockFetch.mockResolvedValue({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({ - data: { - event: { - ...rootGql, - replies: { nodes: [] }, - parentRefs: { nodes: [] }, - quotedContent: { nodes: [] }, - }, - }, - }), - }); - - const { createNaggFeedClient } = jest.requireActual< - typeof import('@/features/feed/data/naggFeedClient') - >('@/features/feed/data/naggFeedClient'); - const client = createNaggFeedClient(); + it('never issues a GraphQL request', async () => { + mockFetch.mockResolvedValue( + restResponse({ + items: [], + ordering: { orderBy: 'created_at', elements: [] }, + metrics: {}, + profiles: {}, + quoted: {}, + paginationUntil: 0, + paginationOffset: 0, + }) + ); - await client.getThread({ eventId: 'root', sort: 'likes' }); - await client.getThread({ eventId: 'root', sort: 'zaps' }); - await client.getThread({ eventId: 'root', sort: 'reposts' }); + await loadClient().getFeed({ spec: JSON.stringify({ id: 'feed' }), userPubkey: 'viewer', limit: 5 }); - const [likesCall, zapsCall, repostsCall] = mockFetch.mock.calls.map((call) => - JSON.parse(call[1].body) - ); - expect(likesCall.variables.rank).toMatchObject({ - references: { kinds: [7], limit: 500 }, - metric: { name: 'likes', op: 'COUNT_DISTINCT', distinctField: 'PUBKEY' }, - }); - expect(zapsCall.variables.rank).toMatchObject({ - references: { kinds: [9735], limit: 500 }, - metric: { name: 'zapSats', op: 'SUM', derived: 'nip57.amount_sats' }, - }); - expect(repostsCall.variables.rank).toMatchObject({ - references: { kinds: [6, 16], limit: 500 }, - metric: { name: 'reposts', op: 'COUNT_DISTINCT', distinctField: 'PUBKEY' }, - }); + for (const call of mockFetch.mock.calls) { + expect(String(call[0])).not.toContain('/graphql'); + } }); }); diff --git a/__tests__/naggFeedClientTransport.test.ts b/__tests__/naggFeedClientTransport.test.ts deleted file mode 100644 index 75d40ca19..000000000 --- a/__tests__/naggFeedClientTransport.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -/** - * Cross-transport equivalence: each view has ONE parse path, so the GraphQL - * transport (distil `data` with `graphqlToData`, then parse) and the REST - * app-view transport (parse the already-canonical body) must yield identical - * parsed output. The flags pick the URL; the result is the same either way. - */ -jest.mock('@sovranbitcoin/colada', () => ({ - combineSignals: (...signals: (AbortSignal | undefined)[]) => - signals.find((signal): signal is AbortSignal => !!signal) ?? new AbortController().signal, - createNostrGraphqlMintEnrichment: jest.fn(() => ({ - fetchMintReviews: jest.fn(), - resolveMintContactProfile: jest.fn(), - })), - isAbortError: () => false, - timeoutSignal: () => new AbortController().signal, -})); - -jest.mock('@/shared/lib/cashu/profileScopedStorage', () => ({ - createProfileScopedStorage: () => ({ - getItem: async () => null, - setItem: async () => {}, - removeItem: async () => {}, - }), -})); - -const ENV_KEYS = [ - 'EXPO_PUBLIC_NOSTR_APPVIEW_BASE_URL', - 'EXPO_PUBLIC_NAGG_BASE_URL', - 'EXPO_PUBLIC_API_BASE_URL', - 'EXPO_PUBLIC_SCORE_API_BASE_URL', - 'EXPO_PUBLIC_NOSTR_GRAPHQL_ENDPOINT', - 'EXPO_PUBLIC_NOSTR_FEED_APPVIEW', - 'EXPO_PUBLIC_NOSTR_NOTIFICATIONS_APPVIEW', -] as const; - -const originalFetchDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); -const originalEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); -const mockFetch = jest.fn(); - -const PAD = (value: string) => value.padEnd(64, '0'); - -const rootGql = { - id: PAD('root'), - kind: 1, - pubkey: PAD('alice'), - content: 'root', - tags: [] as string[][], - createdAt: 100, - authorMetadata: [ - { - id: PAD('profile-alice'), - kind: 0, - pubkey: PAD('alice'), - content: JSON.stringify({ name: 'Alice' }), - tags: [], - createdAt: 99, - }, - ], - rootContext: { nodes: [] }, - eventRefs: { nodes: [] }, - quotedContent: { nodes: [] }, - likes: { rows: [{ metrics: { pubkeys: 5 } }] }, - reposts: { rows: [] }, - replyStats: { rows: [] }, - zaps: { rows: [] }, -}; - -// The canonical NaggFeedPage the GraphQL distiller produces for `rootGql` — the -// REST app-view emits exactly this body, so the REST mock returns it verbatim. -const canonicalFeedPage = { - items: [ - { - type: 'note', - event: { - id: PAD('root'), - kind: 1, - pubkey: PAD('alice'), - content: 'root', - tags: [], - created_at: 100, - }, - }, - ], - metrics: { - [PAD('root')]: { likeCount: 5, repostCount: 0, replyCount: 0, satsZapped: 0 }, - }, - profiles: { [PAD('alice')]: { name: 'Alice' } }, - quoted: {}, - paginationUntil: 100, - paginationOffset: 1, -}; - -function jsonResponse(body: unknown) { - return { - ok: true, - status: 200, - statusText: 'OK', - json: async () => body, - }; -} - -function setupEnv(extra: Record = {}) { - for (const key of ENV_KEYS) delete process.env[key]; - process.env.EXPO_PUBLIC_NOSTR_APPVIEW_BASE_URL = 'http://nagg.test/'; - for (const [key, value] of Object.entries(extra)) process.env[key] = value; -} - -function loadClient() { - return jest - .requireActual< - typeof import('@/features/feed/data/naggFeedClient') - >('@/features/feed/data/naggFeedClient') - .createNaggFeedClient(); -} - -describe('naggFeedClient transport equivalence', () => { - beforeEach(() => { - jest.resetModules(); - mockFetch.mockReset(); - Object.defineProperty(globalThis, 'fetch', { - configurable: true, - writable: true, - value: mockFetch as unknown as typeof fetch, - }); - }); - - afterEach(() => { - for (const key of ENV_KEYS) { - const value = originalEnv[key]; - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - }); - - afterAll(() => { - if (originalFetchDescriptor) { - Object.defineProperty(globalThis, 'fetch', originalFetchDescriptor); - return; - } - Reflect.deleteProperty(globalThis, 'fetch'); - }); - - it('getUserFeed yields identical output from GraphQL and app-view transports', async () => { - // GraphQL: distil the rich node tree. - setupEnv(); - mockFetch.mockResolvedValueOnce( - jsonResponse({ data: { events: { nodes: [rootGql], pageInfo: { hasNextPage: false } } } }) - ); - const graphqlResult = await loadClient().getUserFeed({ pubkey: PAD('alice') }); - const graphqlUrl = String(mockFetch.mock.calls[0][0]); - - // App-view: parse the canonical REST body directly. - jest.resetModules(); - mockFetch.mockReset(); - setupEnv({ EXPO_PUBLIC_NOSTR_FEED_APPVIEW: 'true' }); - mockFetch.mockResolvedValueOnce(jsonResponse(canonicalFeedPage)); - const appViewResult = await loadClient().getUserFeed({ pubkey: PAD('alice') }); - const appViewUrl = String(mockFetch.mock.calls[0][0]); - - // Different URLs, identical parsed output. - expect(graphqlUrl).toContain('/graphql'); - expect(appViewUrl).toContain('/v1/nostr/feed/user'); - expect(appViewResult.orderedFeedItems).toEqual(graphqlResult.orderedFeedItems); - expect([...appViewResult.metricsMap]).toEqual([...graphqlResult.metricsMap]); - expect([...appViewResult.profilesMap]).toEqual([...graphqlResult.profilesMap]); - expect(appViewResult.paginationUntil).toBe(graphqlResult.paginationUntil); - expect(appViewResult.paginationOffset).toBe(graphqlResult.paginationOffset); - }); - - it('sends the query document operation name on the GraphQL path, not the app-view binding label', async () => { - // Regression: runNaggQuery used to send the app-view binding's REST label - // ('UserFeed' / 'RankedFeed' / 'Notifications') as the GraphQL operationName. - // nagg rejects that with "Unknown operation named …", silently EMPTYING the - // feed. The GraphQL request must use the query document's operation name. - setupEnv(); // flags off -> GraphQL transport, with the userFeedAppView binding present - mockFetch.mockResolvedValueOnce( - jsonResponse({ data: { events: { nodes: [rootGql], pageInfo: { hasNextPage: false } } } }) - ); - await loadClient().getUserFeed({ pubkey: PAD('alice') }); - - const requestInit = mockFetch.mock.calls[0][1] as { body?: string }; - const body = JSON.parse(requestInit.body ?? '{}') as { query?: string; operationName?: string }; - expect(body.operationName).toMatch(/^NaggGraphql/); - expect(body.operationName).not.toBe('UserFeed'); - // …and the document actually defines that operation (so nagg can run it). - expect(body.query).toContain(`query ${body.operationName}`); - }); - - // Thread intentionally has NO dual-transport equivalence case: it is the - // documented exception to "prefer app-view" and stays GraphQL-only, because - // nagg's REST `/nostr/thread` cannot reproduce the viewer-specific relevance - // ranking (authoredReplyChain + rankedReferencedBy over the follow graph). - - it('getNotifications yields identical output from GraphQL and app-view transports', async () => { - const notificationNodes = [{ reason: 'mention', actorVertexScore: 7, event: rootGql }]; - - setupEnv(); - // Notifications ALWAYS prefer the app-view; miss it (404) so this leg - // rides the documented fallback onto the GraphQL transport under test. - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 404, - statusText: 'Not Found', - json: async () => ({}), - }); - mockFetch.mockResolvedValueOnce( - jsonResponse({ - data: { notifications: { nodes: notificationNodes, pageInfo: { hasNextPage: false } } }, - }) - ); - const graphqlNotifs = await loadClient().getNotifications({ viewerPubkey: PAD('viewer') }); - const graphqlUrl = String(mockFetch.mock.calls[1][0]); - - // Canonical notifications body the REST `/nostr/notifications` route emits. - const canonicalNotifications = { - notifications: { - nodes: [ - { - event: { - id: PAD('root'), - kind: 1, - pubkey: PAD('alice'), - content: 'root', - tags: [], - created_at: 100, - }, - reason: 'mention', - actorVertexScore: 7, - }, - ], - pageInfo: { hasNextPage: false }, - }, - metrics: { - [PAD('root')]: { likeCount: 5, repostCount: 0, replyCount: 0, satsZapped: 0 }, - }, - profiles: { [PAD('alice')]: { name: 'Alice' } }, - quoted: {}, - }; - - jest.resetModules(); - mockFetch.mockReset(); - setupEnv({ EXPO_PUBLIC_NOSTR_NOTIFICATIONS_APPVIEW: 'true' }); - mockFetch.mockResolvedValueOnce(jsonResponse(canonicalNotifications)); - const appViewNotifs = await loadClient().getNotifications({ viewerPubkey: PAD('viewer') }); - const appViewUrl = String(mockFetch.mock.calls[0][0]); - - expect(graphqlUrl).toContain('/graphql'); - expect(appViewUrl).toContain('/v1/nostr/notifications'); - expect(appViewNotifs.notifications).toEqual(graphqlNotifs.notifications); - expect([...appViewNotifs.metricsMap]).toEqual([...graphqlNotifs.metricsMap]); - expect([...appViewNotifs.profilesMap]).toEqual([...graphqlNotifs.profilesMap]); - expect(appViewNotifs.paginationUntil).toBe(graphqlNotifs.paginationUntil); - }); -}); diff --git a/__tests__/nostrMetadataCacheSeed.test.ts b/__tests__/nostrMetadataCacheSeed.test.ts new file mode 100644 index 000000000..19ff3aa0f --- /dev/null +++ b/__tests__/nostrMetadataCacheSeed.test.ts @@ -0,0 +1,70 @@ +/** + * Unit tests for nostrMetadataCache's low-confidence seed — the single-profile- + * cache path that nagg feed profiles (and search results) flow through. It must + * never clobber authoritative relay kind-0 data, only fill gaps, and mark new + * entries immediately stale so a real fetch still runs. + */ +import { useNostrMetadataCache } from '@/shared/stores/global/nostrMetadataCache'; + +jest.mock('@/shared/lib/logger', () => ({ + storeLog: { info: jest.fn(), debug: jest.fn(), warn: jest.fn(), error: jest.fn() }, + log: { info: jest.fn(), debug: jest.fn(), warn: jest.fn(), error: jest.fn() }, + redactError: (e: unknown) => e, +})); + +jest.mock('@/shared/lib/cashu/profileScopedStorage', () => ({ + createProfileScopedStorage: () => ({ + getItem: async () => null, + setItem: async () => {}, + removeItem: async () => {}, + }), +})); + +beforeEach(() => { + useNostrMetadataCache.setState({ byPubkey: {} }); +}); + +describe('seedManyProfilesLowConfidence', () => { + it('inserts a new profile as immediately stale (fetchedAt: 0)', () => { + useNostrMetadataCache.getState().seedManyProfilesLowConfidence({ + pk1: { name: 'alice', picture: 'http://x/a.png' }, + }); + const entry = useNostrMetadataCache.getState().byPubkey.pk1; + expect(entry.name).toBe('alice'); + expect(entry.fetchedAt).toBe(0); // stale → a real kind-0 fetch still runs + }); + + it('never clobbers existing (relay-authoritative) fields; only fills gaps', () => { + // Authoritative relay kind-0: rich displayName, freshly fetched. + useNostrMetadataCache.getState().setProfile('pk1', { + displayName: 'Alice (verified)', + nip05: 'alice@example.com', + }); + const before = useNostrMetadataCache.getState().byPubkey.pk1; + + // nagg low-confidence seed brings a name + picture. + useNostrMetadataCache.getState().seedManyProfilesLowConfidence({ + pk1: { name: 'alice', picture: 'http://x/a.png' }, + }); + + const after = useNostrMetadataCache.getState().byPubkey.pk1; + expect(after.displayName).toBe('Alice (verified)'); // authoritative field untouched + expect(after.nip05).toBe('alice@example.com'); + expect(after.picture).toBe('http://x/a.png'); // gap filled + expect(after.fetchedAt).toBe(before.fetchedAt); // freshness preserved, not reset to 0 + }); + + it('is a no-op when the seed adds nothing new', () => { + useNostrMetadataCache.getState().setProfile('pk1', { name: 'alice', picture: 'p' }); + const before = useNostrMetadataCache.getState().byPubkey; + useNostrMetadataCache.getState().seedManyProfilesLowConfidence({ pk1: { name: 'alice' } }); + expect(useNostrMetadataCache.getState().byPubkey).toBe(before); // same reference, no write + }); + + it('seedFromSearchResults delegates to the same low-confidence path', () => { + useNostrMetadataCache + .getState() + .seedFromSearchResults([{ pubkey: 'pk2', profile: { name: 'bob' } }]); + expect(useNostrMetadataCache.getState().byPubkey.pk2.fetchedAt).toBe(0); + }); +}); diff --git a/__tests__/nostrSocialStoreOwnSync.test.ts b/__tests__/nostrSocialStoreOwnSync.test.ts index abe31f331..55863ea84 100644 --- a/__tests__/nostrSocialStoreOwnSync.test.ts +++ b/__tests__/nostrSocialStoreOwnSync.test.ts @@ -20,11 +20,7 @@ jest.mock('@/shared/lib/cashu/profileScopedStorage', () => ({ })); beforeEach(() => { - useNostrSocialStore.setState({ - likesByEventId: {}, - repostsByEventId: {}, - repliedByEventId: {}, - }); + useNostrSocialStore.setState({ engagementByEventId: {} }); }); describe('nostrSocialStore own-events ingest', () => { @@ -37,29 +33,43 @@ describe('nostrSocialStore own-events ingest', () => { // A second batch that omits t2 must NOT delete it (unlike the old scoped sync). s.ingestOwnLikes([{ targetEventId: 't1', reactionEventId: 'r1b', createdAt: 200 }]); - const { likesByEventId } = useNostrSocialStore.getState(); - expect(likesByEventId.t1.reactionEventId).toBe('r1b'); // newer wins - expect(likesByEventId.t2.reactionEventId).toBe('r2'); // preserved + const { engagementByEventId } = useNostrSocialStore.getState(); + expect(engagementByEventId.t1.liked?.ownEventId).toBe('r1b'); // newer wins + expect(engagementByEventId.t2.liked?.ownEventId).toBe('r2'); // preserved + }); + + it('merges multiple action types into ONE record per target (no +3 maps)', () => { + const s = useNostrSocialStore.getState(); + s.ingestOwnLikes([{ targetEventId: 't1', reactionEventId: 'like1', createdAt: 100 }]); + s.ingestOwnReposts([{ targetEventId: 't1', repostEventId: 'repost1', createdAt: 200 }]); + + const record = useNostrSocialStore.getState().engagementByEventId.t1; + expect(record.liked?.ownEventId).toBe('like1'); + expect(record.reposted?.ownEventId).toBe('repost1'); + expect(record.updatedAt).toBe(200); // newest contributing action }); - it('populates the replied index for the "you replied" highlight', () => { + it('populates the replied field for the "you replied" highlight', () => { useNostrSocialStore .getState() .ingestOwnReplies([{ targetEventId: 'parent1', replyEventId: 'reply1', createdAt: 100 }]); - expect(useNostrSocialStore.getState().repliedByEventId.parent1.replyEventId).toBe('reply1'); + expect(useNostrSocialStore.getState().engagementByEventId.parent1.replied?.ownEventId).toBe( + 'reply1' + ); }); - it('applies kind:5 deletions by own event id across likes/reposts/replies', () => { + it('applies kind:5 deletions by own event id, clearing only the deleted action', () => { const s = useNostrSocialStore.getState(); + // t1 has BOTH a like (to delete) and a repost (to keep) — the record survives. s.ingestOwnLikes([{ targetEventId: 't1', reactionEventId: 'like-del', createdAt: 100 }]); - s.ingestOwnReposts([{ targetEventId: 't2', repostEventId: 'repost-keep', createdAt: 100 }]); + s.ingestOwnReposts([{ targetEventId: 't1', repostEventId: 'repost-keep', createdAt: 100 }]); s.ingestOwnReplies([{ targetEventId: 't3', replyEventId: 'reply-del', createdAt: 100 }]); s.applyOwnDeletions(['like-del', 'reply-del']); const next = useNostrSocialStore.getState(); - expect(next.likesByEventId.t1).toBeUndefined(); // like deleted → un-highlight - expect(next.repliedByEventId.t3).toBeUndefined(); // reply deleted - expect(next.repostsByEventId.t2?.repostEventId).toBe('repost-keep'); // untouched + expect(next.engagementByEventId.t1.liked).toBeUndefined(); // like deleted → un-highlight + expect(next.engagementByEventId.t1.reposted?.ownEventId).toBe('repost-keep'); // sibling kept + expect(next.engagementByEventId.t3).toBeUndefined(); // last action gone → record dropped }); }); diff --git a/__tests__/notificationGroups.test.ts b/__tests__/notificationGroups.test.ts index b1384413d..e9b260091 100644 --- a/__tests__/notificationGroups.test.ts +++ b/__tests__/notificationGroups.test.ts @@ -23,31 +23,35 @@ const notification = (id: string, reason: string, targetEventId?: string): FeedN }); describe('buildNotificationListItems', () => { - it('batches only contiguous follows and reposts', () => { + it('groups follows and same-target reposts across the whole page (not just contiguous)', () => { const items = buildNotificationListItems([ notification('follow-1', 'follow'), notification('follow-2', 'follow'), notification('reply-1', 'reply'), - notification('follow-3', 'follow'), + notification('follow-3', 'follow'), // not contiguous with the others — still groups notification('repost-1', 'repost', 'post-1'), notification('repost-2', 'repost', 'post-1'), - notification('zap-1', 'zap'), + notification('zap-1', 'zap'), // lone batchable → demoted to single notification('quote-1', 'quote'), ]); expect(items).toEqual([ - expect.objectContaining({ type: 'group', reason: 'follow' }), + // Anchored at follow-1's position; follow-3 merges up despite reply-1 between. expect.objectContaining({ - type: 'single', - notification: expect.objectContaining({ reason: 'reply' }), + type: 'group', + reason: 'follow', + total: 3, + notifications: [ + expect.objectContaining({ event: expect.objectContaining({ id: 'follow-1' }) }), + expect.objectContaining({ event: expect.objectContaining({ id: 'follow-2' }) }), + expect.objectContaining({ event: expect.objectContaining({ id: 'follow-3' }) }), + ], }), expect.objectContaining({ type: 'single', - notification: expect.objectContaining({ - event: expect.objectContaining({ id: 'follow-3' }), - }), + notification: expect.objectContaining({ reason: 'reply' }), }), - expect.objectContaining({ type: 'group', reason: 'repost' }), + expect.objectContaining({ type: 'group', reason: 'repost', total: 2 }), expect.objectContaining({ type: 'single', notification: expect.objectContaining({ reason: 'zap' }), diff --git a/__tests__/receiveScreenLayout.test.tsx b/__tests__/receiveScreenLayout.test.tsx index 047e5b70e..710fede94 100644 --- a/__tests__/receiveScreenLayout.test.tsx +++ b/__tests__/receiveScreenLayout.test.tsx @@ -20,12 +20,23 @@ jest.mock('@/shared/lib/logger', () => ({ paymentLog: { warn: jest.fn(), }, + // The loading placeholder's SkeletonLoadingShimmer routes through + // contentShiftLog, which reads feedLog.isLevelEnabled. Disabled → no logging. + feedLog: { + isLevelEnabled: () => false, + info: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, useLifecycleLogger: jest.fn(), })); jest.mock('@/shared/hooks/useThemeColor', () => ({ + // Hex values: the loading placeholder now renders a SkeletonLoadingShimmer + // whose gradient runs colors through hex-color-opacity, which rejects names. useThemeColor: (tokens: string | string[]) => - Array.isArray(tokens) ? tokens.map(() => 'muted') : 'muted', + // eslint-disable-next-line no-restricted-syntax -- test mock needs a real hex + Array.isArray(tokens) ? tokens.map(() => '#888888') : '#888888', })); jest.mock('@/shared/hooks/useMintInfo', () => ({ diff --git a/__tests__/sendMemoSheet.test.ts b/__tests__/sendMemoSheet.test.ts index b0a8b277d..303ed695f 100644 --- a/__tests__/sendMemoSheet.test.ts +++ b/__tests__/sendMemoSheet.test.ts @@ -16,7 +16,7 @@ jest.mock('@gorhom/bottom-sheet', () => ({ BottomSheetScrollView: 'BottomSheetScrollView', BottomSheetTextInput: 'BottomSheetTextInput', })); -jest.mock('@legendapp/list/react-native', () => ({ LegendList: 'LegendList' })); +jest.mock('@shopify/flash-list', () => ({ FlashList: 'FlashList' })); jest.mock('heroui-native', () => ({ BottomSheet: { Title: 'BottomSheet.Title' }, })); diff --git a/__tests__/skeletonContentCrossfade.test.tsx b/__tests__/skeletonContentCrossfade.test.tsx new file mode 100644 index 000000000..d9b238f5b --- /dev/null +++ b/__tests__/skeletonContentCrossfade.test.tsx @@ -0,0 +1,239 @@ +/** + * @jest-environment node + * + * `SkeletonContentCrossfade` phase machine. These pin the behaviors the whole + * app's skeleton→content swaps rely on: + * - loading shows the skeleton (+ region wave), never the content; + * - `wave="none"` and reduced motion suppress the shimmer; + * - on the loading→loaded edge the content mounts under a still-present + * skeleton overlay (the crossfade), while reduced motion / `exit="none"` + * swap instantly; + * - re-entering loading cancels an in-flight exit (no stacked overlays); + * - a row that mounts already-loaded never plays a fade. + * + * Reanimated is mocked so `withTiming` does NOT auto-complete — that lets us + * observe the live "exiting" frame (content + fading skeleton) deterministically. + */ + +import React from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; + +import { SkeletonContentCrossfade } from '@/shared/ui/composed/SkeletonContentCrossfade'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let mockReducedMotion = false; + +jest.mock('react-native-reanimated', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: { + View: ({ children, ...props }: { children?: React.ReactNode }) => + ReactActual.createElement(View, props, children), + }, + useReducedMotion: () => mockReducedMotion, + useSharedValue: (initial: number) => ({ + value: initial, + get() { + return this.value; + }, + set(next: number) { + this.value = next; + }, + }), + // Return the target but never invoke the completion callback, so the + // component stays in its 'exiting' frame for assertions. + withTiming: (toValue: number) => toValue, + cancelAnimation: () => {}, + useAnimatedStyle: () => ({}), + runOnJS: (fn: (...args: unknown[]) => unknown) => fn, + interpolate: () => 0, + Extrapolation: { CLAMP: 'clamp' }, + Easing: { + out: () => (t: number) => t, + inOut: () => (t: number) => t, + cubic: (t: number) => t, + quad: (t: number) => t, + }, + ReduceMotion: { System: 'system' }, + }; +}); + +jest.mock('@/shared/hooks/useThemeColor', () => ({ + // SkeletonLoadingShimmer is mocked here, so the value is never run through + // hex-color-opacity — a plain token string is fine. + useThemeColor: (tokens: string | readonly string[]) => + Array.isArray(tokens) ? tokens.map(() => 'theme-surface') : 'theme-surface', +})); + +jest.mock('@/shared/ui/composed/SkeletonExitShimmer', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + SkeletonLoadingShimmer: ({ active }: { active?: boolean }) => + active ? ReactActual.createElement(View, { testID: 'shimmer' }) : null, + }; +}); + +const skeletonNode = () => ; +const contentNode = () => ; + +function makeView() { + return jest.requireActual('react-native').View; +} +const View = makeView(); + +function present(renderer: TestRenderer.ReactTestRenderer, testID: string): boolean { + return renderer.root.findAll((n) => n.props?.testID === testID).length > 0; +} + +function render(element: React.ReactElement) { + let renderer!: TestRenderer.ReactTestRenderer; + act(() => { + renderer = TestRenderer.create(element); + }); + return renderer; +} + +describe('SkeletonContentCrossfade', () => { + beforeEach(() => { + mockReducedMotion = false; + }); + + it('shows skeleton + region wave while loading, not content', () => { + const r = render( + + ); + expect(present(r, 'sk')).toBe(true); + expect(present(r, 'shimmer')).toBe(true); + expect(present(r, 'ct')).toBe(false); + act(() => r.unmount()); + }); + + it('omits the wave when wave="none"', () => { + const r = render( + + ); + expect(present(r, 'sk')).toBe(true); + expect(present(r, 'shimmer')).toBe(false); + act(() => r.unmount()); + }); + + it('omits the wave under reduced motion', () => { + mockReducedMotion = true; + const r = render( + + ); + expect(present(r, 'shimmer')).toBe(false); + act(() => r.unmount()); + }); + + it('crossfades on the loading→loaded edge: content mounts under a fading skeleton', () => { + const r = render( + + ); + act(() => { + r.update( + + ); + }); + // Exiting frame: real content present AND skeleton overlay still fading out. + expect(present(r, 'ct')).toBe(true); + expect(present(r, 'sk')).toBe(true); + act(() => r.unmount()); + }); + + it('swaps instantly under reduced motion (no lingering skeleton)', () => { + mockReducedMotion = true; + const r = render( + + ); + act(() => { + r.update( + + ); + }); + expect(present(r, 'ct')).toBe(true); + expect(present(r, 'sk')).toBe(false); + act(() => r.unmount()); + }); + + it('swaps instantly with exit="none" (no fading skeleton overlay)', () => { + const r = render( + + ); + act(() => { + r.update( + + ); + }); + expect(present(r, 'ct')).toBe(true); + expect(present(r, 'sk')).toBe(false); + act(() => r.unmount()); + }); + + it('re-entering loading cancels the exit: skeleton returns, content gone (no stacked overlay)', () => { + const r = render( + + ); + act(() => { + r.update( + + ); + }); + act(() => { + r.update( + + ); + }); + expect(present(r, 'sk')).toBe(true); + expect(present(r, 'ct')).toBe(false); + act(() => r.unmount()); + }); + + it('a row mounted already-loaded shows content with no skeleton (no spurious fade)', () => { + const r = render( + + ); + expect(present(r, 'ct')).toBe(true); + expect(present(r, 'sk')).toBe(false); + act(() => r.unmount()); + }); +}); diff --git a/__tests__/threadFixedItemSize.test.ts b/__tests__/threadFixedItemSize.test.ts deleted file mode 100644 index 0a9a8a8f1..000000000 --- a/__tests__/threadFixedItemSize.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * @jest-environment node - * - * `threadFixedItemSize` feeds LegendList deterministic heights for the - * content-free skeleton / sort-tabs rows so they don't snap from the generic - * estimate on first paint. Dynamic rows (target, replies) stay unmeasured. - */ - -import { - REPLY_SORT_TABS_FIXED_HEIGHT, - TARGET_SKELETON_FIXED_HEIGHT, - threadFixedItemSize, -} from '@/features/feed/lib/threadListLayout'; - -describe('threadFixedItemSize', () => { - it('reserves the fixed sort-tabs height for the reply-sort-tabs row', () => { - expect(threadFixedItemSize({ type: 'reply-sort-tabs' })).toBe(REPLY_SORT_TABS_FIXED_HEIGHT); - }); - - it('reserves the fixed target-skeleton height', () => { - expect(threadFixedItemSize({ type: 'target-skeleton' })).toBe(TARGET_SKELETON_FIXED_HEIGHT); - }); - - it('returns a finite, positive height for a reply-skeleton', () => { - const height = threadFixedItemSize({ type: 'reply-skeleton', skeletonIndex: 0 }); - expect(typeof height).toBe('number'); - expect(height).toBeGreaterThan(0); - }); - - it('leaves dynamic rows (target, replies) unmeasured so LegendList measures them', () => { - expect(threadFixedItemSize({ type: 'target' })).toBeUndefined(); - expect(threadFixedItemSize({ type: 'reply' })).toBeUndefined(); - }); -}); diff --git a/__tests__/threadItems.test.ts b/__tests__/threadItems.test.ts index 1bfb6fcf1..5553266aa 100644 --- a/__tests__/threadItems.test.ts +++ b/__tests__/threadItems.test.ts @@ -173,4 +173,34 @@ describe('thread item builders', () => { built?.items.filter((item) => item.type === 'reply').map((item) => item.event.id) ).toEqual(['seeded-reply', 'next-reply']); }); + + it('initial fetch keeps the seeded reply order as a stable prefix (no reshuffle)', () => { + const root = note({ id: 'root', content: 'root', tags: [], createdAt: 100 }); + const seededReply = note({ + id: 'seeded-reply', content: 'seeded', tags: [['e', 'root', '', 'reply']], createdAt: 101, + }); + const rankedFirst = note({ + id: 'ranked-first', content: 'ranked higher by the server', tags: [['e', 'root', '', 'reply']], createdAt: 102, + }); + const result: ThreadResult = { + allEvents: mapEvents([root, seededReply, rankedFirst]), + profiles: new Map(), + metrics: new Map(), + quotedEvents: new Map(), + thread: { parents: [], target: root, replies: [seededReply, rankedFirst] }, + // The server ranks rankedFirst ABOVE the already-on-screen seeded reply. + replyPageEventIds: ['ranked-first', 'seeded-reply'], + replyPageSize: 10, + loadedReplyCount: 2, + hasMoreReplies: false, + }; + + // The seeded reply (already painted) stays first; the ranked delta appends. + const orderedIds = orderedReplyIdsForThreadResult(result, 'initial', ['seeded-reply']); + expect(orderedIds).toEqual(['seeded-reply', 'ranked-first']); + + // With no seed, the server order is used as-is. + const cold = orderedReplyIdsForThreadResult(result, 'initial', []); + expect(cold).toEqual(['ranked-first', 'seeded-reply']); + }); }); diff --git a/__tests__/visualLayoutCoverage.test.ts b/__tests__/visualLayoutCoverage.test.ts deleted file mode 100644 index ef03b0391..000000000 --- a/__tests__/visualLayoutCoverage.test.ts +++ /dev/null @@ -1,367 +0,0 @@ -/** - * @jest-environment node - */ - -import fs from 'fs'; -import path from 'path'; -import * as ts from 'typescript'; - -const ROOT = path.resolve(__dirname, '..'); -const SKIP_DIRS = new Set(['.git', '.expo', '.next', 'android', 'coverage', 'ios', 'node_modules']); -const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx']); -const REQUIRED_LIST_PROPS = [ - 'onItemSizeChanged', - 'onLoad', - 'onMetricsChange', - 'onStickyHeaderChange', - 'onViewableItemsChanged', - 'viewabilityConfig', -] as const; - -type SourceFile = { - path: string; - source: string; -}; - -type LegendListCallSite = SourceFile & { - line: number; - tagText: string; -}; - -type ActivityIndicatorCallSite = SourceFile & { - line: number; - wrappedByVisualLayoutProbe: boolean; -}; - -type FlatListCallSite = SourceFile & { - line: number; - tagText: string; -}; - -function walkSourceFiles(dir: string, files: string[] = []): string[] { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - if (entry.isDirectory()) { - if (!SKIP_DIRS.has(entry.name)) { - walkSourceFiles(path.join(dir, entry.name), files); - } - continue; - } - if (entry.isFile() && SOURCE_EXTENSIONS.has(path.extname(entry.name))) { - files.push(path.join(dir, entry.name)); - } - } - return files; -} - -function isLegendListElementName(tagName: ts.JsxTagNameExpression): boolean { - return ( - ts.isIdentifier(tagName) && - (tagName.text === 'LegendList' || tagName.text === 'AnimatedLegendList') - ); -} - -function jsxElementName(tagName: ts.JsxTagNameExpression): string | null { - if (ts.isIdentifier(tagName)) return tagName.text; - if (ts.isPropertyAccessExpression(tagName)) return tagName.name.text; - return null; -} - -function collectLegendListCallSites(filePath: string): LegendListCallSite[] { - const source = fs.readFileSync(filePath, 'utf8'); - const sourceFile = ts.createSourceFile( - filePath, - source, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TSX - ); - const callSites: LegendListCallSite[] = []; - - function visit(node: ts.Node): void { - if ( - (ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)) && - isLegendListElementName(node.tagName) - ) { - callSites.push({ - path: filePath, - source, - line: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1, - tagText: source.slice(node.getStart(sourceFile), node.end), - }); - } - ts.forEachChild(node, visit); - } - - visit(sourceFile); - return callSites; -} - -function collectActivityIndicatorCallSites(filePath: string): ActivityIndicatorCallSite[] { - const source = fs.readFileSync(filePath, 'utf8'); - const sourceFile = ts.createSourceFile( - filePath, - source, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TSX - ); - const callSites: ActivityIndicatorCallSite[] = []; - const elementStack: string[] = []; - - function pushCallSite(node: ts.JsxSelfClosingElement | ts.JsxOpeningElement): void { - callSites.push({ - path: filePath, - source, - line: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1, - wrappedByVisualLayoutProbe: elementStack.includes('VisualLayoutProbe'), - }); - } - - function visit(node: ts.Node): void { - if (ts.isJsxElement(node)) { - const name = jsxElementName(node.openingElement.tagName); - if (name === 'ActivityIndicator') { - pushCallSite(node.openingElement); - } - if (name) elementStack.push(name); - for (const child of node.children) { - visit(child); - } - if (name) elementStack.pop(); - return; - } - - if (ts.isJsxSelfClosingElement(node) && jsxElementName(node.tagName) === 'ActivityIndicator') { - pushCallSite(node); - } - - ts.forEachChild(node, visit); - } - - visit(sourceFile); - return callSites; -} - -function collectFlatListCallSites(filePath: string): FlatListCallSite[] { - const source = fs.readFileSync(filePath, 'utf8'); - const sourceFile = ts.createSourceFile( - filePath, - source, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TSX - ); - const callSites: FlatListCallSite[] = []; - - function visit(node: ts.Node): void { - if ( - (ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)) && - jsxElementName(node.tagName) === 'FlatList' - ) { - callSites.push({ - path: filePath, - source, - line: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1, - tagText: source.slice(node.getStart(sourceFile), node.end), - }); - } - ts.forEachChild(node, visit); - } - - visit(sourceFile); - return callSites; -} - -function legendListCallSites(): LegendListCallSite[] { - return walkSourceFiles(ROOT).flatMap(collectLegendListCallSites); -} - -function activityIndicatorCallSites(): ActivityIndicatorCallSite[] { - return walkSourceFiles(ROOT).flatMap(collectActivityIndicatorCallSites); -} - -function flatListCallSites(): FlatListCallSite[] { - return walkSourceFiles(ROOT).flatMap(collectFlatListCallSites); -} - -function relative(filePath: string): string { - return path.relative(ROOT, filePath); -} - -function siteLabel(site: { path: string; line: number }): string { - return `${relative(site.path)}:${site.line}`; -} - -function hasJsxProp(tagText: string, prop: string): boolean { - return new RegExp(`\\b${prop}\\s*=`).test(tagText); -} - -describe('visual layout telemetry coverage', () => { - it('keeps each direct LegendList element wired into visual list telemetry', () => { - const missing = legendListCallSites() - .map((site) => ({ - site, - missingProps: REQUIRED_LIST_PROPS.filter((prop) => !hasJsxProp(site.tagText, prop)), - })) - .filter(({ missingProps }) => missingProps.length > 0); - - expect( - missing.map( - ({ site, missingProps }) => `${siteLabel(site)} missing ${missingProps.join(', ')}` - ) - ).toEqual([]); - }); - - it('keeps direct LegendList row files measurable by visual layout probes', () => { - const missing = legendListCallSites().filter((site) => { - return !site.source.includes('VisualLayoutProbe'); - }); - - expect(missing.map(siteLabel)).toEqual([]); - }); - - it('keeps direct LegendList files wired to virtual position state snapshots', () => { - const missing = legendListCallSites().filter((site) => { - return !site.source.includes('getListState'); - }); - - expect(missing.map(siteLabel)).toEqual([]); - }); - - it('keeps direct native activity indicators wrapped by visual layout probes', () => { - const missing = activityIndicatorCallSites().filter((site) => !site.wrappedByVisualLayoutProbe); - - expect(missing.map(siteLabel)).toEqual([]); - }); - - it('keeps feed FlatLists wired into visual viewability telemetry', () => { - const missing = flatListCallSites() - .filter((site) => relative(site.path).startsWith('features/feed/')) - .map((site) => ({ - site, - missingProps: ['viewabilityConfig', 'onViewableItemsChanged'].filter( - (prop) => !hasJsxProp(site.tagText, prop) - ), - })) - .filter(({ missingProps }) => missingProps.length > 0); - - expect( - missing.map( - ({ site, missingProps }) => `${siteLabel(site)} missing ${missingProps.join(', ')}` - ) - ).toEqual([]); - }); - - it('keeps feed FlatLists backed by visual list loggers', () => { - const missing = flatListCallSites().filter((site) => { - return ( - relative(site.path).startsWith('features/feed/') && - !site.source.includes('useVisualListLogger') - ); - }); - - expect(missing.map(siteLabel)).toEqual([]); - }); - - it('keeps shared status indicators measurable without double-logging Spinner internals', () => { - const loadingIndicator = fs.readFileSync( - path.join(ROOT, 'shared/blocks/status/LoadingIndicator.tsx'), - 'utf8' - ); - const spinner = fs.readFileSync(path.join(ROOT, 'shared/ui/primitives/Spinner.tsx'), 'utf8'); - - expect(loadingIndicator).toContain('useVisualLayoutLogger'); - expect(loadingIndicator).toContain("itemType: 'status-indicator'"); - expect(spinner).toContain('visualDisabled'); - }); - - it('keeps shared loading placeholders measurable', () => { - const text = fs.readFileSync(path.join(ROOT, 'shared/ui/primitives/Text.tsx'), 'utf8'); - const avatar = fs.readFileSync(path.join(ROOT, 'shared/ui/primitives/Avatar.tsx'), 'utf8'); - - expect(text).toContain('useVisualLayoutLogger'); - expect(text).toContain("itemType: 'text-loading'"); - expect(text).toContain('collapsable={false}'); - expect(avatar).toContain('useVisualLayoutLogger'); - expect(avatar).toContain("itemType: 'avatar-loading'"); - expect(avatar).toContain('collapsable={false}'); - }); - - it('keeps composer ScrollViews wired into visual scroll metrics', () => { - const composer = fs.readFileSync( - path.join(ROOT, 'features/composer/ui/PostComposer.tsx'), - 'utf8' - ); - - expect(composer).toContain('useVisualScrollMetricsLogger'); - expect(composer).toContain("component: 'PostComposerScrollView'"); - expect(composer).toContain("component: 'PostComposerMediaTrayScrollView'"); - expect(composer).toContain('onContentSizeChange={composerScrollMetrics.onContentSizeChange}'); - expect(composer).toContain('onScroll={composerScrollMetrics.onScroll}'); - expect(composer).toContain('onContentSizeChange={mediaTrayScrollMetrics.onContentSizeChange}'); - expect(composer).toContain('onScroll={mediaTrayScrollMetrics.onScroll}'); - }); - - it('keeps horizontal visual rails wired into scroll metrics', () => { - const recentPeople = fs.readFileSync( - path.join(ROOT, 'features/feed/components/RecentPeopleSearchStrip.tsx'), - 'utf8' - ); - const sectionAnchorList = fs.readFileSync( - path.join(ROOT, 'shared/ui/composed/SectionAnchorList.tsx'), - 'utf8' - ); - - expect(recentPeople).toContain('useVisualScrollMetricsLogger'); - expect(recentPeople).toContain("component: 'RecentPeopleSearchStripScrollView'"); - expect(recentPeople).toContain('onLayout={handleStripLayout}'); - expect(recentPeople).toContain('onContentSizeChange={handleStripContentSizeChange}'); - expect(recentPeople).toContain('reportStripScroll(event)'); - expect(sectionAnchorList).toContain('useVisualScrollMetricsLogger'); - expect(sectionAnchorList).toContain("component: 'SectionAnchorListAnchorScrollView'"); - expect(sectionAnchorList).toContain('onLayout={anchorScrollMetrics.onLayout}'); - expect(sectionAnchorList).toContain( - 'onContentSizeChange={anchorScrollMetrics.onContentSizeChange}' - ); - expect(sectionAnchorList).toContain('onScroll={anchorScrollMetrics.onScroll}'); - }); - - it('keeps profile surfaces wired into visual state-change snapshots', () => { - const userFeed = fs.readFileSync( - path.join(ROOT, 'features/feed/components/UserFeed.tsx'), - 'utf8' - ); - const profileScreen = fs.readFileSync( - path.join(ROOT, 'features/user/screens/UserProfileScreen.tsx'), - 'utf8' - ); - - expect(userFeed).toContain('useVisualStateLogger'); - expect(userFeed).toContain("stateKey: 'user-feed-state'"); - expect(userFeed).toContain("reason: 'user-feed-state'"); - expect(profileScreen).toContain('useVisualStateLogger'); - expect(profileScreen).toContain("stateKey: 'profile-state'"); - expect(profileScreen).toContain("reason: 'profile-state'"); - }); - - it('keeps visual probes reporting style stack context', () => { - const probe = fs.readFileSync( - path.join(ROOT, 'shared/ui/composed/VisualLayoutProbe.tsx'), - 'utf8' - ); - const contentShiftLog = fs.readFileSync( - path.join(ROOT, 'shared/lib/contentShiftLog.ts'), - 'utf8' - ); - - expect(probe).toContain('StyleSheet.flatten'); - expect(probe).toContain('stylePosition'); - expect(probe).toContain('styleZIndex'); - expect(probe).toContain('styleElevation'); - expect(probe).toContain('pointerEvents'); - expect(contentShiftLog).toContain('mountOrder'); - expect(contentShiftLog).toContain('outsideContainer'); - expect(contentShiftLog).toContain('containerViolationCount'); - expect(contentShiftLog).toContain('visual.layout.virtual_positions'); - }); -}); diff --git a/app/(settings-flow)/_layout.tsx b/app/(settings-flow)/_layout.tsx index a149c011e..3f61f6b11 100644 --- a/app/(settings-flow)/_layout.tsx +++ b/app/(settings-flow)/_layout.tsx @@ -18,7 +18,7 @@ const PROFILE_OPTIONS = { title: 'Profile' }; const AVATAR_OPTIONS = { title: 'Avatar fallback' }; const NOTIFICATION_POLICY_OPTIONS = { title: 'Notifications' }; const ROUTING_OPTIONS = { title: 'Swap routing' }; -const RELAYS_OPTIONS = { title: 'Relays' }; +const NETWORK_OPTIONS = { title: 'Network' }; const KEYRING_OPTIONS = { title: 'P2PK Keys' }; const STORAGE_OPTIONS = { title: 'Storage inventory' }; const DESIGN_SYSTEM_OPTIONS = { title: 'Design system' }; @@ -26,6 +26,7 @@ const DESIGN_SYSTEM_LOADING_OPTIONS = { title: 'Loading indicator' }; const DESIGN_SYSTEM_SEGMENTED_OPTIONS = { title: 'Segmented progress' }; const DESIGN_SYSTEM_TIMELINE_OPTIONS = { title: 'Timeline' }; const DESIGN_SYSTEM_EMPTY_STATES_OPTIONS = { title: 'Empty states' }; +const DESIGN_SYSTEM_SKELETON_CROSSFADE_OPTIONS = { title: 'Skeleton crossfade' }; const RECOVERY_OPTIONS = { title: 'Recover wallet' }; const DELETE_OPTIONS = { title: 'Delete account' }; @@ -45,7 +46,7 @@ export default function SettingsFlowLayout() { - + @@ -56,6 +57,10 @@ export default function SettingsFlowLayout() { name="design-system-empty-states" options={DESIGN_SYSTEM_EMPTY_STATES_OPTIONS} /> + diff --git a/app/(settings-flow)/design-system-skeleton-crossfade.tsx b/app/(settings-flow)/design-system-skeleton-crossfade.tsx new file mode 100644 index 000000000..9a1f50da8 --- /dev/null +++ b/app/(settings-flow)/design-system-skeleton-crossfade.tsx @@ -0,0 +1,5 @@ +import { SettingsDesignSystemSkeletonCrossfadeScreen } from '@/features/settings'; + +export default function DesignSystemSkeletonCrossfadeRoute() { + return ; +} diff --git a/app/(settings-flow)/network.tsx b/app/(settings-flow)/network.tsx new file mode 100644 index 000000000..31fa604e4 --- /dev/null +++ b/app/(settings-flow)/network.tsx @@ -0,0 +1,3 @@ +import { SettingsNetworkScreen } from '@/features/settings'; + +export default SettingsNetworkScreen; diff --git a/app/(settings-flow)/relays.tsx b/app/(settings-flow)/relays.tsx deleted file mode 100644 index da1f20bb1..000000000 --- a/app/(settings-flow)/relays.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { SettingsRelaysScreen } from '@/features/settings'; - -export default SettingsRelaysScreen; diff --git a/app/_layout.tsx b/app/_layout.tsx index a488ccbe1..65fdc698c 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -52,6 +52,7 @@ import { useAppBalance } from '@/features/wallet'; import { usePaymentStatusListener } from '@/shared/hooks/usePaymentStatusListener'; import { useSwapStatusListener } from '@/shared/hooks/useSwapStatusListener'; import { useOwnEventsSync } from '@/shared/lib/nostr/ownsync/useOwnEventsSync'; +import { useOwnSocialGraphSeed } from '@/shared/lib/nostr/ownsync/useOwnSocialGraphSeed'; import PopupHost from '@/shared/blocks/popup/PopupHost'; import { ActionMenuHost } from '@/shared/blocks/popup/ActionMenuHost'; import { AndroidImageOverlayHost } from '@/features/feed/components/nostr/image-overlay/AndroidImageOverlayHost'; @@ -246,6 +247,9 @@ function ProfileBalanceSync() { */ function OwnEventsSync() { useOwnEventsSync(); + // Read-side follow seed from the tiered facade (ADR 0003); the relay sub above + // stays the write-authoritative live-delta listener, merged via the same LWW gate. + useOwnSocialGraphSeed(); return null; } diff --git a/bun.lock b/bun.lock index 382af68d6..b4d131893 100644 --- a/bun.lock +++ b/bun.lock @@ -18,7 +18,6 @@ "@gandlaf21/bolt11-decode": "^3.1.1", "@gorhom/bottom-sheet": "5.2.9", "@internet-privacy/marmot-ts": "file:./vendor/marmot-ts", - "@legendapp/list": "3.0.0", "@mealection/react-native-boring-avatars": "1.1.2", "@monicon/metro": "^2.0.7", "@monicon/native": "^1.2.2", @@ -36,6 +35,7 @@ "@scure/base": "^2.0.0", "@scure/bip32": "^1.3.3", "@scure/bip39": "^1.2.2", + "@shopify/flash-list": "2", "@shopify/react-native-skia": "2.4.18", "@sovranbitcoin/coco-cashu-plugin-p2pk-import": "^0.1.0", "@sovranbitcoin/colada": "^0.7.0", @@ -820,8 +820,6 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@legendapp/list": ["@legendapp/list@3.0.0", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*" } }, "sha512-b5PzA2OUQrBATFqFi0978N7pNuMPRiANfLWuYzKsl6+CWEN8o5KSDlor2CrLNtxDbIirHJVionON5vQCS4bF2A=="], - "@manypkg/find-root": ["@manypkg/find-root@1.1.0", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@types/node": "^12.7.1", "find-up": "^4.1.0", "fs-extra": "^8.1.0" } }, "sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA=="], "@manypkg/get-packages": ["@manypkg/get-packages@1.1.3", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@changesets/types": "^4.0.1", "@manypkg/find-root": "^1.1.0", "fs-extra": "^8.1.0", "globby": "^11.0.0", "read-yaml-file": "^1.1.0" } }, "sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A=="], @@ -1066,6 +1064,8 @@ "@scure/bip39": ["@scure/bip39@1.6.0", "", { "dependencies": { "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A=="], + "@shopify/flash-list": ["@shopify/flash-list@2.3.2", "", { "peerDependencies": { "@babel/runtime": "*", "react": "*", "react-native": "*" } }, "sha512-vQnd0y0Ag11yzS30llaeCrtIU3xYTMe7KEOP3dveLImnVjQEUw6BEg1+Ztja6aODlVNz1BpvxtlQ5eZGxiLwKw=="], + "@shopify/react-native-skia": ["@shopify/react-native-skia@2.4.18", "", { "dependencies": { "canvaskit-wasm": "0.40.0", "react-reconciler": "0.31.0" }, "peerDependencies": { "react": ">=19.0", "react-native": ">=0.78", "react-native-reanimated": ">=3.19.1" }, "optionalPeers": ["react-native", "react-native-reanimated"], "bin": { "setup-skia-web": "scripts/setup-canvaskit.js" } }, "sha512-/AB/mvb2dGSQVIJTxyG9ZMFn2PjwWlVcFxHU4TV+K25LCU49ntJXl4xda2ghXxdENMggKgO9R5pA+P/AQ0UUrA=="], "@sinclair/typebox": ["@sinclair/typebox@0.34.49", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="], diff --git a/codereview/README.md b/codereview/README.md index f8eeab0e0..b9c94d04b 100644 --- a/codereview/README.md +++ b/codereview/README.md @@ -170,8 +170,10 @@ a typical session — your numbers will differ. | `startup` | ~5K | Initialization waterfall, gate sequence | | `full --format md` | ~6K | Pipe-delimited dense summary | | `slow` | ~18K | Operations exceeding threshold | +| `perf` | varies | Per-event latency p50/p95/p99 + sparkline | +| `redaction` | ~1K | Secret-redaction audit (brands + un-redacted flags) | | `screens` | ~70K | Screen flow + content snapshots | -| `errors` | ~90K | Errors with full context | +| `errors` | clustered | Errors collapsed to exemplars (`--all` = full context) | ### Recipes diff --git a/codereview/log-doctor/OVERVIEW.md b/codereview/log-doctor/OVERVIEW.md new file mode 100644 index 000000000..20e2e6151 --- /dev/null +++ b/codereview/log-doctor/OVERVIEW.md @@ -0,0 +1,273 @@ +# log-doctor — overview + +A CLI that turns a raw Sovran session log into compact, LLM-readable summaries. +You capture structured logs from a dev session, then ask log-doctor focused +questions ("what broke?", "why is startup slow?", "where did layout shift?") +instead of scrolling through 20k log lines. + +- **Tool:** `sovran-app/codereview/log-doctor/index.ts` (run with `bun run`) +- **Input:** a `log.txt` in `sovran-app/` (or piped on stdin) +- **Output:** dense text/markdown/json, sized to fit an LLM context window + +--- + +## 1. How we write logs in the app + +All logging goes through the scoped logger barrel at +`shared/lib/logger.ts`. You never `console.log`. + +### The logger API + +```ts +import { paymentLog, feedLog, log } from 'shared/lib/logger'; + +paymentLog.info('payment.send.start', { mintUrl, amount }); +feedLog.debug('feed.shift.note.height', { key, deltaHeight }); +log.error('nostr.relay.connect.failed', { relay, reason }); +``` + +- Five levels: `debug` / `info` / `warn` / `error` / `fatal`. +- Every call takes a **dot-separated event name** (`payment.send.start`) plus a + flat **params** object. The event name is the queryable key — log-doctor + filters and groups on it. +- **Pre-made child loggers** carry a module tag so events sort cleanly: + `nfcLog, cashuLog, nostrLog, walletLog, paymentLog, feedLog, apiLog, + storeLog, aiLog, chatLog, bitchatLog, wnLog, popupLog, mapLog`. Make your own + with `log.child({ module: 'x' })`. + +### Timing & spans + +```ts +await log.timed('coco.recovery', () => recover(), { warnThresholdMs: 1000 }); + +const span = paymentLog.startSpan('payment.melt', { mint }, { warnAtMs: 2000 }); +span.end({ ok: true }); // logs payment.melt.end with duration_ms +``` + +`timed`/`startSpan` auto-emit `.start`/`.end` entries with `duration_ms` and +**auto-escalate** to WARN/ERROR when an op runs slow (defaults 1s/5s). This is +what feeds the `slow` mode. + +### What each entry looks like + +Entries are structured JSON (`LogEntry` in `loggerCore.ts`): + +```jsonc +{ + "ts": "...", // ISO timestamp + "_t": 4611.2, // monotonic ms since app start (skew-free; subtract two to get a gap) + "level": "warn", + "event": "perf.js_thread_blocked", + "src": { "file": "...", "func": "...", "line": 42 }, + "params": { "blocked_ms": 2702.7 }, + "duration_ms": 51.5, // present on span ends + "device": { "platform": "ios", "logSessionId": "..." } +} +``` + +### Secret redaction (automatic) + +The logger compacts and redacts before anything is written, so dumps are safe +to paste into an LLM: + +- Fields named like secrets (`*nsec`, `*privateKey`, `mnemonic`, `seed`, + `*token`, `password`, `authorization`, …) are replaced with + `{ _kind, len }` — never the value. +- Values matching secret patterns (nsec, cashu token, JWT, PEM, 32-byte hex) + are branded by kind, not printed. +- Embedded secrets inside larger strings are scrubbed (you'll see `[mint_url]`, + `[error]` placeholders in real output). +- Long strings are truncated to a short preview. + +**Rules when adding logs:** +- Use scoped loggers from `shared/lib/logger`; never `console.log`. +- Event names are queryable prefixes: `payment.*`, `nostr.*`, `wallet.*`, + `perf.*`, `theme.*`, `feed.*`, `visual.*`. +- Never log raw secrets, raw ecash tokens, full invoices, or imported nsecs. +- Narrow unknown errors with `redactError(e)`; don't dump arbitrary nested objects. + +### Getting logs out of the app + +The console transport prints JSON; an on-device **file transport** mirrors every +entry to `log.txt` so logs survive dev-server disconnects +(`applyFileLogging`, `exportLogFile`, `getLogFileInfo` in `loggerFile.ts`). You +can also call `log.dumpForLLM({ format: 'md' })` to flush the in-memory ring +buffer to a paste-ready string. + +To feed log-doctor: capture a dev session's stdout to `sovran-app/log.txt` +(e.g. `expo start 2>&1 | tee log.txt`), or paste a `dumpForLLM()` dump into that +file. log-doctor reads `log.txt` by default, or stdin when piped. + +--- + +## 2. Running log-doctor + +```bash +cd sovran-app + +bun run codereview/log-doctor/index.ts [options] # reads ./log.txt +cat some-logs.jsonl | bun run codereview/log-doctor/index.ts stats +``` + +> `npm run log-doctor -- ` is the documented alias, but in this repo the +> npm/npx path currently hits a dependency-override error, so use `bun run`. + +**`--latest` is the flag you'll use most** — it keeps only the most recent app +session (it detects restarts via `_t` resets), so you're not mixing three runs. + +--- + +## 3. The modes + +24 modes. They fall into a few families. + +### Triage / overview +| Mode | Tokens | What it shows | +|------|--------|----------------| +| `budget` | tiny | Token cost of every mode for *this* log + what fits in which context window. Run it first. | +| `stats` | ~1K | Event frequency, slowest ops, error rate, timing gaps, noise/duplicate detection. | +| `devices` | small | Device/session labels in a mixed-device log file. | +| `timeline` | ~5K | One line per entry with delta timing. Pair with `--event`. | +| `full` | ~5–23K | All entries, deduped/trimmed. `--format md` is ~40% cheaper than json. | +| `diff` | varies | Compares latest session vs previous to isolate failure-specific entries. | + +### Failure & performance +| Mode | Tokens | What it shows | +|------|--------|----------------| +| `errors` | large | warn/error/fatal entries. **Clustered by default** — near-identical errors collapse to exemplar + count + first/last; `--all`/`--no-cluster` restores the full per-entry listing with `--context N`. | +| `slow` | ~5–18K | Gaps between consecutive entries exceeding `--threshold` ms (default 500). Note: measures *log-line gaps*, not op durations — use `perf` for durations. | +| `startup` | ~1.5–5K | Init waterfall, stage timing, gate sequence. | +| `gc` | varies | Hermes memory trend, GC pressure, JS-thread blocks, leak detection. | +| `perf` | varies | Per-event latency distribution (**p50/p95/p99 + sparkline**) from `params.ms` / `_perf`-tagged ops, plus slow-op and network/compute breakdowns. | +| `renders` | ~200 | Re-render counts + why-did-update hints. | + +### Domain-specific +| Mode | What it shows | +|------|----------------| +| `coco` | Coco/Colada wallet module breakdown, issues, mint requests. | +| `payment` | Payment/receive/redeem timeline grouped by operation id. | +| `toasts` | Toast lifecycle grouped by toast/payment id. | +| `crypto` | Crypto/cashu amount + proof operations. | +| `network` | Request/response pairs with latency. | +| `ws` | WebSocket health, subscription analysis, message rates. | +| `feed` | Feed/thread GraphQL, page mapping, reply seed/render flow. | +| `flows` | Reconstruct cross-async traces via `flowId` in ctx. | +| `ops` | General operation/span breakdown. | +| `screens` | Screen navigation flow + content snapshots + durations. | +| `redaction` | Read-side secret-redaction audit: counts `{_kind}` brands + `` markers, and heuristically flags raw values that look **un-redacted** (high-signal: nsec/xprv/cashu-token/jwt/email; low-signal: npub/note/64-hex). Reports event names, never values. | + +### Visual / content-shift (the big one for UI jank) +`visual` reads layout telemetry — row rects, item-size changes, virtual-position +snapshots, overlaps, container-boundary violations, sticky-header markers, large +jumps — and flags content-shift bugs. Narrow it with `--scope`, `--component`, +`--key`, `--item-type`. + +```bash +bun run codereview/log-doctor/index.ts visual --latest +bun run codereview/log-doctor/index.ts visual --latest --scope 'thread\.' +bun run codereview/log-doctor/index.ts visual --latest --component PostComposerToolbar +bun run codereview/log-doctor/index.ts timeline --event 'visual\.layout|\.shift\.' --latest +``` + +### Device control (separate tool) +`phone` drives a real iPhone over WebDriverAgent (`tap`, `tap-id`, `tree`, +`shot`, `swipe`, plus a `phone test` DSL runner). Not a log analyzer — it's how +you *generate* a fresh session to then inspect. + +--- + +## 4. Key options + +| Flag | Effect | +|------|--------| +| `--latest` | Most recent session only. Use almost always. | +| `--event ` | Filter to events matching substring/regex. | +| `--threshold ` | Duration cutoff for `slow` (default 500). | +| `--context ` | Entries around each error in `--all` mode (default 3). | +| `--all` / `--no-cluster` | `errors` mode: list every entry instead of clustering. | +| `--token-budget ` | Auto-prune output to fit N tokens. | +| `--since/--until ` | Time-window filter on `_t`. | +| `--limit/--offset` | Paginate huge sessions. | +| `--format json\|yaml\|md` | Output format for `full`. | +| `--no-inst` | Strip instrumentation (render.count, state.change). | +| `--device/--platform/--session` | Filter a mixed log. | +| `--scope/--component/--key/--item-type` | Narrow `visual`. | + +--- + +## 5. Worked examples (real output) + +### `budget` — what's affordable on this session +``` +TOKEN BUDGET ANALYSIS: + Total entries: 19939 + +MODE TOKEN COSTS (approximate): + renders 162 tokens █ + stats 1035 tokens █ + startup 1452 tokens █ + ... + screens 25207 tokens ████████ + errors 124694 tokens ████████████████████████████████████████ + +FITS IN CONTEXT WINDOW: + Small prompt (8K): renders, stats, startup, network, slow, timeline, coco, full (md), feed +``` +*Takeaway: `errors` is huge here — narrow it before reaching for it.* + +### `stats --latest` — frequency + timing snapshot +``` +=== LOG SESSION STATISTICS === +Device: {"platform":"ios","appVersion":"0.1.0","osVersion":"26.1",...} +Entries: 19939 Time span: 55.3s +BY LEVEL: DEBUG 14076 INFO 4769 WARN 1087 ERROR 7 + +TOP APP EVENTS: + 1744x visual.layout.measure + 1392x history.filters.matchesFilters + 980x init.timing +TIMING: Largest gap 33860ms ; Median gap 0ms +``` +*Takeaway: `history.filters.*` fires ~1.4k times — a re-computation hotspot.* + +### `errors --latest --context 1` — what went wrong, with neighbours +``` +>>> WARN transactions.filter.slow duration_ms=51.56 input=82 output=75 +>>> WARN perf.js_thread_blocked blocked_ms=2702.7 expected_ms=200 actual_ms=2902.7 +>>> WARN coco...failed_to_check_inflight_proofs_for_mint mintUrl=[mint_url] error=[error] + DEBUG coco...mint_response_error status=429 errorData="Rate limit exceeded." +``` +*Takeaway: a 2.7s JS-thread block + mint 429s. Note `[mint_url]`/`[error]` are +redacted automatically.* + +### `startup --latest` — init waterfall +``` +STARTUP WATERFALL: + 1786ms ████████████████ updateStage (18.3s) + 3559ms █████ SplashMorph (2.4s) + 6211ms ██████████ Coco-bg.receiveRecovery (13.9s) +MILESTONES: App ready: 1818ms Total init span: 18323ms +``` +*Takeaway: `receiveRecovery` dominates init — the place to optimize.* + +--- + +## 6. Typical workflows + +**Audit-prep sweep (fits < 30K tokens together):** +```bash +bun run codereview/log-doctor/index.ts stats --latest +bun run codereview/log-doctor/index.ts errors --latest --context 5 +bun run codereview/log-doctor/index.ts slow --latest --threshold 200 +bun run codereview/log-doctor/index.ts coco --latest +``` + +**The loop (from the `sovran-quality` skill):** +1. Find the relevant event namespace / symptom. +2. Run the **narrowest** query first (`--event`, `--scope`, `--latest`). +3. Correlate output with code paths and store/request state. +4. Add scoped logs only if they make future diagnosis cheaper. +5. Remove noisy or secret-risk logs before shipping. + +See also: `skills/sovran-quality/references/log-doctor.md` and +`codereview/README.md` for token budgets and audit.md/fix.md integration. diff --git a/codereview/log-doctor/__tests__/analysis.test.ts b/codereview/log-doctor/__tests__/analysis.test.ts new file mode 100644 index 000000000..b6a0b1f80 --- /dev/null +++ b/codereview/log-doctor/__tests__/analysis.test.ts @@ -0,0 +1,163 @@ +import { + clusterErrorEntries, + errorClusterKey, + normalizeErrorText, + percentile, + scanRedactionAudit, + sparkline, + summarizeDurations, + type AnalyzableEntry, +} from '../analysis'; + +describe('percentile / summarizeDurations', () => { + const samples = [100, 300, 500, 800, 1000]; + + it('computes nearest-rank percentiles', () => { + expect(percentile(samples, 50)).toBe(500); + expect(percentile(samples, 95)).toBe(1000); + expect(percentile(samples, 99)).toBe(1000); + expect(percentile(samples, 0)).toBe(100); + }); + + it('is order-independent', () => { + expect(percentile([1000, 100, 500, 300, 800], 50)).toBe(500); + }); + + it('handles the empty sample', () => { + expect(percentile([], 50)).toBe(0); + expect(summarizeDurations([])).toEqual({ + count: 0, + min: 0, + max: 0, + avg: 0, + p50: 0, + p95: 0, + p99: 0, + }); + }); + + it('summarizes min/max/avg/percentiles', () => { + const d = summarizeDurations(samples); + expect(d.count).toBe(5); + expect(d.min).toBe(100); + expect(d.max).toBe(1000); + expect(d.avg).toBe(540); + expect(d.p50).toBe(500); + }); +}); + +describe('sparkline', () => { + it('returns empty for no samples', () => { + expect(sparkline([])).toBe(''); + }); + + it('collapses a uniform sample', () => { + expect(sparkline([5, 5, 5])).toContain('all 5ms'); + }); + + it('renders a min–max legend for a varied sample', () => { + expect(sparkline([1, 2, 3, 100])).toContain('(1–100ms)'); + }); +}); + +describe('normalizeErrorText', () => { + it('collapses urls, uuids, hex and numbers', () => { + expect(normalizeErrorText('connect to https://relay.example.com/ws failed')).toBe( + 'connect to failed' + ); + expect(normalizeErrorText('job 550e8400-e29b-41d4-a716-446655440000 stalled')).toBe( + 'job stalled' + ); + expect(normalizeErrorText('key abcdef0123456789 rejected')).toBe('key rejected'); + expect(normalizeErrorText('retry 42 of 99')).toBe('retry of '); + }); +}); + +describe('error clustering', () => { + const mk = ( + over: Partial & { error?: AnalyzableEntry['error'] } + ): AnalyzableEntry => ({ + level: 'error', + event: 'net.fail', + _t: 0, + ...over, + }); + + it('gives two errors that differ only by ids the same cluster key', () => { + const a = mk({ + error: { name: 'TimeoutError', message: 'timeout after 1200 ms', stack: [] }, + params: { url: 'https://a.com/1' }, + }); + const b = mk({ + error: { name: 'TimeoutError', message: 'timeout after 3400 ms', stack: [] }, + params: { url: 'https://a.com/2' }, + }); + expect(errorClusterKey(a)).toBe(errorClusterKey(b)); + }); + + it('separates a genuinely different error', () => { + const a = mk({ error: { name: 'TimeoutError', message: 'x', stack: [] } }); + const c = mk({ error: { name: 'NetworkError', message: 'x', stack: [] } }); + expect(errorClusterKey(a)).not.toBe(errorClusterKey(c)); + }); + + it('clusters with counts, exemplar and time span', () => { + const entries: AnalyzableEntry[] = [ + mk({ + _t: 10, + error: { name: 'TimeoutError', message: 'timeout after 1 ms', stack: [] }, + params: { url: 'https://a.com/1' }, + }), + mk({ + _t: 50, + error: { name: 'TimeoutError', message: 'timeout after 9 ms', stack: [] }, + params: { url: 'https://a.com/2' }, + }), + mk({ _t: 30, error: { name: 'NetworkError', message: 'down', stack: [] } }), + ]; + const clusters = clusterErrorEntries(entries.map((entry, index) => ({ entry, index }))); + expect(clusters).toHaveLength(2); + expect(clusters[0].count).toBe(2); // sorted by count desc + expect(clusters[0].firstT).toBe(10); + expect(clusters[0].lastT).toBe(50); + expect(clusters[0].exemplarIndex).toBe(0); + }); +}); + +describe('scanRedactionAudit', () => { + it('counts brands, inline markers, and flags un-redacted secrets by tier', () => { + const entries: AnalyzableEntry[] = [ + { level: 'info', event: 'auth.login', params: { key: { _kind: 'nsec', len: 63 } } }, + { level: 'info', event: 'wallet.recv', params: { msg: 'got ok' } }, + { + level: 'warn', + event: 'profile.import', + params: { leaked: 'nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq' }, + }, + { level: 'debug', event: 'nostr.event', params: { pubkey: 'a'.repeat(64) } }, + ]; + const audit = scanRedactionAudit(entries); + + expect(audit.brandCounts.nsec).toBe(1); + expect(audit.redactedSubstrCounts['cashu-token']).toBe(1); + expect(audit.totalRedactions).toBe(2); + + const nsec = audit.suspicious.find((s) => s.category === 'nsec'); + expect(nsec?.highSignal).toBe(true); + expect(nsec?.sampleEvents).toContain('profile.import'); + + const hex64 = audit.suspicious.find((s) => s.category === 'hex64'); + expect(hex64?.highSignal).toBe(false); + + // high-signal findings sort ahead of low-signal ones + expect(audit.suspicious[0].highSignal).toBe(true); + }); + + it('does not flag a value once the logger has branded it', () => { + const audit = scanRedactionAudit([ + { level: 'info', event: 'x', params: { sk: { _kind: 'private_key', len: 64 } } }, + ]); + expect(audit.suspicious).toHaveLength(0); + expect(audit.brandCounts.private_key).toBe(1); + }); +}); diff --git a/codereview/log-doctor/analysis.ts b/codereview/log-doctor/analysis.ts new file mode 100644 index 000000000..ca04ccd31 --- /dev/null +++ b/codereview/log-doctor/analysis.ts @@ -0,0 +1,287 @@ +// Pure, dependency-free deterministic log analysis for log-doctor. +// +// Kept separate from index.ts (the CLI entrypoint, which pulls in WDA + +// test-dsl) so these functions can be unit-tested in isolation without loading +// the whole tool. The index.ts modes consume them. Everything here is a pure +// function of its inputs — no I/O, no globals — which is what makes +// __tests__/analysis.test.ts cheap and deterministic. + +/** The structural subset of a log entry the analysis needs. index.ts's + * richer LogEntry is assignable to this. */ +export interface AnalyzableEntry { + level: string; + event: string; + _t?: number; + params?: Record; + ctx?: Record; + error?: { name: string; message: string; stack: string[] }; +} + +// ─── Percentiles, distribution summary, sparkline ──────────────────────────── + +function percentileSorted(sorted: number[], p: number): number { + if (sorted.length === 0) return 0; + if (p <= 0) return sorted[0]; + if (p >= 100) return sorted[sorted.length - 1]; + // Nearest-rank: simple, exact, no interpolation surprises. + const rank = Math.ceil((p / 100) * sorted.length); + return sorted[Math.min(sorted.length - 1, Math.max(0, rank - 1))]; +} + +/** Nearest-rank percentile over an unsorted sample. */ +export function percentile(samples: number[], p: number): number { + return percentileSorted( + [...samples].sort((a, b) => a - b), + p + ); +} + +export interface DurationSummary { + count: number; + min: number; + max: number; + avg: number; + p50: number; + p95: number; + p99: number; +} + +export function summarizeDurations(samples: number[]): DurationSummary { + if (samples.length === 0) return { count: 0, min: 0, max: 0, avg: 0, p50: 0, p95: 0, p99: 0 }; + const sorted = [...samples].sort((a, b) => a - b); + const sum = sorted.reduce((a, b) => a + b, 0); + return { + count: sorted.length, + min: sorted[0], + max: sorted[sorted.length - 1], + avg: sum / sorted.length, + p50: percentileSorted(sorted, 50), + p95: percentileSorted(sorted, 95), + p99: percentileSorted(sorted, 99), + }; +} + +const SPARK_BLOCKS = '▁▂▃▄▅▆▇█'; + +/** Compact, token-cheap distribution of `samples` across `buckets` linear bins, + * with a min–max legend. Returns '' for an empty sample. */ +export function sparkline(samples: number[], buckets = 8): string { + if (samples.length === 0) return ''; + const min = Math.min(...samples); + const max = Math.max(...samples); + if (max === min) return `${SPARK_BLOCKS[0].repeat(buckets)} (all ${min.toFixed(0)}ms)`; + const counts = new Array(buckets).fill(0); + for (const s of samples) { + const idx = Math.min(buckets - 1, Math.floor(((s - min) / (max - min)) * buckets)); + counts[idx]++; + } + const maxCount = Math.max(...counts); + const spark = counts + .map((c) => SPARK_BLOCKS[Math.round((c / maxCount) * (SPARK_BLOCKS.length - 1))]) + .join(''); + return `${spark} (${min.toFixed(0)}–${max.toFixed(0)}ms)`; +} + +// ─── Error clustering ──────────────────────────────────────────────────────── + +const URL_RE = /\bhttps?:\/\/[^\s"]+/gi; +const UUID_RE = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi; +const HEX_RE = /\b[0-9a-f]{8,}\b/gi; +const NUM_RE = /\b\d[\d.,_]*\b/g; + +/** Collapse the varying parts of a string (urls, uuids, long hex, numbers) so + * that two errors differing only by ids/amounts share a normalized form. */ +export function normalizeErrorText(value: unknown): string { + let s = typeof value === 'string' ? value : JSON.stringify(value ?? ''); + // Order matters: url, then uuid (before bare hex eats its segments), then hex, then numbers. + s = s + .replace(URL_RE, '') + .replace(UUID_RE, '') + .replace(HEX_RE, '') + .replace(NUM_RE, ''); + return s.slice(0, 200); +} + +/** Normalize params into a deterministic template: sorted keys (so serialization + * order can't split a cluster), underscore-prefixed keys dropped, values + * collapsed via normalizeErrorText. */ +function normalizeParams(params: Record | undefined): string { + if (!params) return ''; + return Object.keys(params) + .filter((k) => !k.startsWith('_')) + .sort() + .map((k) => `${k}=${normalizeErrorText(params[k])}`) + .join('&'); +} + +function topStackFrame(entry: AnalyzableEntry): string { + const frame = entry.error?.stack?.[0]; + return frame ? normalizeErrorText(frame) : ''; +} + +/** The stable identity an error clusters by: level + event + error name + + * normalized message + normalized params + normalized top stack frame. */ +export function errorClusterKey(entry: AnalyzableEntry): string { + return [ + entry.level, + entry.event, + entry.error?.name ?? '', + normalizeErrorText(entry.error?.message ?? ''), + normalizeParams(entry.params), + topStackFrame(entry), + ].join('|'); +} + +export interface ErrorCluster { + key: string; + count: number; + exemplar: T; + exemplarIndex: number; + firstT: number; + lastT: number; +} + +/** Group error entries by errorClusterKey, keeping the first-seen entry as the + * exemplar. Sorted by count desc, then earliest occurrence. */ +export function clusterErrorEntries( + items: Array<{ entry: T; index: number }> +): ErrorCluster[] { + const map = new Map>(); + for (const { entry, index } of items) { + const key = errorClusterKey(entry); + const t = entry._t ?? 0; + const existing = map.get(key); + if (!existing) { + map.set(key, { key, count: 1, exemplar: entry, exemplarIndex: index, firstT: t, lastT: t }); + } else { + existing.count++; + existing.firstT = Math.min(existing.firstT, t); + existing.lastT = Math.max(existing.lastT, t); + } + } + return [...map.values()].sort((a, b) => b.count - a.count || a.firstT - b.firstT); +} + +// ─── Redaction audit (read-side) ───────────────────────────────────────────── + +interface SuspiciousPattern { + category: string; + re: RegExp; + /** true = almost certainly a secret/PII that should have been redacted; + * false = usually a PUBLIC id (npub/event/pubkey hash) — low signal. */ + highSignal: boolean; +} + +const SUSPICIOUS_PATTERNS: SuspiciousPattern[] = [ + { category: 'nsec', re: /\bnsec1[0-9a-z]{20,}\b/i, highSignal: true }, + { category: 'xprv', re: /\bxprv[0-9a-zA-Z]{20,}\b/, highSignal: true }, + { category: 'cashu-token', re: /\bcashu[AB][A-Za-z0-9_-]{20,}\b/, highSignal: true }, + { + category: 'jwt', + re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/, + highSignal: true, + }, + { + category: 'email', + re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/, + highSignal: true, + }, + { category: 'npub', re: /\bnpub1[0-9a-z]{20,}\b/i, highSignal: false }, + { category: 'note', re: /\bnote1[0-9a-z]{20,}\b/i, highSignal: false }, + { category: 'hex64', re: /\b[0-9a-f]{64}\b/i, highSignal: false }, +]; + +const REDACTED_SUBSTR_RE = /]+)>/g; +const MAX_DEPTH = 8; + +export interface SuspiciousFinding { + category: string; + count: number; + highSignal: boolean; + sampleEvents: string[]; +} + +export interface RedactionAudit { + /** {_kind} brand objects the logger emits for stripped secrets, by kind. */ + brandCounts: Record; + /** Inline substrings, by label. */ + redactedSubstrCounts: Record; + /** brandCounts + redactedSubstrCounts totals — confirmed redactions. */ + totalRedactions: number; + /** Raw values that look UN-redacted (heuristic). Never includes the values. */ + suspicious: SuspiciousFinding[]; +} + +/** Scan already-parsed (already-redacted) entries: count confirmed redactions + * and heuristically flag any raw values that look like un-redacted secrets/PII. + * Pure and read-only — it changes nothing about how the app logs. */ +export function scanRedactionAudit(entries: AnalyzableEntry[]): RedactionAudit { + const brandCounts: Record = {}; + const redactedSubstrCounts: Record = {}; + const suspMap = new Map; highSignal: boolean }>(); + + const note = (pattern: SuspiciousPattern, event: string): void => { + let s = suspMap.get(pattern.category); + if (!s) { + s = { count: 0, events: new Set(), highSignal: pattern.highSignal }; + suspMap.set(pattern.category, s); + } + s.count++; + if (s.events.size < 5) s.events.add(event); + }; + + const walk = (value: unknown, event: string, depth: number): void => { + if (value == null || depth > MAX_DEPTH) return; + if (typeof value === 'string') { + let m: RegExpExecArray | null; + REDACTED_SUBSTR_RE.lastIndex = 0; + while ((m = REDACTED_SUBSTR_RE.exec(value)) !== null) { + redactedSubstrCounts[m[1]] = (redactedSubstrCounts[m[1]] ?? 0) + 1; + } + for (const pattern of SUSPICIOUS_PATTERNS) { + if (pattern.re.test(value)) note(pattern, event); + } + return; + } + if (typeof value !== 'object') return; + if (Array.isArray(value)) { + for (const v of value) walk(v, event, depth + 1); + return; + } + const obj = value as Record; + // A {_kind: ...} brand means the logger already stripped the value — count + // it and stop; the raw secret is gone, so don't recurse or flag. + if (typeof obj._kind === 'string') { + brandCounts[obj._kind] = (brandCounts[obj._kind] ?? 0) + 1; + return; + } + for (const v of Object.values(obj)) walk(v, event, depth + 1); + }; + + for (const e of entries) { + walk(e.params, e.event, 0); + walk(e.ctx, e.event, 0); + if (e.error) { + walk(e.error.message, e.event, 0); + walk(e.error.stack, e.event, 0); + } + } + + const totalBrands = Object.values(brandCounts).reduce((a, b) => a + b, 0); + const totalSubstr = Object.values(redactedSubstrCounts).reduce((a, b) => a + b, 0); + const suspicious: SuspiciousFinding[] = [...suspMap.entries()] + .map(([category, s]) => ({ + category, + count: s.count, + highSignal: s.highSignal, + sampleEvents: [...s.events], + })) + .sort((a, b) => Number(b.highSignal) - Number(a.highSignal) || b.count - a.count); + + return { + brandCounts, + redactedSubstrCounts, + totalRedactions: totalBrands + totalSubstr, + suspicious, + }; +} diff --git a/codereview/log-doctor/index.ts b/codereview/log-doctor/index.ts index 57e9d2d63..9c02a2819 100644 --- a/codereview/log-doctor/index.ts +++ b/codereview/log-doctor/index.ts @@ -42,12 +42,17 @@ * flows Reconstruct cross-async traces using flowId in ctx * ws WebSocket connection health, subscription analysis, message rates * gc Hermes memory trend, GC pressure, JS thread blocks, leak detection + * crypto Crypto/cashu amount + proof operation breakdown + * ops Operation/span breakdown + * perf Per-event latency distribution (p50/p95/p99 + histogram) from params.ms + * redaction Read-side redaction audit: confirms secrets stripped, flags raw un-redacted values * budget Token cost meta-analysis — shows which modes fit in which context windows * phone Drive a real iPhone via WebDriverAgent (subcommands: tap, tap-id, tree, shot, …) * * OPTIONS: * --threshold Duration threshold for 'slow' mode (default: 500) * --context Number of entries before/after errors (default: 3) + * --all, --no-cluster errors mode: list every entry with context instead of clustering * --limit Page size (default: 200) * --offset Skip first N entries for pagination (default: 0) * --no-device Omit device info block @@ -105,6 +110,17 @@ import { wdaRequest, } from './wda'; +// Pure deterministic analysis (clustering, percentiles, redaction audit). Lives +// in its own module so it can be unit-tested without loading the CLI/WDA stack. +import { + clusterErrorEntries, + percentile, + scanRedactionAudit, + sparkline, + summarizeDurations, + type RedactionAudit, +} from './analysis'; + // ─── Types ─────────────────────────────────────────────────────────────────── interface LogEntry { @@ -142,6 +158,9 @@ interface Options { format: 'json' | 'yaml' | 'md'; /** Max approximate token budget. Output is pruned to fit. null = unlimited. */ tokenBudget: number | null; + /** errors mode: cluster near-identical errors into exemplars (default true). + * Set false via --all / --no-cluster to list every entry with context. */ + clusterErrors: boolean; /** Positional args after the mode name. Used by `phone` mode for subcommands. */ restArgs: string[]; } @@ -171,6 +190,7 @@ export function parseArgs(argv: string[]): Options { latest: false, format: 'json', tokenBudget: null, + clusterErrors: true, restArgs: [], }; @@ -220,6 +240,9 @@ export function parseArgs(argv: string[]): Options { if (f === 'yaml' || f === 'md' || f === 'json') opts.format = f; } else if (arg === '--token-budget' && args[i + 1]) { opts.tokenBudget = parseInt(args[++i], 10); + } else if (arg === '--all' || arg === '--no-cluster') { + // errors mode: expand every entry with context instead of clustering. + opts.clusterErrors = false; } else { // Unknown flag — pass through to subcommand-style modes (phone test ...). opts.restArgs.push(arg); @@ -619,7 +642,8 @@ function modeStats(entries: LogEntry[], opts: Options): string { .map((g) => Math.round(g) + 'ms') .join(', ')}` ); - lines.push(` Median gap: ${Math.round(gaps[Math.floor(gaps.length / 2)])}ms`); + lines.push(` Median gap: ${Math.round(percentile(gaps, 50))}ms`); + lines.push(` p95 gap: ${Math.round(percentile(gaps, 95))}ms`); lines.push(''); } @@ -737,6 +761,22 @@ function modeStats(entries: LogEntry[], opts: Options): string { lines.push(' No events with 3+ occurrences.'); } + // Redaction at a glance — full breakdown lives in the `redaction` mode. + const audit = scanRedactionAudit(entries); + const brandTotal = Object.values(audit.brandCounts).reduce((a, b) => a + b, 0); + const highSignal = audit.suspicious.filter((s) => s.highSignal); + lines.push(''); + lines.push('REDACTION:'); + lines.push( + ` ${audit.totalRedactions} redacted values (${brandTotal} branded, ${audit.totalRedactions - brandTotal} inline)` + ); + if (highSignal.length > 0) { + const total = highSignal.reduce((a, s) => a + s.count, 0); + lines.push(` ⚠ ${total} possible un-redacted secret(s) — run "redaction" mode`); + } else { + lines.push(' No un-redacted high-signal secrets detected.'); + } + return lines.join('\n'); } @@ -1004,6 +1044,57 @@ function modeErrors(entries: LogEntry[], opts: Options): string { if (errorIndices.length === 0) return 'No warnings, errors, or fatal entries found.'; + return opts.clusterErrors + ? renderErrorClusters(entries, errorIndices, opts) + : renderErrorEntriesWithContext(entries, errorIndices, opts); +} + +// Default errors view: collapse near-identical errors to exemplars + counts. +// Attacks the biggest token consumer; `--all` restores the full listing below. +function renderErrorClusters(entries: LogEntry[], errorIndices: number[], opts: Options): string { + const clusters = clusterErrorEntries( + errorIndices.map((index) => ({ entry: entries[index], index })) + ); + const lines: string[] = []; + lines.push( + `Found ${errorIndices.length} warning/error/fatal entries in ${clusters.length} cluster(s). ` + + `Showing exemplars — re-run with --all for every entry with context.\n` + ); + + const { page, footer } = paginate(clusters, opts); + for (const c of page) { + const e = c.exemplar; + lines.push( + `>>> ${String(c.count).padStart(4)}x ${levelIcon(e.level)} ${e.event} ${shortParams(e.params)}` + ); + const span = + c.lastT > c.firstT + ? `first +${(c.firstT / 1000).toFixed(1)}s, last +${(c.lastT / 1000).toFixed(1)}s` + : `at +${(c.firstT / 1000).toFixed(1)}s`; + lines.push(` ${span}`); + if (e.error) { + lines.push(` ERROR: ${e.error.name}: ${e.error.message}`); + if (e.error.stack?.length > 0) { + lines.push(` STACK: ${e.error.stack.slice(0, 3).join(' -> ')}`); + } + } + lines.push(''); + } + + const suppressed = errorIndices.length - clusters.length; + lines.push( + `Clustered ${errorIndices.length} entries into ${clusters.length} exemplar(s) ` + + `(${suppressed} duplicate(s) suppressed; --all to expand).` + ); + lines.push(footer); + return lines.join('\n'); +} + +function renderErrorEntriesWithContext( + entries: LogEntry[], + errorIndices: number[], + opts: Options +): string { const lines: string[] = []; lines.push(`Found ${errorIndices.length} warning/error/fatal entries:\n`); @@ -3478,20 +3569,25 @@ function modePerf(entries: LogEntry[], _opts: Options): string { lines.push('BOTTLENECK RANKING (by total time):'); lines.push(''); lines.push( - ' Event Count Total ms Avg ms Min ms Max ms P95 ms' + ' Event Count Total ms Avg ms P50 ms P95 ms P99 ms' ); lines.push(' ' + '-'.repeat(100)); for (const [event, stats] of sorted) { - const avg = stats.totalMs / stats.count; - const sorted95 = [...stats.samples].sort((a, b) => a - b); - const p95 = sorted95[Math.floor(sorted95.length * 0.95)] ?? stats.maxMs; + const d = summarizeDurations(stats.samples); lines.push( - ` ${event.padEnd(40).slice(0, 40)} ${String(stats.count).padStart(5)} ${stats.totalMs.toFixed(1).padStart(8)} ${avg.toFixed(1).padStart(6)} ${stats.minMs.toFixed(1).padStart(6)} ${stats.maxMs.toFixed(1).padStart(6)} ${p95.toFixed(1).padStart(6)}` + ` ${event.padEnd(40).slice(0, 40)} ${String(stats.count).padStart(5)} ${stats.totalMs.toFixed(1).padStart(8)} ${d.avg.toFixed(1).padStart(6)} ${d.p50.toFixed(1).padStart(6)} ${d.p95.toFixed(1).padStart(6)} ${d.p99.toFixed(1).padStart(6)}` ); } lines.push(''); + // Per-event latency distribution for the worst offenders (token-cheap sparkline). + lines.push('LATENCY DISTRIBUTION (top 5 by total time):'); + for (const [event, stats] of sorted.slice(0, 5)) { + lines.push(` ${event.slice(0, 40).padEnd(40)} ${sparkline(stats.samples)}`); + } + lines.push(''); + // Show entries with ms > 500 (slow operations) const slowOps = perfEntries.filter( (e) => ((e.params as Record).ms as number) > 500 @@ -3541,6 +3637,65 @@ function modePerf(entries: LogEntry[], _opts: Options): string { // ─── Mode: budget ─────────────────────────────────────────────────────────── // Meta-analysis: shows token cost of each mode to help pick the right one. +// ─── Mode: redaction ───────────────────────────────────────────────────────── +// Read-side audit of an ALREADY-redacted log: confirms the logger stripped +// secrets, and flags raw values that look un-redacted. Heuristic; never prints +// values. Changes nothing about how the app logs. + +function renderRedactionAudit(audit: RedactionAudit): string[] { + const lines: string[] = []; + + const brands = Object.entries(audit.brandCounts).sort((a, b) => b[1] - a[1]); + const brandTotal = brands.reduce((a, [, c]) => a + c, 0); + lines.push(`REDACTED SECRET BRANDS ({_kind}): ${brandTotal} total`); + if (brands.length === 0) lines.push(' none'); + else for (const [kind, count] of brands) lines.push(` ${String(count).padStart(5)}x ${kind}`); + lines.push(''); + + const substr = Object.entries(audit.redactedSubstrCounts).sort((a, b) => b[1] - a[1]); + if (substr.length > 0) { + lines.push('INLINE MARKERS:'); + for (const [label, count] of substr) lines.push(` ${String(count).padStart(5)}x ${label}`); + lines.push(''); + } + + const high = audit.suspicious.filter((s) => s.highSignal); + const low = audit.suspicious.filter((s) => !s.highSignal); + lines.push('SUSPICIOUS RAW VALUES (heuristic — events named, values never shown):'); + if (high.length > 0) { + lines.push(' ⚠ HIGH SIGNAL — likely secrets/PII that should have been redacted:'); + for (const s of high) { + lines.push( + ` ${String(s.count).padStart(5)}x ${s.category} (events: ${s.sampleEvents.join(', ')})` + ); + } + } else { + lines.push(' ⚠ HIGH SIGNAL: none — no raw nsec/xprv/cashu-token/jwt/email detected.'); + } + if (low.length > 0) { + lines.push(' · LOW SIGNAL — usually public ids (npub/note/64-hex); investigate only if unexpected:'); + for (const s of low) { + lines.push( + ` ${String(s.count).padStart(5)}x ${s.category} (events: ${s.sampleEvents.join(', ')})` + ); + } + } + + return lines; +} + +function modeRedaction(entries: LogEntry[], _opts: Options): string { + const audit = scanRedactionAudit(entries); + const lines: string[] = []; + lines.push('=== REDACTION AUDIT ==='); + lines.push(''); + lines.push('Read-side scan of an already-redacted log. Confirms the logger stripped'); + lines.push('secrets and flags raw values that look un-redacted. Heuristic; values never shown.'); + lines.push(''); + lines.push(...renderRedactionAudit(audit)); + return lines.join('\n'); +} + function modeBudget(entries: LogEntry[], opts: Options): string { const lines: string[] = []; @@ -3557,6 +3712,7 @@ function modeBudget(entries: LogEntry[], opts: Options): string { { name: 'network', fn: modeNetwork }, { name: 'feed', fn: modeFeed }, { name: 'visual', fn: modeVisual }, + { name: 'redaction', fn: modeRedaction }, { name: 'full (json)', fn: (e, o) => modeFull(e, { ...o, format: 'json' }) }, { name: 'full (md)', fn: (e, o) => modeFull(e, { ...o, format: 'md' }) }, { name: 'full (yaml)', fn: (e, o) => modeFull(e, { ...o, format: 'yaml' }) }, @@ -4149,7 +4305,7 @@ async function main() { console.error(' 2. Pipe logs: cat logs.jsonl | npm run log-doctor -- stats'); console.error(''); console.error( - 'Modes: stats, timeline, errors, slow, renders, screens, startup, coco, network, feed, visual, full, diff, devices, payment, toasts, flows, ws, gc, budget, phone' + 'Modes: stats, timeline, errors, slow, renders, screens, startup, coco, network, feed, visual, full, diff, devices, payment, toasts, flows, ws, gc, budget, crypto, ops, perf, redaction, phone' ); process.exit(1); } @@ -4245,10 +4401,13 @@ async function main() { case 'perf': output = modePerf(entries, opts); break; + case 'redaction': + output = modeRedaction(entries, opts); + break; default: console.error(`Unknown mode: ${opts.mode}`); console.error( - 'Valid modes: stats, timeline, errors, slow, renders, screens, startup, coco, network, feed, visual, full, diff, devices, payment, toasts, flows, ws, gc, budget, crypto, ops, perf, phone' + 'Valid modes: stats, timeline, errors, slow, renders, screens, startup, coco, network, feed, visual, full, diff, devices, payment, toasts, flows, ws, gc, budget, crypto, ops, perf, redaction, phone' ); process.exit(1); } diff --git a/docs/adr/0003-tiered-nostr-data-layer.md b/docs/adr/0003-tiered-nostr-data-layer.md new file mode 100644 index 000000000..4380e9d89 --- /dev/null +++ b/docs/adr/0003-tiered-nostr-data-layer.md @@ -0,0 +1,91 @@ +# 3. Resilient three-tier Nostr data layer behind one nagg-ts facade + +Date: 2026-06-20 +Status: Accepted (supersedes ADR 0002's seeding mandate; keeps its store-authority mandate) + +## Context + +Every Nostr read in the app reaches the network through ad-hoc, surface-specific +paths: `naggFeedClient` branches between a REST app-view and a GraphQL fallback +inline; own-state comes from one hardcoded relay subscription (`useOwnEventsSync`, +ADR 0002); mint reviews have two unrelated read paths (colada GraphQL and a +raw-NDK discovery hook); DMs, notifications, and the social graph each assemble +events their own way. Each surface knows about transport, picks relays, and +validates (or fails to validate) on its own. There is no graceful degradation: if +nagg is unavailable a surface simply breaks rather than falling back. + +We want to honestly advertise the product as decentralised while keeping the +nagg-first UX when things work. Three independently-operated sources exist: + +- **nagg** — our own app-view (we run it): fully bundled, ranked, gold UX. +- **Primal cache** — Primal runs it; we build only a client adapter. "Almost as + good" (server-owned algo feeds, bundled responses, ordering manifest). +- **raw relays** — many operators: "a bit rough" but functional; the honest floor. + +## Decision + +Make `@sovranbitcoin/nagg-ts` the single, opinionated **facade** for every Nostr +read. It is expressed in sovran-app domain terms (feed, thread, notifications, +conversations, social graph, own viewer-state, mint reviews) and internally +selects the best available tier — **nagg → Primal → raw relays** — with fallback, +bundling, ordering, Zod validation, dedupe, and cache-write all hidden inside it. +**Callers never choose a tier or assemble events.** The one explicit exception is +live listening (the "Load new" seam), where a caller may ask for a relay +subscription for genuinely-new items. + +Structural commitments (the shared contract lives in `@sovranbitcoin/schemas` +`nostr-data-layer.ts`): + +- **One enriched bundle per read** — notes + author profiles + aggregate stats + + optional per-viewer action overlay, joined client-side by id. +- **Viewer-INDEPENDENT counts (`NoteStats`) stay separate from the per-VIEWER + overlay (`NoteActions`)** — counts are cacheable and shared; the overlay is not. +- **Server-authoritative `OrderingManifest`** — the client renders strictly by an + ordered id list, a structural defense against the list reshuffling. The relay + floor synthesizes one from a stable sort key. +- **One Zod parse per tier at ingest**, inside nagg-ts — no tier can corrupt the + shared cache, and app code never validates raw events. +- **Calm streaming** on the relay floor (settle window + stable-sort insert + + resolve-on-EOSE) so even the roughest tier delivers a coherent set, not a + reshuffling stream. + +GraphQL is retired once the REST `/nostr/thread` app-view reaches ranking parity +(`authoredReplyChain` + `rankedReferencedBy`) — the one remaining GraphQL-only +surface. + +### Relationship to ADR 0002 + +ADR 0002 stands where it said the **local store is authoritative** (optimistic +last-writer-wins, the store is the merge target). What this ADR changes is the +**seed/backfill source**: instead of a hardcoded relay subscription, own +viewer-state seeds from the tiered facade (`getOwnHistory`, lazy cursor paging). +The relay subscription survives as the bottom tier and the live-delta listener, +merged through the same LWW gate so a facade backfill and a relay delta cannot +fight. + +## Consequences + +- App + colada code shrinks: a screen asks for a ready-to-render result and never + branches on transport. The `naggFeedClient` transport fallback moves down into + nagg-ts and gains the Primal + relay tiers. +- Graceful degradation everywhere, with honest, accepted ceilings: For-You and + grouped notifications degrade on raw relays; the cache tier can't per-peer-bucket + NIP-17 DMs (the sender is sealed); Primal can't list my-likes / my-reposts. +- The fragmented engagement stores consolidate into a viewer-independent stats + store and a single authoritative per-viewer overlay, fed identically regardless + of which tier answered. +- Backend work (nagg): REST thread parity, an ordering manifest, paginated + own-events read endpoints + by-author indexes, a 1059-by-`#p` DM index, and a + per-mint NIP-87 aggregate. These land first as prerequisites. + +## Alternatives considered + +- **Keep transport-branching in the app, nagg-ts stays transport-only** — + rejected; it re-leaks transport into every surface and forces colada to + duplicate fallback logic. The facade-owns-tiers design keeps callers trivial. +- **One combined record carrying counts + viewer-state** — rejected; conflates + cacheable viewer-independent counts with per-viewer state, breaking the shared + cache and reintroducing reshuffle when counts update. +- **Two tiers only (defer Primal)** — considered; the Primal adapter is pure + client code (Primal operates the server) with no infra cost, so all three tiers + ship together for a real second tier and a stronger decentralisation story. diff --git a/docs/adr/0004-thread-list-anchoring.md b/docs/adr/0004-thread-list-anchoring.md new file mode 100644 index 000000000..3741fefcf --- /dev/null +++ b/docs/adr/0004-thread-list-anchoring.md @@ -0,0 +1,149 @@ +# 0004 — Thread list anchoring: anchor on the tapped note, never auto-pin + +Status: accepted +Date: 2026-06-21 + +## Context + +Opening a thread on a reply must land on that reply and keep it visually fixed +while the rest of the thread fills in. The data arrives in two passes +(`features/feed/hooks/useThread.ts`): + +- **T0 (seed):** a cached slice renders the target (focused) note immediately — + stable key `t_${event.id}`. +- **T1 (full fetch):** `buildThreadItemsFromResult` rebuilds `items` as + `[...parents, target, ...replies]`, **prepending the entire parent/ancestor + chain above the target in one update**. Replies then append below on + pagination. + +The reader's requirement: the focused note must not move when (a) the parent +chain mounts above it or (b) replies load below it, and the thread must never +auto-scroll to the bottom like a chat. + +The thread `LegendList` (`@legendapp/list@3.0.0`) was rendered **without** +`maintainVisibleContentPosition`. In v3 the prop normalizes via +`normalizeMaintainVisibleContentPosition`: + +``` +true → { data: true, size: true } +{ … } → { data: ?? false, size: ?? true } +false → { data: false, size: false } +(omitted) → { data: false, size: true } // ← what the thread got +``` + +The prepend offset-compensation path is gated on `.data` +(`if (dataChanged && doMVCP && state.props.maintainVisibleContentPosition.data && …)`). +With `data:false` it never runs, so the T1 parent prepend was not compensated and +the focused note dropped by the measured height of the parent chain. + +A prior attempt (PR #225) layered manual `scrollToIndex` re-pins on top +(one-shot, then sustained via `onItemSizeChanged`). It still shifted: those +manual corrections race the library's own anchoring — the "don't fight the +library's correction" failure mode. + +## Decision + +Anchor the thread declaratively, reusing the same mechanism the DM `ChatScreen` +already relies on: + +1. **`maintainVisibleContentPosition={{ data: true, size: false }}`** on the thread + `LegendList`. `data:true` lets the library compensate the parent prepend; + `size:false` hands the size axis entirely to our counter-scroll (3) so the two + never both move the scroll — that double-correction is what oscillated into jitter. +2. **`focusReserve` — explicit bottom padding** so the focused note can reach (and + be held at) the top of the viewport. mVCP compensates a prepend by _raising the + scroll offset_, but that is clamped at the max scroll offset — a thread with + little content below the target can't scroll far enough, so the note drops, the + scroll bottoms out (snaps), and the note can't be refocused. We add + `viewport − (rows already below the note × approx row height)` to `paddingBottom`, + so it's generous on short threads and shrinks toward 0 as replies fill the screen + (no dead gap on long threads). + + This is owned in `ThreadView` rather than the library's `anchoredEndSpace` on + purpose: `anchoredEndSpace` is opaque (no public type on the RN entry; not + reflected in our shift logs) and behaved inconsistently across 3.0.x. The + explicit reserve is deterministic, hot-reloads with the component (no bundle / + dependency-version ambiguity), and emits a `thread.reserve` log so a trace can + confirm it's live and its size. + +3. **Own the size axis** (`onItemSizeChanged`), split on whether the real list is + shown yet (`revealedRef`). The note can be moved by above-note rows changing size: + the prepend's **estimate→measured** jump (parents range ~145–523px; v3 has no + per-item estimate) and **late media** (an image with no `imeta` dims reshapes 16:9 + → real on `onLoad`; videos are locked, so they don't reshape). + - **Before the swap (off-screen):** do **not** counter-scroll per row. Summing many + deltas in a burst undershoots — with 3 parents (711px total) it landed at scroll + 575, ~136px short, and arrived as one visible `jumpY:−530` at the swap. Instead we + just wait for the parents to go quiet, then (4) lands the note authoritatively. + - **After the swap:** a slow parent image can still reshape; the parent is off-screen + above the note, so we counter-scroll by its exact delta to absorb it. One change at + a time here → no burst, no undershoot. + + We're the **sole** owner of this axis (mVCP runs `size:false`), so it can't race the + library the way PR #225's sustained `scrollToIndex` re-pin did. A `readerMovedRef` + (set on `onScrollBeginDrag`) hands control back to the user, after which mVCP `data` + still anchors future prepends. + +4. **`initialScrollIndex={targetIndex}`** lands the first paint on the tapped note. +5. **No bottom-dock / auto-pin props.** Unlike `ChatScreen`, the thread omits + `initialScrollAtEnd`, `alignItemsAtEnd`, and `maintainScrollAtEnd` — it anchors + on the note, not the tail, and must never auto-scroll down. +6. **Resolve the WHOLE thread off-screen, land authoritatively, then crossfade.** Even + with (1)–(3), the reconcile plays out over several frames and _reads as jitter_. So + we render **two lists**: a **seed list** (tapped note + reply **skeletons**, no + parents) the reader sees immediately, and the **real list** (full thread) that + resolves **off-screen** (`opacity: 0`, `pointerEvents: none`) — parents settle AND + replies measure + load their images there. We wait for the on-screen rows to stop + changing size (`scheduleReveal` debounce **+ absolute cap**, so a slow network image + can't stall the seed forever), then do one **`scrollToIndex(noteIndex, +{ viewPosition: 0 })`** to land the note (exact for any parent count, parents + measured by now) and **crossfade** the real list in over the seed (`listOpacity` + 0→1, ~200ms), dropping the seed when the fade finishes. The crossfade is seamless + for the note (pixel match — looks static) and gives the replies their skeleton→real + fade. This replaces the per-row "reveal hack", the fragile delta-summing landing, + and the earlier instant swap. + +7. **Replies resolve off-screen so they don't reshift after appearing.** The real list + renders the full thread (incl. replies) the whole time, so replies measure and load + images while hidden and are **already settled** when the crossfade reveals them — + no post-appearance reshift, and the skeleton→real transition is the crossfade (6), + not a live reflow. The seed shows reply **skeletons** only (`seedData` filters out + `parent` and `reply` rows) so it's stable and doesn't double-load the reply images + (only the real list renders real replies). Tradeoff: a reply image slower than the + cap finishes after the crossfade — but it's below the focus and rare (imeta dims / + the warmed aspect cache settle it off-screen in the common case). + +## Consequences + +- The focused note holds across the T0→T1 parent prepend with no manual scroll + math; replies appending below do not move it. +- Behaviour is verified at the source level against the installed v3.0.0, and the + proof is on-device (native `ScrollView` mVCP cannot be exercised in jest). +- `data:true` routes the data path through native `ScrollView` mVCP (Android needs + RN ≥ 0.72; satisfied by our Expo SDK). The known racy edge (LegendApp/legend-list + #463) bites _near the bottom_ of a list; our anchor is the top-pinned note with a + single prepend — the well-behaved case. +- **Requires `@legendapp/list` ≥ 3.0.4.** The combination was inert/janky on the + first v3 release (3.0.0): the data-anchoring path was mis-batched (fixed 3.0.3), + `anchoredEndSpace` reported stale sizes during load (fixed 3.0.4), and + `scrollToIndex`/`initialScrollIndex` mislanded on iOS (fixed 3.0.1). We pin 3.0.6. + Re-verify these prop contracts on any future bump — v3 is still beta. +- **Known residual (out of scope): replies settling _below_ the note.** The anchor + holds the note perfectly (verified: pageY constant across a 49-reply load), but the + replies under it still reflow as they resolve — reply skeletons (fixed height) don't + match variable real-reply heights, and reply images without `imeta` dims reshape on + load (16:9 → real). The two-list swap can't hide this: the seed list renders the + same replies, so reply reflow is visible on whichever list is shown (unlike the + parents, which exist only on the real list). This is the same variable-height / + media-reservation problem the feed has; the durable fix is feed-wide (skeleton + height matching + reply-image dimension reservation via `imeta` / the aspect cache), + not thread-anchor work. + +## Alternatives rejected + +- **Manual `scrollToIndex` re-pin (PR #225).** Races the library's anchoring; + still shifted in practice. +- **`inverted`.** A transform hack; legend-list refuses it and the native mVCP + primitive explicitly ignores transforms. +- **Sharing the DM bottom-dock preset.** Would auto-pin to bottom — the opposite + of the thread's anchor-on-note requirement. diff --git a/docs/adr/0005-flashlist-v2-list-migration.md b/docs/adr/0005-flashlist-v2-list-migration.md new file mode 100644 index 000000000..636dca9c3 --- /dev/null +++ b/docs/adr/0005-flashlist-v2-list-migration.md @@ -0,0 +1,76 @@ +# 0005 — Replace @legendapp/list with FlashList v2 across the app + +Status: accepted +Date: 2026-06-22 + +## Context + +Every virtualized list in the app was built on `@legendapp/list` (LegendList, +v3.0.6). The thread reader's scroll-stability work ([[0004]]) accreted a large +amount of scaffolding to fight LegendList's **asynchronous measurement**: rows +render at an `estimatedItemSize`, then measure a frame later, so anchored content +shifts. Working around that needed a two-list off-screen crossfade, +`getFixedItemSize` skeleton hints, an `onItemSizeChanged` counter-scroll, a +`{ data: true, size: false }` mVCP split, and reveal gating — "very hacky," in +the reporter's words, with residual artifacts. + +A spike (`spike/flashlist-v2-thread`, behind a Settings toggle) proved that +`@shopify/flash-list@2` holds the focused note with **no** scaffolding beyond a +bottom `focusReserve`: FlashList v2 is New-Arch-only and lays out synchronously +under Fabric, with `maintainVisibleContentPosition` on by default, so the +estimate→measure shift class disappears at the source. + +## Decision + +1. **Remove `@legendapp/list` entirely; FlashList v2 everywhere.** No + compatibility shim, no dual-path toggle — the spike's `flashListThread` + Settings flag and the whole LegendList thread path are deleted. + +2. **One shared seam for the common case.** `shared/ui/composed/List.tsx` + (``) owns the only app import of `@shopify/flash-list` and applies the + app defaults (hidden scroll indicator, `drawDistance`). Plain data lists + render through ``; the four specialized surfaces that need full control + of the underlying list import FlashList directly: + - **ThreadView** — anchor on the tapped note (`focusReserve` + index-0 seed + + `initialScrollIndex` + default mVCP); the legend two-list / counter-scroll / + reveal scaffolding is retired. + - **ChatScreen / AiChatScreen** — chat-bottom via + `maintainVisibleContentPosition={{ startRenderingFromBottom: true, + autoscrollToBottomThreshold: 0.1 }}` (replaces LegendList's + `initialScrollAtEnd` / `alignItemsAtEnd` / `maintainScrollAtEnd`). + - **SectionAnchorList** — `getItemType` recycling, `scrollToIndex({viewOffset})`, + and gorhom `BottomSheetScrollView` injection via `renderScrollComponent`. + - **Transactions** — section list; per-row collapse keeps its reanimated + `layout` transition (Transaction.tsx). LegendList's `itemLayoutAnimation` + (which animated *sibling* reflow) has no FlashList v2 equivalent, so sibling + reflow is now immediate. + +3. **Drop the content-shift *list* telemetry.** FlashList lacks + `onItemSizeChanged` / `onMetricsChange` / `onStickyHeaderChange` / `getState()`, + and FlashList's synchronous layout makes the diagnostics they fed obsolete. + All list-level instrumentation (the `useVisualListLogger` wiring, the five + callbacks, `getListState`, and the per-row `VisualLayoutProbe` wrappers inside + list `renderItem`s) is removed. The shared content-shift logging library + (`contentShiftLog.ts`) stays — its element-level hooks remain in use by the + composer, primitives, and the notification screens. + +## Consequences + +- `estimatedItemSize`, `recycleItems`, `getFixedItemSize`, `itemsAreEqual` and + the legend-only `getState()` scroll-state read are gone; FlashList measures + synchronously and recycles by default. +- `threadListLayout.ts` collapses to a single `NOTE_CONTENT_LINE_HEIGHT` export + (the skeleton fixed-size helpers were legend-only). `threadFixedItemSize.test` + and `visualLayoutCoverage.test` (which enforced the now-removed list telemetry + contract) are deleted. +- Transaction sibling-reflow on a row collapse is no longer animated. Accepted + as a minor degradation; revisit with a `CellRendererComponent` layout-animation + shim only if it reads poorly on device. +- Gates green: `tsc` clean, `eslint` clean on the changed files, all + migration-touching tests pass. (Two pre-existing `naggFeedClient*` suite + failures and the `facadeFeedAdapter` prettier errors are unrelated to this + change — they fail identically on the branch without it.) +- Device verification pending — the matrix to walk: thread anchor, feed + pull-to-refresh + pagination, profile feed, chat bottom-stick + keyboard, + emoji/mention sheet scroll, transaction collapse, contacts refresh + + pagination. diff --git a/features/ai/screens/AiChatScreen.tsx b/features/ai/screens/AiChatScreen.tsx index a05df7508..0ad25c0fd 100644 --- a/features/ai/screens/AiChatScreen.tsx +++ b/features/ai/screens/AiChatScreen.tsx @@ -1,10 +1,10 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Keyboard, ScrollView, View as RNView, type LayoutChangeEvent } from 'react-native'; import { useHeaderHeight } from '@react-navigation/elements'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useReanimatedKeyboardAnimation } from 'react-native-keyboard-controller'; import Reanimated, { useAnimatedStyle } from 'react-native-reanimated'; -import { LegendList, type LegendListRef } from '@legendapp/list/react-native'; +import { FlashList } from '@shopify/flash-list'; import { Pressable } from '@/shared/ui/primitives/Pressable'; import { PatternBackground } from '@/shared/ui/composed/PatternBackground'; @@ -17,13 +17,6 @@ import { useChatSurfacePerfLogger, } from '@/shared/ui/composed/chat/useChatSurfacePerfLogger'; import { aiLog, useLifecycleLogger } from '@/shared/lib/logger'; -import { - remeasureVisualLayoutScope, - useVisualListLogger, - VISUAL_LIST_VIEWABILITY_CONFIG, - visualLayoutScopePart, -} from '@/shared/lib/contentShiftLog'; -import { VisualLayoutProbe } from '@/shared/ui/composed/VisualLayoutProbe'; import { isExpo55NativeTabsSupported } from '@/navigation/nativeTabs'; import { SOVRAN_TAB_BAR_ROW_HEIGHT, @@ -36,35 +29,39 @@ import { useAiSend } from '../hooks/useAiSend'; import { deriveActivePath, getSiblingInfo, withSynthesisedParents } from '../lib/branching'; const SURFACE = 'ai'; -const AI_VISUAL_SCOPE = `chat.${visualLayoutScopePart(SURFACE)}.list`; + +// Horizontal gutter applied to every message row (FlashList rows have no +// padding of their own). Stable module ref so recycled cells don't re-create it. +const MESSAGE_ROW_STYLE = { paddingHorizontal: 16 } as const; /** Visual gap between the composer's outer bottom edge and the keyboard top * when focused. Matches the shared ChatScreen — 0pt reads as "the composer * is sitting on the keyboard" instead of floating mid-air. */ const COMPOSER_FOCUSED_BOTTOM_GAP = 0; -/** Hint for LegendList's virtualization math. Measured AI bubbles run +/** Hint for FlashList's virtualization math. Measured AI bubbles run * ~74pt for short user pills and 150–400pt for assistant blocks; 80 is * closer to the short-bubble median than to the long-tail average. The * value isn't load-bearing for correctness — only first-render scroll - * position accuracy — and LegendList recomputes once real layouts + * position accuracy — and FlashList recomputes once real layouts * measure. */ const ESTIMATED_BUBBLE_HEIGHT = 80; /** - * AI tab chat surface. Built directly on LegendList rather than going + * AI tab chat surface. Built directly on FlashList rather than going * through the shared `` because the AI surface needs a * bubble-less assistant renderer + ModelChip row inline with the composer. * The shared `` (BitChat, WhiteNoise, Nostr DM, geohash) is - * also LegendList-backed now, so the architecture is consistent: ascending - * data, `alignItemsAtEnd` for the chat-style bottom dock, - * `maintainScrollAtEnd` for stay-at-latest, and a Reanimated translate - * driving the keyboard lift for both list and composer in lock-step. + * also FlashList-backed now, so the architecture is consistent: ascending + * data, FlashList `maintainVisibleContentPosition` (`startRenderingFromBottom` + * for the chat-style bottom dock, `autoscrollToBottomThreshold` for + * stay-at-latest), and a Reanimated translate driving the keyboard lift for + * both list and composer in lock-step. * - * LegendList replaced the previous GiftedChat / inverted FlatList stack + * FlashList replaced the previous GiftedChat / inverted FlatList stack * across all chat surfaces; iOS 26 applies a soft `UIScrollEdgeEffect` to * RN `FlatList` / `VirtualizedList` instances by default that surfaces as - * a visible band where the list meets the composer, and LegendList's + * a visible band where the list meets the composer, and FlashList's * separate virtualization sidesteps it cleanly. */ export function AiChatScreen() { @@ -134,14 +131,13 @@ export function AiChatScreen() { [conversationHistory, activeChildren] ); - // Mount visibility — narrow set, fires once. No imperative - // scroll-chase plumbing: `alignItemsAtEnd` docks short content to the - // bottom, `maintainScrollAtEnd` keeps the user pinned during streaming - // appends, and `initialScrollAtEnd` handles the first paint for - // histories larger than the viewport. Earlier attempts at - // setTimeout-based chasers landed mid-list when item measurements - // settled async — fragile for streaming content. Trust the library; - // reach for telemetry if behavior regresses. + // Mount visibility — narrow set, fires once. No imperative scroll-chase + // plumbing: FlashList's `maintainVisibleContentPosition` with + // `startRenderingFromBottom` docks short content and lands the first paint at + // the latest message, and `autoscrollToBottomThreshold` keeps the user pinned + // during streaming appends. Earlier attempts at setTimeout-based chasers + // landed mid-list when item measurements settled async — fragile for + // streaming content. Trust the library; reach for telemetry if it regresses. useEffect(() => { aiLog.info('ai.list.mount', { messageCount: activeMessages.length, @@ -208,7 +204,7 @@ export function AiChatScreen() { // above the window bottom). Without the `sovranTabBarHeight` term the // SovranTabBar path overshoots the keyboard top by the bar's height when // focused — exactly the "too much margin" symptom. The same translate is - // applied to a wrapper around the LegendList so the latest message rises + // applied to a wrapper around the FlashList so the latest message rises // with the composer instead of getting hidden behind the keyboard. // // Why this instead of ``: in RN's @@ -273,85 +269,18 @@ export function AiChatScreen() { }); useChatKeyboardAnimationLogger({ log: aiLog, surface: SURFACE }); - const visualPhase = isSending ? 'sending' : activeMessages.length === 0 ? 'empty' : 'ready'; - const visualExtra = useCallback( - () => ({ - surface: SURFACE, - messageCount: activeMessages.length, - composerHeight, - bottomInset, - sovranTabBarHeight, - headerHeight, - isSending, - draftLength: draft.length, - streaming: !!streamingMessageId, - }), - [ - activeMessages.length, - bottomInset, - composerHeight, - draft.length, - headerHeight, - isSending, - sovranTabBarHeight, - streamingMessageId, - ] - ); - const listRef = useRef(null); - const visualList = useVisualListLogger({ - scope: AI_VISUAL_SCOPE, - surface: 'chat', - component: 'AiLegendList', - phase: visualPhase, - extra: visualExtra, - getItemKey: (message, index) => `message:${message.role}:${message.timestamp}:${index}`, - getItemContext: (message, index) => ({ - itemType: message.role === 'user' ? 'ai-user-message' : 'ai-assistant-message', - role: message.role, - timestamp: message.timestamp, - pending: message.pending === true, - streaming: message.id === streamingMessageId, - index, - }), - getListState: () => listRef.current?.getState() ?? null, - }); - - const handleListScroll = useCallback(() => { - remeasureVisualLayoutScope(AI_VISUAL_SCOPE, 'scroll', { - extra: visualExtra(), - maxItems: 24, - minIntervalMs: 300, - }); - }, [visualExtra]); - const renderItem = useCallback( - ({ item, index }: { item: RoutstrMessage; index: number }) => ( - ({ - ...visualExtra(), - role: item.role, - pending: item.pending === true, - streaming: item.id === streamingMessageId, - hasReasoning: !!item.reasoningContent, - hasCost: typeof item.costSats === 'number', - })} - style={{ paddingHorizontal: 16 }}> + ({ item }: { item: RoutstrMessage; index: number }) => ( + - + ), - [branchNavById, handleRetry, isSending, streamingMessageId, visualExtra, visualPhase] + [branchNavById, handleRetry, isSending, streamingMessageId] ); const keyExtractor = useCallback((m: RoutstrMessage) => m.id, []); @@ -378,7 +307,7 @@ export function AiChatScreen() { // glass on scroll-up (the iMessage / Telegram bleed-under-input look). // No `paddingTop` here: adding one breaks `alignItemsAtEnd`'s // "content < viewport → dock to bottom" math (the contentContainer's - // own paddingTop counts toward effective content height, so LegendList + // own paddingTop counts toward effective content height, so FlashList // thinks the viewport is already filled and skips the auto-bottom // padding it would otherwise insert). The AI Stack header is its own // opaque/translucent surface above the screen scene; content sliding @@ -398,74 +327,32 @@ export function AiChatScreen() { the latest message stays just above the composer instead of getting hidden behind the keyboard. */} - + {activeMessages.length === 0 ? ( - - {emptyContent} - + {emptyContent} ) : ( - )} - + - + - + ); diff --git a/features/bitchat/screens/NetworkSheet.tsx b/features/bitchat/screens/NetworkSheet.tsx index d0522ba93..ca3bc095c 100644 --- a/features/bitchat/screens/NetworkSheet.tsx +++ b/features/bitchat/screens/NetworkSheet.tsx @@ -6,20 +6,14 @@ * same info density: nickname, connection state, last-seen, antenna icon. */ -import React, { useCallback, useMemo, useRef } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { StyleSheet } from 'react-native'; -import { LegendList, type LegendListRef } from '@legendapp/list/react-native'; import { Stack } from 'expo-router'; import { guardedRouter as router } from '@/shared/hooks/useGuardedRouter'; import { ScreenHeaderAction } from '@/shared/ui/composed/ScreenHeaderAction'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { Log, useLifecycleLogger, bitchatLog } from '@/shared/lib/logger'; -import { - VISUAL_LIST_VIEWABILITY_CONFIG, - useVisualListLogger, - visualLayoutScopePart, -} from '@/shared/lib/contentShiftLog'; import { Text } from '@/shared/ui/primitives/Text'; import { HStack } from '@/shared/ui/primitives/View/HStack'; import { VStack } from '@/shared/ui/primitives/View/VStack'; @@ -28,7 +22,7 @@ import opacity from 'hex-color-opacity'; import type { BLEPeer } from 'bitchat-module'; import { ContactRow, bleIdentity } from '@/shared/ui/composed/ContactRow'; -import { VisualLayoutProbe } from '@/shared/ui/composed/VisualLayoutProbe'; +import { List } from '@/shared/ui/composed/List'; import { resolveIdentityName } from '@/shared/lib/identity'; import { BLUETOOTH_ACCENT } from '@/shared/lib/brandColors'; import { useBLEPeers } from '../hooks/useBLEPeers'; @@ -101,47 +95,7 @@ export default function NetworkSheet() { router.back(); }, []); const keyExtractor = useCallback((peer: BLEPeer) => peer.peerID, []); - const listRef = useRef(null); - const visualList = useVisualListLogger({ - scope: 'bitchat.network.list', - surface: 'bitchat', - component: 'NetworkSheetLegendList', - phase: bluetoothBlocked ? 'blocked' : peers.length === 0 ? 'scanning' : 'peers', - extra: () => ({ - peerCount: sortedPeers.length, - connectedCount, - directLinkCount, - bluetoothStatus: bluetooth.status, - }), - getItemKey: (peer) => visualLayoutScopePart(peer.peerID), - getItemContext: (peer, index) => ({ - itemType: 'ble-peer', - index, - connected: peer.isConnected, - hasDirectLink: peer.hasDirectLink, - }), - getListState: () => listRef.current?.getState() ?? null, - }); - const renderPeerItem = useCallback( - ({ item, index }: { item: BLEPeer; index: number }) => ( - - - - ), - [bluetoothBlocked, peers.length, sortedPeers.length] - ); + const renderPeerItem = useCallback(({ item }: { item: BLEPeer }) => , []); const subtitleText = useMemo(() => { if (bluetoothBlocked) return 'Bluetooth unavailable'; @@ -190,20 +144,12 @@ export default function NetworkSheet() { - ({ - pubkeyCount: pubkeys.length, - rows: noteItems.length, - loading, - }), - [loading, noteItems.length, pubkeys.length] - ); - const listRef = useRef(null); - const visualList = useVisualListLogger({ - scope: SEARCH_POSTS_VISUAL_SCOPE, - surface: 'search', - component: 'SearchPostsLegendList', - phase: loading ? 'loading' : 'ready', - extra: visualExtra, - getItemKey: (item, _index, fallbackKey) => (item.type === 'note' ? item.event.id : fallbackKey), - getItemContext: (item, index) => ({ - itemType: item.type, - index, - }), - getListState: () => listRef.current?.getState() ?? null, - }); - const renderItem = useCallback( - ({ item, index }: LegendListRenderItemProps) => { + ({ item }: { item: FeedItem }) => { if (item.type !== 'note') return null; return ( - - - + ); }, - [getMetrics, loading, profilesMap, quotedMap, visualExtra] + [getMetrics, profilesMap, quotedMap] ); - const handleScroll = useCallback((event: { nativeEvent: { contentOffset: { y: number } } }) => { - remeasureVisualLayoutScope(SEARCH_POSTS_VISUAL_SCOPE, 'scroll', { - minIntervalMs: 500, - maxItems: 24, - extra: { scrollY: Math.round(event.nativeEvent.contentOffset.y) }, - }); - }, []); - const renderEmptyPeople = useMemo( () => ( - + - + ), - [visualExtra] + [] ); const renderEmptyPosts = useMemo( () => ( - + - + ), - [visualExtra] + [] ); if (pubkeys.length === 0) { @@ -219,23 +150,9 @@ export function SearchPostsList({ pubkeys }: { pubkeys: string[] }) { } if (loading && noteItems.length === 0) { return ( - - - + + + ); } if (noteItems.length === 0) { @@ -243,20 +160,11 @@ export function SearchPostsList({ pubkeys }: { pubkeys: string[] }) { } return ( - (item.type === 'note' ? item.event.id : '')} renderItem={renderItem} contentContainerStyle={styles.list} - onItemSizeChanged={visualList.onItemSizeChanged} - onLoad={visualList.onLoad} - onMetricsChange={visualList.onMetricsChange} - onStickyHeaderChange={visualList.onStickyHeaderChange} - onScroll={handleScroll} - onViewableItemsChanged={visualList.onViewableItemsChanged} - viewabilityConfig={VISUAL_LIST_VIEWABILITY_CONFIG} - scrollEventThrottle={16} /> ); } diff --git a/features/contacts/screens/ContactsScreen.tsx b/features/contacts/screens/ContactsScreen.tsx index 76c823633..58fc5b846 100644 --- a/features/contacts/screens/ContactsScreen.tsx +++ b/features/contacts/screens/ContactsScreen.tsx @@ -1,6 +1,6 @@ -import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react'; +import React, { useState, useMemo, useCallback, useEffect } from 'react'; import { View, Text, StyleSheet } from 'react-native'; -import { LegendList, type LegendListRef } from '@legendapp/list/react-native'; +import { List } from '@/shared/ui/composed/List'; import Icon from 'assets/icons'; import { useGuardedRouter } from '@/shared/hooks/useGuardedRouter'; @@ -19,11 +19,6 @@ import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { formatRelative } from '@/shared/lib/date'; import { SearchOverlay } from '@/shared/ui/composed/search/SearchOverlay'; import { Log, log, paymentLog, useLifecycleLogger } from '@/shared/lib/logger'; -import { - VISUAL_LIST_VIEWABILITY_CONFIG, - useVisualListLogger, - visualLayoutScopePart, -} from '@/shared/lib/contentShiftLog'; import { ContactRow, geohashIdentity, @@ -32,7 +27,6 @@ import { type Identity, } from '@/shared/ui/composed/ContactRow'; import { UnderlineTabs } from '@/shared/ui/composed/UnderlineTabs'; -import { VisualLayoutProbe } from '@/shared/ui/composed/VisualLayoutProbe'; import { usePullToAiRefreshControl } from '@/shared/blocks/PullToAiRefreshControl'; import { ScreenContainer } from '../components/ScreenContainer'; import { navigateToProfile } from '../lib/navigateToProfile'; @@ -67,12 +61,6 @@ function contactsListItemKey(item: ContactsListItem, index: number): string { return item.pubkey || `contact-${index}`; } -function contactsListItemType(item: ContactsListItem): string { - if (item.type === 'request') return 'whitenoise-request'; - if (item.type === 'mint') return 'mint-contact'; - return `contact-${item.protocol ?? 'nip17'}`; -} - function GroupsTierRow({ tier }: { tier: TierEntry }) { const router = useGuardedRouter(); return ( @@ -278,54 +266,13 @@ export const ContactsScreen = () => { (item) => item.type === 'contact' && MOCK_ALLOWED_PUBKEYS_HEX.has(item.pubkey) ); }, [rawListData, mockMode]); - const contactListRef = useRef(null); - const groupListRef = useRef(null); - const contactVisualList = useVisualListLogger({ - scope: 'contacts.contacts.list', - surface: 'contacts', - component: 'ContactsLegendList', - phase: showContactsSpinner ? 'loading' : activeFilter.toLowerCase(), - extra: () => ({ - activeFilter, - activeTab, - rowCount: currentListData.length, - mockMode, - mintInfoLoading, - }), - getItemKey: (item, index) => visualLayoutScopePart(contactsListItemKey(item, index)), - getItemContext: (item, index) => ({ - itemType: contactsListItemType(item), - index, - }), - getListState: () => contactListRef.current?.getState() ?? null, - }); - const groupVisualList = useVisualListLogger({ - scope: 'contacts.groups.list', - surface: 'contacts', - component: 'ContactGroupsLegendList', - phase: activeTab, - extra: () => ({ - rowCount: locationTiers.length, - activeTab, - }), - getItemKey: (item) => visualLayoutScopePart(item.key), - getItemContext: (item, index) => ({ - itemType: 'location-tier', - index, - transport: item.transport, - }), - getListState: () => groupListRef.current?.getState() ?? null, - }); - const handleFilterChange = useCallback((filter: ContactsFilter) => { log.debug('contacts.filter_changed', { filter }); setActiveFilter(filter); }, []); const renderContactItem = useCallback( - ({ item, index }: { item: ContactsListItem; index: number }) => { - const visualKey = visualLayoutScopePart(contactsListItemKey(item, index)); - const visualType = contactsListItemType(item); + ({ item }: { item: ContactsListItem; index: number }) => { // White Noise pending invite — keep it in this list so the empty/ // loading/scrolling behaviour is the same as the other pills, but // swap the trailing slot for accept/decline buttons. @@ -336,40 +283,25 @@ export const ContactsScreen = () => { // relay set — that's the whole point of a "request". So render with // the seeded fallback immediately rather than a skeleton forever. return ( - - { - paymentLog.info('contact.whitenoise.accept', { pubkey: req.fromPubkey }); - void acceptWhitenoiseRequest(req); - }} - onDecline={() => { - paymentLog.info('contact.whitenoise.decline', { pubkey: req.fromPubkey }); - void declineWhitenoiseRequest(req); - }} - /> - } - testID={`request-row:${req.fromPubkey}`} - /> - + { + paymentLog.info('contact.whitenoise.accept', { pubkey: req.fromPubkey }); + void acceptWhitenoiseRequest(req); + }} + onDecline={() => { + paymentLog.info('contact.whitenoise.decline', { pubkey: req.fromPubkey }); + void declineWhitenoiseRequest(req); + }} + /> + } + testID={`request-row:${req.fromPubkey}`} + /> ); } @@ -420,37 +352,20 @@ export const ContactsScreen = () => { // suppresses metadata — the pill row would read as noise next to a // human sentence. return ( - - - {[protocolLabel, lastMessageAt].filter(Boolean).join(' · ')} - - ) : undefined - } - onPress={() => navigateToProfile(item.pubkey, mintUrl)} - testID={`contact-row:nostr:${item.pubkey}`} - /> - + + {[protocolLabel, lastMessageAt].filter(Boolean).join(' · ')} + + ) : undefined + } + onPress={() => navigateToProfile(item.pubkey, mintUrl)} + testID={`contact-row:nostr:${item.pubkey}`} + /> ); }, [ @@ -465,24 +380,8 @@ export const ContactsScreen = () => { ] ); const renderGroupItem = useCallback( - ({ item, index }: { item: TierEntry; index: number }) => ( - - - - ), - [activeTab, locationTiers.length] + ({ item }: { item: TierEntry; index: number }) => , + [] ); const renderEmpty = useCallback(() => { @@ -557,22 +456,14 @@ export const ContactsScreen = () => { ); } return ( - { // Groups tab — the user's location tiers (provinces, countries, transports). const renderGroupsList = () => ( - item.key} refreshControl={pullToAi.refreshControl} renderItem={renderGroupItem} - onItemSizeChanged={groupVisualList.onItemSizeChanged} - onLoad={groupVisualList.onLoad} - onMetricsChange={groupVisualList.onMetricsChange} - onStickyHeaderChange={groupVisualList.onStickyHeaderChange} - onViewableItemsChanged={groupVisualList.onViewableItemsChanged} - viewabilityConfig={VISUAL_LIST_VIEWABILITY_CONFIG} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="always" ListEmptyComponent={ diff --git a/features/feed/components/HomeFeed.tsx b/features/feed/components/HomeFeed.tsx index 9213e9108..2fd33feac 100644 --- a/features/feed/components/HomeFeed.tsx +++ b/features/feed/components/HomeFeed.tsx @@ -1,7 +1,9 @@ /** * @fileoverview Home Feed ("For You") Component * - * Algorithmic feed powered by Nagg's GraphQL API. + * Algorithmic feed served through the tier-selecting facade: nagg's REST + * app-view returns one fully-bundled page (events + profiles + engagement + * stats), with Primal cache / raw relays as fallback tiers. */ import { @@ -19,19 +21,7 @@ import { View } from '@/shared/ui/primitives/View/View'; import opacity from 'hex-color-opacity'; import { useLatestRef } from '@/shared/hooks/useLatestRef'; import { log, Log, feedLog } from '@/shared/lib/logger'; -import { - remeasureVisualLayoutScope, - useShiftLogger, - useVisualListLogger, - useVisualStateLogger, - VISUAL_LIST_VIEWABILITY_CONFIG, -} from '@/shared/lib/contentShiftLog'; -import { VisualLayoutProbe } from '@/shared/ui/composed/VisualLayoutProbe'; -import { - LegendList, - type LegendListRenderItemProps, - type LegendListRef, -} from '@legendapp/list/react-native'; +import { List } from '@/shared/ui/composed/List'; import { useNostrKeysContext } from '@/shared/providers/NostrKeysProvider'; import { useBackgroundConfig } from '@/shared/providers/BackgroundProvider'; import { router } from 'expo-router'; @@ -60,7 +50,6 @@ import { import { buildFeedRows, DEFAULT_ENGAGEMENT_STATE, - feedRowsAreEqual, getFeedRowItemType, getFeedRowKey, type FeedRow, @@ -103,7 +92,6 @@ export const FEED_FILTER_FOLLOWING_RECENT = 'Following Recent'; // the user scrolls, rather than fetching a large batch up front. const FEED_INITIAL_LIMIT = 15; const FEED_PAGE_LIMIT = 10; -const HOME_FEED_VISUAL_SCOPE = 'feed.home.list'; // Stable config object — avoids re-triggering useBackgroundConfig every render const BG_CONFIG = { blurMode: 'full' as const }; @@ -156,21 +144,14 @@ function FeedThreadPair({ }) { const foreground = useThemeColor('foreground'); const [firstHeight, setFirstHeight] = useState(0); - const shift = useShiftLogger('FeedThreadPair'); - - const handleFirstLayout = useCallback( - (event: LayoutChangeEvent) => { - const nextHeight = Math.round(event.nativeEvent.layout.height); - // The first post's height drives the connector line length AND the - // second (reply) post's vertical offset. When it changes after async - // content settles, the whole pair reflows below it. - shift.report('feed.shift.threadpair.height', 'first', nextHeight); - setFirstHeight((currentHeight) => - currentHeight === nextHeight ? currentHeight : nextHeight - ); - }, - [shift] - ); + + const handleFirstLayout = useCallback((event: LayoutChangeEvent) => { + const nextHeight = Math.round(event.nativeEvent.layout.height); + // The first post's height drives the connector line length AND the + // second (reply) post's vertical offset. When it changes after async + // content settles, the whole pair reflows below it. + setFirstHeight((currentHeight) => (currentHeight === nextHeight ? currentHeight : nextHeight)); + }, []); const connectorStyle = useMemo(() => { const secondAvatarTop = firstHeight + secondAvatarCenterY - FEED_AVATAR_SIZE / 2; @@ -269,8 +250,6 @@ export function HomeFeed({ activeFilter }: HomeFeedProps) { const isFirstRender = useRef(true); - const listRef = useRef(null); - const scrollOffsetRef = useRef(0); const loadSequenceRef = useRef(0); const activeAbortControllerRef = useRef(null); @@ -650,9 +629,18 @@ export function HomeFeed({ activeFilter }: HomeFeedProps) { // ── Derived data ── + // Read the live `metricsMap` state (not `metricsRef`): this accessor feeds the + // render path via `getDisplayMetrics` → `feedRows`. `useLatestRef` writes its + // ref in `useInsertionEffect`, i.e. AFTER commit, so during the render where a + // late `setMetricsMap` lands, `metricsRef.current` is still the previous map. + // Reading the ref here left rows rendered with DEFAULT_METRICS until an + // unrelated re-render (a scroll-refresh) rebuilt `feedRows` once the ref had + // caught up — the "metrics only appear after pull-to-refresh" bug. Depending on + // `metricsMap` instead changes this callback's identity the moment metrics + // arrive, so `feedRows` recomputes against fresh aggregate counts immediately. const getMetrics = useCallback( - (noteId: string): NoteMetrics => metricsRef.current.get(noteId) || DEFAULT_METRICS, - [] + (noteId: string): NoteMetrics => metricsMap.get(noteId) || DEFAULT_METRICS, + [metricsMap] ); const actionableEvents = useMemo(() => { @@ -795,26 +783,6 @@ export function HomeFeed({ activeFilter }: HomeFeedProps) { feedRowsRef.current = feedRows; }, [feedRows]); - const visualList = useVisualListLogger({ - scope: HOME_FEED_VISUAL_SCOPE, - surface: 'feed', - component: 'HomeFeedList', - phase: isLoading ? 'loading' : isRefreshing ? 'refreshing' : 'ready', - extra: () => ({ - filter: feedSpecs[activeSpecIndex]?.name ?? null, - rows: feedRowsRef.current.length, - isLoading, - isLoadingMore, - }), - getItemKey: (row) => row.key, - getItemContext: (row) => ({ - rowKey: row.key, - rowLabel: getFeedRowItemType(row), - itemType: getFeedRowItemType(row), - }), - getListState: () => listRef.current?.getState() ?? null, - }); - // Render boundary: how many feed items became rendered rows, and whether the // screen is currently showing the empty state. `feedItems > 0 && rows === 0` // means a render-stage drop; `empty: true` with items 0 after load means the @@ -829,37 +797,8 @@ export function HomeFeed({ activeFilter }: HomeFeedProps) { }); }, [feedItems.length, feedRows.length, isLoading, activeSpecIndex, feedSpecs]); - useVisualStateLogger({ - scope: HOME_FEED_VISUAL_SCOPE, - surface: 'feed', - component: 'HomeFeed', - stateKey: 'feed-state', - phase: isLoading - ? 'loading' - : isRefreshing - ? 'refreshing' - : isLoadingMore - ? 'loading-more' - : 'ready', - state: { - filter: feedSpecs[activeSpecIndex]?.name ?? null, - feedItems: feedItems.length, - rows: feedRows.length, - isLoading, - isRefreshing, - isLoadingMore, - loadError, - empty: !isLoading && feedRows.length === 0, - }, - remeasure: { - reason: 'feed-state', - minIntervalMs: 300, - maxItems: 32, - }, - }); - const renderFeedItem = useCallback( - ({ item: row, index }: LegendListRenderItemProps) => { + ({ item: row, index }: { item: FeedRow; index: number }) => { const item = row.item; const feedIndex = index; if (item.type === 'note') { @@ -1145,43 +1084,14 @@ export function HomeFeed({ activeFilter }: HomeFeedProps) { tintColor: refreshTintColor, }); - const renderItem = useCallback( - (props: LegendListRenderItemProps) => ( - ({ - scrollY: Math.round(scrollOffsetRef.current), - filter: feedSpecs[activeSpecIndex]?.name ?? null, - rows: feedRowsRef.current.length, - })}> - {renderFeedItem(props)} - - ), - [activeSpecIndex, feedSpecs, renderFeedItem] - ); - - const handleScroll = useCallback((e: { nativeEvent: { contentOffset: { y: number } } }) => { - scrollOffsetRef.current = e.nativeEvent.contentOffset.y; - remeasureVisualLayoutScope(HOME_FEED_VISUAL_SCOPE, 'scroll', { - minIntervalMs: 500, - maxItems: 32, - extra: { scrollY: Math.round(e.nativeEvent.contentOffset.y) }, - }); - }, []); - const onScroll = useCallback( (e: { nativeEvent: { contentOffset: { y: number } } }) => { - handleScroll(e); + scrollOffsetRef.current = e.nativeEvent.contentOffset.y; if (imageOverlay?.scrollOffsetY != null) { imageOverlay.scrollOffsetY.value = e.nativeEvent.contentOffset.y; } }, - [handleScroll, imageOverlay] + [imageOverlay] ); return ( @@ -1192,28 +1102,15 @@ export function HomeFeed({ activeFilter }: HomeFeedProps) { onSwipeUpToNextPost={onSwipeUpToNextPost} getVideoFeedLayoutsAndIndex={getVideoFeedLayoutsAndIndex}> - - - + ) : ( 0 ? ( - ({ - scrollY: Math.round(scrollOffsetRef.current), - rows: feedRowsRef.current.length, - })}> - - + ) : null } onEndReached={handleEndReached} onEndReachedThreshold={0.4} - onItemSizeChanged={visualList.onItemSizeChanged} - onLoad={visualList.onLoad} - onMetricsChange={visualList.onMetricsChange} - onStickyHeaderChange={visualList.onStickyHeaderChange} - onViewableItemsChanged={visualList.onViewableItemsChanged} - viewabilityConfig={VISUAL_LIST_VIEWABILITY_CONFIG} style={styles.flex1} contentContainerStyle={LIST_CONTENT_STYLE} showsVerticalScrollIndicator={false} diff --git a/features/feed/components/ThreadView.tsx b/features/feed/components/ThreadView.tsx index fbe12e788..fbf12c672 100644 --- a/features/feed/components/ThreadView.tsx +++ b/features/feed/components/ThreadView.tsx @@ -6,19 +6,9 @@ */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { StyleSheet } from 'react-native'; -import Animated, { - FadeIn, - FadeOut, - useAnimatedStyle, - useSharedValue, - withTiming, -} from 'react-native-reanimated'; -import { - LegendList, - type LegendListRef, - type LegendListRenderItemProps, -} from '@legendapp/list/react-native'; +import { StyleSheet, useWindowDimensions } from 'react-native'; +import Animated, { FadeIn } from 'react-native-reanimated'; +import { FlashList } from '@shopify/flash-list'; import { useHeaderHeight } from '@react-navigation/elements'; import opacity from 'hex-color-opacity'; @@ -46,23 +36,15 @@ import { useOpenComposer } from '@/features/composer/publish/useComposerActions' import { deriveReplyTarget } from '@/features/feed/lib/replyTarget'; import { useQuotePost } from '@/features/feed/lib/useQuotePost'; import { ThreadReplyBar } from '@/features/feed/components/ThreadReplyBar'; -import { VisualLayoutProbe } from '@/shared/ui/composed/VisualLayoutProbe'; import type { ThreadReplySort } from '@/features/feed/data/feedClient'; import { useNostrEngagement } from '@/features/feed/hooks/useNostrEngagement'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { feedLog, Log } from '@/shared/lib/logger'; import { actionMenuPopup } from '@/shared/lib/popup'; -import { - remeasureVisualLayoutScope, - useVisualListLogger, - useVisualStateLogger, - VISUAL_LIST_VIEWABILITY_CONFIG, -} from '@/shared/lib/contentShiftLog'; import { DEFAULT_REPLY_SKELETON_COUNT, MAX_REPLY_SKELETON_COUNT, } from '@/features/feed/lib/threadReplySkeletons'; -import { threadFixedItemSize } from '@/features/feed/lib/threadListLayout'; interface ThreadViewProps { eventId: string; @@ -106,10 +88,16 @@ const REPLY_SORT_OPTIONS: { }, ]; -// When a reply's real content loads it fades in while the skeleton it replaces -// fades out — a crossfade in the same row slot. +// When a reply's real content loads it fades in over the skeleton it replaces. +// The ENTER animation is recycling-tolerant on FlashList (it plays at the cell's +// correct spot and doesn't re-fire on scroll, since recycled cells reuse the +// instance), so the skeleton it replaces simply unmounts without an exit fade. const REPLY_FADE_IN = FadeIn.duration(220); -const SKELETON_FADE_OUT = FadeOut.duration(220); + +// Rough per-row height used only to discount the content already below the +// focused note when sizing the focus reserve (see `focusReserve`). Deliberately +// approximate — it just keeps the reserve from over-padding long threads. +const FOCUS_RESERVE_ROW_APPROX = 150; type ThreadSkeletonItem = | { type: 'target-skeleton'; id: string } @@ -245,12 +233,12 @@ function ThreadViewInner({ eventId }: ThreadViewProps) { 'surface-tertiary', ] as const); const headerHeight = useHeaderHeight(); + const { height: windowHeight } = useWindowDimensions(); const imageOverlay = useImageOverlay(); const embed = useThreadEmbed(); const embedOpen = embed?.open; const targetFooterOpacity = embed?.targetFooterOpacity; const [replyBarHeight, setReplyBarHeight] = useState(0); - const threadVisualScope = useMemo(() => `thread.${eventId}.list`, [eventId]); const { items, @@ -278,34 +266,6 @@ function ThreadViewInner({ eventId }: ThreadViewProps) { const openPostActions = usePostActions({ getProfileName }); const openComposer = useOpenComposer(); - const listRef = useRef(null); - - // The sort-tabs ("Relevant") row and the replies sit below the target, so their - // on-screen position is whatever LegendList computes for the target — which - // starts at `estimatedItemSize` and snaps to the real measured height a frame - // later, shifting everything below. Rather than fight that reconciliation, we - // keep those rows occupying their space but at opacity 0 until the real target - // has laid out, then fade them in at their settled positions (the shift happens - // while they're invisible). The target itself is the stable anchor and renders - // immediately. - const revealedRef = useRef(false); - const revealOpacity = useSharedValue(0); - const revealStyle = useAnimatedStyle(() => ({ opacity: revealOpacity.value })); - const handleTargetSettled = useCallback(() => { - if (revealedRef.current) return; - revealedRef.current = true; - // One frame after the target measures, LegendList has repositioned the rows - // below it — reveal then so they fade in already in their final spot. - requestAnimationFrame(() => { - revealOpacity.value = withTiming(1, { duration: 220 }); - }); - }, [revealOpacity]); - useEffect(() => { - revealedRef.current = false; - revealOpacity.value = 0; - }, [eventId, revealOpacity]); - - const targetIndex = useMemo(() => items.findIndex((item) => item.type === 'target'), [items]); const targetItem = useMemo(() => items.find((item) => item.type === 'target'), [items]); const hasParents = useMemo(() => items.some((i) => i.type === 'parent'), [items]); @@ -341,6 +301,42 @@ function ThreadViewInner({ eventId }: ThreadViewProps) { return withReplySortTabs([...items, ...createReplySkeletonItems(skeletonCount)]); }, [isFetching, isLoading, items, metricsRef]); + // The note's index in the list (parents precede it; replies/skeletons follow) — + // for the focus reserve and the initial landing. + const fullTargetIndex = useMemo( + () => displayItems.findIndex((i) => i.type === 'target'), + [displayItems] + ); + + // Focus reserve: extra bottom space so the tapped reply can always be scrolled + // to (and held at) the top of the viewport, even when little content sits below + // it. Opening a thread on a reply lands a tall parent chain above the note; with + // no room below, the scroll bottoms out (clamps/snaps) and the note can't be + // refocused, and `maintainVisibleContentPosition` has nowhere to scroll to hold + // it as the parent grows. We reserve `viewport − (content already below the + // note)`, so the reserve is generous on short threads and shrinks toward 0 as + // replies fill the screen (no dead gap on long threads). Owned here (as plain + // bottom padding) so it's deterministic, hot-reloadable, and logged. + // `fullTargetIndex` indexes `displayItems` — the sort-tabs row is inserted + // after the target and skeletons are appended last. + const focusReserve = useMemo(() => { + if (fullTargetIndex <= 0) return 0; + const listViewport = Math.max(0, windowHeight - headerHeight - (replyBarHeight || 80)); + const belowCount = Math.max(0, displayItems.length - 1 - fullTargetIndex); + return Math.max(0, listViewport - belowCount * FOCUS_RESERVE_ROW_APPROX); + }, [fullTargetIndex, windowHeight, headerHeight, replyBarHeight, displayItems.length]); + + useEffect(() => { + if (focusReserve <= 0) return; + feedLog.info('thread.reserve', { + eventId, + targetIndex: fullTargetIndex, + rows: displayItems.length, + focusReserve: Math.round(focusReserve), + windowHeight: Math.round(windowHeight), + }); + }, [focusReserve, eventId, fullTargetIndex, displayItems.length, windowHeight]); + const threadPhase = isLoading && items.length === 0 ? 'loading-skeletons' @@ -348,72 +344,6 @@ function ThreadViewInner({ eventId }: ThreadViewProps) { ? 'fetching-with-skeletons' : 'replies'; - const visualList = useVisualListLogger({ - scope: threadVisualScope, - surface: 'thread', - component: 'ThreadLegendList', - phase: threadPhase, - extra: () => ({ - eventId, - rows: displayItems.length, - replySort, - replyBarHeight, - isFetching, - isLoadingMoreReplies, - }), - getItemKey: (item) => threadKeyExtractor(item), - getItemContext: (item) => ({ - rowKey: threadKeyExtractor(item), - rowLabel: threadItemType(item), - itemType: threadItemType(item), - }), - getListState: () => listRef.current?.getState() ?? null, - }); - - const threadVisualState = useMemo(() => { - let skeletons = 0; - let realReplies = 0; - for (const it of displayItems) { - if (it.type === 'target-skeleton' || it.type === 'reply-skeleton') skeletons += 1; - else if (it.type === 'reply') realReplies += 1; - } - return { - eventId, - phase: threadPhase, - rows: displayItems.length, - skeletons, - realReplies, - replySort, - replyBarHeight, - isFetching, - isLoading, - isLoadingMoreReplies, - }; - }, [ - displayItems, - eventId, - isFetching, - isLoading, - isLoadingMoreReplies, - replyBarHeight, - replySort, - threadPhase, - ]); - - useVisualStateLogger({ - scope: threadVisualScope, - surface: 'thread', - component: 'ThreadView', - stateKey: 'thread-state', - phase: threadVisualState.phase, - state: threadVisualState, - remeasure: { - reason: 'thread-state', - minIntervalMs: 250, - maxItems: 32, - }, - }); - // Content-shift trace: log each reply-list phase change with its row makeup so // a jump can be tied to the exact transition. Pairs with `thread.reply_skeleton.*`. const lastThreadPhaseRef = useRef(''); @@ -460,31 +390,27 @@ function ThreadViewInner({ eventId }: ThreadViewProps) { }, [items, profilesRef, metricsRef, quotedEventsRef]); const renderThreadItem = useCallback( - ({ item, index }: LegendListRenderItemProps) => { + ({ item, index }: { item: ThreadListItem; index: number }) => { if (item.type === 'target-skeleton') { return ; } if (item.type === 'reply-skeleton') { - // Fades out as the real reply that replaces it fades in (crossfade). - return ( - - - - ); + // FlashList recycles cells, which fights reanimated exit animations — an + // exiting skeleton renders in a recycled cell's position for a frame (the + // "skeleton in the wrong place" glitch), so the skeleton simply unmounts + // and the real reply fades IN over it (see REPLY_FADE_IN below). + return ; } if (item.type === 'reply-sort-tabs') { - // Hidden until the target settles, then fades in at its final position. return ( - - - + ); } @@ -526,9 +452,11 @@ function ThreadViewInner({ eventId }: ThreadViewProps) { /> ); - // Each reply fades its real content in as it loads, crossfading with the - // skeleton it replaces (which fades out via SKELETON_FADE_OUT). The - // target/parents are the stable anchor and render immediately. + // Each reply fades its real content in as it loads. The ENTER animation is + // recycling-tolerant on FlashList (it plays at the cell's correct spot and + // doesn't re-fire on scroll, since recycled cells reuse the instance) — unlike + // a skeleton EXIT, which lingered in a recycled cell's slot (the "wrong + // place" glitch), so the reply only ever fades IN. if (item.type === 'reply') { return {card}; } @@ -543,7 +471,6 @@ function ThreadViewInner({ eventId }: ThreadViewProps) { getEngagementState, getMetrics, hasParents, - revealStyle, profilesRef, quotedEventsRef, replySort, @@ -556,40 +483,6 @@ function ThreadViewInner({ eventId }: ThreadViewProps) { ] ); - const renderItem = useCallback( - (props: LegendListRenderItemProps) => ( - - {renderThreadItem(props)} - - ), - [ - displayItems.length, - eventId, - handleTargetSettled, - isFetching, - renderThreadItem, - replyBarHeight, - replySort, - threadVisualScope, - threadPhase, - ] - ); - const handleEndReached = useCallback(() => { // Don't start reply pagination while the initial fetch is running — the // footer spinner would otherwise overlap the loading skeletons. @@ -597,6 +490,22 @@ function ThreadViewInner({ eventId }: ThreadViewProps) { void loadMoreReplies(); }, [loadMoreReplies, isFetching]); + // FlashList re-renders rows when `data` changes by reference or when `extraData` + // changes. Engagement (likes/reposts), pagination flags and the sort live outside + // `displayItems`, so fold them into extraData so those updates repaint the rows. + const threadExtraData = useMemo( + () => + [ + dataVersion, + engagementRevision, + isFetching ? 1 : 0, + isLoadingMoreReplies ? 1 : 0, + hasMoreReplies ? 1 : 0, + replySort, + ].join(':'), + [dataVersion, engagementRevision, isFetching, isLoadingMoreReplies, hasMoreReplies, replySort] + ); + // Target-post actions for the floating embed action bar (mirrors the // target PostCard's MetricsFooter wiring). Hooks stay above the early // return below. @@ -663,24 +572,23 @@ function ThreadViewInner({ eventId }: ThreadViewProps) { onHeightChange={setReplyBarHeight} /> }> - @@ -695,38 +603,22 @@ function ThreadViewInner({ eventId }: ThreadViewProps) { ) : null } - onEndReached={handleEndReached} - onEndReachedThreshold={0.4} - onItemSizeChanged={visualList.onItemSizeChanged} - onLoad={visualList.onLoad} - onMetricsChange={visualList.onMetricsChange} - onStickyHeaderChange={visualList.onStickyHeaderChange} - onViewableItemsChanged={visualList.onViewableItemsChanged} - viewabilityConfig={VISUAL_LIST_VIEWABILITY_CONFIG} - style={{ flex: 1 }} + showsVerticalScrollIndicator={false} contentContainerStyle={{ - // The embed sheet is positioned starting just below the header - // (`expandedOffset`), so the list itself no longer pads the top. + // The embed sheet starts just below the header, so the list pads no + // top. `replyBarHeight` already includes the bottom safe-area inset; + // `focusReserve` adds the room below the note (see its definition). paddingTop: 0, - // replyBarHeight already includes the bottom safe-area inset (the - // bar's opaque container reaches the screen bottom), so the inset - // is not added again here. - paddingBottom: (replyBarHeight || 80) + 16, + paddingBottom: (replyBarHeight || 80) + 16 + focusReserve, }} - showsVerticalScrollIndicator={false} - onScroll={(e: { nativeEvent: { contentOffset: { y: number } } }) => { + onScroll={(e) => { const y = e.nativeEvent.contentOffset.y; if (imageOverlay?.scrollOffsetY != null) imageOverlay.scrollOffsetY.value = y; if (embed) embed.scrollY.value = y; - remeasureVisualLayoutScope(threadVisualScope, 'scroll', { - minIntervalMs: 500, - maxItems: 32, - extra: { eventId, scrollY: Math.round(y), replySort }, - }); }} scrollEventThrottle={16} scrollEnabled={embed ? embed.listScrollEnabled : undefined} - initialScrollIndex={!isLoading && targetIndex > 0 ? targetIndex : undefined} + initialScrollIndex={!isLoading && fullTargetIndex > 0 ? fullTargetIndex : undefined} /> {embed && targetEvent ? ( diff --git a/features/feed/components/UserFeed.tsx b/features/feed/components/UserFeed.tsx index 483b7925f..824d1fe42 100644 --- a/features/feed/components/UserFeed.tsx +++ b/features/feed/components/UserFeed.tsx @@ -40,11 +40,6 @@ import { HStack } from '@/shared/ui/primitives/View/HStack'; import { View } from '@/shared/ui/primitives/View/View'; import Icon from 'assets/icons'; import opacity from 'hex-color-opacity'; -import { - LegendList, - LegendListRef, - type LegendListRenderItemProps, -} from '@legendapp/list/react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Reanimated, { useSharedValue, @@ -77,7 +72,6 @@ import { getFeedClient } from '@/features/feed/data/useFeedClient'; import { buildFeedRows, DEFAULT_ENGAGEMENT_STATE, - feedRowsAreEqual, getFeedRowItemType, getFeedRowKey, type FeedRow, @@ -94,13 +88,7 @@ import { useNostrEngagement } from '@/features/feed/hooks/useNostrEngagement'; import { usePostActions } from '@/features/feed/hooks/usePostActions'; import { useNostrSocialStore } from '@/shared/stores/profile/nostrSocialStore'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; -import { - remeasureVisualLayoutScope, - useVisualListLogger, - useVisualStateLogger, - VISUAL_LIST_VIEWABILITY_CONFIG, -} from '@/shared/lib/contentShiftLog'; -import { VisualLayoutProbe } from '@/shared/ui/composed/VisualLayoutProbe'; +import { List } from '@/shared/ui/composed/List'; // ============================================================================ // Types (UserFeed-specific) @@ -363,7 +351,6 @@ export function UserFeed({ }: UserFeedProps) { const foreground = useThemeColor('foreground'); const imageOverlay = useImageOverlay(); - const userFeedVisualScope = useMemo(() => `feed.user.${pubkey.slice(0, 12)}.list`, [pubkey]); const [, startTransition] = useTransition(); const [feedItems, setFeedItems] = useState([]); const [metricsMap, setMetricsMap] = useState>(new Map()); @@ -394,7 +381,6 @@ export function UserFeed({ // (protects against accidental taps). Nagg's app-view cache will catch up eventually. const deletedRepostIdsRef = useRef | null>(null); - const feedListRef = useRef(null); const overlaySourceIndexRef = useRef(-1); const scrollOffsetRef = useRef(0); @@ -808,53 +794,6 @@ export function UserFeed({ feedRowsRef.current = feedRows; }, [feedRows]); - const visualList = useVisualListLogger({ - scope: userFeedVisualScope, - surface: 'profile', - component: 'UserFeedList', - phase: isLoading ? 'loading' : 'ready', - extra: () => ({ - rows: feedRowsRef.current.length, - isLoading, - isLoadingMore, - isOwnProfile: !!isOwnProfile, - }), - getItemKey: (row) => row.key, - getItemContext: (row) => ({ - rowKey: row.key, - rowLabel: getFeedRowItemType(row), - itemType: getFeedRowItemType(row), - }), - getListState: () => feedListRef.current?.getState() ?? null, - }); - - useVisualStateLogger({ - scope: userFeedVisualScope, - surface: 'profile', - component: 'UserFeed', - stateKey: 'user-feed-state', - phase: isLoading ? 'loading' : isLoadingMore ? 'loading-more' : 'ready', - state: { - authorKnown: Boolean(authorName), - empty: !isLoading && feedRows.length === 0, - feedItems: feedItems.length, - hasHeader: ListHeaderComponent != null, - isLoading, - isLoadingMore, - isOwnProfile: !!isOwnProfile, - metrics: metricsMap.size, - profiles: profilesMap.size, - quotedEvents: quotedEventsMap.size, - rows: feedRows.length, - videos: videoPosts.length, - }, - remeasure: { - reason: 'user-feed-state', - minIntervalMs: 300, - maxItems: 32, - }, - }); - // Render boundary / skeleton→content swap: mirrors HomeFeed's `feed.ui.render` // so a profile-feed content shift can be traced the same way. useEffect(() => { @@ -868,7 +807,7 @@ export function UserFeed({ }, [feedItems.length, feedRows.length, isLoading]); const renderFeedItem = useCallback( - ({ item: row, index }: LegendListRenderItemProps) => { + ({ item: row, index }: { item: FeedRow; index: number }) => { const item = row.item; if (item.type === 'note') { const metrics = row.metrics; @@ -1059,26 +998,6 @@ export function UserFeed({ ] ); - const renderItem = useCallback( - (props: LegendListRenderItemProps) => ( - ({ - scrollY: Math.round(scrollOffsetRef.current), - rows: feedRowsRef.current.length, - isOwnProfile: !!isOwnProfile, - })}> - {renderFeedItem(props)} - - ), - [isOwnProfile, renderFeedItem, userFeedVisualScope] - ); - const handleListScroll = useCallback( (e: { nativeEvent: { contentOffset: { y: number } } }) => { const y = e.nativeEvent.contentOffset.y; @@ -1086,13 +1005,8 @@ export function UserFeed({ if (imageOverlay?.scrollOffsetY != null) { imageOverlay.scrollOffsetY.value = y; } - remeasureVisualLayoutScope(userFeedVisualScope, 'scroll', { - minIntervalMs: 500, - maxItems: 32, - extra: { scrollY: Math.round(y) }, - }); }, - [imageOverlay, userFeedVisualScope] + [imageOverlay] ); const feedHeader = ( @@ -1103,16 +1017,7 @@ export function UserFeed({ Notes {isLoading ? ( - - - + ) : feedItems.length === 0 ? ( ) : null} @@ -1122,65 +1027,32 @@ export function UserFeed({ const feedList = isLoading || feedItems.length === 0 ? ( - // When loading or empty, render without LegendList (header-only mode) - null} ListHeaderComponent={feedHeader} - onItemSizeChanged={visualList.onItemSizeChanged} - onLoad={visualList.onLoad} - onMetricsChange={visualList.onMetricsChange} - onStickyHeaderChange={visualList.onStickyHeaderChange} - onViewableItemsChanged={visualList.onViewableItemsChanged} - viewabilityConfig={VISUAL_LIST_VIEWABILITY_CONFIG} - style={{ flex: 1 }} - contentContainerStyle={{ paddingBottom: 120 }} + style={styles.flexOne} + contentContainerStyle={USER_FEED_CONTENT_STYLE} showsVerticalScrollIndicator={false} onScroll={handleListScroll} scrollEventThrottle={16} /> ) : ( - ({ - scrollY: Math.round(scrollOffsetRef.current), - rows: feedRowsRef.current.length, - })}> - - - ) : null + isLoadingMore ? : null } onEndReached={handleEndReached} onEndReachedThreshold={0.4} - onItemSizeChanged={visualList.onItemSizeChanged} - onLoad={visualList.onLoad} - onMetricsChange={visualList.onMetricsChange} - onStickyHeaderChange={visualList.onStickyHeaderChange} - onViewableItemsChanged={visualList.onViewableItemsChanged} - viewabilityConfig={VISUAL_LIST_VIEWABILITY_CONFIG} - style={{ flex: 1 }} - contentContainerStyle={{ paddingBottom: 120 }} + style={styles.flexOne} + contentContainerStyle={USER_FEED_CONTENT_STYLE} showsVerticalScrollIndicator={false} onScroll={handleListScroll} scrollEventThrottle={16} @@ -1222,7 +1094,12 @@ export function UserFeed({ // Styles (UserFeed-specific only — shared styles live in nostr/shared.tsx) // ============================================================================ +const USER_FEED_CONTENT_STYLE = { paddingBottom: 120 }; + const styles = StyleSheet.create({ + flexOne: { + flex: 1, + }, emptyState: { paddingVertical: 32, paddingHorizontal: 16, diff --git a/features/feed/components/nostr/MetricsFooter.tsx b/features/feed/components/nostr/MetricsFooter.tsx index ccba3128b..4a31af1ca 100644 --- a/features/feed/components/nostr/MetricsFooter.tsx +++ b/features/feed/components/nostr/MetricsFooter.tsx @@ -1,4 +1,11 @@ import React from 'react'; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withSequence, + withTiming, +} from 'react-native-reanimated'; import opacity from 'hex-color-opacity'; import { alpha, iconSize } from '@/shared/styles/tokens'; import { Pressable } from '@/shared/ui/primitives/Pressable'; @@ -26,6 +33,49 @@ export const POST_ACTION_ICON_SIZES = { }, } as const; +/** + * A count/sats value that "rolls in" when it changes instead of snapping — so a + * lazily-loaded or updated engagement count animates rather than abruptly + * changing. Wraps the app `Text` (font/colour stay exact); only a parent + * Animated.View's opacity + a few-px translateY tween, so there's no clipping. + * The first render never animates (avoids every count counting up on mount). + */ +const AnimatedCountValue = React.memo(function AnimatedCountValue({ + value, + size, + color, + overpass = false, +}: { + value: string; + size: number; + color: string; + overpass?: boolean; +}) { + const progress = useSharedValue(1); + const firstRender = React.useRef(true); + React.useEffect(() => { + if (firstRender.current) { + firstRender.current = false; + return; + } + progress.value = withSequence( + withTiming(0, { duration: 0 }), + withTiming(1, { duration: 280, easing: Easing.out(Easing.cubic) }) + ); + }, [value, progress]); + const animatedStyle = useAnimatedStyle(() => ({ + opacity: 0.25 + 0.75 * progress.value, + transform: [{ translateY: (1 - progress.value) * 6 }], + })); + return ( + + + {value} + + + ); +}); + const AnimatedMetric = React.memo(function AnimatedMetric({ iconName, iconSize, @@ -49,9 +99,7 @@ const AnimatedMetric = React.memo(function AnimatedMetric({ return ( - - {text} - + ); }); @@ -176,9 +224,12 @@ export const MetricsFooter = React.memo(function MetricsFooter({ {metrics.satsZapped > 0 ? ( - - {formatSats(metrics.satsZapped)} - + ) : ( diff --git a/features/feed/components/nostr/PostCard.tsx b/features/feed/components/nostr/PostCard.tsx index 4900c3e63..605236255 100644 --- a/features/feed/components/nostr/PostCard.tsx +++ b/features/feed/components/nostr/PostCard.tsx @@ -28,6 +28,7 @@ import { formatDate, formatRelative } from '@/shared/lib/date'; import { tryNpubEncode } from './feedParse'; import { useQuotePost } from '@/features/feed/lib/useQuotePost'; import { NoteContent, NOTE_CONTENT_FONT_SIZE, NOTE_CONTENT_LINE_HEIGHT } from './NoteContent'; +import { useProfile } from '@/shared/lib/nostr/useEntityCache'; import { MetricsFooter, POST_ACTION_ICON_SIZES } from './MetricsFooter'; import { SkeletonExitReveal, @@ -50,6 +51,106 @@ const AVATAR_SIZE = 36; const METRIC_SKELETON_ITEMS = [0, 1, 2, 3] as const; +/** + * The gutter post's header row (name · time, plus the "⋯ more" button), shared by + * the real `PostCard` and `PostCardSkeleton` so their chrome can NEVER drift — a + * skeleton that hand-duplicated this row had silently dropped the more-button and + * rendered ~6px short, growing the row when real content replaced it. Rendering both + * states from one component guarantees identical height/structure. + */ +function PostCardGutterHeader({ + loading = false, + foreground, + hasMore, + isThread = false, + displayName, + nameFallback, + shortTime, + placeholderAuthor, + placeholderTimestamp, + onProfilePress, + onMorePress, + onNestedPressIn, + onNestedPressOut, +}: { + loading?: boolean; + foreground: string; + hasMore: boolean; + isThread?: boolean; + displayName?: string; + nameFallback?: string; + shortTime?: string; + placeholderAuthor?: string; + placeholderTimestamp?: string; + onProfilePress?: () => void; + onMorePress?: () => void; + onNestedPressIn?: () => void; + onNestedPressOut?: () => void; +}) { + const textPrimary = { color: opacity(foreground, 0.9) }; + const textMuted = { color: opacity(foreground, 0.4) }; + const textDimmed = { color: opacity(foreground, 0.3) }; + return ( + + + {loading ? ( + + ) : ( + + + {displayName} + + + )} + {loading ? ( + + ) : shortTime ? ( + <> + + {'•'} + + + {shortTime} + + + ) : null} + + {hasMore ? ( + loading ? ( + // Empty box at the real more-button's exact dimensions — reserves the same + // height so the skeleton row matches the real row. + + ) : ( + + + + ) + ) : null} + + ); +} + interface PostCardProps { event: FeedEvent; metrics: NoteMetrics; @@ -134,7 +235,12 @@ export const PostCard = React.memo(function PostCard({ }: PostCardProps) { const [foreground, defaultColor] = useThemeColor(['foreground', 'default'] as const); - const profile = profiles.get(event.pubkey); + // Author identity comes from the authoritative entity cache, reactively: the + // row re-renders in place when this pubkey's profile arrives (no manual re-key), + // and any surface that saw this author renders it here too. `status` tells a + // genuine loading skeleton apart from a fallback. (`profiles` is still passed to + // NoteContent for inline mention chips.) + const { profile, status: authorStatus } = useProfile(event.pubkey); // Real display name only. The abbreviated npub fallback is passed as Text's // `fallback` prop so the name never flashes through a pubkey placeholder. const displayName = profile?.name; @@ -251,7 +357,9 @@ export const PostCard = React.memo(function PostCard({ onPress={navigateToProfile}> - - - - - {displayName} - - - {shortTime ? ( - <> - - {'•'} - - - {shortTime} - - - ) : null} - - {onMorePress ? ( - - - - ) : null} - + - - - - + {/* Same header component as the real post (loading state) — guarantees the + author row (incl. the more-button box) matches the real row's height. */} + {skeletonVariant.content.map((line) => ( @@ -629,7 +710,11 @@ const MetricsFooterSkeleton = React.memo(function MetricsFooterSkeleton({ labelWidth: number; }) { const glyph = compact ? POST_ACTION_ICON_SIZES.compact.base : POST_ACTION_ICON_SIZES.regular.base; - const labelHeight = compact ? 14 : spacing.md; + // The real footer's count is a `Text size={11/13}` with no lineHeight, so the + // footer row is as tall as that font's line box. The skeleton must use the SAME + // size (via a `Text loading` placeholder) — a hardcoded label rectangle was ~7px + // shorter, which made the reply row grow when real text replaced the skeleton. + const labelTextSize = compact ? 11 : 13; const skeletonFill = useMemo(() => opacity(borderColor, 0.07), [borderColor]); const footerStyle = useMemo( () => [ @@ -648,15 +733,7 @@ const MetricsFooterSkeleton = React.memo(function MetricsFooterSkeleton({ }), [glyph, skeletonFill] ); - const labelStyle = useMemo( - () => ({ - width: labelWidth, - height: labelHeight, - borderRadius: radius.sm, - backgroundColor: skeletonFill, - }), - [labelHeight, labelWidth, skeletonFill] - ); + const labelStyle = useMemo(() => ({ width: labelWidth }), [labelWidth]); return ( @@ -664,7 +741,17 @@ const MetricsFooterSkeleton = React.memo(function MetricsFooterSkeleton({ {METRIC_SKELETON_ITEMS.map((item) => ( - {item < 3 ? : null} + {item < 3 ? ( + // Self-sizes to the real count's line box (same `size`), so the footer + // row height matches the real footer exactly. Width kept via `labelWidth`. + + ) : null} ))} diff --git a/features/feed/components/nostr/image-overlay/ImageBlock.tsx b/features/feed/components/nostr/image-overlay/ImageBlock.tsx index aa59e0e1a..b87f593b4 100644 --- a/features/feed/components/nostr/image-overlay/ImageBlock.tsx +++ b/features/feed/components/nostr/image-overlay/ImageBlock.tsx @@ -178,7 +178,7 @@ export const ImageBlock = React.memo(function ImageBlock({ ); /** - * Just-in-time re-measure for dismiss targeting. Recycled LegendList rows + * Just-in-time re-measure for dismiss targeting. Recycled FlashList rows * never re-fire onLayout when size is unchanged, so the rect registered at * onLayout can be stale by close time; close() calls this to re-measure the * live node. Resolves null when the node is unmounted/unmeasurable. diff --git a/features/feed/components/nostr/image-overlay/provider.tsx b/features/feed/components/nostr/image-overlay/provider.tsx index 005fe38c6..fe887f771 100644 --- a/features/feed/components/nostr/image-overlay/provider.tsx +++ b/features/feed/components/nostr/image-overlay/provider.tsx @@ -196,7 +196,7 @@ export function ImageOverlayProvider({ /** See ImageOverlayContextValue.measureSpaceCorrection — tap-calibrated * measure-space delta, stable ref so actionsValue identity is unaffected. */ const measureSpaceCorrection = useRef({ dx: 0, dy: 0 }); - /** Per-key just-in-time measure callbacks: close() re-measures the live thumbnail because recycled LegendList rows never re-fire onLayout when size is unchanged, leaving the registered rect stale. */ + /** Per-key just-in-time measure callbacks: close() re-measures the live thumbnail because recycled FlashList rows never re-fire onLayout when size is unchanged, leaving the registered rect stale. */ const thumbnailMeasureNowRef = useRef Promise>>({}); /** Bumped on every open/openReplace; a close() awaiting a re-measure aborts when the session changed under it (no double-close / close-after-reopen race). */ const openSessionIdRef = useRef(0); @@ -874,7 +874,7 @@ export function ImageOverlayProvider({ * (activeIndex can lag behind the pager, so we use the index passed from the overlay). * * When the dismiss-target key has a registered measureNow, the live node is - * re-measured just-in-time (recycled LegendList rows never re-fire onLayout + * re-measured just-in-time (recycled FlashList rows never re-fire onLayout * when size is unchanged, so the open-time snapshot can be stale). The close * animation then starts one tick later; the snapshot path stays the fallback * (timeout, unmounted node, or no callback). diff --git a/features/feed/components/nostr/image-overlay/types.ts b/features/feed/components/nostr/image-overlay/types.ts index 5299130b5..8892abb7f 100644 --- a/features/feed/components/nostr/image-overlay/types.ts +++ b/features/feed/components/nostr/image-overlay/types.ts @@ -91,7 +91,7 @@ export type ImageOverlayContextValue = { openToCenter: () => void; /** * Register a thumbnail's layout (e.g. from onLayout + measureInWindow). Use eventId + imageIndex when opening from a post so dismiss uses this post's position, not another card's. - * Pass measureNow so close() can re-measure the live node just-in-time: recycled LegendList rows never re-fire onLayout when size is unchanged, so the registered rect can be stale. + * Pass measureNow so close() can re-measure the live node just-in-time: recycled FlashList rows never re-fire onLayout when size is unchanged, so the registered rect can be stale. */ registerThumbnailLayout: ( url: string, diff --git a/features/feed/data/facadeFeedAdapter.ts b/features/feed/data/facadeFeedAdapter.ts new file mode 100644 index 000000000..53ca5d54b --- /dev/null +++ b/features/feed/data/facadeFeedAdapter.ts @@ -0,0 +1,100 @@ +import { facade } from '@sovranbitcoin/nagg-ts'; + +import { parseJson } from '../components/nostr/feedParse'; +import type { FeedEvent, FeedItem, NoteMetrics, ProfileInfo } from '../components/nostr/feedTypes'; +import type { FeedParseResult } from './feedClient'; + +// Pure shape bridge between the tier-selecting facade and the app's feed UI. +// Kept dependency-light (facade types + feed types only) so it's unit-testable +// without the facade-builder's React-Native chain. + +/** Map the app's JSON feed spec to the facade FeedSpec (ranked home feeds only). */ +export function mapAppSpecToFeedSpec(specJson: string, userPubkey?: string): facade.FeedSpec | null { + const parsed = parseJson>(specJson); + if (!parsed || parsed.kind !== 'notes') return null; + switch (parsed.id) { + case 'for-you': + return { kind: 'for-you', ...(userPubkey ? { viewerPubkey: userPubkey } : {}) }; + case 'following-popular': + return userPubkey ? { kind: 'following-popular', viewerPubkey: userPubkey } : null; + default: + return null; + } +} + +/** Adapt the facade's ResolvedFeedPage to the app's FeedParseResult. */ +export function resolvedFeedPageToParseResult(page: facade.ResolvedFeedPage): FeedParseResult { + const orderedFeedItems = page.items.map(toAppFeedItem); + + const metricsMap = new Map(); + for (const [id, s] of Object.entries(page.stats)) { + metricsMap.set(id, { + likeCount: s.likes, + repostCount: s.reposts, + replyCount: s.replies, + satsZapped: s.satsZapped, + }); + } + + const profilesMap = new Map( + Object.entries(page.profiles).map(([pk, p]) => [ + pk, + { name: p.name, ...(p.picture ? { picture: p.picture } : {}) }, + ]) + ); + const quotedEventsMap = new Map( + Object.entries(page.quoted) as [string, FeedEvent][] + ); + + // Gaps the existing enrichment path (delegated to the old client) can fill. + const neededPubkeys = new Set(); + const neededQuoteIds = new Set(); + for (const item of orderedFeedItems) { + const events = item.type === 'note' ? [item.event] : [item.repostEvent, item.originalEvent]; + for (const event of events) { + if (!event) continue; + neededPubkeys.add(event.pubkey); + for (const tag of event.tags) { + if (tag[0] === 'q' && tag[1]) neededQuoteIds.add(tag[1]); + } + } + } + + return { + orderedFeedItems, + metricsMap, + profilesMap, + quotedEventsMap, + missingQuotedIds: [...neededQuoteIds].filter((id) => !quotedEventsMap.has(id)), + missingProfilePubkeys: [...neededPubkeys].filter((pk) => !profilesMap.has(pk)), + paginationUntil: page.cursor?.createdAt ?? 0, + paginationOffset: orderedFeedItems.length, + }; +} + +function toAppFeedItem(item: facade.FeedItem): FeedItem { + if (item.type === 'note') { + return { + type: 'note', + event: item.event as FeedEvent, + ...(item.rootEvent ? { rootEvent: item.rootEvent as FeedEvent } : {}), + ...(item.rootEventId ? { rootEventId: item.rootEventId } : {}), + ...(item.replyPreviewEvents + ? { replyPreviewEvents: item.replyPreviewEvents as FeedEvent[] } + : {}), + timestamp: item.event.created_at ?? 0, + }; + } + return { + type: 'repost', + repostEvent: item.repostEvent as FeedEvent, + originalEvent: (item.originalEvent ?? undefined) as FeedEvent | undefined, + originalEventId: item.originalEventId ?? item.repostEvent.id, + ...(item.rootEvent ? { rootEvent: item.rootEvent as FeedEvent } : {}), + ...(item.rootEventId ? { rootEventId: item.rootEventId } : {}), + ...(item.reposters + ? { reposters: item.reposters as { pubkey: string; event: FeedEvent }[] } + : {}), + timestamp: item.repostEvent.created_at ?? 0, + }; +} diff --git a/features/feed/data/facadeFeedClient.ts b/features/feed/data/facadeFeedClient.ts new file mode 100644 index 000000000..2fee2deb2 --- /dev/null +++ b/features/feed/data/facadeFeedClient.ts @@ -0,0 +1,200 @@ +import { facade } from '@sovranbitcoin/nagg-ts'; + +import type { FeedEvent, FeedItem } from '@/features/feed/components/nostr/feedTypes'; +import { feedLog } from '@/shared/lib/logger'; +import { buildNostrDataLayer } from '@/shared/lib/nostr/buildNostrDataLayer'; +import { getNostrTierConfig } from '@/shared/lib/nostr/nostrTierConfig'; +import { mapAppSpecToFeedSpec, resolvedFeedPageToParseResult } from './facadeFeedAdapter'; +import { + resolvedNotificationsToResult, + toFacadeNotificationsRequest, +} from './facadeNotificationsAdapter'; +import { + emptyThreadResult, + resolvedThreadToResult, + toFacadeThreadRequest, +} from './facadeThreadAdapter'; +import { + emptyFeedParseResult, + type FeedClient, + type FeedNotificationsResult, + type FeedParseResult, + type ThreadResult, + type ThreadSeedBuckets, +} from './feedClient'; + +// --------------------------------------------------------------------------- +// Cache bridge: the nagg GraphQL fast-paths (thread, user feeds, posts-by-pubkey) +// return app-shaped results WITHOUT writing to the shared entity cache the way +// the facade reads do. These helpers write those results back into the singleton +// cache, tagged 'nagg' (the source that served them), so a later read — opening a +// reply as its own thread, revisiting a profile — serves them instantly. The +// for-you/notifications facade paths already ingest; this closes the gap. +// --------------------------------------------------------------------------- + +function eventsFromAppFeedItem(item: FeedItem): FeedEvent[] { + const out: FeedEvent[] = []; + if (item.type === 'note') { + out.push(item.event); + if (item.rootEvent) out.push(item.rootEvent); + if (item.replyPreviewEvents) out.push(...item.replyPreviewEvents); + } else { + out.push(item.repostEvent); + if (item.originalEvent) out.push(item.originalEvent); + if (item.rootEvent) out.push(item.rootEvent); + if (item.reposters) for (const reposter of item.reposters) out.push(reposter.event); + } + return out; +} + +/** Write a thread result's notes/profiles/metrics into the shared cache. */ +function ingestThreadIntoCache(result: ThreadSeedBuckets): void { + const cache = buildNostrDataLayer()?.cache; + if (!cache) return; + cache.ingestNotes([...result.allEvents.values(), ...result.quotedEvents.values()]); + cache.ingestProfileInfos(Object.fromEntries(result.profiles), 'nagg'); + cache.ingestNoteStats(facade.statsFromMetrics(Object.fromEntries(result.metrics)), 'nagg'); +} + +/** Write a parsed feed/user-feed page's notes/profiles/metrics into the shared cache. */ +function ingestFeedPageIntoCache(result: FeedParseResult): void { + const cache = buildNostrDataLayer()?.cache; + if (!cache) return; + const events: FeedEvent[] = []; + for (const item of result.orderedFeedItems) events.push(...eventsFromAppFeedItem(item)); + events.push(...result.quotedEventsMap.values()); + cache.ingestNotes(events); + cache.ingestProfileInfos(Object.fromEntries(result.profilesMap), 'nagg'); + cache.ingestNoteStats(facade.statsFromMetrics(Object.fromEntries(result.metricsMap)), 'nagg'); +} + +// --------------------------------------------------------------------------- +// Facade-backed feed client (local-dev tier validation). +// +// Routes the for-you / following-popular home feeds through the tier-selecting +// nagg-ts facade so the Settings → Network toggles actually change which source +// (nagg → Primal → relays) serves the feed — visible in the bridged nostr.* logs. +// Every other read (threads, user feeds, following-replies, enrichment, +// notifications) delegates to the existing client unchanged. +// +// The pure shape bridge lives in facadeFeedAdapter; see buildNostrDataLayer for +// the published-nagg-ts caveat (local-dev only). +// --------------------------------------------------------------------------- + +export function createFacadeFeedClient(fallback: FeedClient): FeedClient { + return { + ...fallback, + async getFeed(request): Promise { + const spec = mapAppSpecToFeedSpec(request.spec, request.userPubkey); + if (!spec) { + const result = await fallback.getFeed(request); + ingestFeedPageIntoCache(result); + return result; + } + + const layer = buildNostrDataLayer(); + if (!layer) { + // Every tier disabled — surface the empty feed so the toggle is visible. + return emptyFeedParseResult(); + } + + const result = await layer.getFeedPage({ + spec, + limit: request.limit, + refresh: request.refresh, + signal: request.signal, + cursor: request.until ? { createdAt: request.until, id: '' } : null, + }); + + return result.match( + (page) => resolvedFeedPageToParseResult(page), + (error) => { + feedLog.warn('feed.facade.exhausted', { + attempts: error.attempts.map((a) => `${a.tier}=${a.outcome}`), + }); + return emptyFeedParseResult(); + } + ); + }, + + async getThread(request): Promise { + // nagg's GraphQL thread is the viewer-ranked gold path (authoredReplyChain + // + rankedReferencedBy); its REST app-view — and thus the facade's nagg + // tier — can't reproduce that ranking. So keep the GraphQL path whenever + // nagg is enabled, and only route threads through the facade (Primal → + // relay) when nagg is toggled off, so the cache/relay tiers can serve them. + if (getNostrTierConfig().nagg.enabled) { + const result = await fallback.getThread(request); + ingestThreadIntoCache(result); + return result; + } + + // Never throw: useThread's seeded-error path keeps isLoading=true on a + // throw (so a transient nagg error doesn't clobber a seeded render), which + // would leave the thread on skeletons forever. Any failure here resolves + // to an empty thread so loading always clears. + try { + const layer = buildNostrDataLayer(); + if (!layer) return emptyThreadResult(request); + + const result = await layer.getThread(toFacadeThreadRequest(request)); + return result.match( + (thread) => resolvedThreadToResult(thread, request), + (error) => { + feedLog.warn('thread.facade.exhausted', { + attempts: error.attempts.map((a) => `${a.tier}=${a.outcome}`), + }); + return emptyThreadResult(request); + } + ); + } catch (error) { + feedLog.warn('thread.facade.threw', { + error: error instanceof Error ? error.message : String(error), + }); + return emptyThreadResult(request); + } + }, + + async getUserFeed(request): Promise { + const result = await fallback.getUserFeed(request); + ingestFeedPageIntoCache(result); + return result; + }, + + async getPostsByPubkeys(request): Promise { + const result = await fallback.getPostsByPubkeys(request); + ingestFeedPageIntoCache(result); + return result; + }, + + async getNotifications(request): Promise { + const facadeRequest = toFacadeNotificationsRequest(request); + if (!facadeRequest) return fallback.getNotifications(request); + + const layer = buildNostrDataLayer(); + if (!layer) return emptyNotificationsResult(); + + const result = await layer.getNotifications(facadeRequest); + return result.match( + (page) => resolvedNotificationsToResult(page), + (error) => { + feedLog.warn('notifications.facade.exhausted', { + attempts: error.attempts.map((a) => `${a.tier}=${a.outcome}`), + }); + return emptyNotificationsResult(); + } + ); + }, + }; +} + +function emptyNotificationsResult(): FeedNotificationsResult { + return { + notifications: [], + profilesMap: new Map(), + metricsMap: new Map(), + quotedEventsMap: new Map(), + paginationUntil: 0, + hasNextPage: false, + }; +} diff --git a/features/feed/data/facadeNotificationsAdapter.ts b/features/feed/data/facadeNotificationsAdapter.ts new file mode 100644 index 000000000..6dfc7aca0 --- /dev/null +++ b/features/feed/data/facadeNotificationsAdapter.ts @@ -0,0 +1,84 @@ +import { facade } from '@sovranbitcoin/nagg-ts'; + +import type { FeedEvent, NoteMetrics, ProfileInfo } from '../components/nostr/feedTypes'; +import type { + FeedNotification, + FeedNotificationActor, + FeedNotificationsRequest, + FeedNotificationsResult, +} from './feedClient'; + +// Pure shape bridge: facade ResolvedNotifications → app FeedNotificationsResult. +// Dependency-light so it's unit-testable without the facade-builder's RN chain. + +/** Map the app notifications request to the facade NotificationsRequest. */ +export function toFacadeNotificationsRequest( + request: FeedNotificationsRequest +): facade.NotificationsRequest | null { + // Only the server-shaped tabs route through the facade; APP (client-only) falls back. + if (request.tab && request.tab !== 'ALL' && request.tab !== 'MENTIONS') return null; + return { + viewerPubkey: request.viewerPubkey, + tab: request.tab as 'ALL' | 'MENTIONS' | undefined, + policy: request.policy, + replyScope: request.replyScope, + grouped: request.grouped, + since: request.since, + limit: request.limit, + refresh: request.refresh, + signal: request.signal, + timeoutMs: request.timeoutMs, + cursor: request.until ? { createdAt: request.until, id: '' } : null, + }; +} + +export function resolvedNotificationsToResult( + page: facade.ResolvedNotifications +): FeedNotificationsResult { + const notifications: FeedNotification[] = page.notifications.map((n) => { + const targetEventId = typeof n.targetEventId === 'string' ? n.targetEventId : undefined; + const targetEvent = n.targetEvent as FeedEvent | undefined; + return { + event: n.event as FeedEvent, + reason: n.reason, + actorVertexScore: n.actorVertexScore, + ...(n.type ? { type: n.type } : {}), + ...(typeof n.total === 'number' ? { total: n.total } : {}), + ...(typeof n.totalCapped === 'boolean' ? { totalCapped: n.totalCapped } : {}), + ...(n.sampleActors ? { sampleActors: n.sampleActors as FeedNotificationActor[] } : {}), + ...(targetEventId ? { targetEventId } : {}), + ...(targetEvent ? { targetEvent } : {}), + }; + }); + + const metricsMap = new Map(); + for (const [id, s] of Object.entries(page.stats)) { + metricsMap.set(id, { + likeCount: s.likes, + repostCount: s.reposts, + replyCount: s.replies, + satsZapped: s.satsZapped, + }); + } + + const profilesMap = new Map( + Object.entries(page.profiles).map(([pk, p]) => [ + pk, + { name: p.name, ...(p.picture ? { picture: p.picture } : {}) }, + ]) + ); + const quotedEventsMap = new Map( + Object.entries(page.quoted) as [string, FeedEvent][] + ); + + return { + notifications, + profilesMap, + metricsMap, + quotedEventsMap, + paginationUntil: page.cursor?.createdAt ?? 0, + // Conservative: only keep paging while a page returns items (the screen + // dedupes by id, so this can't loop on a repeated tail). + hasNextPage: notifications.length > 0 && page.cursor !== null, + }; +} diff --git a/features/feed/data/facadeThreadAdapter.ts b/features/feed/data/facadeThreadAdapter.ts new file mode 100644 index 000000000..8605cba7c --- /dev/null +++ b/features/feed/data/facadeThreadAdapter.ts @@ -0,0 +1,163 @@ +import { facade } from '@sovranbitcoin/nagg-ts'; + +import { buildThreadStructure } from '@/features/feed/lib/buildThreadStructure'; +import type { FeedEvent, NoteMetrics, ProfileInfo } from '../components/nostr/feedTypes'; +import type { ThreadRequest, ThreadResult } from './feedClient'; + +// Pure shape bridge: facade ResolvedThread → the app's ThreadResult. Used only +// on the cache/relay path (nagg toggled off); the nagg-enabled path keeps the +// GraphQL viewer-ranked thread in naggFeedClient, which the facade's REST nagg +// tier can't reproduce. Dependency-light (facade + feed types + the pure +// buildThreadStructure) so it stays unit-testable. + +// Per-tier budget for a facade thread fetch. Lower than the facade's 30s default +// so a Primal→relay fall-through (when Primal lacks the note) can't hold the +// thread skeleton for a full minute before settling to "no replies". +const THREAD_TIER_TIMEOUT_MS = 12_000; + +/** Map the app's 5-way reply sort to what the facade tiers serve natively. + * Primal's thread_view has NO server sort param (the client post-sorts), so we + * fetch in relevance/new order and post-sort engagement modes below. */ +function toFacadeSort(sort: ThreadRequest['sort']): facade.ThreadSort { + return sort === 'new' ? 'new' : 'relevant'; +} + +export function toFacadeThreadRequest(request: ThreadRequest): facade.ThreadRequest { + return { + noteId: request.eventId, + sort: toFacadeSort(request.sort), + ...(request.viewerPubkey ? { viewerPubkey: request.viewerPubkey } : {}), + ...(typeof request.limit === 'number' ? { limit: request.limit } : {}), + ...(request.signal ? { signal: request.signal } : {}), + timeoutMs: request.timeoutMs ?? THREAD_TIER_TIMEOUT_MS, + }; +} + +type ReplyStat = { + likes: number; + reposts: number; + replies: number; + zaps: number; + satsZapped: number; +}; +const ZERO_STAT: ReplyStat = { likes: 0, reposts: 0, replies: 0, zaps: 0, satsZapped: 0 }; + +/** + * Post-sort reply ids to approximate the app's 5 sort modes against the bundled + * per-note stats — Primal's cache has no server-side reply sort, so (like the + * Primal clients) we sort locally. The UI renders replies in replyPageEventIds + * order, so this ordering is what the sort tabs actually change. + */ +function sortedReplyIds( + events: readonly FeedEvent[], + stats: Record, + sort: ThreadRequest['sort'] +): string[] { + const stat = (id: string): ReplyStat => stats[id] ?? ZERO_STAT; + const recency = (event: FeedEvent): number => event.created_at ?? 0; + const score = (event: FeedEvent): number => { + const s = stat(event.id); + switch (sort) { + case 'new': + return recency(event); + case 'likes': + return s.likes; + case 'zaps': + return s.satsZapped; + case 'reposts': + return s.reposts; + case 'relevant': + default: + // No cache relevance score is bundled, so approximate "top" replies by + // weighted engagement; recency breaks ties. + return s.likes + s.reposts * 2 + s.replies; + } + }; + return [...events] + .sort((a, b) => score(b) - score(a) || recency(b) - recency(a)) + .map((event) => event.id); +} + +function feedItemEvent(item: facade.FeedItem): FeedEvent | undefined { + return (item.type === 'note' ? item.event : (item.originalEvent ?? item.repostEvent)) as + | FeedEvent + | undefined; +} + +export function resolvedThreadToResult( + thread: facade.ResolvedThread, + request: ThreadRequest +): ThreadResult { + const allEvents = new Map(); + const replyEvents: FeedEvent[] = []; + + const root = feedItemEvent(thread.root); + if (root) allEvents.set(root.id, root); + // Ancestors go into allEvents (NOT the reply list) so buildThreadStructure + // renders the parent chain — the relay/primal floor now fetches it. + for (const item of thread.parents) { + const event = feedItemEvent(item); + if (event) allEvents.set(event.id, event); + } + for (const item of thread.replies) { + const event = feedItemEvent(item); + if (!event) continue; + allEvents.set(event.id, event); + replyEvents.push(event); + } + + const quotedEvents = new Map( + Object.entries(thread.quoted) as [string, FeedEvent][] + ); + const profiles = new Map( + Object.entries(thread.profiles).map(([pk, p]) => [ + pk, + { name: p.name, ...(p.picture ? { picture: p.picture } : {}) }, + ]) + ); + const metrics = new Map(); + for (const [id, s] of Object.entries(thread.stats)) { + metrics.set(id, { + likeCount: s.likes, + repostCount: s.reposts, + replyCount: s.replies, + satsZapped: s.satsZapped, + }); + } + + const replyPageEventIds = sortedReplyIds( + replyEvents, + thread.stats as Record, + request.sort + ); + return { + allEvents, + profiles, + metrics, + quotedEvents, + // The tier returned the whole thread already ordered by its manifest, so the + // pure tree-builder arranges parents/replies; there's no separate page merge. + thread: buildThreadStructure(request.eventId, allEvents), + replyPageEventIds, + replyPageSize: request.limit ?? replyEvents.length, + loadedReplyCount: replyEvents.length, + // Primal/relay return the full thread in one shot (cursor null) → no paging. + hasMoreReplies: thread.cursor !== null, + }; +} + +/** Empty result when every enabled tier is exhausted — keeps the thread screen + * honest (shows "no replies") rather than silently using nagg behind a toggle. */ +export function emptyThreadResult(request: ThreadRequest): ThreadResult { + return { + allEvents: new Map(), + profiles: new Map(), + metrics: new Map(), + quotedEvents: new Map(), + thread: buildThreadStructure(request.eventId, new Map()), + replyPageEventIds: [], + replyPageSize: request.limit ?? 0, + loadedReplyCount: 0, + hasMoreReplies: false, + }; +} diff --git a/features/feed/data/naggFeedClient.ts b/features/feed/data/naggFeedClient.ts index c196b2e3b..26054df21 100644 --- a/features/feed/data/naggFeedClient.ts +++ b/features/feed/data/naggFeedClient.ts @@ -1,50 +1,31 @@ import { createNaggClient, - NAGG_CAPABILITIES, + NaggEnrichmentSchema, NaggFeedPageSchema, NaggNotificationsPageSchema, - NaggUnknownDataSchema, + NaggThreadSchema, type NaggAppViewBinding, - type NaggCapability, type NaggError, type NaggNotificationsPage, } from '@sovranbitcoin/nagg-ts'; import { - authoredReplyChainInput, - followingRecentEventsInput, - followingRepliesEventsInput, + eventsAppView, followingPopularRankedEventsInput, followsFeedAppView, forYouRankedEventsInput, - mergeRelevantReplyNodes as mergeNaggRelevantReplyNodes, notificationsAppView, - notificationsInput, + profilesAppView, rankedFeedAppView, - recentNotesEventsInput, - threadReplyRankInput as buildThreadReplyRankInput, + threadAppView, userFeedAppView, - withEventExclusions, withRankedTargetExclusions, - type EventQueryInput, type RankedEventsInput, - type ReferenceRankInput, } from '@sovranbitcoin/nagg-ts/recipes'; -import { - graphqlNodesToNaggPage, - metricsFromGraphqlNode, - normalizeGraphqlEvent, - profileFromMetadataEvent, - type NaggFeedPage, - type NaggGraphqlConnection as GraphqlConnection, - type NaggGraphqlEventNode as GraphqlEventNode, -} from '@sovranbitcoin/nagg-ts/map'; +import { type NaggFeedPage } from '@sovranbitcoin/nagg-ts/map'; import type { z } from 'zod'; import { backendConfig } from '@/shared/config/backend'; import { apiLog, feedLog, redactError } from '@/shared/lib/logger'; -import { - buildThreadStructure, - type ThreadStructure, -} from '@/features/feed/lib/buildThreadStructure'; +import { buildThreadStructure } from '@/features/feed/lib/buildThreadStructure'; import { mapNaggFeedPage } from './mapNaggFeedPage'; import type { FeedClient, @@ -56,13 +37,13 @@ import type { FeedNotificationsRequest, FeedNotificationsResult, ThreadRequest, - ThreadReplySort, ThreadResult, UserFeedPageRequest, PostsByPubkeysRequest, } from './feedClient'; import { emptyFeedParseResult } from './feedClient'; import { ingestOwnContent } from '@/shared/stores/profile/ownContentStore'; +import { useNostrMetadataCache } from '@/shared/stores/global/nostrMetadataCache'; import { hasEmptyExplicitPubkeys, hydrateSpecWithPubkey } from './feedSpec'; import { parseJson } from '../components/nostr/feedParse'; import type { FeedEvent, NoteMetrics, ProfileInfo } from '../components/nostr/feedTypes'; @@ -70,484 +51,26 @@ import { useFeedIgnoreStore } from '../stores/ignoreStore'; const RELEVANT_AUTHOR_REPLY_LIMIT = 50; -const BASE_EVENT_SELECTION = ` - id - pubkey - kind - createdAt - content - tags -`; - -// Precomputed per-note engagement counts: ONE batched server lookup over nagg's -// rollup tables (note_*_counts / note_engagement_real) instead of four live -// aggregateReferencedBy aggregations PER node. `replies` is the NIP-10/22 direct -// reply count (not any-e-tag), matching the thread view. This is the primary For -// You latency fix — it removes ~4 live ClickHouse aggregations per feed node. -const METRIC_SELECTION = ` - noteStats { likes reposts replies zapSats } -`; - -const AUTHOR_METADATA_SELECTION = ` - authorMetadata: pubkeyEvents(kinds: [0], limit: 1) { - ${BASE_EVENT_SELECTION} - } -`; - -const QUOTED_CONTENT_SELECTION = ` - quotedContent: selectedReferences(input: { fallback: { key: "q" }, limit: 4 }) { - nodes { - ${BASE_EVENT_SELECTION} - ${AUTHOR_METADATA_SELECTION} - ${METRIC_SELECTION} - } - } -`; - -const FEED_REPLY_PREVIEW_BASE_SELECTION = ` - ${BASE_EVENT_SELECTION} - ${AUTHOR_METADATA_SELECTION} -`; - -const FEED_REPLY_PREVIEW_CHILD_SELECTION = ` - ${FEED_REPLY_PREVIEW_BASE_SELECTION} -`; - -const FEED_REPLY_PREVIEW_SOURCE_SELECTION = ` - ${FEED_REPLY_PREVIEW_BASE_SELECTION} - childFollowedReply: followedReply(viewer: $viewerPubkey) { - nodes { - ${FEED_REPLY_PREVIEW_CHILD_SELECTION} - } - } -`; - -// Precomputed "a person you follow replied" preview: ONE batched server lookup -// (note_reply_edges ⋈ note_like_counts ⋈ the viewer's follow set) per feed page, -// replacing the per-node rankedReferencedBy over the viewer's 2000-entry follow -// list — the single most expensive per-node selection on the For You feed. Same -// `{ nodes }` shape as the old alias, so the page mapper is unchanged. Requires -// nagg to expose the `followedReply(viewer:)` field (deploy nagg before shipping). -const FEED_FOLLOWED_REPLY_SELECTION = ` - followedReply(viewer: $viewerPubkey) { - nodes { - ${FEED_REPLY_PREVIEW_BASE_SELECTION} - } - } -`; - -const EVENT_REFS_SELECTION = ` - eventRefs: selectedReferences(input: { fallback: { key: "e" }, limit: 2 }) { - nodes { - ${BASE_EVENT_SELECTION} - ${AUTHOR_METADATA_SELECTION} - ${QUOTED_CONTENT_SELECTION} - ${METRIC_SELECTION} - } - } -`; - -const NOTIFICATION_EVENT_REFS_SELECTION = ` - eventRefs: selectedReferences(input: { fallback: { key: "e" }, limit: 8 }) { - nodes { - ${BASE_EVENT_SELECTION} - ${AUTHOR_METADATA_SELECTION} - ${QUOTED_CONTENT_SELECTION} - ${METRIC_SELECTION} - } - } -`; - -const ROOT_CONTEXT_SELECTION = ` - rootContext: selectedReferences(input: { - selectors: [{ key: "e", marker: "root" }] - fallback: { key: "e", excludeMarkers: ["mention"] } - maxDepth: 8 - limit: 1 - }) { - nodes { - ${BASE_EVENT_SELECTION} - ${AUTHOR_METADATA_SELECTION} - ${QUOTED_CONTENT_SELECTION} - ${METRIC_SELECTION} - } - } -`; - -const FEED_QUERY = ` -query NaggGraphqlFeed($input: EventQueryInput!) { - events(input: $input) { - nodes { - ${BASE_EVENT_SELECTION} - ${AUTHOR_METADATA_SELECTION} - ${ROOT_CONTEXT_SELECTION} - ${EVENT_REFS_SELECTION} - ${QUOTED_CONTENT_SELECTION} - ${METRIC_SELECTION} - } - pageInfo { endCursor hasNextPage } - } -} -`; - -const RANKED_FEED_QUERY = ` -query NaggGraphqlRankedFeed($input: RankedEventsInput!) { - rankedEvents(input: $input) { - nodes { - ${BASE_EVENT_SELECTION} - ${AUTHOR_METADATA_SELECTION} - ${ROOT_CONTEXT_SELECTION} - ${EVENT_REFS_SELECTION} - ${QUOTED_CONTENT_SELECTION} - ${METRIC_SELECTION} - } - pageInfo { endCursor hasNextPage } - } -} -`; - -const RANKED_FEED_WITH_VIEWER_QUERY = ` -query NaggGraphqlRankedFeed($input: RankedEventsInput!, $viewerPubkey: String!, $authorChain: AuthoredReplyChainInput!) { - rankedEvents(input: $input) { - nodes { - ${BASE_EVENT_SELECTION} - ${AUTHOR_METADATA_SELECTION} - ${ROOT_CONTEXT_SELECTION} - ${EVENT_REFS_SELECTION} - ${QUOTED_CONTENT_SELECTION} - authorReplies: authoredReplyChain(input: $authorChain) { - nodes { - ${FEED_REPLY_PREVIEW_SOURCE_SELECTION} - } - } - ${FEED_FOLLOWED_REPLY_SELECTION} - ${METRIC_SELECTION} - } - pageInfo { endCursor hasNextPage } - } -} -`; - -const RANKED_FEED_WITH_VIEWER_LEGACY_QUERY = ` -query NaggGraphqlRankedFeed($input: RankedEventsInput!, $viewerPubkey: String!) { - rankedEvents(input: $input) { - nodes { - ${BASE_EVENT_SELECTION} - ${AUTHOR_METADATA_SELECTION} - ${ROOT_CONTEXT_SELECTION} - ${EVENT_REFS_SELECTION} - ${QUOTED_CONTENT_SELECTION} - ${FEED_FOLLOWED_REPLY_SELECTION} - ${METRIC_SELECTION} - } - pageInfo { endCursor hasNextPage } - } -} -`; - -const FOLLOWING_REPLIES_QUERY = ` -query NaggGraphqlFollowingReplies($input: EventQueryInput!) { - events(input: $input) { - nodes { - ${BASE_EVENT_SELECTION} - ${AUTHOR_METADATA_SELECTION} - ${ROOT_CONTEXT_SELECTION} - ${QUOTED_CONTENT_SELECTION} - ${METRIC_SELECTION} - } - pageInfo { endCursor hasNextPage } - } -} -`; - -const FOLLOWING_POPULAR_WITH_VIEWER_QUERY = ` -query NaggGraphqlFollowingPopular($input: RankedEventsInput!, $viewerPubkey: String!, $authorChain: AuthoredReplyChainInput!) { - rankedEvents(input: $input) { - nodes { - ${BASE_EVENT_SELECTION} - ${AUTHOR_METADATA_SELECTION} - ${ROOT_CONTEXT_SELECTION} - ${EVENT_REFS_SELECTION} - ${QUOTED_CONTENT_SELECTION} - authorReplies: authoredReplyChain(input: $authorChain) { - nodes { - ${FEED_REPLY_PREVIEW_SOURCE_SELECTION} - } - } - ${FEED_FOLLOWED_REPLY_SELECTION} - ${METRIC_SELECTION} - } - pageInfo { endCursor hasNextPage } - } -} -`; - -const FOLLOWING_POPULAR_WITH_VIEWER_LEGACY_QUERY = ` -query NaggGraphqlFollowingPopular($input: RankedEventsInput!, $viewerPubkey: String!) { - rankedEvents(input: $input) { - nodes { - ${BASE_EVENT_SELECTION} - ${AUTHOR_METADATA_SELECTION} - ${ROOT_CONTEXT_SELECTION} - ${EVENT_REFS_SELECTION} - ${QUOTED_CONTENT_SELECTION} - ${FEED_FOLLOWED_REPLY_SELECTION} - ${METRIC_SELECTION} - } - pageInfo { endCursor hasNextPage } - } -} -`; - -const ENRICH_EVENTS_QUERY = ` -query NaggGraphqlEnrichEvents($input: EventQueryInput!) { - events(input: $input) { - nodes { - ${BASE_EVENT_SELECTION} - authorMetadata: pubkeyEvents(kinds: [0], limit: 1) { - ${BASE_EVENT_SELECTION} - } - quotedContent: references(input: { tags: [{ key: "q" }], limit: 4 }) { - nodes { - ${BASE_EVENT_SELECTION} - authorMetadata: pubkeyEvents(kinds: [0], limit: 1) { - ${BASE_EVENT_SELECTION} - } - ${METRIC_SELECTION} - } - } - ${METRIC_SELECTION} - } - } -} -`; - -const PROFILE_EVENTS_QUERY = ` -query NaggGraphqlProfiles($input: EventQueryInput!) { - events(input: $input) { - nodes { - ${BASE_EVENT_SELECTION} - } - } -} -`; - -const NOTIFICATIONS_QUERY = ` -query NaggGraphqlNotifications($input: NotificationInput!) { - notifications(input: $input) { - nodes { - reason - actorVertexScore - event { - ${BASE_EVENT_SELECTION} - ${AUTHOR_METADATA_SELECTION} - ${ROOT_CONTEXT_SELECTION} - ${NOTIFICATION_EVENT_REFS_SELECTION} - ${QUOTED_CONTENT_SELECTION} - ${METRIC_SELECTION} - } - } - pageInfo { endCursor hasNextPage } - } -} -`; - -const THREAD_REPLY_SELECTION = ` - ${BASE_EVENT_SELECTION} - authorMetadata: pubkeyEvents(kinds: [0], limit: 1) { - ${BASE_EVENT_SELECTION} - } - quotedContent: references(input: { tags: [{ key: "q" }], limit: 4 }) { - nodes { - ${BASE_EVENT_SELECTION} - authorMetadata: pubkeyEvents(kinds: [0], limit: 1) { - ${BASE_EVENT_SELECTION} - } - ${METRIC_SELECTION} - } - } - ${METRIC_SELECTION} -`; - -const THREAD_RELEVANT_REPLY_SELECTION = ` - ${THREAD_REPLY_SELECTION} - childFollowedReply: followedReply(viewer: $viewerPubkey) { - nodes { - ${THREAD_REPLY_SELECTION} - } - } -`; - -const AUTHOR_REPLIES_SELECTION = ` - authorReplies: authoredReplyChain(input: $authorChain) { - nodes { - ${THREAD_RELEVANT_REPLY_SELECTION} - } - } -`; - -const FOLLOWED_REPLY_SELECTION = ` - followedReply(viewer: $viewerPubkey) { - nodes { - ${THREAD_REPLY_SELECTION} - } - } -`; - -const THREAD_EVENT_SELECTION = ` - ${BASE_EVENT_SELECTION} - authorMetadata: pubkeyEvents(kinds: [0], limit: 1) { - ${BASE_EVENT_SELECTION} - } - parentRefs: references(input: { tags: [{ key: "e" }], limit: 8 }) { - nodes { - ${BASE_EVENT_SELECTION} - authorMetadata: pubkeyEvents(kinds: [0], limit: 1) { - ${BASE_EVENT_SELECTION} - } - ${METRIC_SELECTION} - } - } - quotedContent: references(input: { tags: [{ key: "q" }], limit: 4 }) { - nodes { - ${BASE_EVENT_SELECTION} - authorMetadata: pubkeyEvents(kinds: [0], limit: 1) { - ${BASE_EVENT_SELECTION} - } - ${METRIC_SELECTION} - } - } -`; - -const THREAD_NEW_QUERY = ` -query NaggGraphqlThread($id: String!, $limit: Int!, $offset: Int!) { - event(id: $id) { - ${THREAD_EVENT_SELECTION} - replies: referencedBy(input: { - via: { key: "e", directReplies: true } - events: { kinds: [1, 1111], limit: $limit } - limit: $limit - offset: $offset - }) { - nodes { - ${THREAD_REPLY_SELECTION} - } - } - ${METRIC_SELECTION} - } -} -`; - -const THREAD_RANKED_QUERY = ` -query NaggGraphqlThread( - $id: String! - $limit: Int! - $offset: Int! - $candidateLimit: Int! - $rank: ReferenceRankInput! -) { - event(id: $id) { - ${THREAD_EVENT_SELECTION} - replies: rankedReferencedBy(input: { - via: { key: "e", directReplies: true } - events: { kinds: [1, 1111], limit: $candidateLimit } - rank: $rank - limit: $limit - offset: $offset - }) { - nodes { - ${THREAD_REPLY_SELECTION} - } - } - ${METRIC_SELECTION} - } -} -`; - -const THREAD_RELEVANT_QUERY = ` -query NaggGraphqlThreadRelevant( - $id: String! - $candidateLimit: Int! - $rankedLimit: Int! - $rank: ReferenceRankInput! - $viewerPubkey: String! - $authorChain: AuthoredReplyChainInput! -) { - event(id: $id) { - ${THREAD_EVENT_SELECTION} - ${AUTHOR_REPLIES_SELECTION} - ${FOLLOWED_REPLY_SELECTION} - replies: rankedReferencedBy(input: { - via: { key: "e", directReplies: true } - events: { kinds: [1, 1111], limit: $candidateLimit } - rank: $rank - limit: $rankedLimit - }) { - nodes { - ${THREAD_REPLY_SELECTION} - } - } - allReplies: referencedBy(input: { - via: { key: "e", directReplies: true } - events: { kinds: [1, 1111], limit: $candidateLimit } - limit: $candidateLimit - }) { - nodes { - ${THREAD_REPLY_SELECTION} - } - } - ${METRIC_SELECTION} - } -} -`; - -type GraphqlFeedData = { - events?: GraphqlConnection; - rankedEvents?: GraphqlConnection; -}; - -type GraphqlNotificationNode = { - reason?: string | null; - actorVertexScore?: number | null; - event?: GraphqlEventNode | null; -}; - -type GraphqlNotificationsData = { - notifications?: GraphqlConnection; -}; - -type GraphqlThreadData = { - event?: GraphqlEventNode | null; -}; - type FeedQueryOptions = { includeNote?: (event: FeedEvent, rootEvent?: FeedEvent) => boolean; includeRepost?: (event: FeedEvent, originalEvent?: FeedEvent, rootEvent?: FeedEvent) => boolean; extraProfile?: { pubkey: string; profile: ProfileInfo }; }; -function graphqlEndpoint(): string { - return backendConfig.nostrGraphqlEndpoint; -} - function isRootNote(event: { tags: string[][] }): boolean { const eTags = (event.tags || []).filter((tag) => tag[0] === 'e'); if (eTags.length === 0) return true; return eTags.every((tag) => tag[3] === 'mention'); } -function graphqlOperationName(query: string): string { - return query.match(/\b(?:query|mutation)\s+([A-Za-z0-9_]+)/)?.[1] ?? 'anonymous'; -} - function shortId(value: unknown): string | undefined { return typeof value === 'string' && value.length > 0 ? value.slice(0, 10) : undefined; } function endpointLogFields(): Record { try { - const url = new URL(graphqlEndpoint()); - return { host: url.host, path: url.pathname }; + const url = new URL(backendConfig.nostrAppViewBaseUrl); + return { host: url.host }; } catch { return { endpoint: 'invalid' }; } @@ -559,184 +82,66 @@ function objectRecord(value: unknown): Record | null { : null; } -function summarizeGraphqlInput(input: Record): Record { - const summary: Record = {}; - for (const key of ['limit', 'offset', 'since', 'until', 'target'] as const) { - if (input[key] !== undefined) summary[key] = input[key]; - } - if (Array.isArray(input.kinds)) summary.kinds = input.kinds; - if (Array.isArray(input.pubkeys)) summary.pubkeys = input.pubkeys.length; - if (Array.isArray(input.pubkeysFrom)) summary.pubkeysFrom = input.pubkeysFrom.length; - if (Array.isArray(input.tags)) summary.tags = input.tags.length; - const references = objectRecord(input.references); - if (references) { - summary.references = { - kinds: Array.isArray(references.kinds) ? references.kinds : undefined, - limit: references.limit, - since: references.since, - }; - } - const rankedTarget = objectRecord(input.target); - if (rankedTarget) { - summary.target = { - kinds: Array.isArray(rankedTarget.kinds) ? rankedTarget.kinds : undefined, - pubkeys: Array.isArray(rankedTarget.pubkeys) ? rankedTarget.pubkeys.length : undefined, - pubkeysFrom: Array.isArray(rankedTarget.pubkeysFrom) - ? rankedTarget.pubkeysFrom.length - : undefined, - }; - } - const metric = objectRecord(input.metric); - if (metric) summary.metric = metric.name; - return summary; -} - -function summarizeRankInput(rank: Record): Record { - const metric = objectRecord(rank.metric); - return { - metric: metric?.name, - terms: Array.isArray(rank.terms) ? rank.terms.length : 0, - candidatePubkeyBoosts: Array.isArray(rank.candidatePubkeyBoosts) - ? rank.candidatePubkeyBoosts.length - : 0, - }; -} - -function summarizeGraphqlVariables(variables: Record): Record { - const summary: Record = {}; - for (const key of ['limit', 'offset', 'candidateLimit'] as const) { - if (variables[key] !== undefined) summary[key] = variables[key]; - } - const id = shortId(variables.id); - if (id) summary.id = id; - const viewerPubkey = shortId(variables.viewerPubkey); - if (viewerPubkey) summary.viewerPubkey = viewerPubkey; - const input = objectRecord(variables.input); - if (input) summary.input = summarizeGraphqlInput(input); - const rank = objectRecord(variables.rank); - if (rank) summary.rank = summarizeRankInput(rank); - return summary; -} - function dataKeysOf(value: unknown): string[] { const record = objectRecord(value); return record ? Object.keys(record).slice(0, 8) : []; } -/** - * One transport-agnostic request per view. The caller supplies the canonical - * `dataSchema` plus the GraphQL distiller (`graphqlToData`) and the REST app-view - * `appView` binding; `transport` (derived from a feature flag) selects the URL. - * - * - `transport: 'graphql'` → POST the GraphQL endpoint, distil `data` with - * `graphqlToData`, then `dataSchema.safeParse`. - * - `transport: 'appview'` (with a binding) → GET/POST the REST route; the body - * is already canonical, so `dataSchema.safeParse` runs with no distill step. - * - * Both branches return the SAME parsed canonical type — there is no - * per-transport fork at the call site, only a different URL. - */ -type NaggQueryOptions = { - dataSchema: TSchema; - graphqlToData?: (data: unknown) => unknown; - appView?: NaggAppViewBinding; - transport?: 'graphql' | 'appview'; +// The single nagg client — REST app-view only. There is no GraphQL transport. +const naggClient = createNaggClient({ + appView: { baseUrl: backendConfig.nostrAppViewBaseUrl, version: 'v1' }, +}); + +type NaggRestOptions = { + responseSchema: TSchema; signal?: AbortSignal; timeoutMs?: number; }; -async function runNaggQuery( - query: string, - variables: Record, +/** + * One REST app-view request per view. The caller supplies the route `binding` + * (path + params/body, from a recipe) and the canonical `responseSchema`; nagg's + * app-view emits the canonical shape directly, so the raw body is parsed by the + * schema with no distill step. Errors are logged and rethrown. + */ +async function runNaggRest( + binding: NaggAppViewBinding, refresh: boolean | undefined, - options: NaggQueryOptions + options: NaggRestOptions ): Promise> { - // Prefer the REST app-view whenever a binding exists and a base URL is - // configured — it is the optimized, product-shaped path (e.g. grouped - // notifications). GraphQL is the fallback: used when there's no app-view - // binding/base, or when the app-view attempt errors or comes back empty. The - // optional per-call `transport: 'graphql'` still forces GraphQL outright. - const appViewBase = backendConfig.nostrAppViewBaseUrl?.trim(); - const canAppView = !!options.appView && !!appViewBase && options.transport !== 'graphql'; - const preferred: 'appview' | 'graphql' = canAppView ? 'appview' : 'graphql'; - logBackendConfigOnce(); - - const client = createNaggClient({ - endpoint: graphqlEndpoint(), - appView: { baseUrl: backendConfig.nostrAppViewBaseUrl, version: 'v1' }, - }); - - const attempt = async (transport: 'appview' | 'graphql') => { - // The GraphQL request's operationName MUST match the query document's - // operation. The app-view binding's label ('RankedFeed', 'Notifications', …) - // is a REST identifier, NOT a GraphQL operation name, so only use it on the - // app-view path; on GraphQL always derive it from the query document. - const operationName = - transport === 'appview' - ? (options.appView?.operationName ?? graphqlOperationName(query)) - : graphqlOperationName(query); - const requestFields = { - operationName, - refresh: !!refresh, - timeoutMs: options.timeoutMs, - transport, - variables: summarizeGraphqlVariables(variables), - ...endpointLogFields(), - }; - apiLog.info('nagg.graphql.request.start', requestFields); - const startedAt = Date.now(); - const result = await client.query({ - query, - variables, - operationName, - dataSchema: options.dataSchema, - graphqlToData: options.graphqlToData, - transport, - appView: options.appView, - refresh, - signal: options.signal, - timeoutMs: options.timeoutMs, - }); - return { result, requestFields, durationMs: Date.now() - startedAt }; + const operationName = binding.operationName ?? binding.path; + const requestFields = { + operationName, + method: binding.method ?? 'GET', + path: binding.path, + refresh: !!refresh, + timeoutMs: options.timeoutMs, + ...endpointLogFields(), }; - - let outcome = await attempt(preferred); - if ( - preferred === 'appview' && - (outcome.result.isErr() || countCanonical(outcome.result.value) === 0) - ) { - apiLog.warn('nagg.appview.fallback_to_graphql', { - ...outcome.requestFields, - durationMs: outcome.durationMs, - reason: outcome.result.isErr() ? outcome.result.error.type : 'empty', - }); - outcome = await attempt('graphql'); - } - - const { result, requestFields, durationMs } = outcome; + apiLog.info('nagg.appview.request.start', requestFields); + const startedAt = Date.now(); + const result = await naggClient.rest({ + path: binding.path, + method: binding.method, + searchParams: binding.searchParams, + body: binding.body, + responseSchema: options.responseSchema, + operationName, + refresh, + signal: options.signal, + timeoutMs: options.timeoutMs, + }); + const durationMs = Date.now() - startedAt; if (result.isErr()) { const error = result.error; - if (error.type === 'graphql') { - apiLog.error('nagg.graphql.request.graphql_error', { - ...requestFields, - durationMs, - errorCount: error.errors.length, - messages: error.errors.slice(0, 3).map((graphqlError) => graphqlError.message), - }); - } else if (error.type === 'missing_data') { - apiLog.error('nagg.graphql.request.missing_data', { - ...requestFields, - durationMs, - }); - } else if (error.type === 'schema') { - // The canonical shape failed `dataSchema.safeParse` — this is the prime - // suspect for an EMPTY feed/notifications: the request succeeded but the - // client rejected the payload, so nothing reaches the UI. Log the exact - // failing field(s) so we can see which part of the live data the schema - // rejected. - apiLog.error('nagg.graphql.request.schema_error', { + const thrown = errorFromNaggError(error); + if (error.type === 'schema') { + // The app-view body failed `responseSchema.safeParse` — the prime suspect + // for an EMPTY feed/notifications: the request succeeded but the client + // rejected the payload. Log the exact failing field(s). + apiLog.error('nagg.appview.request.schema_error', { ...requestFields, durationMs, issueCount: error.issues?.length ?? 0, @@ -748,23 +153,19 @@ async function runNaggQuery( message: issue.message, })), }); + } else if (redactError(thrown).message === 'Aborted') { + apiLog.warn('nagg.appview.request.aborted', { ...requestFields, durationMs }); } else { - const thrown = errorFromNaggError(error); - const params = { + apiLog.error('nagg.appview.request.error', { ...requestFields, durationMs, error: redactError(thrown), - }; - if (redactError(thrown).message === 'Aborted') { - apiLog.warn('nagg.graphql.request.aborted', params); - } else { - apiLog.error('nagg.graphql.request.error', params); - } + }); } - throw errorFromNaggError(error); + throw thrown; } - apiLog.info('nagg.graphql.request.done', { + apiLog.info('nagg.appview.request.done', { ...requestFields, durationMs, dataKeys: dataKeysOf(result.value), @@ -773,19 +174,13 @@ async function runNaggQuery( return result.value; } -// Logs the resolved backend config once per session: the endpoint + which -// transport each view uses (GraphQL vs REST app-view). If feeds are empty -// because a flag is unexpectedly enabling the REST path, this surfaces it. +// Logs the resolved app-view base once per session. let loggedBackendConfig = false; function logBackendConfigOnce(): void { if (loggedBackendConfig) return; loggedBackendConfig = true; apiLog.info('nagg.backend.config', { - graphqlEndpoint: backendConfig.nostrGraphqlEndpoint, appViewBaseUrl: backendConfig.nostrAppViewBaseUrl, - feedAppView: backendConfig.nostrFeedAppView, - notificationsAppView: backendConfig.nostrNotificationsAppView, - dmAppView: backendConfig.nostrDmAppView, }); } @@ -806,264 +201,30 @@ function countCanonical(value: unknown): number { } /** - * The feed seam: resolve a query to a parsed {@link NaggFeedPage} regardless of - * transport. GraphQL distils its node tree with `graphqlToData`; the app-view - * returns the canonical page directly. Both end at `NaggFeedPageSchema` and feed - * straight into `mapNaggFeedPage`. + * The feed seam: fetch a route binding's canonical {@link NaggFeedPage} from the + * REST app-view and feed it straight into `mapNaggFeedPage`. */ function fetchNaggFeedPage( - query: string, - variables: Record, + binding: NaggAppViewBinding, refresh: boolean | undefined, - options: { - graphqlToData: (data: unknown) => unknown; - appView?: NaggAppViewBinding; - transport?: 'graphql' | 'appview'; - signal?: AbortSignal; - timeoutMs?: number; - } + options: { signal?: AbortSignal; timeoutMs?: number } = {} ): Promise> { - return runNaggQuery(query, variables, refresh, { - dataSchema: NaggFeedPageSchema, - graphqlToData: options.graphqlToData, - appView: options.appView, - transport: options.transport, + return runNaggRest(binding, refresh, { + responseSchema: NaggFeedPageSchema, signal: options.signal, timeoutMs: options.timeoutMs, }) as Promise>; } -/** The flag-derived transport for feed/thread views. */ -function feedTransport(): 'graphql' | 'appview' { - return backendConfig.nostrFeedAppView ? 'appview' : 'graphql'; -} - function errorFromNaggError(error: NaggError): Error { const out = new Error(error.message); out.name = error.type === 'network' && /abort|timed out|timeout/i.test(error.message) ? 'AbortError' - : 'NaggGraphqlError'; + : 'NaggError'; return out; } -const unsupportedGraphqlCapabilities = new Set(); - -/** - * A `runNaggQuery` invocation that can stand in for the primary or a fallback. - * Fallbacks are always GraphQL-only (no app-view binding), so they re-derive the - * canonical shape from the lighter GraphQL query via the same `graphqlToData`. - */ -type NaggQueryAttempt = { - query: string; - variables: Record; - appView?: NaggAppViewBinding; - transport?: 'graphql' | 'appview'; -}; - -async function postGraphqlWithCapabilityFallback({ - capability, - primary, - fallback, - refresh, - controls, - isCapabilityError, -}: { - capability: NaggCapability; - primary: NaggQueryAttempt; - fallback: NaggQueryAttempt; - refresh: boolean | undefined; - controls: { dataSchema: TSchema; graphqlToData: (data: unknown) => unknown } & { - signal?: AbortSignal; - timeoutMs?: number; - }; - isCapabilityError: (error: unknown) => boolean; -}): Promise> { - const run = (attempt: NaggQueryAttempt): Promise> => - runNaggQuery(attempt.query, attempt.variables, refresh, { - dataSchema: controls.dataSchema, - graphqlToData: controls.graphqlToData, - appView: attempt.appView, - transport: attempt.transport, - signal: controls.signal, - timeoutMs: controls.timeoutMs, - }); - - if (unsupportedGraphqlCapabilities.has(capability)) { - apiLog.warn('nagg.graphql.capability_fallback', { - capability, - operationName: graphqlOperationName(primary.query), - fallbackOperationName: graphqlOperationName(fallback.query), - reason: 'cached_unsupported_capability', - }); - return run(fallback); - } - - try { - return await run(primary); - } catch (error) { - if (!isCapabilityError(error)) throw error; - unsupportedGraphqlCapabilities.add(capability); - apiLog.warn('nagg.graphql.capability_fallback', { - capability, - operationName: graphqlOperationName(primary.query), - fallbackOperationName: graphqlOperationName(fallback.query), - reason: redactError(error), - }); - return run(fallback); - } -} - -async function postGraphqlWithAuthorReplyFallback( - primary: NaggQueryAttempt, - fallback: NaggQueryAttempt, - refresh: boolean | undefined, - controls: { dataSchema: TSchema; graphqlToData: (data: unknown) => unknown } & { - signal?: AbortSignal; - timeoutMs?: number; - } -): Promise> { - return postGraphqlWithCapabilityFallback({ - capability: NAGG_CAPABILITIES.AUTHORED_REPLY_CHAIN, - primary, - fallback, - refresh, - controls, - isCapabilityError: isAuthorReplyCapabilityError, - }); -} - -async function postGraphqlWithAuthorReplyAndTimeoutFallback( - primary: NaggQueryAttempt, - authorReplyFallback: NaggQueryAttempt, - timeoutFallback: NaggQueryAttempt, - refresh: boolean | undefined, - controls: { dataSchema: TSchema; graphqlToData: (data: unknown) => unknown } & { - signal?: AbortSignal; - timeoutMs?: number; - } -): Promise> { - try { - return await postGraphqlWithAuthorReplyFallback( - primary, - authorReplyFallback, - refresh, - controls - ); - } catch (error) { - if (controls.signal?.aborted || !isGraphqlTimeoutError(error)) throw error; - apiLog.warn('nagg.graphql.timeout_fallback', { - operationName: graphqlOperationName(primary.query), - fallbackOperationName: graphqlOperationName(timeoutFallback.query), - reason: redactError(error), - }); - return runNaggQuery(timeoutFallback.query, timeoutFallback.variables, refresh, { - dataSchema: controls.dataSchema, - graphqlToData: controls.graphqlToData, - appView: timeoutFallback.appView, - transport: timeoutFallback.transport, - signal: controls.signal, - timeoutMs: controls.timeoutMs, - }); - } -} - -/** - * Thread is the documented exception to "prefer app-view": nagg's REST - * `/nostr/thread` cannot reproduce the viewer-specific relevance ranking - * (`authoredReplyChain` + `rankedReferencedBy` over the follow graph) that the - * GraphQL thread relies on. So the thread path stays GraphQL-only and consumes - * the raw GraphQL node tree directly — `runNaggQuery` with the passthrough - * `NaggUnknownDataSchema` and no app-view binding gives us that raw tree while - * reusing the shared client/logging. - */ -function postGraphqlThread( - query: string, - variables: Record, - refresh: boolean | undefined, - controls: { signal?: AbortSignal; timeoutMs?: number } = {} -): Promise { - return runNaggQuery(query, variables, refresh, { - dataSchema: NaggUnknownDataSchema, - signal: controls.signal, - timeoutMs: controls.timeoutMs, - }) as Promise; -} - -async function postGraphqlThreadWithAuthorReplyFallback( - query: string, - variables: Record, - fallbackQuery: string, - fallbackVariables: Record, - refresh: boolean | undefined, - controls: { signal?: AbortSignal; timeoutMs?: number } = {} -): Promise { - const capability = NAGG_CAPABILITIES.AUTHORED_REPLY_CHAIN; - if (unsupportedGraphqlCapabilities.has(capability)) { - apiLog.warn('nagg.graphql.capability_fallback', { - capability, - operationName: graphqlOperationName(query), - fallbackOperationName: graphqlOperationName(fallbackQuery), - reason: 'cached_unsupported_capability', - }); - return postGraphqlThread(fallbackQuery, fallbackVariables, refresh, controls); - } - try { - return await postGraphqlThread(query, variables, refresh, controls); - } catch (error) { - if (!isAuthorReplyCapabilityError(error)) throw error; - unsupportedGraphqlCapabilities.add(capability); - apiLog.warn('nagg.graphql.capability_fallback', { - capability, - operationName: graphqlOperationName(query), - fallbackOperationName: graphqlOperationName(fallbackQuery), - reason: redactError(error), - }); - return postGraphqlThread(fallbackQuery, fallbackVariables, refresh, controls); - } -} - -function isAuthorReplyCapabilityError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error); - return ( - message.includes('authoredReplyChain') || - message.includes('AuthoredReplyChainInput') || - message.includes('PubkeySourceInput') - ); -} - -function isGraphqlTimeoutError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error); - return ( - message.includes('context deadline exceeded') || - message.includes('deadline exceeded') || - message === 'Aborted' - ); -} - -function feedInputFromSpec({ - spec, - limit, - until, - offset, -}: { - spec: string; - limit: number; - until?: number; - offset?: number; -}): EventQueryInput { - const parsed = parseJson>(spec); - const pubkeys = pubkeysFromSpec(parsed); - const input: EventQueryInput = { - kinds: pubkeys.length > 0 ? [1, 6, 16] : [1], - limit, - ...(until && { until }), - ...(offset && { offset }), - }; - if (pubkeys.length > 0) input.pubkeys = pubkeys; - return input; -} - function forYouInputFromSpec({ parsed, viewerPubkey, @@ -1085,23 +246,6 @@ function forYouInputFromSpec({ }); } -function recentInputFromSpec({ - parsed, - limit, - offset, -}: { - parsed: Record | null; - limit: number; - offset?: number; -}): EventQueryInput { - const hours = feedWindowHours(parsed); - return recentNotesEventsInput({ - since: Math.floor(Date.now() / 1000) - hours * 60 * 60, - limit, - offset, - }) as EventQueryInput; -} - function isForYouSpec(parsed: Record | null): boolean { return parsed?.id === 'for-you' && parsed.kind === 'notes'; } @@ -1118,81 +262,6 @@ function isFollowingRecentSpec(parsed: Record | null): boolean return parsed?.id === 'following-recent' && parsed.kind === 'notes'; } -function threadRankCandidateLimit(limit: number, offset: number): number { - return Math.max(100, offset + limit + RELEVANT_AUTHOR_REPLY_LIMIT + 1); -} - -function childAuthorReplyNodes(node: GraphqlEventNode | undefined): GraphqlEventNode[] { - return node?.childAuthorReplies?.nodes ?? []; -} - -function childFollowedReplyNodes(node: GraphqlEventNode | undefined): GraphqlEventNode[] { - return node?.childFollowedReply?.nodes ?? []; -} - -function replyGraphEventFromNode(node: GraphqlEventNode | null | undefined) { - const event = normalizeGraphqlEvent(node); - if (!event) return undefined; - return { - id: event.id, - pubkey: event.pubkey, - tags: event.tags, - createdAt: event.created_at, - }; -} - -function mergeRelevantReplyNodes({ - sourceNode, - authorNodes = [], - followedNodes = [], - rankedNodes = [], - allNodes = [], - offset = 0, - limit, -}: { - sourceNode?: GraphqlEventNode | null; - authorNodes?: GraphqlEventNode[]; - followedNodes?: GraphqlEventNode[]; - rankedNodes?: GraphqlEventNode[]; - allNodes?: GraphqlEventNode[]; - offset?: number; - limit?: number; -}): { - nodes: GraphqlEventNode[]; - pageNodes: GraphqlEventNode[]; - pageEventIds: string[]; - hasMore: boolean; -} { - const merged = mergeNaggRelevantReplyNodes({ - sourceNode, - authorNodes, - followedNodes, - rankedNodes, - allNodes, - offset, - limit, - toEvent: replyGraphEventFromNode, - childAuthorNodesFor: childAuthorReplyNodes, - childFollowedNodesFor: childFollowedReplyNodes, - }); - const pageEnd = limit == null ? merged.nodes.length : offset + limit; - return { - nodes: merged.nodes, - pageNodes: merged.pageNodes, - pageEventIds: merged.pageNodes - .map((node) => normalizeGraphqlEvent(node)?.id) - .filter((id): id is string => !!id), - hasMore: merged.nodes.length > pageEnd, - }; -} - -function threadReplyRankInput( - sort: ThreadReplySort, - viewerPubkey?: string -): ReferenceRankInput | null { - return buildThreadReplyRankInput(sort, { viewerPubkey }) as ReferenceRankInput | null; -} - function followingPopularInput({ viewerPubkey, parsed, @@ -1245,16 +314,6 @@ function currentFeedPreferenceFilters(): FeedPreferenceFilters { }; } -function withPreferenceEventFilters( - input: EventQueryInput, - filters: FeedPreferenceFilters -): EventQueryInput { - return withEventExclusions(input, { - excludeIds: filters.ignoredEventIds, - excludePubkeys: filters.ignoredPubkeys, - }); -} - function withPreferenceRankedFilters( input: RankedEventsInput, filters: FeedPreferenceFilters @@ -1287,99 +346,55 @@ function feedQueryOptionsFromPreferences(filters: FeedPreferenceFilters): FeedQu type NaggFeedPageOf = NaggFeedPage; -/** - * The `events`-feed distiller (`graphqlToData`): distils the GraphQL `events` - * connection into the canonical {@link NaggFeedPage}. Offset/until pagination is - * the page's min `created_at` (set by `graphqlNodesToNaggPage`). - */ -// Logs the GraphQL distiller's node→item conversion. A large drop (many nodes -// in, few/zero items out) means the distiller is discarding the response — a key -// EMPTY-feed signal, distinct from "no nodes returned" or "schema rejected". -function logDistill(distiller: string, nodesIn: number, itemsOut: number): void { - feedLog.info('feed.nagg.distill', { - distiller, - nodesIn, - itemsOut, - dropped: nodesIn - itemsOut, - }); +function emptyNaggFeedPage(): NaggFeedPageOf { + return { + items: [], + metrics: {}, + profiles: {}, + quoted: {}, + paginationUntil: 0, + paginationOffset: 0, + }; } -function eventsFeedToPage(data: unknown): NaggFeedPageOf { - const feed = data as GraphqlFeedData; - const nodes = feed.events?.nodes ?? []; - const page = graphqlNodesToNaggPage(nodes) as NaggFeedPageOf; - logDistill('events', nodes.length, page.items.length); - return page; +function compareNotificationsNewestFirst( + left: FeedNotificationsResult['notifications'][number], + right: FeedNotificationsResult['notifications'][number] +): number { + return ( + right.event.created_at - left.event.created_at || right.event.id.localeCompare(left.event.id) + ); } -/** - * The ranked-feed distiller (`graphqlToData`): distils `rankedEvents` (or the - * timeout-fallback `events`) into a {@link NaggFeedPage}. Ranked results paginate - * by offset, so when the response is ranked we stamp the ranked sentinel - * (`paginationUntil: 1`, `paginationOffset` = node count) the way the legacy - * `mapRankedGraphqlFeed` did; the events fallback keeps min-`created_at` paging. - */ -function rankedFeedToPage(data: unknown): NaggFeedPageOf { - const feed = data as GraphqlFeedData; - if (feed.rankedEvents) { - const nodes = feed.rankedEvents.nodes ?? []; - const page = graphqlNodesToNaggPage(nodes) as NaggFeedPageOf; - logDistill('ranked', nodes.length, page.items.length); - return { - ...page, - paginationUntil: nodes.length > 0 ? 1 : 0, - paginationOffset: nodes.length, - }; +// Own-note candidates from a feed page — note events, their roots, and reposted +// originals. `ingestOwnContent` filters these to our own kind:1. +function ownNoteCandidatesFromFeed(result: FeedParseResult): FeedEvent[] { + const events: FeedEvent[] = []; + for (const item of result.orderedFeedItems) { + if (item.type === 'note') { + events.push(item.event); + if (item.rootEvent) events.push(item.rootEvent); + } else if (item.originalEvent) { + events.push(item.originalEvent); + } } - const nodes = feed.events?.nodes ?? []; - const page = graphqlNodesToNaggPage(nodes) as NaggFeedPageOf; - logDistill('ranked-events-fallback', nodes.length, page.items.length); - return page; -} - -/** - * The following-replies distiller (`graphqlToData`): resolves each reply's parent - * context from the rich reference selections before distilling, so reply rows - * render with their root/parent attached. - */ -function followingRepliesToPage(data: unknown): NaggFeedPageOf { - const feed = data as GraphqlFeedData; - const nodes = feed.events?.nodes ?? []; - const page = graphqlNodesToNaggPage(nodes, { - parentForNode: (node) => - node.rootContext?.nodes?.[0] ?? - node.parentReplyRefs?.nodes?.[0] ?? - node.parentRootRefs?.nodes?.[0] ?? - node.eventRefs?.nodes?.[0], - }) as NaggFeedPageOf; - logDistill('following-replies', nodes.length, page.items.length); - return page; + return events; } -/** - * The profile-enrichment distiller (`graphqlToData`): the profile query returns - * raw kind-0 metadata events, so distil them straight into the page's `profiles` - * map (the rest of the page is empty — only the side map is consumed). - */ -function profileEventsToPage(data: unknown): NaggFeedPageOf { - const feed = data as GraphqlFeedData; - const profiles: NaggFeedPageOf['profiles'] = {}; - for (const node of feed.events?.nodes ?? []) { - const profile = profileFromMetadataEvent(node); - if (profile) profiles[node.pubkey] = profile; +function seedFeedProfilesIntoCache(profilesMap: Map): void { + if (profilesMap.size === 0) return; + const entries: Record = {}; + for (const [pubkey, profile] of profilesMap) { + entries[pubkey] = { + ...(profile.name ? { name: profile.name } : {}), + ...(profile.picture ? { picture: profile.picture } : {}), + }; } - return { - items: [], - metrics: {}, - profiles, - quoted: {}, - paginationUntil: 0, - paginationOffset: 0, - }; + useNostrMetadataCache.getState().seedManyProfilesLowConfidence(entries); } /** - * Map a {@link NaggFeedPage} (from either transport) through the shared mapper. + * Map a {@link NaggFeedPage} through the shared mapper. * Pagination fields ride on the page itself, so ranked and events feeds paginate * exactly as they did before — there is no transport-specific post-processing. */ @@ -1420,230 +435,6 @@ function logFeedPageResult( return result; } -function graphqlNodeKindCounts(nodes: GraphqlEventNode[]): Record { - const counts: Record = {}; - for (const node of nodes) { - const kind = typeof node.kind === 'number' ? String(node.kind) : 'unknown'; - counts[kind] = (counts[kind] ?? 0) + 1; - } - return counts; -} - -function includeSelectedReplyNodesInThread( - thread: ThreadStructure, - replyNodes: readonly GraphqlEventNode[] -): ThreadStructure { - if (!thread.target || replyNodes.length === 0) return thread; - const existingIds = new Set([ - thread.target.id, - ...thread.parents.map((event) => event.id), - ...thread.replies.map((event) => event.id), - ]); - const selectedReplies: FeedEvent[] = []; - for (const node of replyNodes) { - const event = normalizeGraphqlEvent(node); - if (!event || existingIds.has(event.id)) continue; - existingIds.add(event.id); - selectedReplies.push(event); - } - if (selectedReplies.length === 0) return thread; - return { - ...thread, - replies: [...thread.replies, ...selectedReplies], - }; -} - -function collectHydrationFromGraphqlNodes(nodes: GraphqlEventNode[]): { - metrics: Map; - profiles: Map; - quotedEvents: Map; -} { - const page = graphqlNodesToNaggPage(nodes); - return { - metrics: new Map(Object.entries(page.metrics)), - profiles: new Map(Object.entries(page.profiles)), - quotedEvents: new Map(Object.entries(page.quoted)), - }; -} - -/** - * Own-note candidates from a feed page — note events, their roots, and reposted - * originals. `ingestOwnContent` filters these to our own kind:1, so passing the - * superset here is safe and keeps the choke-point in one place. - */ -function ownNoteCandidatesFromFeed(result: FeedParseResult): FeedEvent[] { - const events: FeedEvent[] = []; - for (const item of result.orderedFeedItems) { - if (item.type === 'note') { - events.push(item.event); - if (item.rootEvent) events.push(item.rootEvent); - } else if (item.originalEvent) { - events.push(item.originalEvent); - } - } - return events; -} - -function compareNotificationsNewestFirst( - left: FeedNotificationsResult['notifications'][number], - right: FeedNotificationsResult['notifications'][number] -): number { - return ( - right.event.created_at - left.event.created_at || right.event.id.localeCompare(left.event.id) - ); -} - -function notificationTargetFromGraphqlNode( - node: GraphqlEventNode | null | undefined, - reason: string -): { targetEvent?: FeedEvent; targetEventId?: string } { - if (!node) return {}; - const event = normalizeGraphqlEvent(node); - const ownId = event?.id; - const eventRefs = node.eventRefs?.nodes ?? []; - const quoteRefs = node.quotedContent?.nodes ?? []; - const rootRefs = node.rootContext?.nodes ?? []; - const refById = new Map(); - - for (const ref of [...eventRefs, ...quoteRefs, ...rootRefs]) { - const refEvent = normalizeGraphqlEvent(ref); - if (!refEvent || refEvent.id === ownId) continue; - refById.set(refEvent.id.toLowerCase(), ref); - } - - const eventForId = (id: string | undefined): FeedEvent | undefined => { - if (!id) return undefined; - return normalizeGraphqlEvent(refById.get(id.toLowerCase())); - }; - const firstEvent = (refs: readonly GraphqlEventNode[]): FeedEvent | undefined => { - for (const ref of refs) { - const refEvent = normalizeGraphqlEvent(ref); - if (refEvent && refEvent.id !== ownId) return refEvent; - } - return undefined; - }; - const firstTagId = (key: string, markers?: readonly string[]): string | undefined => { - for (const tag of node.tags ?? []) { - if (tag[0] !== key || typeof tag[1] !== 'string' || tag[1].length !== 64) continue; - if (markers && !markers.includes(tag[3] ?? '')) continue; - return tag[1]; - } - return undefined; - }; - const directReplyParentId = (): string | undefined => { - const eTags = (node.tags ?? []).filter( - (tag) => tag[0] === 'e' && typeof tag[1] === 'string' && tag[1].length === 64 - ); - const replyMarker = eTags.find((tag) => (tag[3] ?? '').toLowerCase() === 'reply'); - if (replyMarker?.[1]) return replyMarker[1]; - const lastUnmarked = [...eTags].reverse().find((tag) => !tag[3]); - if (lastUnmarked?.[1]) return lastUnmarked[1]; - const rootMarker = eTags.find((tag) => (tag[3] ?? '').toLowerCase() === 'root'); - return rootMarker?.[1]; - }; - const withId = (targetEvent?: FeedEvent, targetEventId?: string) => { - const id = targetEvent?.id ?? targetEventId; - if (!id) return {}; - return targetEvent ? { targetEvent, targetEventId: id } : { targetEventId: id }; - }; - - switch (reason) { - case 'quote': { - const qId = firstTagId('q'); - const mentionId = firstTagId('e', ['mention']); - return withId( - eventForId(qId) ?? firstEvent(quoteRefs) ?? eventForId(mentionId) ?? firstEvent(eventRefs), - qId ?? mentionId - ); - } - case 'reply': { - const parentId = directReplyParentId(); - return withId( - eventForId(parentId) ?? firstEvent(eventRefs) ?? firstEvent(rootRefs), - parentId - ); - } - case 'reaction': - case 'repost': - case 'zap': { - const targetId = firstTagId('e'); - return withId(eventForId(targetId) ?? firstEvent(eventRefs), targetId); - } - default: - return {}; - } -} - -function addThreadNode( - node: GraphqlEventNode | undefined, - buckets: { - allEvents: Map; - profiles: Map; - metrics: Map; - quotedEvents: Map; - } -) { - const event = normalizeGraphqlEvent(node); - if (!node || !event) return; - buckets.allEvents.set(event.id, event); - buckets.metrics.set(event.id, metricsFromGraphqlNode(node)); - const profile = profileFromMetadataEvent(node.authorMetadata?.[0]); - if (profile) buckets.profiles.set(event.pubkey, profile); - for (const quoteNode of node.quotedContent?.nodes ?? []) { - const quote = normalizeGraphqlEvent(quoteNode); - if (quote) buckets.quotedEvents.set(quote.id, quote); - addThreadNode(quoteNode, buckets); - } - for (const childNode of node.childAuthorReplies?.nodes ?? []) addThreadNode(childNode, buckets); - for (const childNode of node.childFollowedReply?.nodes ?? []) addThreadNode(childNode, buckets); -} - -/** - * The notifications distiller (`graphqlToData`): distils the GraphQL - * `notifications` connection into the canonical {@link NaggNotificationsPage}. - * Each node carries its `{ event, reason, actorVertexScore }` plus the resolved - * `targetEvent`/`targetEventId` (the schema passes these through), and the feed - * hydration (`metrics`/`profiles`/`quoted`) rides alongside as page side maps — - * exactly the shape nagg's REST `/nostr/notifications` route emits. - */ -function notificationsToPage(data: unknown): NaggNotificationsPage { - const feed = data as GraphqlNotificationsData; - const sourceNodes = feed.notifications?.nodes ?? []; - const eventNodes = sourceNodes - .map((node) => node.event) - .filter((event): event is GraphqlEventNode => !!event); - const hydration = collectHydrationFromGraphqlNodes(eventNodes); - const nodes: NaggNotificationsPage['notifications']['nodes'] = []; - for (const node of sourceNodes) { - const event = normalizeGraphqlEvent(node.event); - if (!event) continue; - const reason = node.reason || 'mention'; - const target = notificationTargetFromGraphqlNode(node.event, reason); - nodes.push({ - event, - reason, - actorVertexScore: - typeof node.actorVertexScore === 'number' && Number.isFinite(node.actorVertexScore) - ? node.actorVertexScore - : 0, - ...target, - }); - } - logDistill('notifications', sourceNodes.length, nodes.length); - return { - notifications: { - nodes, - pageInfo: { - endCursor: feed.notifications?.pageInfo?.endCursor ?? undefined, - hasNextPage: feed.notifications?.pageInfo?.hasNextPage ?? false, - }, - }, - metrics: Object.fromEntries(hydration.metrics), - profiles: Object.fromEntries(hydration.profiles) as NaggNotificationsPage['profiles'], - quoted: Object.fromEntries(hydration.quotedEvents) as NaggNotificationsPage['quoted'], - }; -} - /** * Build a {@link FeedNotificationsResult} from the parsed canonical * {@link NaggNotificationsPage}, identically for both transports: order notifs @@ -1703,6 +494,9 @@ export function createNaggFeedClient(): FeedClient { const logResult = (source: string, result: FeedParseResult): FeedParseResult => { // Passive convergence: settle any of our own notes this page surfaced. ingestOwnContent(ownNoteCandidatesFromFeed(result), userPubkey); + // Single profile cache: low-confidence-seed this page's inline (nagg) + // profiles so they're not an ephemeral map the relay layer re-fetches. + seedFeedProfilesIntoCache(result.profilesMap); return logFeedPageResult(source, result, { limit, until, @@ -1729,63 +523,19 @@ export function createNaggFeedClient(): FeedClient { }), preferenceFilters ); - const timeoutFallbackInput = withPreferenceEventFilters( - recentInputFromSpec({ parsed: parsedSpec, limit, offset }), - preferenceFilters - ); - const viewerVariables = { - input, - ...(userPubkey ? { viewerPubkey: userPubkey } : {}), - }; - const variables = { - ...viewerVariables, - authorChain: authoredReplyChainInput(), - }; - const transport = feedTransport(); - const rankedControls = { - dataSchema: NaggFeedPageSchema, - graphqlToData: rankedFeedToPage, + const page = await fetchNaggFeedPage(rankedFeedAppView(input), refresh, { signal, timeoutMs, - }; - const page = userPubkey - ? await postGraphqlWithAuthorReplyAndTimeoutFallback( - { - query: RANKED_FEED_WITH_VIEWER_QUERY, - variables, - appView: rankedFeedAppView(input), - transport, - }, - { query: RANKED_FEED_WITH_VIEWER_LEGACY_QUERY, variables: viewerVariables }, - { query: FEED_QUERY, variables: { input: timeoutFallbackInput } }, - refresh, - rankedControls - ) - : await runNaggQuery(RANKED_FEED_QUERY, { input }, refresh, { - ...rankedControls, - appView: rankedFeedAppView(input), - transport, - }); - return logResult('for-you', mapNaggFeedPageResult(page as NaggFeedPageOf, feedOptions)); + }); + return logResult('for-you', mapNaggFeedPageResult(page, feedOptions)); } if (isFollowingRepliesSpec(parsedSpec)) { if (!userPubkey) return logResult('following-replies-no-viewer', emptyFeedParseResult()); - const input = withPreferenceEventFilters( - followingRepliesEventsInput({ - viewerPubkey: userPubkey, - limit, - until, - offset, - }) as EventQueryInput, - preferenceFilters + const page = await fetchNaggFeedPage( + followsFeedAppView({ pubkeys: [userPubkey], until, limit, offset }), + refresh, + { signal, timeoutMs } ); - const page = await fetchNaggFeedPage(FOLLOWING_REPLIES_QUERY, { input }, refresh, { - graphqlToData: followingRepliesToPage, - appView: followsFeedAppView({ pubkeys: [userPubkey], until, limit, offset }), - transport: feedTransport(), - signal, - timeoutMs, - }); return logResult('following-replies', mapNaggFeedPageResult(page, feedOptions)); } if (isFollowingPopularSpec(parsedSpec)) { @@ -1799,76 +549,27 @@ export function createNaggFeedClient(): FeedClient { }), preferenceFilters ); - const timeoutFallbackInput = withPreferenceEventFilters( - followingRecentEventsInput({ - viewerPubkey: userPubkey, - limit, - offset, - }) as EventQueryInput, - preferenceFilters - ); - const viewerVariables = { - input, - viewerPubkey: userPubkey, - }; - const variables = { - ...viewerVariables, - authorChain: authoredReplyChainInput(), - }; - const page = await postGraphqlWithAuthorReplyAndTimeoutFallback( - { - query: FOLLOWING_POPULAR_WITH_VIEWER_QUERY, - variables, - appView: rankedFeedAppView(input), - transport: feedTransport(), - }, - { query: FOLLOWING_POPULAR_WITH_VIEWER_LEGACY_QUERY, variables: viewerVariables }, - { query: FEED_QUERY, variables: { input: timeoutFallbackInput } }, - refresh, - { - dataSchema: NaggFeedPageSchema, - graphqlToData: rankedFeedToPage, - signal, - timeoutMs, - } - ); - return logResult( - 'following-popular', - mapNaggFeedPageResult(page as NaggFeedPageOf, feedOptions) - ); + const page = await fetchNaggFeedPage(rankedFeedAppView(input), refresh, { + signal, + timeoutMs, + }); + return logResult('following-popular', mapNaggFeedPageResult(page, feedOptions)); } if (isFollowingRecentSpec(parsedSpec)) { if (!userPubkey) return logResult('following-recent-no-viewer', emptyFeedParseResult()); - const input = withPreferenceEventFilters( - followingRecentEventsInput({ - viewerPubkey: userPubkey, - limit, - until, - offset, - }) as EventQueryInput, - preferenceFilters + const page = await fetchNaggFeedPage( + followsFeedAppView({ pubkeys: [userPubkey], until, limit, offset }), + refresh, + { signal, timeoutMs } ); - const page = await fetchNaggFeedPage(FEED_QUERY, { input }, refresh, { - graphqlToData: eventsFeedToPage, - appView: followsFeedAppView({ pubkeys: [userPubkey], until, limit, offset }), - transport: feedTransport(), - signal, - timeoutMs, - }); return logResult('following-recent', mapNaggFeedPageResult(page, feedOptions)); } - const input = withPreferenceEventFilters( - feedInputFromSpec({ spec: hydratedSpec, limit, until, offset }), - preferenceFilters - ); const genericPubkeys = pubkeysFromSpec(parsedSpec); - const page = await fetchNaggFeedPage(FEED_QUERY, { input }, refresh, { - graphqlToData: eventsFeedToPage, - appView: followsFeedAppView({ pubkeys: genericPubkeys, until, limit, offset }), - transport: feedTransport(), - signal, - timeoutMs, - }); + const page = await fetchNaggFeedPage( + followsFeedAppView({ pubkeys: genericPubkeys, until, limit, offset }), + refresh, + { signal, timeoutMs } + ); return logResult('generic', mapNaggFeedPageResult(page, feedOptions)); }, @@ -1884,24 +585,9 @@ export function createNaggFeedClient(): FeedClient { timeoutMs, }: UserFeedPageRequest) { const page = await fetchNaggFeedPage( - FEED_QUERY, - { - input: { - pubkeys: [pubkey], - kinds: [1, 6, 16], - limit, - ...(until && { until }), - ...(offset && { offset }), - }, - }, + userFeedAppView({ pubkey, until, limit, offset }), refresh, - { - graphqlToData: eventsFeedToPage, - appView: userFeedAppView({ pubkey, until, limit, offset }), - transport: feedTransport(), - signal, - timeoutMs, - } + { signal, timeoutMs } ); return mapNaggFeedPage(page, { includeNote: (event) => event.pubkey === pubkey && isRootNote(event), @@ -1922,31 +608,16 @@ export function createNaggFeedClient(): FeedClient { timeoutMs, }: PostsByPubkeysRequest) { if (pubkeys.length === 0) { - return mapNaggFeedPage(graphqlNodesToNaggPage([]) as NaggFeedPageOf, { + return mapNaggFeedPage(emptyNaggFeedPage(), { includeNote: () => false, includeRepost: () => false, }); } const pubkeySet = new Set(pubkeys); const page = await fetchNaggFeedPage( - FEED_QUERY, - { - input: { - pubkeys, - kinds: [1, 6, 16], - limit, - ...(until && { until }), - ...(offset && { offset }), - }, - }, + followsFeedAppView({ pubkeys, until, limit, offset }), refresh, - { - graphqlToData: eventsFeedToPage, - appView: followsFeedAppView({ pubkeys, until, limit, offset }), - transport: feedTransport(), - signal, - timeoutMs, - } + { signal, timeoutMs } ); return mapNaggFeedPage(page, { includeNote: (event) => pubkeySet.has(event.pubkey) && isRootNote(event), @@ -1965,12 +636,11 @@ export function createNaggFeedClient(): FeedClient { if (missingQuotedIds.length > 0) { tasks.push( - runNaggQuery( - ENRICH_EVENTS_QUERY, - { input: { ids: missingQuotedIds, limit: missingQuotedIds.length } }, - refresh, - { dataSchema: NaggFeedPageSchema, graphqlToData: eventsFeedToPage, signal, timeoutMs } - ).then((page) => ({ + runNaggRest(eventsAppView(missingQuotedIds), refresh, { + responseSchema: NaggEnrichmentSchema, + signal, + timeoutMs, + }).then((page) => ({ metrics: new Map(Object.entries(page.metrics)) as Map, profiles: new Map(Object.entries(page.profiles)) as Map, quotedEvents: new Map(Object.entries(page.quoted)) as Map, @@ -1980,23 +650,11 @@ export function createNaggFeedClient(): FeedClient { if (missingProfilePubkeys.length > 0) { tasks.push( - runNaggQuery( - PROFILE_EVENTS_QUERY, - { - input: { - pubkeys: missingProfilePubkeys, - kinds: [0], - limit: missingProfilePubkeys.length, - }, - }, - refresh, - { - dataSchema: NaggFeedPageSchema, - graphqlToData: profileEventsToPage, - signal, - timeoutMs, - } - ).then((page) => ({ + runNaggRest(profilesAppView(missingProfilePubkeys), refresh, { + responseSchema: NaggEnrichmentSchema, + signal, + timeoutMs, + }).then((page) => ({ profiles: new Map(Object.entries(page.profiles)) as Map, })) ); @@ -2035,19 +693,8 @@ export function createNaggFeedClient(): FeedClient { signal, timeoutMs, }: FeedNotificationsRequest): Promise { - const input = notificationsInput({ - pubkey: viewerPubkey, - tab, - policy, - replyScope, - since, - until, - limit, - }); - const page = await runNaggQuery(NOTIFICATIONS_QUERY, { input }, refresh, { - dataSchema: NaggNotificationsPageSchema, - graphqlToData: notificationsToPage, - appView: notificationsAppView({ + const page = await runNaggRest( + notificationsAppView({ pubkey: viewerPubkey, tab, policy, @@ -2057,13 +704,9 @@ export function createNaggFeedClient(): FeedClient { limit, grouped, }), - // Always prefer the app-view (it serves the grouped, optimized - // notifications); runNaggQuery falls back to GraphQL if the app-view is - // unavailable, errors, or returns nothing. - transport: 'appview', - signal, - timeoutMs, - }); + refresh, + { responseSchema: NaggNotificationsPageSchema, signal, timeoutMs } + ); const result = notificationsResultFromPage(page); feedLog.info('feed.notifications.fetch.done', { transport: 'appview', @@ -2090,127 +733,70 @@ export function createNaggFeedClient(): FeedClient { timeoutMs, }: ThreadRequest): Promise { const startedAt = Date.now(); - const rank = threadReplyRankInput(sort, viewerPubkey); - const isRelevantSort = sort === 'relevant' && !!rank; - const candidateLimit = threadRankCandidateLimit(limit, offset); - const rankedLimit = Math.min(candidateLimit, 50); - const query = isRelevantSort - ? THREAD_RELEVANT_QUERY - : rank - ? THREAD_RANKED_QUERY - : THREAD_NEW_QUERY; - const variables = isRelevantSort - ? { - id: eventId, - candidateLimit, - rankedLimit, - rank, - viewerPubkey: viewerPubkey ?? '', - authorChain: authoredReplyChainInput(), - } - : rank - ? { - id: eventId, - limit, - offset, - candidateLimit, - rank, - } - : { id: eventId, limit, offset }; - const fallbackVariables = - isRelevantSort && rank - ? { - id: eventId, - limit, - offset, - candidateLimit, - rank, - } - : variables; - // Thread is the documented exception to "prefer app-view": it stays on the - // GraphQL relevance-ranked path because nagg's REST `/nostr/thread` cannot - // reproduce the viewer-specific ranking (authoredReplyChain + - // rankedReferencedBy over the follow graph). The reply order is derived - // here by the relevant-reply merge, NOT by buildThreadStructure. - const data = isRelevantSort - ? await postGraphqlThreadWithAuthorReplyFallback( - query, - variables, - THREAD_RANKED_QUERY, - fallbackVariables, - false, - { signal, timeoutMs } - ) - : await postGraphqlThread(query, variables, false, { - signal, - timeoutMs, - }); + // A generous candidate fetch so the server-side relevance merge + ordering + // see the full reply set; the page window (offset/limit) is applied to the + // returned manifest. The viewer-specific reply order is computed by nagg + // (`sort=relevant`), replacing the old client-side GraphQL merge. + const candidateLimit = Math.max(100, offset + limit + RELEVANT_AUTHOR_REPLY_LIMIT + 1); + // nagg's thread endpoint sorts by relevant | ranked | new; the legacy + // engagement sorts (likes/zaps/reposts) all map to the server's `ranked`. + const threadSort: 'relevant' | 'ranked' | 'new' = + sort === 'relevant' || sort === 'new' ? sort : 'ranked'; + const page = await runNaggRest( + threadAppView({ + id: eventId, + limit: candidateLimit, + sort: threadSort, + viewer: viewerPubkey, + offset, + replyLimit: limit, + candidateLimit, + rankedLimit: Math.min(candidateLimit, 50), + }), + false, + { responseSchema: NaggThreadSchema, signal, timeoutMs } + ); - const rankedReplyNodes = data.event?.replies?.nodes ?? []; - const allReplyNodes = data.event?.allReplies?.nodes ?? []; - const supportsSourceAuthorReplies = isRelevantSort && !!data.event?.authorReplies; - const relevantReplies = supportsSourceAuthorReplies - ? mergeRelevantReplyNodes({ - sourceNode: data.event, - authorNodes: data.event?.authorReplies?.nodes, - followedNodes: data.event?.followedReply?.nodes, - rankedNodes: rankedReplyNodes, - allNodes: allReplyNodes, - offset, - limit, - }) - : null; - const replyNodes = relevantReplies?.pageNodes ?? rankedReplyNodes; - const replyPageEventIds = - relevantReplies?.pageEventIds ?? - replyNodes - .map((node) => normalizeGraphqlEvent(node)?.id) - .filter((id): id is string => !!id); + // The canonical thread carries the root + flat descendants + side maps; + // FeedEvent/NoteMetrics/ProfileInfo are structurally the canonical shapes, + // so hydrate the buckets directly. buildThreadStructure builds the tree; + // the server `ordering` manifest is the reply render order. const buckets = { allEvents: seed ? new Map(seed.allEvents) : new Map(), profiles: seed ? new Map(seed.profiles) : new Map(), metrics: seed ? new Map(seed.metrics) : new Map(), quotedEvents: seed ? new Map(seed.quotedEvents) : new Map(), }; - addThreadNode(data.event ?? undefined, buckets); - for (const parent of data.event?.parentRefs?.nodes ?? []) addThreadNode(parent, buckets); - if (supportsSourceAuthorReplies) { - for (const reply of data.event?.authorReplies?.nodes ?? []) addThreadNode(reply, buckets); - for (const reply of data.event?.followedReply?.nodes ?? []) addThreadNode(reply, buckets); - for (const reply of rankedReplyNodes) addThreadNode(reply, buckets); - for (const reply of allReplyNodes) addThreadNode(reply, buckets); - } else { - for (const reply of replyNodes) addThreadNode(reply, buckets); + const root = page.root as FeedEvent; + buckets.allEvents.set(root.id, root); + for (const event of page.events as FeedEvent[]) buckets.allEvents.set(event.id, event); + for (const [id, metrics] of Object.entries(page.metrics)) { + buckets.metrics.set(id, metrics as NoteMetrics); + } + for (const [pubkey, profile] of Object.entries(page.profiles)) { + buckets.profiles.set(pubkey, profile as ProfileInfo); + } + for (const [id, quoted] of Object.entries(page.quoted)) { + buckets.quotedEvents.set(id, quoted as FeedEvent); } - const thread = includeSelectedReplyNodesInThread( - buildThreadStructure(eventId, buckets.allEvents), - replyNodes - ); - const hasMoreReplies = relevantReplies - ? relevantReplies.hasMore || allReplyNodes.length >= candidateLimit - : replyNodes.length >= limit; + const orderedReplyIds = page.ordering?.elements ?? page.events.map((event) => event.id); + const thread = buildThreadStructure(eventId, buckets.allEvents); const targetMetrics = buckets.metrics.get(eventId); + // Another page may exist when the candidate fetch saturated. + const hasMoreReplies = page.events.length >= candidateLimit; - feedLog.info('thread.nagg.graphql.result', { + feedLog.info('thread.nagg.appview.result', { eventId, sort, limit, offset, candidateLimit, - rankedLimit, - query: graphqlOperationName(query), durationMs: Date.now() - startedAt, seedEvents: seed?.allEvents.size ?? 0, allEvents: buckets.allEvents.size, - replyNodeCount: replyNodes.length, - rankedReplyNodeCount: rankedReplyNodes.length, - allReplyNodeCount: allReplyNodes.length, - authorReplyNodeCount: data.event?.authorReplies?.nodes?.length ?? 0, - followedReplyNodeCount: data.event?.followedReply?.nodes?.length ?? 0, - supportsSourceAuthorReplies, - replyPageEventIds: replyPageEventIds.map(shortId), - replyNodeKinds: graphqlNodeKindCounts(replyNodes), + replyCount: orderedReplyIds.length, + replyPageEventIds: orderedReplyIds.map(shortId), renderedReplies: thread.replies.length, targetReplyCount: targetMetrics?.replyCount ?? null, hasMoreReplies, @@ -2219,9 +805,9 @@ export function createNaggFeedClient(): FeedClient { return { ...buckets, thread, - replyPageEventIds, + replyPageEventIds: orderedReplyIds, replyPageSize: limit, - loadedReplyCount: replyPageEventIds.length, + loadedReplyCount: orderedReplyIds.length, hasMoreReplies, }; }, diff --git a/features/feed/data/useFeedClient.ts b/features/feed/data/useFeedClient.ts index e2be12208..2069a60db 100644 --- a/features/feed/data/useFeedClient.ts +++ b/features/feed/data/useFeedClient.ts @@ -1,6 +1,10 @@ import type { FeedClient } from './feedClient'; import { createNaggFeedClient } from './naggFeedClient'; +import { createFacadeFeedClient } from './facadeFeedClient'; export function getFeedClient(): FeedClient { - return createNaggFeedClient(); + // The for-you / following-popular home feeds route through the tier-selecting + // facade (so the Settings → Network toggles take effect); everything else + // delegates to the existing nagg feed client. + return createFacadeFeedClient(createNaggFeedClient()); } diff --git a/features/feed/hooks/useNostrEngagement.ts b/features/feed/hooks/useNostrEngagement.ts index 9e8fc1e61..e26403352 100644 --- a/features/feed/hooks/useNostrEngagement.ts +++ b/features/feed/hooks/useNostrEngagement.ts @@ -169,21 +169,14 @@ export function useNostrEngagement( // State slices — grouped with useShallow to minimise re-subscriptions. The // canonical maps are populated globally by useOwnEventsSync, so this hook only // reads them (no per-screen relay subscription) and owns the optimistic toggle. - const { - likesByEventId, - repostsByEventId, - repliedByEventId, - optimisticLikesByEventId, - optimisticRepostsByEventId, - } = useNostrSocialStore( - useShallow((s) => ({ - likesByEventId: s.likesByEventId, - repostsByEventId: s.repostsByEventId, - repliedByEventId: s.repliedByEventId, - optimisticLikesByEventId: s.optimisticLikesByEventId, - optimisticRepostsByEventId: s.optimisticRepostsByEventId, - })) - ); + const { engagementByEventId, optimisticLikesByEventId, optimisticRepostsByEventId } = + useNostrSocialStore( + useShallow((s) => ({ + engagementByEventId: s.engagementByEventId, + optimisticLikesByEventId: s.optimisticLikesByEventId, + optimisticRepostsByEventId: s.optimisticRepostsByEventId, + })) + ); const lastStaleWarningRef = useRef(0); @@ -205,13 +198,13 @@ export function useNostrEngagement( for (const eventId of eventIds) { settleOptimistic( optimisticLikesByEventId[eventId], - !!likesByEventId[eventId], + !!engagementByEventId[eventId]?.liked, getBaseMetrics(eventId).likeCount, () => clearLikeOptimistic(eventId) ); settleOptimistic( optimisticRepostsByEventId[eventId], - !!repostsByEventId[eventId], + !!engagementByEventId[eventId]?.reposted, getBaseMetrics(eventId).repostCount, () => clearRepostOptimistic(eventId) ); @@ -219,10 +212,9 @@ export function useNostrEngagement( }, [ eventIds, getBaseMetrics, - likesByEventId, + engagementByEventId, optimisticLikesByEventId, optimisticRepostsByEventId, - repostsByEventId, ]); // ---- DEV stale-optimistic warning ---- @@ -251,28 +243,22 @@ export function useNostrEngagement( engagementRevisionRef.current += 1; return engagementRevisionRef.current; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - eventIds, - likesByEventId, - repostsByEventId, - repliedByEventId, - optimisticLikesByEventId, - optimisticRepostsByEventId, - ]); + }, [eventIds, engagementByEventId, optimisticLikesByEventId, optimisticRepostsByEventId]); // ---- public getters ---- const getEngagementState = useCallback( (eventId: string): EngagementState => { - const baseLiked = !!likesByEventId[eventId]; - const baseReposted = !!repostsByEventId[eventId]; + const record = engagementByEventId[eventId]; + const baseLiked = !!record?.liked; + const baseReposted = !!record?.reposted; const optLike = optimisticLikesByEventId[eventId]; const optRepost = optimisticRepostsByEventId[eventId]; return { liked: optLike ? optLike.value : baseLiked, reposted: optRepost ? optRepost.value : baseReposted, - replied: !!repliedByEventId[eventId], + replied: !!record?.replied, likePending: !!optLike?.pending, repostPending: !!optRepost?.pending, likePendingDirection: optLike?.pending @@ -287,13 +273,7 @@ export function useNostrEngagement( : undefined, }; }, - [ - likesByEventId, - repliedByEventId, - optimisticLikesByEventId, - optimisticRepostsByEventId, - repostsByEventId, - ] + [engagementByEventId, optimisticLikesByEventId, optimisticRepostsByEventId] ); const getDisplayMetrics = useCallback( @@ -327,7 +307,7 @@ export function useNostrEngagement( currentState: state.liked, isPending: state.likePending, previousOptimistic: optimisticLikesByEventId[target.id], - relatedEventIdFromStore: likesByEventId[target.id]?.reactionEventId, + relatedEventIdFromStore: engagementByEventId[target.id]?.liked?.ownEventId, displayedCount: getDisplayMetrics(target.id).likeCount, baseCount: getBaseMetrics(target.id).likeCount, setOptimistic: setLikeOptimistic, @@ -340,7 +320,7 @@ export function useNostrEngagement( getBaseMetrics, getDisplayMetrics, getEngagementState, - likesByEventId, + engagementByEventId, ndk, nostrKeys?.pubkey, optimisticLikesByEventId, @@ -363,7 +343,7 @@ export function useNostrEngagement( currentState: state.reposted, isPending: state.repostPending, previousOptimistic: optimisticRepostsByEventId[target.id], - relatedEventIdFromStore: repostsByEventId[target.id]?.repostEventId, + relatedEventIdFromStore: engagementByEventId[target.id]?.reposted?.ownEventId, displayedCount: getDisplayMetrics(target.id).repostCount, baseCount: getBaseMetrics(target.id).repostCount, setOptimistic: setRepostOptimistic, @@ -378,10 +358,10 @@ export function useNostrEngagement( getBaseMetrics, getDisplayMetrics, getEngagementState, + engagementByEventId, ndk, nostrKeys?.pubkey, optimisticRepostsByEventId, - repostsByEventId, ] ); diff --git a/features/feed/hooks/useThread.ts b/features/feed/hooks/useThread.ts index 185677333..569c1dd33 100644 --- a/features/feed/hooks/useThread.ts +++ b/features/feed/hooks/useThread.ts @@ -12,7 +12,8 @@ import type { ThreadSeedBuckets, } from '@/features/feed/data/feedClient'; import { getFeedClient } from '@/features/feed/data/useFeedClient'; -import { consumeThreadSeed, type ThreadSeed } from '@/features/feed/lib/threadSeedCache'; +import { consumeThreadSeed } from '@/features/feed/lib/threadSeedCache'; +import { buildNostrDataLayer } from '@/shared/lib/nostr/buildNostrDataLayer'; import { bucketsFromThreadResult, buildThreadItemsFromResult, @@ -30,7 +31,10 @@ import { ingestOwnContent, useOwnContentStore } from '@/shared/stores/profile/ow * posted (or other-client) note opens its thread instantly even before it has * round-tripped through relays/nagg. Scoped to the active viewer. */ -function ownContentSeed(eventId: string, viewerPubkey: string | undefined): ThreadSeed | undefined { +function ownContentSeed( + eventId: string, + viewerPubkey: string | undefined +): ThreadSeedBuckets | undefined { const entry = useOwnContentStore.getState().getOwn(eventId, viewerPubkey); if (!entry) return undefined; return { @@ -41,6 +45,43 @@ function ownContentSeed(eventId: string, viewerPubkey: string | undefined): Thre }; } +/** + * First-frame seed projected from the shared nagg-ts entity cache (populated by + * every feed/notifications read). When the tapped note was already seen, this + * paints the post + its cached ancestor chain + author profiles/metrics with NO + * network. It carries the post + its cached ancestor chain + cached direct reply + * previews + author profiles/metrics, fully replacing the old transient nav + * snapshot. Returns undefined when the tapped note isn't cached (e.g. a cold deep + * link), so the network fetch drives the first frame instead. + */ +function cachedThreadSeed(eventId: string): ThreadSeedBuckets | undefined { + const layer = buildNostrDataLayer(); + if (!layer) return undefined; + const view = layer.readThread(eventId); + if (!view.root) return undefined; // not cached — no instant frame available + + const allEvents = new Map(); + allEvents.set(view.root.id, view.root); + for (const note of view.relatedNotes) allEvents.set(note.id, note); + + const metrics = new Map(); + for (const [id, stats] of Object.entries(view.stats)) { + metrics.set(id, { + likeCount: stats.likes, + repostCount: stats.reposts, + replyCount: stats.replies, + satsZapped: stats.satsZapped, + }); + } + + return { + allEvents, + profiles: new Map(Object.entries(view.profiles)), + metrics, + quotedEvents: new Map(Object.entries(view.quoted)), + }; +} + export type { ThreadItem } from '@/features/feed/lib/threadItems'; type UseThreadResult = { @@ -87,7 +128,6 @@ export function useThread(eventId: string): UseThreadResult { const hasMoreRepliesRef = useRef(false); const isInitialFetchingRef = useRef(false); const isLoadingMoreRepliesRef = useRef(false); - const currentEventIdRef = useRef(null); const requestGenerationRef = useRef(0); const loadMoreAbortControllerRef = useRef(null); @@ -207,9 +247,6 @@ export function useThread(eventId: string): UseThreadResult { if (!eventId) return; let cancelled = false; - const preservedSeed = threadSeedRef.current; - const eventChanged = currentEventIdRef.current !== eventId; - currentEventIdRef.current = eventId; const generation = requestGenerationRef.current + 1; requestGenerationRef.current = generation; const controller = new AbortController(); @@ -226,8 +263,12 @@ export function useThread(eventId: string): UseThreadResult { setIsLoadingMoreReplies(false); setHasMoreReplies(false); + // Cache first (authoritative, complete via readThread's ancestor walk + reply + // scan); the transient nav snapshot is a safety net for anything not yet + // ingested; own-content covers a just-posted note not yet round-tripped. const seed = - (eventChanged ? consumeThreadSeed(eventId) : (preservedSeed ?? consumeThreadSeed(eventId))) ?? + cachedThreadSeed(eventId) ?? + consumeThreadSeed(eventId) ?? ownContentSeed(eventId, viewerPubkey); if (seed) { threadSeedRef.current = seed; diff --git a/features/feed/lib/notificationGroups.ts b/features/feed/lib/notificationGroups.ts index 7403e1e3d..3060fd925 100644 --- a/features/feed/lib/notificationGroups.ts +++ b/features/feed/lib/notificationGroups.ts @@ -65,11 +65,13 @@ export function buildNotificationListItems( notifications: readonly FeedNotification[] ): NotificationListItem[] { const out: NotificationListItem[] = []; - let index = 0; + // Open client-side groups keyed by reason+target, so members that aren't + // adjacent in the (chronological) stream still merge into one row instead of + // each landing on its own. A group is anchored at its first member's position. + const clientGroups = new Map>(); - while (index < notifications.length) { - const notification = notifications[index]; - if (!notification) break; + for (const notification of notifications) { + if (!notification) continue; // Server-grouped node: one item already represents the whole group, with the // representative event plus sampled actors and an exact/capped total. @@ -90,52 +92,48 @@ export function buildNotificationListItems( total, ...(notification.totalCapped ? { totalCapped: true } : {}), }); - index += 1; continue; } } const reason = batchableReason(notification.reason); - // Single (server-marked or non-batchable reason) renders on its own. + // Single (server-marked or non-batchable reason, e.g. reply/mention) renders + // on its own. if (!reason || notification.type === 'single') { out.push({ type: 'single', id: notification.event.id, notification }); - index += 1; continue; } - // Fallback: client-side consecutive-run grouping for the ungrouped (GraphQL) - // transport, which doesn't carry server group metadata. - const group = [notification]; - const batchKey = batchKeyFor(notification, reason); - index += 1; - while (index < notifications.length) { - const next = notifications[index]; - if ( - !next || - next.type === 'group' || - next.reason !== reason || - batchKeyFor(next, reason) !== batchKey - ) - break; - group.push(next); - index += 1; - } - - if (group.length === 1) { - out.push({ type: 'single', id: notification.event.id, notification }); + // Fallback: client-side grouping for ungrouped transports (raw relays / + // GraphQL) that don't carry server group metadata. Group across the WHOLE + // page by reason+target — not just consecutive runs — then demote any + // single-member group back to a row below. + const key = `${reason}:${batchKeyFor(notification, reason)}`; + const open = clientGroups.get(key); + if (open) { + open.notifications.push(notification); + open.total = open.notifications.length; continue; } - - const first = group[0]!; - const last = group[group.length - 1]!; - out.push({ + const group: Extract = { type: 'group', - id: `${reason}:${first.event.id}:${last.event.id}:${group.length}`, + id: `client:${key}`, reason, - notifications: group, - total: group.length, - }); + notifications: [notification], + total: 1, + }; + clientGroups.set(key, group); + out.push(group); } - return out; + // A client group that attracted no other members is really a single. + return out.map((item) => + item.type === 'group' && item.id.startsWith('client:') && item.notifications.length === 1 + ? { + type: 'single', + id: item.notifications[0]!.event.id, + notification: item.notifications[0]!, + } + : item + ); } diff --git a/features/feed/lib/threadItems.ts b/features/feed/lib/threadItems.ts index 043820b73..c847e212b 100644 --- a/features/feed/lib/threadItems.ts +++ b/features/feed/lib/threadItems.ts @@ -79,7 +79,12 @@ export function orderedReplyIdsForThreadResult( return [...existingOrder, ...nextPageReplyIds.filter((id) => !existingOrderSet.has(id))]; } - return pageReplyIds.length > 0 ? pageReplyIds : fallbackReplyIds; + // Initial fetch: keep whatever was already on screen (the cache-seeded previews) + // as a stable prefix and APPEND the ranked delta, instead of replacing the order + // outright — so the replies don't visibly reshuffle when the network result lands. + // With no seed, existingOrder is empty and this is just the server's order. + const initialReplyIds = pageReplyIds.length > 0 ? pageReplyIds : fallbackReplyIds; + return [...existingOrder, ...initialReplyIds.filter((id) => !existingOrderSet.has(id))]; } export function buildThreadItemsFromSeed( @@ -106,7 +111,13 @@ export function buildThreadItemsFromSeed( profiles: seed.profiles, metrics: seed.metrics, quotedEvents: seed.quotedEvents, - thread, + // Seed the focused note at index 0 — drop any ancestors the originating + // context happened to include. The note then mounts at the TOP and stays + // focused as the real parent chain loads in above it (held by + // maintainVisibleContentPosition), instead of landing mid-list via a fragile + // estimated `initialScrollIndex`. This matches the notifications entry (which + // seeds no ancestors) so every entry point focuses the note the same way. + thread: { ...thread, parents: [] }, replyPageEventIds, replyPageSize: replyPageEventIds.length, loadedReplyCount: 0, diff --git a/features/feed/lib/threadListLayout.ts b/features/feed/lib/threadListLayout.ts index 9ac476087..63fb4be8e 100644 --- a/features/feed/lib/threadListLayout.ts +++ b/features/feed/lib/threadListLayout.ts @@ -1,48 +1,5 @@ -import { REPLY_SKELETON_VARIANTS } from './threadReplySkeletons'; - // Height (px) of one rendered content line in a note/reply body. Single source // of truth: `NoteContent` re-exports this for its own text rendering, and the -// skeleton/fixed-size math below uses it, so the rendered note and its reserved -// skeleton height can never drift. +// reply skeleton sizes its content bars to the same value, so a rendered note +// and the skeleton it replaces never drift in height. export const NOTE_CONTENT_LINE_HEIGHT = 24; - -// Height hints (px) for the content-free skeleton / sort-tabs rows, fed to -// LegendList's `getFixedItemSize` so it doesn't lay them out at the generic -// `estimatedItemSize` and snap them on first paint. The skeletons size -// themselves naturally — these are deterministic now that each skeleton bar is -// pinned to a single line (`numberOfLines={1}` in `PostCardSkeleton`), so a -// content line is exactly `NOTE_CONTENT_LINE_HEIGHT`. The values are biased a -// hair high so the list never under-reserves (a few px of gap is invisible; an -// under-reservation would overlap rows). Keep in sync with `PostCardSkeleton` / -// `ReplySortPicker`. -// -// reply skeleton = chrome (gutter padding + author row + spacer + metrics -// footer) + one NOTE_CONTENT_LINE_HEIGHT per content line of its variant. -export const REPLY_SKELETON_CHROME_HEIGHT = 80; -export const TARGET_SKELETON_FIXED_HEIGHT = 176; -export const REPLY_SORT_TABS_FIXED_HEIGHT = 52; - -// Minimal structural shape of a thread list row needed to reserve its size. -// Kept local so this pure module doesn't depend on the ThreadView component -// (which pulls native deps and can't load in a node test). -type FixedSizeItem = { type: string; skeletonIndex?: number }; - -export function replySkeletonHeight(skeletonIndex: number): number { - const variant = REPLY_SKELETON_VARIANTS[skeletonIndex % REPLY_SKELETON_VARIANTS.length]; - return REPLY_SKELETON_CHROME_HEIGHT + variant.content.length * NOTE_CONTENT_LINE_HEIGHT; -} - -// Deterministic heights for the content-free skeleton / sort-tabs rows. Dynamic -// rows (target, replies) return undefined so LegendList measures them. -export function threadFixedItemSize(item: FixedSizeItem): number | undefined { - switch (item.type) { - case 'target-skeleton': - return TARGET_SKELETON_FIXED_HEIGHT; - case 'reply-skeleton': - return replySkeletonHeight(item.skeletonIndex ?? 0); - case 'reply-sort-tabs': - return REPLY_SORT_TABS_FIXED_HEIGHT; - default: - return undefined; - } -} diff --git a/features/feed/screens/NotificationsScreen.tsx b/features/feed/screens/NotificationsScreen.tsx index 97203d090..53c17feff 100644 --- a/features/feed/screens/NotificationsScreen.tsx +++ b/features/feed/screens/NotificationsScreen.tsx @@ -53,6 +53,8 @@ import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { feedLog, Log, useLifecycleLogger } from '@/shared/lib/logger'; import { useNostrKeysContext } from '@/shared/providers/NostrKeysProvider'; import { useTabBarBottomPadding } from '@/shared/hooks/useTabBarBottomPadding'; +import { useNostrProfileMetadataMany } from '@/shared/hooks/useNostrProfileMetadata'; +import { useOwnContentStore } from '@/shared/stores/profile/ownContentStore'; import { actionMenuPopup } from '@/shared/lib/popup'; import { Screen } from '@/shared/ui/composed/Screen'; import { Avatar } from '@/shared/ui/primitives/Avatar'; @@ -391,11 +393,11 @@ export function NotificationsScreen() { }); return; } - const openEvent = notificationOpenEvent(notification); + const openEventId = notificationOpenEventId(notification); const allEvents = new Map([[notification.event.id, notification.event]]); if (notification.targetEvent) allEvents.set(notification.targetEvent.id, notification.targetEvent); - seedThread(openEvent.id, { + seedThread(openEventId, { allEvents, profiles: threadContext.profiles, metrics: threadContext.metrics, @@ -403,7 +405,7 @@ export function NotificationsScreen() { }); router.push({ pathname: '/(user-flow)/thread', - params: { eventId: openEvent.id }, + params: { eventId: openEventId }, }); }, [threadContext] @@ -423,7 +425,63 @@ export function NotificationsScreen() { const seedCreatedAt = useWalletLifecycleStore((s) => s.seedCreatedAt); const termsDate = useSettingsStore((s) => s.termsAccepted?.date ?? null); - const notifications = result?.notifications ?? EMPTY_NOTIFICATIONS; + const rawNotifications = result?.notifications ?? EMPTY_NOTIFICATIONS; + + // A like/repost/zap is always engagement on one of OUR posts, so its target's + // content is in the own-content cache (notes we authored, keyed by id). nagg + // bundles the full `targetEvent`; the relay/cache tiers only carry + // `targetEventId`, leaving no preview. Resolve the missing target from local + // own-content so the post preview renders regardless of serving tier — its + // author is us, so no profile refetch is needed (see viewerPubkey below). + const ownContentById = useOwnContentStore((s) => s.byId); + const notifications = useMemo(() => { + let changed = false; + const hydrated = rawNotifications.map((n) => { + if (n.targetEvent) return n; + if (n.reason !== 'reaction' && n.reason !== 'repost' && n.reason !== 'zap') return n; + const targetId = n.targetEventId; + if (!targetId) return n; + const entry = ownContentById[targetId]; + if (!entry) return n; + changed = true; + return { ...n, targetEvent: entry.event }; + }); + return changed ? hydrated : rawNotifications; + }, [rawNotifications, ownContentById]); + + // The serving tier's `profilesMap` is empty on the relay/cache path (only nagg + // bundles notification author profiles), leaving rows with a truncated-pubkey + // name + fallback avatar. Warm + read the shared metadata cache (filled by the + // facade getProfiles: Primal user_infos → relay kind-0) for every actor and + // merge it in as a fallback, so names/avatars resolve regardless of tier. + const actorPubkeys = useMemo(() => { + const set = new Set(); + // Our own profile authors every like/repost/zap target preview — warm it too + // so the contained post shows our name + avatar, not a truncated pubkey. + if (viewerPubkey) set.add(viewerPubkey); + for (const n of notifications) { + if (n.event?.pubkey) set.add(n.event.pubkey); + for (const actor of n.sampleActors ?? []) if (actor.pubkey) set.add(actor.pubkey); + } + return [...set]; + }, [notifications, viewerPubkey]); + const { metadata: cachedProfiles } = useNostrProfileMetadataMany(actorPubkeys); + + const resultForRows = useMemo(() => { + if (!result || cachedProfiles.size === 0) return result; + const profilesMap = new Map(result.profilesMap); + for (const [pk, meta] of cachedProfiles) { + const existing = profilesMap.get(pk); + // Fill a missing actor, or upgrade a name-only tier entry that lacks a picture. + if (existing && existing.picture) continue; + const name = meta.displayName || meta.name || existing?.name; + const picture = meta.picture ?? existing?.picture; + if (!name && !picture) continue; + profilesMap.set(pk, { name: name ?? '', ...(picture ? { picture } : {}) }); + } + return { ...result, profilesMap }; + }, [result, cachedProfiles]); + const notificationItems = useMemo(() => { // The App tab is purely app announcements — the welcome card lives here, not // mixed into the real notifications on All. @@ -642,7 +700,7 @@ export function NotificationsScreen() { extra={{ tab: activeTab, phase: visualPhase }}> 0 ? formatRelative(createdAt * 1000, 'compact') : ''; } -function notificationOpenEvent(notification: FeedNotification): FeedEvent { +// For a like/repost/zap, tapping the row should open the POST that was engaged +// with — not the reaction/repost/zap event. nagg bundles the full `targetEvent`, +// but the relay/cache tiers only carry `targetEventId` (the post isn't fetched), +// so resolve by id and let the thread screen load it. Falls back to the +// notification's own event for replies/mentions (where the event IS the post). +function notificationOpenEventId(notification: FeedNotification): string { if ( - notification.targetEvent && - (notification.reason === 'reaction' || - notification.reason === 'repost' || - notification.reason === 'zap') + notification.reason === 'reaction' || + notification.reason === 'repost' || + notification.reason === 'zap' ) { - return notification.targetEvent; + return notification.targetEventId ?? notification.targetEvent?.id ?? notification.event.id; } - return notification.event; + return notification.event.id; } function notificationPreviewEvent(notification: FeedNotification): FeedEvent | undefined { diff --git a/features/mint/screens/MintAddScreen.tsx b/features/mint/screens/MintAddScreen.tsx index b17cdc782..bdbfb18a6 100644 --- a/features/mint/screens/MintAddScreen.tsx +++ b/features/mint/screens/MintAddScreen.tsx @@ -1,9 +1,14 @@ -import React, { useState, useMemo, useCallback, useEffect, memo, useRef } from 'react'; -import { Platform, TextInput, useWindowDimensions } from 'react-native'; +import React, { useState, useMemo, useCallback, useEffect, memo } from 'react'; +import { + Platform, + TextInput, + useWindowDimensions, + type NativeScrollEvent, + type NativeSyntheticEvent, +} from 'react-native'; import { useSharedValue } from 'react-native-reanimated'; import { Stack } from 'expo-router'; import { guardedRouter as router } from '@/shared/hooks/useGuardedRouter'; -import { VStack } from '@/shared/ui/primitives/View/VStack'; import { View } from '@/shared/ui/primitives/View/View'; import { Spacer } from '@/shared/ui/primitives/View/Spacer'; import { Text } from '@/shared/ui/primitives/Text'; @@ -26,25 +31,14 @@ import { staticPopup, paramPopup } from '@/shared/lib/popup'; import { BottomButtons } from '@/shared/ui/composed/BottomButtons'; import { ButtonHandler } from '@/shared/ui/composed/ButtonHandler'; import { ContactRow, mintIdentity } from '@/shared/ui/composed/ContactRow'; -import { Skeleton } from '@/shared/ui/primitives/Skeleton'; -import { VisualLayoutProbe } from '@/shared/ui/composed/VisualLayoutProbe'; -import { - LegendList, - type LegendListRef, - type NativeScrollEvent, - type NativeSyntheticEvent, -} from '@legendapp/list/react-native'; +import { List } from '@/shared/ui/composed/List'; +import { SkeletonContentCrossfade } from '@/shared/ui/composed/SkeletonContentCrossfade'; import { Screen } from '@/shared/ui/composed/Screen'; import { LoadingIndicator } from '@/shared/blocks/status'; import { MintCurrencyTabs } from '@/features/mint/components/MintCurrencyTabs'; import { GlassSearchBar } from '@/shared/ui/composed/GlassSearchBar'; import { useMintManagement } from '@/features/mint/hooks/useMintManagement'; import opacity from 'hex-color-opacity'; -import { - VISUAL_LIST_VIEWABILITY_CONFIG, - useVisualListLogger, - visualLayoutScopePart, -} from '@/shared/lib/contentShiftLog'; import { log, cashuLog, useLifecycleLogger } from '@/shared/lib/logger'; import { getHeaderTitleWidthFromWidth } from '@/features/wallet/lib/walletHeader'; import type { GetInfoResponse } from '@cashu/cashu-ts'; @@ -68,6 +62,26 @@ interface PseudoMint { name?: string; } +/** + * Placeholder fed to the result `List` while the first search is in flight. It + * flows through the same `MintItem` → `ContactRow` path as real rows (in loading + * mode), so the skeleton can't drift from real-row height. The `url` is a stable + * synthetic key that also seeds the deterministic placeholder widths. + */ +interface SkeletonMint { + url: string; + isSkeleton: true; +} + +const noop = () => {}; + +/** Stable placeholder rows for the initial-search loading state. Fixed identity + * so FlashList keys are stable and each row's seeded placeholder width holds. */ +const SKELETON_MINTS: SkeletonMint[] = Array.from({ length: 5 }, (_, i) => ({ + url: `skeleton-${i}`, + isSkeleton: true, +})); + interface DisplayMint { url: string; name: string; @@ -90,7 +104,7 @@ interface DisplayMint { reviewCount?: number; } -type SearchableMint = DisplayMint | PseudoMint; +type SearchableMint = DisplayMint | PseudoMint | SkeletonMint; function adaptSearchResult(result: MintSearchResult): DisplayMint { const profile = useMintProfileStore.getState().getCached(result.url); @@ -207,88 +221,29 @@ const FallbackSearchHeader = memo(function FallbackSearchHeader({ ); }); -// Loading skeleton -const LoadingMintsList = memo(function LoadingMintsList({ count = 5 }: { count?: number }) { - return ( - - {Array.from({ length: count }).map((_, index) => ( - - - - - - - - - - - - - - - - ))} - - ); -}); - // Pre-baked mint row — deferred to the shared `ContactRow` so search results // here visually match the mint list, contacts tab, and split-bill picker. +// +// `loading` renders the row in skeleton mode through the SAME `ContactRow`, so +// the loading placeholder and the real row share every layout box (avatar, +// title, subtitle, stats accent, checkbox) and can't drift in height. The mint +// fields are read defensively because skeleton items carry only a synthetic +// `url`; their values are never shown (loading swaps in placeholder bars). const MintItem = memo(function MintItem({ mint, selected, onToggle, globalLoading, + loading, }: { mint: SearchableMint; selected: boolean; onToggle: (url: string) => void; globalLoading: boolean; + loading?: boolean; }) { - const displayName = useMemo( - () => getMintDisplayName(mint.url, mint.mintInfo), - [mint.url, mint.mintInfo] - ); + const mintInfo = 'mintInfo' in mint ? mint.mintInfo : undefined; + const displayName = useMemo(() => getMintDisplayName(mint.url, mintInfo), [mint.url, mintInfo]); // Search-result preview only: the search endpoint returns `serverStats` // (`n_mints`/`n_melts`/`n_errors`) without the per-swap array, so we can't @@ -312,10 +267,11 @@ const MintItem = memo(function MintItem({ return ( onToggle(mint.url)} selectionVariant="checkbox" disabled={globalLoading} - testID={`contact-row:mint:${mint.url}`} + testID={loading ? `contact-row:mint-skeleton:${mint.url}` : `contact-row:mint:${mint.url}`} /> ); }); @@ -474,7 +430,8 @@ export function MintAddScreen() { // pubkey in NUT-06 contact info. Results land in `useMintProfileStore` // and the memo above re-runs once they arrive. const profileFetchInputs = useMemo( - () => displayMints.map((m) => ({ url: m.url, mintInfo: m.mintInfo })), + () => + displayMints.map((m) => ({ url: m.url, mintInfo: 'mintInfo' in m ? m.mintInfo : undefined })), [displayMints] ); useMintProfiles(profileFetchInputs); @@ -618,67 +575,29 @@ export function MintAddScreen() { const keyExtractor = useCallback((item: SearchableMint) => item.url, []); const showContent = !searchLoading || displayMints.length > 0; - const visualPhase = !showContent ? 'loading' : isSearching ? 'searching' : 'catalog'; - const listRef = useRef(null); - const visualList = useVisualListLogger({ - scope: 'mint.add.results', - surface: 'mint', - component: 'MintAddLegendList', - phase: visualPhase, - extra: () => ({ - itemCount: displayMints.length, - selectedCurrency, - isSearching, - queryLength: searchQuery.trim().length, - selectedCount: selectedMints.size, - totalHeaderHeight, - }), - getItemKey: (item) => visualLayoutScopePart(item.url), - getItemContext: (item, index) => ({ - itemType: 'mint-add-row', - index, - selected: selectedMints.has(item.url), - pseudo: 'isPseudoMint' in item, - }), - getListState: () => listRef.current?.getState() ?? null, - }); + + // Feed the result List skeleton placeholders during the first search so the + // loading rows render through the SAME List + ContactRow path as real rows — + // identical container chrome, no content shift on the data swap. + const isInitialLoading = searchLoading && displayMints.length === 0; + const getItemType = useCallback( + (item: SearchableMint) => ('isSkeleton' in item ? 'skeleton' : 'mint'), + [] + ); const renderItem = useCallback( - ({ item, index }: { item: SearchableMint; index: number }) => ( - + ({ item }: { item: SearchableMint }) => + 'isSkeleton' in item ? ( + + ) : ( - - ), - [ - displayMints.length, - handleToggleMint, - isAdding, - isSearching, - searchQuery, - selectedCurrency, - selectedMints, - visualPhase, - ] + ), + [handleToggleMint, isAdding, selectedMints] ); // Log list render state for performance analysis @@ -826,6 +745,39 @@ export function MintAddScreen() { [searchQuery, selectedCurrency] ); + // One List renderer for both crossfade branches: the skeleton branch and the + // real branch render the SAME List + ContactRow path, so the swap shifts + // nothing. Two List instances coexist only for the ~220ms fade. + const renderResultList = useCallback( + (data: SearchableMint[]) => ( + + ), + [ + renderItem, + keyExtractor, + getItemType, + selectedMints, + listHeader, + isInitialLoading, + emptyComponent, + handleScroll, + ] + ); + return ( - {!showContent ? ( - - - - ) : ( - - )} + renderResultList(SKELETON_MINTS)} + renderContent={() => renderResultList(displayMints)} + /> ); } diff --git a/features/mint/screens/MintInfoScreen.tsx b/features/mint/screens/MintInfoScreen.tsx index d3ada281d..df03560e7 100644 --- a/features/mint/screens/MintInfoScreen.tsx +++ b/features/mint/screens/MintInfoScreen.tsx @@ -24,6 +24,7 @@ import { ScreenHeaderAction } from '@/shared/ui/composed/ScreenHeaderAction'; import Icon from 'assets/icons'; import { Badge } from '@/shared/ui/primitives/Badge'; import { MintIcon } from '@/shared/ui/composed/MintIcon'; +import { SkeletonContentCrossfade } from '@/shared/ui/composed/SkeletonContentCrossfade'; import { Avatar } from '@/shared/ui/primitives/Avatar'; import * as Clipboard from 'expo-clipboard'; import { Skeleton } from '@/shared/ui/primitives/Skeleton'; @@ -273,7 +274,9 @@ function StatsGridComponent({ const showSkeleton = !hasValidData; - return ( + // Same grid chrome for both branches (only the `loading` bars differ), so the + // crossfade swaps content under a fading skeleton with zero shift. + const renderGrid = (loading: boolean) => ( {[0, 2].map((rowStart) => ( @@ -290,7 +293,7 @@ function StatsGridComponent({ }, ]}> ); + + return ( + renderGrid(true)} + renderContent={() => renderGrid(false)} + /> + ); } const StatsGrid = React.memo(StatsGridComponent); @@ -370,33 +384,31 @@ function RatingBarChartComponent({ score }: { score: number }) { } }, [isValidScore, goldPercentage, fadeAnim, barScaleAnim]); - if (showSkeleton) { - return ( - - - - - - - {[5, 4, 3, 2, 1].map((stars) => ( - - - {Array.from({ length: stars }).map((_, i) => ( - - ))} - - + const renderSkeleton = () => ( + + + + + + + {[5, 4, 3, 2, 1].map((stars) => ( + + + {Array.from({ length: stars }).map((_, i) => ( + + ))} - ))} - - - ); - } + + + ))} + + + ); - return ( + const renderContent = () => ( @@ -445,6 +457,16 @@ function RatingBarChartComponent({ score }: { score: number }) { ); + + return ( + + ); } const RatingBarChart = React.memo(RatingBarChartComponent); diff --git a/features/mint/screens/MintListScreen.tsx b/features/mint/screens/MintListScreen.tsx index ec6a383cd..90ecf1205 100644 --- a/features/mint/screens/MintListScreen.tsx +++ b/features/mint/screens/MintListScreen.tsx @@ -10,12 +10,9 @@ import React, { memo, useEffect, useRef, useState, useMemo, useCallback } from 'react'; import { useSharedValue } from 'react-native-reanimated'; -import { - LegendList, - type LegendListRef, - type NativeScrollEvent, - type NativeSyntheticEvent, -} from '@legendapp/list/react-native'; +import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native'; + +import { List } from '@/shared/ui/composed/List'; import type { MintListItem } from '@sovranbitcoin/colada'; @@ -32,14 +29,8 @@ import { Screen } from '@/shared/ui/composed/Screen'; import { BottomButtons } from '@/shared/ui/composed/BottomButtons'; import { ButtonHandler } from '@/shared/ui/composed/ButtonHandler'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; -import { - VISUAL_LIST_VIEWABILITY_CONFIG, - useVisualListLogger, - visualLayoutScopePart, -} from '@/shared/lib/contentShiftLog'; import { cashuLog, useLifecycleLogger } from '@/shared/lib/logger'; import { zIndex } from '@/shared/styles/tokens'; -import { VisualLayoutProbe } from '@/shared/ui/composed/VisualLayoutProbe'; // Inspect-button shape mirrors QRButton (rounded-square with continuous border // curve, borderRadius ≈ size × 0.18), but in a neutral surface color so it @@ -217,28 +208,6 @@ export const MintListScreen = memo(function MintListScreen({ [selectedCurrency, foreground] ); const keyExtractor = useCallback((item: MintListItem) => item.mintUrl, []); - const listRef = useRef(null); - const visualList = useVisualListLogger({ - scope: 'mint.list.mints', - surface: 'mint', - component: 'MintListLegendList', - phase: isExecuting ? 'executing' : 'idle', - extra: () => ({ - itemCount: filteredItems.length, - selectedCurrency, - isExecuting, - totalHeaderHeight, - }), - getItemKey: (item) => visualLayoutScopePart(item.mintUrl), - getItemContext: (item, index) => ({ - itemType: 'mint-row', - index, - unit: item.unit, - status: item.status, - reason: item.reason?.message ?? null, - }), - getListState: () => listRef.current?.getState() ?? null, - }); const bottomButtons = useMemo( () => ( @@ -258,48 +227,25 @@ export const MintListScreen = memo(function MintListScreen({ ); const renderItem = useCallback( - ({ item, index }: { item: MintListItem; index: number }) => { + ({ item }: { item: MintListItem }) => { const inspectable = showDetailsButton && !!onInspectMint; const trailing = inspectable ? ( onInspectMint!(item.mintUrl)} /> ) : null; return ( - - handleMintPress(item)} - testID={`contact-row:mint:${item.mintUrl}`} - /> - + handleMintPress(item)} + testID={`contact-row:mint:${item.mintUrl}`} + /> ); }, - [ - filteredItems.length, - isExecuting, - selectedCurrency, - showDetailsButton, - handleMintPress, - onInspectMint, - ] + [isExecuting, showDetailsButton, handleMintPress, onInspectMint] ); return ( @@ -312,20 +258,12 @@ export const MintListScreen = memo(function MintListScreen({ onHeaderHeightChange={setTotalHeaderHeight} footer={bottomButtons} bgColor={surface}> - { if (!isLoading) return null; const skeletonCount = reviews.length > 0 ? 2 : 3; + // Footer-only skeletons: the real reviews populate the list body, so there's + // no in-place content to fade into. Route through the canonical helper for + // the region wave; `exit="none"` lets them unmount as the list fills. return ( - - {Array.from({ length: skeletonCount }).map((_, i) => ( - - ))} - + ( + + {Array.from({ length: skeletonCount }).map((_, i) => ( + + ))} + + )} + renderContent={() => null} + /> ); }, [isLoading, reviews.length]); diff --git a/features/nearPay/screens/NearPayPeerListScreen.tsx b/features/nearPay/screens/NearPayPeerListScreen.tsx index ae77560db..1240519d0 100644 --- a/features/nearPay/screens/NearPayPeerListScreen.tsx +++ b/features/nearPay/screens/NearPayPeerListScreen.tsx @@ -1,7 +1,6 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { StyleSheet } from 'react-native'; import { useHeaderHeight } from '@react-navigation/elements'; -import { LegendList, type LegendListRef } from '@legendapp/list/react-native'; import { Stack } from 'expo-router'; import { guardedRouter as router } from '@/shared/hooks/useGuardedRouter'; import type { BLEPeer } from 'bitchat-module'; @@ -27,14 +26,9 @@ import { useWalletContext } from '@/shared/providers/WalletContextProvider'; import { BLUETOOTH_ACCENT } from '@/shared/lib/brandColors'; import { paymentLog, useLifecycleLogger } from '@/shared/lib/logger'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; -import { - VISUAL_LIST_VIEWABILITY_CONFIG, - useVisualListLogger, - visualLayoutScopePart, -} from '@/shared/lib/contentShiftLog'; import { useNearPaySessionStore, type NearPayDelivery } from '@/shared/stores/runtime/nearPayStore'; import { ContactRow, bleIdentity } from '@/shared/ui/composed/ContactRow'; -import { VisualLayoutProbe } from '@/shared/ui/composed/VisualLayoutProbe'; +import { List } from '@/shared/ui/composed/List'; import { Text } from '@/shared/ui/primitives/Text'; import { HStack } from '@/shared/ui/primitives/View/HStack'; import { View } from '@/shared/ui/primitives/View/View'; @@ -243,47 +237,9 @@ export function NearPayPeerListScreen() { ); const keyExtractor = useCallback((peer: BLEPeer) => peer.peerID, []); - const listRef = useRef(null); - const visualList = useVisualListLogger({ - scope: 'near-pay.peers.list', - surface: 'near-pay', - component: 'NearPayPeerLegendList', - phase: peers.length === 0 ? 'empty' : 'peers', - extra: () => ({ - peerCount: sortedPeers.length, - stalePeerCount: blePeers.length - peers.length, - offline: isOffline, - }), - getItemKey: (peer) => visualLayoutScopePart(peer.peerID), - getItemContext: (peer, index) => ({ - itemType: 'near-pay-peer', - index, - connected: peer.isConnected, - hasDirectLink: peer.hasDirectLink, - hasCreq: peerHasValidCreq(peer), - }), - getListState: () => listRef.current?.getState() ?? null, - }); const renderItem = useCallback( - ({ item, index }: { item: BLEPeer; index: number }) => ( - - - - ), - [handleSelectPeer, peers.length, sortedPeers.length] + ({ item }: { item: BLEPeer }) => , + [handleSelectPeer] ); const emptyContent = useMemo( () => ( @@ -310,18 +266,10 @@ export function NearPayPeerListScreen() { {subtitleText} - - - - - - - - - ); - } + const isPreviewLoading = fetched.status === 'loading' || fetched.status === 'idle'; - if (fetched.status === 'unavailable' || fetched.event === undefined) { + if (!isPreviewLoading && (fetched.status === 'unavailable' || fetched.event === undefined)) { return ( @@ -163,16 +153,37 @@ export function ReferencedNoteCard({ } return ( - - - - {boundDisplay(fetched.event.content.trim(), CONTENT_PREVIEW_MAX_CHARS)} - - + ( + + + + + + + + + )} + renderContent={() => { + if (fetched.event === undefined) return null; + return ( + + + + {boundDisplay(fetched.event.content.trim(), CONTENT_PREVIEW_MAX_CHARS)} + + + ); + }} + /> ); } diff --git a/features/nostrSigner/screens/SignerActivityScreen.tsx b/features/nostrSigner/screens/SignerActivityScreen.tsx index b61da5bd7..0853b8d23 100644 --- a/features/nostrSigner/screens/SignerActivityScreen.tsx +++ b/features/nostrSigner/screens/SignerActivityScreen.tsx @@ -15,9 +15,8 @@ * the app-detail screen's "View Activity" link. */ -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { ScrollView } from 'react-native'; -import { LegendList, type LegendListRef } from '@legendapp/list/react-native'; import { useHeaderHeight } from '@react-navigation/elements'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { router, useLocalSearchParams } from 'expo-router'; @@ -41,24 +40,15 @@ import { connectionForClient } from '@/features/nostrSigner/lib/connectionMatch' import { ACTIVITY_CAP } from '@/features/nostrSigner/lib/nip46Types'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { formatRelative } from '@/shared/lib/date'; -import { - VISUAL_LIST_VIEWABILITY_CONFIG, - useVisualListLogger, - visualLayoutScopePart, -} from '@/shared/lib/contentShiftLog'; import { isNostrPubkeyHex } from '@/shared/lib/nostr/secureStorage'; import { EmptyState } from '@/shared/ui/composed/EmptyState'; +import { List } from '@/shared/ui/composed/List'; import { ListRow } from '@/shared/ui/composed/ListRow'; import { Screen } from '@/shared/ui/composed/Screen'; -import { VisualLayoutProbe } from '@/shared/ui/composed/VisualLayoutProbe'; import { Avatar } from '@/shared/ui/primitives/Avatar'; import { Text } from '@/shared/ui/primitives/Text'; import { View } from '@/shared/ui/primitives/View/View'; -// ListRow compact: 8px vertical padding ×2 + 40px icon circle. -const ESTIMATED_ROW_HEIGHT = 56; -// Chip strip: 10px vertical padding ×2 + ~36px chip height. -const CHIP_HEADER_HEIGHT = 56; const CHIP_AVATAR_SIZE = 18; const FOOTER_CAPTION = `Activity is stored only on this device. Sovran keeps your last ${ACTIVITY_CAP} requests. Decrypted content is never saved.`; @@ -181,31 +171,9 @@ export function SignerActivityScreen(): React.ReactElement { router.push(`/(signer-flow)/activity-detail?id=${encodeURIComponent(entryId)}` as never); }, []); const keyExtractor = useCallback((item: Nip46ActivityEntry) => item.id, []); - const filterPhase = - filter.kind === 'app' ? 'app-filter' : filter.kind === 'denied' ? 'denied-filter' : 'all'; - const listRef = useRef(null); - const visualList = useVisualListLogger({ - scope: 'signer.activity.list', - surface: 'signer', - component: 'SignerActivityLegendList', - phase: filterPhase, - extra: () => ({ - rowCount: filtered.length, - filterKind: filter.kind, - }), - getItemKey: (item) => visualLayoutScopePart(item.id), - getItemContext: (item, index) => ({ - itemType: 'signer-activity', - index, - verdict: item.verdict, - method: item.method, - kind: item.kind ?? null, - }), - getListState: () => listRef.current?.getState() ?? null, - }); const renderItem = useCallback( - ({ item, index }: { item: Nip46ActivityEntry; index: number }) => { + ({ item }: { item: Nip46ActivityEntry }) => { const display = ACTIVITY_VERDICT_DISPLAY[item.verdict]; const entry = permissionEntryFor({ method: item.method, @@ -213,42 +181,26 @@ export function SignerActivityScreen(): React.ReactElement { }); const appName = appDisplayName(connectionForClient(apps, item.clientPubkey)); return ( - - - {display.accentLine} - - ) : undefined - } - trailing={} - onPress={() => openDetail(item.id)} - accessibilityHint="Opens the request details" - /> - + + {display.accentLine} + + ) : undefined + } + trailing={} + onPress={() => openDetail(item.id)} + accessibilityHint="Opens the request details" + /> ); }, - [apps, filter.kind, filterPhase, filtered.length, muted, openDetail, toneColors] + [apps, muted, openDetail, toneColors] ); const chips = ( @@ -292,24 +244,13 @@ export function SignerActivityScreen(): React.ReactElement { return ( - {/* Legend List: the activity log holds up to ACTIVITY_CAP entries — - recycled fixed-height rows keep scrolling cheap. Rows are stateless + {/* The activity log holds up to ACTIVITY_CAP entries; rows are stateless (ListRow + derived props), so recycling is safe. */} - { - const recipeInput = { - viewer: args.viewer, - kinds: args.kinds, - until: args.until, - limit: args.limit, - }; - const result = await client.query({ - query: DM_ENVELOPES_QUERY, - operationName: 'DmEnvelopes', - variables: { input: dmEnvelopesInput(recipeInput) }, - dataSchema: NaggDmEnvelopesDataSchema, - // Dedicated REST app-view for the contacts/DM list when enabled; otherwise - // the GraphQL resolver. Both transports parse the same `{ dmEnvelopes: { - // nodes, pageInfo } }` connection via `NaggDmEnvelopesDataSchema` — no - // per-transport normalize layer (the REST body is already canonical). - transport: backendConfig.nostrDmAppView ? 'appview' : 'graphql', - appView: dmEnvelopesAppView(recipeInput), - refresh: args.refresh, - signal: args.signal, - }); - if (result.isErr()) { - paymentLog.debug('payment.dm.envelopes.failed', { error: result.error.message }); + const layer = buildNostrDataLayer(); + if (!layer) { + paymentLog.debug('payment.dm.envelopes.no_tiers'); return EMPTY_PAGE; } - return toPage(result.value.dmEnvelopes); + const result = await layer.getDmEnvelopes( + toFacadeDmEnvelopesRequest({ + viewer: args.viewer, + until: args.until, + limit: args.limit, + refresh: args.refresh, + signal: args.signal, + }) + ); + return result.match( + (resolved) => { + paymentLog.debug('payment.dm.envelopes.resolved', { + tier: resolved.tier, + envelopes: resolved.envelopes.length, + }); + return resolvedDmEnvelopesToPage(resolved); + }, + (error) => { + paymentLog.debug('payment.dm.envelopes.exhausted', { + attempts: error.attempts.map((a) => `${a.tier}=${a.outcome}`), + }); + return EMPTY_PAGE; + } + ); } /** DM envelopes for one conversation. For gift wraps the counterparty is opaque @@ -117,19 +124,19 @@ export async function fetchDmConversation(args: { refresh?: boolean; signal?: AbortSignal; }): Promise { - const result = await client.query({ - query: DM_CONVERSATION_QUERY, - operationName: 'DmConversation', - variables: { - input: dmConversationInput({ - viewer: args.viewer, - counterparty: args.counterparty, - kinds: args.kinds, - until: args.until, - limit: args.limit, - }), - }, - dataSchema: NaggDmConversationDataSchema, + const binding = dmConversationAppView({ + viewer: args.viewer, + counterparty: args.counterparty, + kinds: args.kinds, + until: args.until, + limit: args.limit, + }); + const result = await client.rest({ + path: binding.path, + method: binding.method, + searchParams: binding.searchParams, + responseSchema: NaggDmConversationDataSchema, + operationName: binding.operationName, refresh: args.refresh, signal: args.signal, }); diff --git a/features/payments/data/facadeDmAdapter.ts b/features/payments/data/facadeDmAdapter.ts new file mode 100644 index 000000000..225ae76cc --- /dev/null +++ b/features/payments/data/facadeDmAdapter.ts @@ -0,0 +1,48 @@ +/** + * Pure adapters between the app's DM envelope shape and the nagg-ts facade's + * `getDmEnvelopes` index surface. No I/O, no app singletons — just mapping, so + * the conversation-list wiring stays testable and the facade caveat (it only + * resolves at runtime in local/symlinked nagg-ts) lives in one transport file. + * + * The facade is an INDEX only: it returns the opaque encrypted envelopes (kind + * 1059 gift wraps + kind 4 legacy) that reference the viewer. Decryption, + * sender identification, and bucketing stay client-side in `dmDecryptPipeline`. + */ +import type { facade } from '@sovranbitcoin/nagg-ts'; + +import type { DmEnvelope, DmEnvelopePage } from './dmEnvelopeClient'; + +/** Translate the app's `until` (wrap arrival seconds) into the facade's cursor. + * Only the nagg tier honours it; the relay floor fetches the whole inbox. */ +export function toFacadeDmEnvelopesRequest(args: { + viewer: string; + until?: number; + limit?: number; + refresh?: boolean; + signal?: AbortSignal; +}): facade.DmEnvelopesRequest { + return { + viewerPubkey: args.viewer, + ...(typeof args.until === 'number' ? { cursor: { createdAt: args.until, id: '' } } : {}), + ...(typeof args.limit === 'number' ? { limit: args.limit } : {}), + ...(args.refresh ? { refresh: true } : {}), + ...(args.signal ? { signal: args.signal } : {}), + }; +} + +/** Map a resolved tier answer back to the app's `DmEnvelopePage`. The app + * re-derives its `until` cursor from envelope `createdAt` (see `dmPagination`), + * so `hasNextPage` is simply "this page had envelopes" — an empty answer (every + * tier drained/disabled) stops `loadMore` rather than looping. */ +export function resolvedDmEnvelopesToPage(resolved: facade.ResolvedDmEnvelopes): DmEnvelopePage { + const envelopes: DmEnvelope[] = resolved.envelopes.map((e) => ({ + id: e.id, + pubkey: e.pubkey, + kind: e.kind, + createdAt: e.createdAt, + content: e.content, + tags: e.tags, + ...(e.sig ? { sig: e.sig } : {}), + })); + return { envelopes, hasNextPage: envelopes.length > 0 }; +} diff --git a/features/payments/hooks/useContactSearch.ts b/features/payments/hooks/useContactSearch.ts index 6fa32b50f..eb444a1ee 100644 --- a/features/payments/hooks/useContactSearch.ts +++ b/features/payments/hooks/useContactSearch.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useMemo } from 'react'; -import { searchUsers as apiSearchUsers, type NostrSearchResult } from '@/shared/lib/apiClient'; +import { type NostrSearchResult } from '@/shared/lib/apiClient'; +import { searchProfilesViaFacade } from '@/shared/lib/nostr/searchProfiles'; import { paymentLog, redactError } from '@/shared/lib/logger'; import { useNostrMetadataCache } from '@/shared/stores/global/nostrMetadataCache'; @@ -67,7 +68,7 @@ export function useContactSearch(searchQuery: string) { const search = async () => { try { paymentLog.debug('payment.contacts.search', { query: debouncedQuery, limit: 10 }); - const result = await apiSearchUsers({ + const result = await searchProfilesViaFacade({ query: debouncedQuery, limit: 10, signal: controller.signal, diff --git a/features/receive/screens/ReceiveScreen.tsx b/features/receive/screens/ReceiveScreen.tsx index 53e5f922e..f0826c6ad 100644 --- a/features/receive/screens/ReceiveScreen.tsx +++ b/features/receive/screens/ReceiveScreen.tsx @@ -30,6 +30,7 @@ import { Screen as ScreenWrapper } from '@/shared/ui/composed/Screen'; import { ScreenErrorState } from '@/shared/ui/composed/ScreenStates'; import { UnderlineTabs } from '@/shared/ui/composed/UnderlineTabs'; import { Skeleton } from '@/shared/ui/primitives/Skeleton'; +import { SkeletonContentCrossfade } from '@/shared/ui/composed/SkeletonContentCrossfade'; import { EnhancedHaptics } from '@/shared/ui/primitives/Haptics'; import { Text } from '@/shared/ui/primitives/Text'; import { View } from '@/shared/ui/primitives/View/View'; @@ -337,21 +338,28 @@ export function ReceiveScreen({ receiveEntry, unit }: ReceiveScreenProps) { )} - {!receiveEntryData ? ( - - ) : quickAccessP2PK && selectedTab === 'P2PK' ? ( - - ) : ( - - )} + } + renderContent={() => { + if (!receiveEntryData) return null; + return quickAccessP2PK && selectedTab === 'P2PK' ? ( + + ) : ( + + ); + }} + /> ); } diff --git a/features/settings/index.ts b/features/settings/index.ts index 846f19e59..0c2bc15d6 100644 --- a/features/settings/index.ts +++ b/features/settings/index.ts @@ -5,13 +5,14 @@ export { SettingsProfileScreen } from './screens/SettingsProfileScreen'; export { SettingsKeyringScreen } from './screens/SettingsKeyringScreen'; export { SettingsRecoveryScreen } from './screens/SettingsRecoveryScreen'; export { SettingsRoutingScreen } from './screens/SettingsRoutingScreen'; -export { SettingsRelaysScreen } from './screens/SettingsRelaysScreen'; +export { SettingsNetworkScreen } from './screens/SettingsNetworkScreen'; export { SettingsStorageScreen } from './screens/SettingsStorageScreen'; export { SettingsDesignSystemScreen } from './screens/SettingsDesignSystemScreen'; export { SettingsDesignSystemLoadingScreen } from './screens/SettingsDesignSystemLoadingScreen'; export { SettingsDesignSystemSegmentedScreen } from './screens/SettingsDesignSystemSegmentedScreen'; export { SettingsDesignSystemTimelineScreen } from './screens/SettingsDesignSystemTimelineScreen'; export { SettingsDesignSystemEmptyStatesScreen } from './screens/SettingsDesignSystemEmptyStatesScreen'; +export { SettingsDesignSystemSkeletonCrossfadeScreen } from './screens/SettingsDesignSystemSkeletonCrossfadeScreen'; export { SettingsAvatarScreen } from './screens/SettingsAvatarScreen'; export { SettingsNotificationPolicyScreen } from './screens/SettingsNotificationPolicyScreen'; export { DeleteScreen } from './screens/DeleteScreen'; diff --git a/features/settings/screens/SettingsDesignSystemScreen.tsx b/features/settings/screens/SettingsDesignSystemScreen.tsx index ecf7bf222..126a50e39 100644 --- a/features/settings/screens/SettingsDesignSystemScreen.tsx +++ b/features/settings/screens/SettingsDesignSystemScreen.tsx @@ -36,6 +36,11 @@ const COMPONENTS: ComponentEntry[] = [ title: 'Empty states', description: 'Every "nothing to show" surface, unified via EmptyState', }, + { + href: '/(settings-flow)/design-system-skeleton-crossfade', + title: 'Skeleton crossfade', + description: 'Region wave, 220ms skeleton→content fade, independent image fades', + }, ]; const DesignSystemLinkItem: React.FC = ({ href, title, description }) => ( diff --git a/features/settings/screens/SettingsDesignSystemSkeletonCrossfadeScreen.tsx b/features/settings/screens/SettingsDesignSystemSkeletonCrossfadeScreen.tsx new file mode 100644 index 000000000..df61c023a --- /dev/null +++ b/features/settings/screens/SettingsDesignSystemSkeletonCrossfadeScreen.tsx @@ -0,0 +1,166 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { ScrollView } from 'react-native'; + +import { Button, Card } from 'heroui-native'; + +import { Screen as ScreenWrapper } from '@/shared/ui/composed/Screen'; +import { Text } from '@/shared/ui/primitives/Text'; +import { VStack } from '@/shared/ui/primitives/View/VStack'; +import { HStack } from '@/shared/ui/primitives/View/HStack'; +import { Avatar } from '@/shared/ui/primitives/Avatar'; +import { useThemeColor } from '@/shared/hooks/useThemeColor'; +import { SkeletonContentCrossfade } from '@/shared/ui/composed/SkeletonContentCrossfade'; + +const STEP_DURATION_MS = 2200; + +/** Profile-style row: SAME chrome in both branches (only the `loading` bars + * differ), so the crossfade reveals content under a fading skeleton with zero + * layout shift. */ +function DemoProfileRow({ loading, pictureUrl }: { loading: boolean; pictureUrl?: string }) { + return ( + + + + + Satoshi Nakamoto + + + @satoshi@sovran.money + + + + ); +} + +export function SettingsDesignSystemSkeletonCrossfadeScreen() { + // The demo cards are `Card variant="secondary"` — the wave must match THAT + // surface (what the skeleton sits on), not the screen background. + const surfaceSecondary = useThemeColor('surface-secondary'); + const [loading, setLoading] = useState(true); + const [auto, setAuto] = useState(true); + // Bust expo-image's cache each cycle so the avatar performs a real load and + // fades in independently of the data crossfade. + const [imageSeed, setImageSeed] = useState(1); + const timerRef = useRef | null>(null); + + useEffect(() => { + if (!auto) { + if (timerRef.current) clearInterval(timerRef.current); + timerRef.current = null; + return; + } + timerRef.current = setInterval(() => { + setLoading((prev) => { + const next = !prev; + if (!next) setImageSeed((s) => s + 1); + return next; + }); + }, STEP_DURATION_MS); + return () => { + if (timerRef.current) clearInterval(timerRef.current); + timerRef.current = null; + }; + }, [auto]); + + const toggle = useCallback(() => { + setAuto(false); + setLoading((prev) => { + const next = !prev; + if (!next) setImageSeed((s) => s + 1); + return next; + }); + }, []); + + const pictureUrl = loading ? undefined : `https://picsum.photos/seed/sovran-${imageSeed}/88`; + + return ( + + + + The canonical{' '} + + SkeletonContentCrossfade + + . While loading, a sizeable region shows the wave; on data arrival the skeleton fades out + and content fades in. Images fade in independently (their own expo-image transition), and + system Reduce Motion turns the wave + fade into an instant swap. + + + {/* 1 — Region wave + crossfade */} + + + + REGION WAVE + CROSSFADE + + } + renderContent={() => } + /> + + + + {/* 2 — Inline (no wave) */} + + + + INLINE — PULSE, NO WAVE + + + Compact swaps keep the cheap pulse and skip the sweep. + + } + renderContent={() => ( + + 21,000 sats + + )} + /> + + + + {/* 3 — Independent image fade */} + + + + INDEPENDENT IMAGE FADE + + + Text settles immediately; the avatar image fades in on its own when it finishes + loading — the crossfade never waits on it. + + + + + + {/* Controls */} + + + + STATE: {loading ? 'LOADING' : 'LOADED'} + + + + + + + + ); +} diff --git a/features/settings/screens/SettingsRelaysScreen.tsx b/features/settings/screens/SettingsNetworkScreen.tsx similarity index 72% rename from features/settings/screens/SettingsRelaysScreen.tsx rename to features/settings/screens/SettingsNetworkScreen.tsx index 71341fce4..5cfd27f90 100644 --- a/features/settings/screens/SettingsRelaysScreen.tsx +++ b/features/settings/screens/SettingsNetworkScreen.tsx @@ -1,9 +1,12 @@ /** - * @fileoverview Relay management (NIP-65). + * @fileoverview Network configuration — the three tiers of the resilient Nostr + * data layer: aggregators (nagg), caching (Primal), and relays. * - * Lists the active profile's relays with live connection health, read/write - * markers, and delete; lets the user add a relay, restore the default set, and - * publish the list as `kind:10002` so the outbox model can route their posts. + * Each tier has an enable/disable switch (a disabled tier drops out of the + * facade's nagg → Primal → relays fallback chain — handy for simulating a tier + * being down). The relays section additionally manages the active profile's + * NIP-65 list (kind:10002): connection health, read/write markers, add/remove, + * restore defaults, and publish. */ import React, { useCallback, useMemo, useState } from 'react'; import { ScrollView, View } from 'react-native'; @@ -11,6 +14,8 @@ import { NDKEvent, useNDK } from '@nostr-dev-kit/ndk-mobile'; import { Button, Card, Input, ListGroup, Separator, Switch, TextField } from 'heroui-native'; import Icon from 'assets/icons'; +import { backendConfig } from '@/shared/config/backend'; +import { useSettingsStore } from '@/shared/stores/global/settingsStore'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { useRelayHealth, type RelayHealth } from '@/shared/hooks/useRelayHealth'; import { log } from '@/shared/lib/logger'; @@ -23,8 +28,14 @@ import { Screen as ScreenWrapper } from '@/shared/ui/composed/Screen'; import { EmptyState } from '@/shared/ui/composed/EmptyState'; import { Text } from '@/shared/ui/primitives/Text'; -export function SettingsRelaysScreen() { +export function SettingsNetworkScreen() { const { ndk } = useNDK(); + const naggTierEnabled = useSettingsStore((s) => s.naggTierEnabled); + const setNaggTierEnabled = useSettingsStore((s) => s.setNaggTierEnabled); + const primalTierEnabled = useSettingsStore((s) => s.primalTierEnabled); + const setPrimalTierEnabled = useSettingsStore((s) => s.setPrimalTierEnabled); + const relayTierEnabled = useSettingsStore((s) => s.relayTierEnabled); + const setRelayTierEnabled = useSettingsStore((s) => s.setRelayTierEnabled); const entries = useRelayListStore((s) => s.entries); const source = useRelayListStore((s) => s.source); const addRelay = useRelayListStore((s) => s.addRelay); @@ -98,11 +109,45 @@ export function SettingsRelaysScreen() { ); return ( - + + + Sovran reads Nostr nagg-first, then falls back to Primal's cache, then raw relays. + Turn a source off to skip it (e.g. to test the fallback). + + +
+ +
+ +
+ +
+ +
+ +
+ {sortedEntries.length === 0 ? ( void; +}) { + return ( + + + + {name} + + {description} + + {url ? ( + + {url} + + ) : null} + + + + + ); +} + function RelayMarkerToggle({ label, value, diff --git a/features/settings/screens/SettingsScreen.tsx b/features/settings/screens/SettingsScreen.tsx index c68aba40d..8ed41e60a 100644 --- a/features/settings/screens/SettingsScreen.tsx +++ b/features/settings/screens/SettingsScreen.tsx @@ -220,9 +220,9 @@ export const SettingsScreen = () => { /> diff --git a/features/transactions/components/Transaction.tsx b/features/transactions/components/Transaction.tsx index 872b3e60b..265a02129 100644 --- a/features/transactions/components/Transaction.tsx +++ b/features/transactions/components/Transaction.tsx @@ -260,9 +260,8 @@ export const Transaction = React.memo(({ historyEntry, onPress, onCancel }: Tran // wrapper with `height: 0, opacity: 0`; Reanimated's LinearTransition // captures the pre/post layouts and interpolates between them on the // UI thread (no per-frame JS re-renders). Yoga commits the new size - // each frame on the native side, so the section's measured height - // shrinks in real time and AnimatedLegendList's `itemLayoutAnimation` - // animates sibling sections in lock-step on the same UI-thread pass. + // each frame on the native side, so this row shrinks in real time and + // the FlashList sections below reflow as the list re-measures. const collapsedStyle = isCollapsing ? { height: 0, opacity: 0 } : null; const row = ( diff --git a/features/transactions/components/Transactions.tsx b/features/transactions/components/Transactions.tsx index ce1cee632..321410724 100644 --- a/features/transactions/components/Transactions.tsx +++ b/features/transactions/components/Transactions.tsx @@ -1,9 +1,7 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { StyleSheet, useWindowDimensions } from 'react-native'; -import { Easing, LinearTransition } from 'react-native-reanimated'; -import { AnimatedLegendList } from '@legendapp/list/reanimated'; -import type { LegendListRef } from '@legendapp/list/react-native'; +import { FlashList } from '@shopify/flash-list'; import { Link } from 'expo-router'; import opacity from 'hex-color-opacity'; import groupBy from 'lodash/groupBy'; @@ -36,12 +34,6 @@ import { type TransactionPaymentType, } from '@sovranbitcoin/colada'; import { log, Log } from '@/shared/lib/logger'; -import { - remeasureVisualLayoutScope, - useVisualListLogger, - VISUAL_LIST_VIEWABILITY_CONFIG, - visualLayoutScopePart, -} from '@/shared/lib/contentShiftLog'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { spacing, zIndex } from '@/shared/styles/tokens'; import { useRollbackStore } from '@/shared/stores/runtime/rollbackStore'; @@ -49,7 +41,6 @@ import { useSwapTransactionsStore, type SwapGroup, } from '@/shared/stores/profile/swapTransactionsStore'; -import { VisualLayoutProbe } from '@/shared/ui/composed/VisualLayoutProbe'; // --------------------------------------------------------------------------- // Timeline item: a discriminated union so transactions and swap groups can @@ -71,11 +62,6 @@ function getTimelineKey(item: TimelineItem): string { return `${entry.type}-${entry.createdAt}-${entry.amount}`; } -function getTimelineVisualKey(item: TimelineItem, rowIndex: number | undefined): string { - const row = rowIndex == null ? 'row' : String(rowIndex); - return `${item.kind}:${getTimelineCreatedAt(item)}:${row}`; -} - // --------------------------------------------------------------------------- interface Account { @@ -88,13 +74,7 @@ interface Section { index?: string; } -function getSectionVisualKey(section: Section): string { - return `section:${section.index ?? section.title}`; -} - const DATE_HEADER_HEIGHT = 30; -const ESTIMATED_TRANSACTION_ROW_HEIGHT = 72; -const ESTIMATED_SECTION_CHROME_HEIGHT = DATE_HEADER_HEIGHT + spacing.xs + spacing.lg; interface Props { header?: React.ReactElement | (() => React.ReactElement) | null; @@ -178,19 +158,12 @@ export const Transactions = React.memo( // Operation ids that are still showing the post-success collapse // animation. Keeping them pinned in the Pending bucket gives the // Transaction row time to play its height-collapse before unmount — - // without this, the LegendList virtualizer recycles the view as soon as + // without this, the FlashList virtualizer recycles the view as soon as // its state flips to `rolledBack`, killing the animation mid-frame. const collapsing = useRollbackStore((s) => s.collapsing); const borderColor = useMemo(() => opacity(muted, 0.3), [muted]); const swapGroupsById = useSwapTransactionsStore((state) => state.groups); - const transactionsVisualScope = useMemo( - () => - `transactions.${embedded ? 'embedded' : showMore ? 'summary' : 'list'}.${visualLayoutScopePart( - listKey ?? `${account.unit}-${tab}` - )}`, - [account.unit, embedded, listKey, showMore, tab] - ); const swapGroups = useMemo(() => { if (account.unit === 'all') return Object.values(swapGroupsById); @@ -391,60 +364,6 @@ export const Transactions = React.memo( return sections.all; }, [sections, tab]); - const estimatedSectionItemSize = useMemo(() => { - if (sectionsToDisplay.length === 0) { - return ESTIMATED_SECTION_CHROME_HEIGHT + ESTIMATED_TRANSACTION_ROW_HEIGHT; - } - const totalRows = sectionsToDisplay.reduce((sum, section) => sum + section.data.length, 0); - const averageRows = Math.max(1, totalRows / sectionsToDisplay.length); - return ESTIMATED_SECTION_CHROME_HEIGHT + averageRows * ESTIMATED_TRANSACTION_ROW_HEIGHT; - }, [sectionsToDisplay]); - - const visualPhase = isFetching ? 'loading' : timelineItems.length === 0 ? 'empty' : 'ready'; - const listRef = useRef(null); - const visualExtra = useCallback( - () => ({ - accountUnit: account.unit, - showMore, - embedded, - tab, - filter, - paymentType: type, - sectionCount: sectionsToDisplay.length, - timelineCount: timelineItems.length, - historyCount: history.length, - isFetching, - }), - [ - account.unit, - embedded, - filter, - history.length, - isFetching, - sectionsToDisplay.length, - showMore, - tab, - timelineItems.length, - type, - ] - ); - const visualList = useVisualListLogger
({ - scope: transactionsVisualScope, - surface: 'transactions', - component: 'TransactionsLegendList', - phase: visualPhase, - extra: visualExtra, - getItemKey: getSectionVisualKey, - getItemContext: (section) => ({ - rowKey: getSectionVisualKey(section), - itemType: 'section', - sectionLabel: section.index ?? 'unindexed', - sectionTitle: section.title, - sectionRows: section.data.length, - }), - getListState: () => listRef.current?.getState() ?? null, - }); - // Embedded (per-person) list groups purely by date — no pending/confirmed/ // expired split — so each date renders once under a single date header. const embeddedSections = useMemo(() => { @@ -492,75 +411,37 @@ export const Transactions = React.memo( const renderTimelineItem = useCallback( (item: TimelineItem, rowIndex?: number) => { const key = getTimelineKey(item); - const visualKey = getTimelineVisualKey(item, rowIndex); - const visualRow = { - scope: transactionsVisualScope, - surface: 'transactions', - component: item.kind === 'swap' ? 'SwapTransactionRow' : 'TransactionRow', - itemKey: `row:${visualKey}`, - itemType: item.kind, - index: rowIndex, - phase: visualPhase, - extra: visualExtra, - }; if (item.kind === 'swap') { - return ( - - - - ); + return ; } return ( - - - + ); }, - [onCancelPendingEcash, onTransactionPress, transactionsVisualScope, visualExtra, visualPhase] + [onCancelPendingEcash, onTransactionPress] ); const renderSection = useCallback( - ({ item: section, index }: { item: Section; index?: number }) => ( - ({ - ...visualExtra(), - sectionTitle: section.title, - sectionRows: section.data.length, - })}> - - - {section.title} - - - - - {section.data.map((item, rowIndex) => renderTimelineItem(item, rowIndex))} - - - - - + ({ item: section }: { item: Section; index?: number }) => ( + + + {section.title} + + + + + {section.data.map((item, rowIndex) => renderTimelineItem(item, rowIndex))} + + + + ), - [ - borderColor, - foreground, - muted, - renderTimelineItem, - transactionsVisualScope, - visualExtra, - visualPhase, - ] + [borderColor, foreground, muted, renderTimelineItem] ); const resolvedHeader = useMemo( @@ -568,18 +449,6 @@ export const Transactions = React.memo( [header] ); - const handleListScroll = useCallback( - (event: Parameters>[0]) => { - onScroll?.(event); - remeasureVisualLayoutScope(transactionsVisualScope, 'scroll', { - extra: visualExtra(), - maxItems: 24, - minIntervalMs: 300, - }); - }, - [onScroll, transactionsVisualScope, visualExtra] - ); - const emptyComponent = useMemo( () => // While the first page is in flight the list is empty, so this renders @@ -587,66 +456,36 @@ export const Transactions = React.memo( // spinner) instead of flashing the "No transactions found" card, which // reads as "you have none" when we simply haven't loaded yet. isFetching ? ( - - - + ) : ( - - - - - - - - No transactions found - - - Try adjusting your filters or check back later - - - - + + + + + + + No transactions found + + + Try adjusting your filters or check back later + + + - + ), - [ - borderColor, - foreground, - isFetching, - muted, - transactionsVisualScope, - visualExtra, - visualPhase, - ] + [borderColor, foreground, isFetching, muted] ); if (embedded) { @@ -666,74 +505,52 @@ export const Transactions = React.memo( if (showMore) { if (isFetching) { return ( - - - - - - Loading Transactions... - - - Please wait while we fetch your history - - - + + + + + Loading Transactions... + + + Please wait while we fetch your history + + ); } if (timelineItems.length === 0) { return ( - - - - - - - - - No History - - - Your history will show up here - - - - + + + + + + + + No History + + + Your history will show up here + + + - + ); } @@ -808,39 +625,22 @@ export const Transactions = React.memo( return ( - section.index ?? section.title} - // Date sections are not fixed-height LegendList items: each one - // wraps a label plus a card of rows whose measured height can change - // with badges, status text, and rollback collapse animations. - // Let LegendList measure the real position; this is only the first - // allocation hint. - estimatedItemSize={estimatedSectionItemSize} + // FlashList v2 measures section heights synchronously, so there is no + // estimate to supply. A collapsing transaction row animates its own + // height via Transaction.tsx's reanimated `layout` transition; the + // sections below reflow as FlashList re-measures (the legend-only + // `itemLayoutAnimation` that animated sibling reflow has no v2 + // equivalent, so that reflow is now immediate). drawDistance={400} - maintainVisibleContentPosition - // One-frame transition. AnimatedLegendList's `itemLayoutAnimation` - // triggers a fresh LinearTransition on every measured-position - // change. With a long duration each delta would start a 260 ms - // animation that gets cancelled by the next frame's update — so - // the next section perpetually chases the target with a quarter- - // second lag. With a one-frame duration, each delta resolves - // before the next arrives, producing real-time tracking that - // moves in lock-step with the row's `layout` shrink. - itemLayoutAnimation={LinearTransition.duration(16).easing(Easing.linear)} contentInsetAdjustmentBehavior={disableContentInsetAdjustment ? 'never' : 'automatic'} ListHeaderComponent={resolvedHeader} ListEmptyComponent={emptyComponent} - onItemSizeChanged={visualList.onItemSizeChanged} - onLoad={visualList.onLoad} - onMetricsChange={visualList.onMetricsChange} - onStickyHeaderChange={visualList.onStickyHeaderChange} - onViewableItemsChanged={visualList.onViewableItemsChanged} - viewabilityConfig={VISUAL_LIST_VIEWABILITY_CONFIG} - onScroll={handleListScroll} + onScroll={onScroll} scrollEventThrottle={16} renderItem={renderSection} contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 250 }} diff --git a/features/transactions/components/detail/HistoryEntryRefresh.tsx b/features/transactions/components/detail/HistoryEntryRefresh.tsx index 3a38238ff..ffc5d349a 100644 --- a/features/transactions/components/detail/HistoryEntryRefresh.tsx +++ b/features/transactions/components/detail/HistoryEntryRefresh.tsx @@ -11,6 +11,7 @@ import type { HistoryEntry } from '@cashu/coco-core'; import Icon from 'assets/icons'; import { MintIcon } from '@/shared/ui/composed/MintIcon'; import { Skeleton } from '@/shared/ui/primitives/Skeleton'; +import { SkeletonContentCrossfade } from '@/shared/ui/composed/SkeletonContentCrossfade'; import { GradientCard } from '@/shared/ui/composed/GradientCard'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { usePaymentCopyResolver } from '@/shared/hooks/usePaymentCopyResolver'; @@ -55,13 +56,18 @@ export function HistoryEntryRefresh({ mintInfo, historyEntry, onPress }: History {statusLabel} - {loading ? ( - - ) : ( - - {mintInfo?.name} - - )} + } + renderContent={() => ( + + {mintInfo?.name} + + )} + /> {onPress && ( diff --git a/features/user/screens/UserProfileScreen.tsx b/features/user/screens/UserProfileScreen.tsx index de5684880..ecba4b95d 100644 --- a/features/user/screens/UserProfileScreen.tsx +++ b/features/user/screens/UserProfileScreen.tsx @@ -243,6 +243,11 @@ function ProfileStatsGridComponent({ ); + // Settled with nothing reliable to show — relay-only mode can't fetch + // follower/following counts (a reverse index relays don't have), reputation, + // or joined date. Render no grid rather than misleading "0 / 0 / N/A". + if (!isLoading && !hasValidData) return null; + // While loading: 2×2 placeholders under one shimmer sweep (thread-style), // rather than four independently-pulsing boxes. if (showSkeleton) { diff --git a/package.json b/package.json index 1291382bb..1841a55ea 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,6 @@ "@gandlaf21/bolt11-decode": "^3.1.1", "@gorhom/bottom-sheet": "5.2.9", "@internet-privacy/marmot-ts": "file:./vendor/marmot-ts", - "@legendapp/list": "3.0.0", "@mealection/react-native-boring-avatars": "1.1.2", "@monicon/metro": "^2.0.7", "@monicon/native": "^1.2.2", @@ -86,6 +85,7 @@ "@scure/base": "^2.0.0", "@scure/bip32": "^1.3.3", "@scure/bip39": "^1.2.2", + "@shopify/flash-list": "2", "@shopify/react-native-skia": "2.4.18", "@sovranbitcoin/coco-cashu-plugin-p2pk-import": "^0.1.0", "@sovranbitcoin/colada": "^0.7.0", diff --git a/scripts/link-local-siblings.mjs b/scripts/link-local-siblings.mjs index e6c1fa644..bf1a494f2 100644 --- a/scripts/link-local-siblings.mjs +++ b/scripts/link-local-siblings.mjs @@ -22,6 +22,7 @@ const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); // package name → sibling directory, relative to the app root. Add a row when a // registry-versioned sibling should resolve to local source during dev. const SIBLINGS = [ + { pkg: '@sovranbitcoin/schemas', dir: '../sovran-schemas' }, { pkg: '@sovranbitcoin/colada', dir: '../colada' }, { pkg: '@sovranbitcoin/nagg-ts', dir: '../nagg-ts' }, ]; diff --git a/shared/blocks/PaymentInfo.tsx b/shared/blocks/PaymentInfo.tsx index 0b9519ad8..4262d1ba8 100644 --- a/shared/blocks/PaymentInfo.tsx +++ b/shared/blocks/PaymentInfo.tsx @@ -14,6 +14,7 @@ import { import { HStack } from '@/shared/ui/primitives/View/HStack'; import { View } from '@/shared/ui/primitives/View/View'; import { Skeleton } from '@/shared/ui/primitives/Skeleton'; +import { SkeletonContentCrossfade } from '@/shared/ui/composed/SkeletonContentCrossfade'; import { copyPopup, type CopyTarget } from '@/shared/lib/popup'; import { EnhancedHaptics } from '@/shared/ui/primitives/Haptics'; import { Log, paymentLog } from '@/shared/lib/logger'; @@ -100,76 +101,84 @@ export function PaymentInfo({ copyPopup(copyTarget); }, [link, selectedValue, copyTarget]); - if (!selectedValue) { + const loading = !selectedValue; + if (loading) { paymentLog.debug('ui.payment_info.empty', { dataType: typeof data, isArray: Array.isArray(data), }); - return ( - - - - ); + } else { + paymentLog.debug('ui.payment_info.render', { + unit, + copyTarget, + animated: willAnimate, + variant, + dataLength: selectedValue.length, + }); } - paymentLog.debug('ui.payment_info.render', { - unit, - copyTarget, - animated: willAnimate, - variant, - dataLength: selectedValue.length, - }); - return ( - - - {/* Hidden Text node carrying the full payment value, so log-doctor's + ( + + + + )} + renderContent={() => ( + + + {/* Hidden Text node carrying the full payment value, so log-doctor's `capture-id-label` step can read the token/address straight from the AX tree without bouncing through the iOS pasteboard. Text elements are always included in the iOS AX tree (unlike Views with opacity:0, which iOS strips), and the visible content is what populates the WDA `name`/`label` fields — that's why the value is the Text child rather than `accessibilityLabel`. */} - - {selectedValue} - - {/* QR code — tap to copy */} - - - - - + + {selectedValue} + + {/* QR code — tap to copy */} + + + + + - {/* Speed + Density controls — outside the copy pressable */} - {willAnimate && ( - - + {/* Speed + Density controls — outside the copy pressable */} + {willAnimate && ( + + + + )} - )} - - + + )} + /> ); } diff --git a/shared/blocks/popup/ActionMenuHost.tsx b/shared/blocks/popup/ActionMenuHost.tsx index 82fe34b5b..f6e6d40d4 100644 --- a/shared/blocks/popup/ActionMenuHost.tsx +++ b/shared/blocks/popup/ActionMenuHost.tsx @@ -474,7 +474,7 @@ export function ActionMenuHost() { // Map ActionMenuSection[] → AnchorSection[] for // SectionAnchorList. Two shapes: // - Sections with `buttons` use the standard data + renderItem - // path so each profile row virtualizes individually (LegendList + // path so each profile row virtualizes individually (FlashList // mounts only the rows in the draw window). Items render as // `Menu.Item`s; the section gets no header. // - Sections with `renderBody` (custom non-button content, e.g. @@ -642,20 +642,15 @@ export function ActionMenuHost() { sections={sectionsForList} // Each section's `data` is its `ActionMenuItem[]` (see // `sectionsForList`); we render one Menu.Item per button. - // LegendList virtualizes the row stream so even a long + // FlashList virtualizes the row stream so even a long // profile list (or future >100-item picker) only mounts // the rows in the draw window. renderItem={(button, sectionId) => renderActionButton(button, `${sectionId}-${button.testID ?? button.text}`) } keyExtractor={(button, sectionId) => `${sectionId}-${button.testID ?? button.text}`} - // Profile rows are ~58px (avatar 36 + paddingVertical from - // Menu.Item). 60 is a safe estimate that overshoots - // slightly so LegendList doesn't under-allocate the - // viewport on first mount. - estimatedItemSize={60} // The 12px horizontal inset that previously wrapped each - // section now lives on the LegendList contentContainer so + // section now lives on the list contentContainer so // every row aligns with `Menu.Label`'s `ml-3` (24px from // the menu's left edge). listContentContainerStyle={{ paddingHorizontal: 12 }} diff --git a/shared/config/backend.ts b/shared/config/backend.ts index 048236a3f..3ab6bea3f 100644 --- a/shared/config/backend.ts +++ b/shared/config/backend.ts @@ -2,6 +2,12 @@ import { z } from 'zod'; const DEFAULT_NOSTR_APPVIEW_BASE_URL = 'https://nagg.up.railway.app'; const DEFAULT_API_BASE_URL = 'https://api.sovran.money/api'; +/** + * Primal's PUBLIC cache server (Primal operates it; we only connect). Tier 2 of + * the resilient Nostr data layer — the `nagg → Primal cache → raw relays` + * fallback. Override via EXPO_PUBLIC_PRIMAL_CACHE_URL for a different instance. + */ +const DEFAULT_PRIMAL_CACHE_URL = 'wss://cache2.primal.net/v1'; const emptyStringToUndefined = (value: unknown) => typeof value === 'string' && value.trim() === '' ? undefined : value; @@ -23,19 +29,8 @@ const BackendEnv = z.object({ EXPO_PUBLIC_API_BASE_URL: RequiredUrl(DEFAULT_API_BASE_URL), EXPO_PUBLIC_SCORE_API_BASE_URL: OptionalUrl, EXPO_PUBLIC_NOSTR_GRAPHQL_ENDPOINT: OptionalUrl, - // Flip the contacts/DM-list fetch from GraphQL `dmEnvelopes` to the dedicated - // REST app-view `/nostr/dm/envelopes` once it's deployed. Defaults to GraphQL. - EXPO_PUBLIC_NOSTR_DM_APPVIEW: z.preprocess(emptyStringToUndefined, z.string().optional()), - // Flip the feed / thread fetches from GraphQL to nagg's REST app-view - // (`/nostr/feed*`, `/nostr/thread`). Defaults to GraphQL; opt in only after - // device testing the REST routes. - EXPO_PUBLIC_NOSTR_FEED_APPVIEW: z.preprocess(emptyStringToUndefined, z.string().optional()), - // Flip the notifications fetch from GraphQL to nagg's REST app-view - // (`/nostr/notifications`). Defaults to GraphQL; opt in after device testing. - EXPO_PUBLIC_NOSTR_NOTIFICATIONS_APPVIEW: z.preprocess( - emptyStringToUndefined, - z.string().optional() - ), + // Primal public cache server (tier 2). Defaults to wss://cache2.primal.net/v1. + EXPO_PUBLIC_PRIMAL_CACHE_URL: OptionalUrl, }); type BackendEnvInput = Partial, string | undefined>>; @@ -44,13 +39,16 @@ type BackendConfig = { nostrAppViewBaseUrl: string; apiBaseUrl: string; scoreApiBaseUrl: string; + /** + * nagg's GraphQL endpoint. The feed/thread/notifications/DM data layer is + * app-view-only (no client GraphQL); this remains ONLY for integrations that + * embed nagg's GraphQL via coco-core (mint enrichment, mint-operator Nostr + * profiles) plus the recent-people-profiles lookup — none of which route + * through nagg-ts. Pending their own migration to REST. + */ nostrGraphqlEndpoint: string; - /** Prefer the REST app-view `/nostr/dm/envelopes` for the contacts/DM list. */ - nostrDmAppView: boolean; - /** Route feed / thread fetches through nagg's REST app-view. */ - nostrFeedAppView: boolean; - /** Route notifications fetches through nagg's REST app-view. */ - nostrNotificationsAppView: boolean; + /** Primal public cache server URL (tier 2 of the Nostr data layer). */ + primalCacheUrl: string; }; function readBackendEnv(): BackendEnvInput { @@ -60,9 +58,7 @@ function readBackendEnv(): BackendEnvInput { EXPO_PUBLIC_API_BASE_URL: process.env.EXPO_PUBLIC_API_BASE_URL, EXPO_PUBLIC_SCORE_API_BASE_URL: process.env.EXPO_PUBLIC_SCORE_API_BASE_URL, EXPO_PUBLIC_NOSTR_GRAPHQL_ENDPOINT: process.env.EXPO_PUBLIC_NOSTR_GRAPHQL_ENDPOINT, - EXPO_PUBLIC_NOSTR_DM_APPVIEW: process.env.EXPO_PUBLIC_NOSTR_DM_APPVIEW, - EXPO_PUBLIC_NOSTR_FEED_APPVIEW: process.env.EXPO_PUBLIC_NOSTR_FEED_APPVIEW, - EXPO_PUBLIC_NOSTR_NOTIFICATIONS_APPVIEW: process.env.EXPO_PUBLIC_NOSTR_NOTIFICATIONS_APPVIEW, + EXPO_PUBLIC_PRIMAL_CACHE_URL: process.env.EXPO_PUBLIC_PRIMAL_CACHE_URL, }; } @@ -75,6 +71,9 @@ export function parseBackendConfig(env: BackendEnvInput = readBackendEnv()): Bac throw new Error(`Invalid backend config: ${issues}`); } + // nagg's REST app-view is the only Nostr transport: one fully bundled payload + // per page (events + profiles + reliable single-query engagement stats). There + // is no client-side GraphQL. const nostrAppViewBaseUrl = parsed.data.EXPO_PUBLIC_NOSTR_APPVIEW_BASE_URL ?? parsed.data.EXPO_PUBLIC_NAGG_BASE_URL ?? @@ -85,9 +84,7 @@ export function parseBackendConfig(env: BackendEnvInput = readBackendEnv()): Bac scoreApiBaseUrl: parsed.data.EXPO_PUBLIC_SCORE_API_BASE_URL ?? nostrAppViewBaseUrl, nostrGraphqlEndpoint: parsed.data.EXPO_PUBLIC_NOSTR_GRAPHQL_ENDPOINT ?? `${nostrAppViewBaseUrl}/graphql`, - nostrDmAppView: parsed.data.EXPO_PUBLIC_NOSTR_DM_APPVIEW === 'true', - nostrFeedAppView: parsed.data.EXPO_PUBLIC_NOSTR_FEED_APPVIEW === 'true', - nostrNotificationsAppView: parsed.data.EXPO_PUBLIC_NOSTR_NOTIFICATIONS_APPVIEW === 'true', + primalCacheUrl: parsed.data.EXPO_PUBLIC_PRIMAL_CACHE_URL ?? DEFAULT_PRIMAL_CACHE_URL, }; } diff --git a/shared/hooks/useNostrProfile.ts b/shared/hooks/useNostrProfile.ts index 196d3c0e7..db0e7080f 100644 --- a/shared/hooks/useNostrProfile.ts +++ b/shared/hooks/useNostrProfile.ts @@ -1,12 +1,49 @@ import { useState, useEffect, useCallback } from 'react'; +import type { facade } from '@sovranbitcoin/nagg-ts'; + import { fetchNostrProfile, type NostrProfileFull } from '@/shared/lib/apiClient'; import { resolveIdentityName } from '@/shared/lib/identity'; import { npubToPubkey } from '@/shared/lib/nostr/client'; +import { tryNpubEncode } from '@/features/feed/components/nostr/feedParse'; +import { getNostrTierConfig } from '@/shared/lib/nostr/nostrTierConfig'; +import { fetchProfileStatsViaFacade } from '@/shared/lib/nostr/fetchProfiles'; import { log } from '@/shared/lib/logger'; export type TopFollower = NostrProfileFull['topFollowers'][number]; +/** + * Synthesize the screen's `NostrProfileFull` shape from a facade profile-stats + * result (Primal `user_profile` / relay floor). Reputation (`score`) and + * `topFollowers` have no Primal/relay equivalent, so they're left empty — + * counts, joined date, and name/picture map across. + */ +function profileFullFromStats( + pubkey: string, + stats: facade.ResolvedProfileStats +): NostrProfileFull { + const m = stats.metadata; + return { + pubkey, + npub: tryNpubEncode(pubkey), + rank: 0, + score: null, + followers: stats.followersCount ?? 0, + follows: stats.followingCount ?? 0, + created_at: stats.joinedAt ?? null, + topFollowers: [], + fromCache: false, + ...(m?.name ? { name: m.name } : {}), + ...(m?.displayName ? { displayName: m.displayName } : {}), + ...(m?.picture ? { picture: m.picture } : {}), + ...(m?.banner ? { banner: m.banner } : {}), + ...(m?.about ? { about: m.about } : {}), + ...(m?.nip05 ? { nip05: m.nip05 } : {}), + ...(m?.website ? { website: m.website } : {}), + ...(m?.lud16 ? { lud16: m.lud16 } : {}), + }; +} + interface UseNostrProfileResult { data: NostrProfileFull | null; isLoading: boolean; @@ -34,14 +71,34 @@ export function useNostrProfile(pubkey: string | null): UseNostrProfileResult { setIsLoading(true); setError(null); - const result = await fetchNostrProfile(pubkey, { signal }); + // nagg's `/nostr/profile` (the score API) is the only source of reputation + // + top followers, so prefer it WHEN nagg is the active tier. When nagg is + // disabled in settings — or enabled but unreachable — fall through to the + // facade's profile-stats surface (Primal `user_profile` → relay floor) so + // counts/joined/metadata still load and the disable flag is actually honored. + const naggEnabled = getNostrTierConfig().nagg.enabled; + + if (naggEnabled) { + const result = await fetchNostrProfile(pubkey, { signal }); + if (signal?.aborted) return; + if (result.isOk()) { + log.debug('feed.profile.fetch.success', { pubkey, source: 'nagg' }); + setData(result.value); + setIsLoading(false); + return; + } + log.warn('feed.profile.fetch.nagg_failed_fallback', { pubkey, error: result.error }); + } + + const stats = await fetchProfileStatsViaFacade(pubkey, { signal }); if (signal?.aborted) return; - if (result.isOk()) { - log.debug('feed.profile.fetch.success', { pubkey, hasData: !!result.value }); - setData(result.value); + if (stats) { + log.debug('feed.profile.fetch.success', { pubkey, source: stats.tier }); + setData(profileFullFromStats(pubkey, stats)); } else { - log.warn('feed.profile.fetch.error', { pubkey, error: result.error }); - setError(result.error); + const err = new Error('profile unavailable from all tiers'); + log.warn('feed.profile.fetch.error', { pubkey }); + setError(err); setData(null); } setIsLoading(false); diff --git a/shared/hooks/useNostrProfileMetadata.ts b/shared/hooks/useNostrProfileMetadata.ts index 863c76931..c1cfb3967 100644 --- a/shared/hooks/useNostrProfileMetadata.ts +++ b/shared/hooks/useNostrProfileMetadata.ts @@ -1,61 +1,70 @@ -import { useEffect, useMemo, useRef } from 'react'; -import { useSubscribe } from '@nostr-dev-kit/ndk-mobile'; -import { Metadata } from 'nostr-tools/kinds'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Kind0MetadataSchema, useCachedNostrProfile, useNostrMetadataCache, type NostrProfileMetadata, } from '@/shared/stores/global/nostrMetadataCache'; -import { nostrLog } from '@/shared/lib/logger'; +import { fetchProfilesViaFacade } from '@/shared/lib/nostr/fetchProfiles'; const STALE_TTL_MS = 24 * 60 * 60 * 1000; -// `useSubscribe` puts `opts` in its re-subscribe effect deps -// (ndk-mobile/src/hooks/subscribe.ts). A fresh `{ closeOnEose: true }` -// per render fails Object.is, the subscription tears down on EOSE, -// `handleClosed` triggers a re-render, and we loop forever -// ("Maximum update depth exceeded"). Module-level constant fixes it. -const SUBSCRIBE_OPTS = { closeOnEose: true } as const; - interface UseNostrProfileMetadataResult { metadata: NostrProfileMetadata | undefined; isLoading: boolean; } +const MAX_FETCH_ATTEMPTS = 3; +const RETRY_BACKOFF_MS = 4_000; + export function useNostrProfileMetadata(pubkey: string | undefined): UseNostrProfileMetadataResult { const setProfile = useNostrMetadataCache((s) => s.setProfile); const { metadata, isStale, isMissing } = useCachedNostrProfile(pubkey ?? ''); + const [isFetching, setIsFetching] = useState(false); + + // Per-pubkey attempt counter, capped at MAX_FETCH_ATTEMPTS. A facade fetch can + // come back empty for a TRANSIENT reason (a tier was momentarily down / the + // cache hadn't warmed). The old code marked the pubkey done after ONE such miss + // and never retried, so kind-0 could stay missing forever. We now retry on a + // short backoff a bounded number of times; a genuine not-found still settles + // after the cap without looping. + const attempts = useRef>(new Map()); + const [retryNonce, setRetryNonce] = useState(0); + const attemptCount = pubkey ? (attempts.current.get(pubkey) ?? 0) : MAX_FETCH_ATTEMPTS; + const needsFetch = !!pubkey && (isMissing || isStale) && attemptCount < MAX_FETCH_ATTEMPTS; - const filters = useMemo(() => { - if (!pubkey) return null; - if (!isMissing && !isStale) return null; - return [{ kinds: [Metadata], authors: [pubkey], limit: 1 }]; - }, [pubkey, isMissing, isStale]); - - const { events, eose } = useSubscribe({ filters, opts: SUBSCRIBE_OPTS }); - - // NDK hands back a fresh `events` array on every relay buffer flush. - // Without an event-id guard, we'd JSON.parse the same kind-0 ~50ms on - // every flush during EOSE traffic. Track the last id we processed. - const lastProcessedId = useRef(null); useEffect(() => { - if (!pubkey || !events?.length) return; - const newest = events.reduce( - (best, e) => ((e.created_at ?? 0) > (best.created_at ?? 0) ? e : best), - events[0] - ); - if (newest.id === lastProcessedId.current) return; - lastProcessedId.current = newest.id ?? null; - const parsed = parseRawMetadata(newest.content); - if (!parsed) { - nostrLog.warn('nostr.metadata.parse_failed', { pubkey: pubkey.slice(0, 8) }); - return; - } - setProfile(pubkey, parsed); - }, [events, pubkey, setProfile]); - - const isLoading = isMissing && !eose; + if (!pubkey || !needsFetch) return; + attempts.current.set(pubkey, (attempts.current.get(pubkey) ?? 0) + 1); + let cancelled = false; + let retryTimer: ReturnType | undefined; + setIsFetching(true); + void fetchProfilesViaFacade([pubkey]) + .then((profiles) => { + if (cancelled) return; + const found = profiles[pubkey]; + if (found) { + attempts.current.set(pubkey, MAX_FETCH_ATTEMPTS); // resolved → stop retrying + setProfile(pubkey, found); + return; + } + // Nothing resolved this round — schedule a bounded retry. + retryTimer = setTimeout(() => { + if (!cancelled) setRetryNonce((n) => n + 1); + }, RETRY_BACKOFF_MS); + }) + .finally(() => { + if (!cancelled) setIsFetching(false); + }); + return () => { + cancelled = true; + if (retryTimer) clearTimeout(retryTimer); + }; + // retryNonce drives the bounded retry: bumping it re-runs the effect, which + // re-reads the (now-incremented) attempt count through `needsFetch`. + }, [pubkey, needsFetch, retryNonce, setProfile]); + + const isLoading = isMissing && isFetching; return { metadata, isLoading }; } @@ -126,50 +135,42 @@ export function useNostrProfileMetadataMany( // eslint-disable-next-line react-hooks/exhaustive-deps }, [stableKey, byPubkey]); - const filters = useMemo(() => { - if (pubkeys.length === 0) return null; + // Pubkeys missing or stale in the cache and not yet attempted this lifetime. + const attempted = useRef>(new Set()); + const toFetch = useMemo(() => { + if (pubkeys.length === 0) return []; const now = Date.now(); - const needsFetch: string[] = []; + const out: string[] = []; for (const pk of pubkeys) { + if (attempted.current.has(pk)) continue; const entry = byPubkey[pk]; - if (!entry || now - entry.fetchedAt > STALE_TTL_MS) needsFetch.push(pk); + if (!entry || now - entry.fetchedAt > STALE_TTL_MS) out.push(pk); } - if (needsFetch.length === 0) return null; - return [{ kinds: [Metadata], authors: needsFetch }]; + return out; // eslint-disable-next-line react-hooks/exhaustive-deps }, [stableKey, byPubkey]); - const { events, eose } = useSubscribe({ filters }); - - // Track high-water-mark for processed events so a fresh `events` - // reference (NDK buffer flush) doesn't re-parse rows we've already - // committed to the cache. The store's `setProfile` short-circuits - // identical writes anyway but JSON.parse of the full batch is the - // cost we want to avoid here. - const processedCount = useRef(0); + const [isFetching, setIsFetching] = useState(false); + const toFetchKey = toFetch.join(','); useEffect(() => { - if (!events?.length) return; - if (events.length === processedCount.current) return; - processedCount.current = events.length; - - const newestByPubkey = new Map(); - for (const e of events) { - const ts = e.created_at ?? 0; - const prev = newestByPubkey.get(e.pubkey); - if (!prev || ts > prev.created_at) { - newestByPubkey.set(e.pubkey, { content: e.content, created_at: ts }); - } - } - - const batch: Record> = {}; - for (const [pk, { content }] of newestByPubkey) { - const parsed = parseRawMetadata(content); - if (parsed) batch[pk] = parsed; - else nostrLog.warn('nostr.metadata.parse_failed', { pubkey: pk.slice(0, 8) }); - } - if (Object.keys(batch).length > 0) setManyProfiles(batch); - }, [events, setManyProfiles]); + if (toFetch.length === 0) return; + for (const pk of toFetch) attempted.current.add(pk); + let cancelled = false; + setIsFetching(true); + void fetchProfilesViaFacade(toFetch) + .then((profiles) => { + if (cancelled || Object.keys(profiles).length === 0) return; + setManyProfiles(profiles); + }) + .finally(() => { + if (!cancelled) setIsFetching(false); + }); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [toFetchKey, setManyProfiles]); - const isLoading = filters !== null && !eose; + const isLoading = isFetching; return { metadata, isLoading }; } diff --git a/shared/hooks/useTabBarBottomPadding.ts b/shared/hooks/useTabBarBottomPadding.ts index 44d32b625..d79a57e14 100644 --- a/shared/hooks/useTabBarBottomPadding.ts +++ b/shared/hooks/useTabBarBottomPadding.ts @@ -12,7 +12,7 @@ const NATIVE_TAB_BAR_HEIGHT = Platform.OS === 'ios' ? (Platform.isPad ? 50 : 49) /** * Bottom padding for in-tab scroll lists so the last row clears the native - * tab bar. Without this, items at the bottom of LegendList / FlatList sit + * tab bar. Without this, items at the bottom of FlashList / FlatList sit * behind the tab bar and become unclickable. * * Use as `contentContainerStyle={{ paddingBottom }}` on any list rendered diff --git a/shared/lib/apiClient.ts b/shared/lib/apiClient.ts index d55c259ce..29c514d19 100644 --- a/shared/lib/apiClient.ts +++ b/shared/lib/apiClient.ts @@ -8,12 +8,6 @@ import { type MintReviewsSummary, type RequestControls, } from '@sovranbitcoin/colada'; -import { - createNaggClient, - NaggProfileSearchDataSchema, - type NaggProfileSearchResult, -} from '@sovranbitcoin/nagg-ts'; -import { PROFILE_SEARCH_QUERY, profileSearchInput } from '@sovranbitcoin/nagg-ts/recipes'; import { ok, err, Result, ResultAsync } from 'neverthrow'; import { z } from 'zod'; import { apiLog } from './logger'; @@ -23,7 +17,6 @@ import { LatestVersionResponse, MintSearchResponse, NostrProfileFull as NostrProfileFullStrict, - SearchUsersResponse, TopFollower as TopFollowerStrict, loggableIssues, parseWith, @@ -70,8 +63,6 @@ type MintReviewsResponseType = { lastUpdated: number | null; fromCache: boolean; }; -type SearchUsersResponseType = z.infer; - const API_BASE_URL = backendConfig.apiBaseUrl; const SCORE_API_BASE_URL = backendConfig.scoreApiBaseUrl; @@ -88,15 +79,6 @@ const mintReviewsEnrichment = createNostrGraphqlMintEnrichment({ endpoint: backendConfig.nostrGraphqlEndpoint, timeoutMs: DEFAULT_TIMEOUT_MS, }); -const nostrGraphqlClient = createNaggClient({ - endpoint: backendConfig.nostrGraphqlEndpoint, - // Expose nagg's REST app-view (/v1/nostr/*) alongside GraphQL. `transport` - // defaults to 'graphql', so behavior is unchanged until a query opts in with - // `transport: 'appview'` + an `appView` binding (see nagg-ts NaggAppViewBinding). - appView: { baseUrl: backendConfig.nostrAppViewBaseUrl, version: 'v1' }, - defaultTimeoutMs: DEFAULT_TIMEOUT_MS, -}); - // Re-export schema-derived types for callers that previously imported them // from this module. export type { @@ -255,7 +237,6 @@ function describeRoute(url: string): { host: string; path: string } { // Parsers — hoisted to module scope to avoid Zod v4 JIT cost on each call. // --------------------------------------------------------------------------- -const parseSearchUsers = parseWith(SearchUsersResponse, 'graphql.profileSearch'); const parseAuditMint = parseWith(AuditMintResponse, 'cashu/mint/audit'); const parseMintSearch = parseWith(MintSearchResponse, 'cashu/mints/search'); const parseNostrProfile = parseWith(NostrProfileFull, 'nostr/profile'); @@ -289,110 +270,10 @@ const parseMintInfo = (input: unknown): Result => { return ok(input as GetInfoResponse); }; -function profileSearchCreatedAtSeconds(value: NaggProfileSearchResult['createdAt']) { - if (value == null) return undefined; - if (typeof value === 'number') return Number.isFinite(value) ? Math.floor(value) : undefined; - const ms = value instanceof Date ? value.getTime() : Date.parse(value); - if (!Number.isFinite(ms)) return undefined; - return Math.floor(ms / 1000); -} - -function mapProfileSearchResult(row: NaggProfileSearchResult) { - const createdAt = profileSearchCreatedAtSeconds(row.createdAt); - return { - pubkey: row.pubkey, - npub: row.npub, - ...(typeof row.rank === 'number' ? { rank: row.rank } : {}), - ...(typeof row.score === 'number' || row.score === null ? { score: row.score } : {}), - ...(row.name ? { name: row.name } : {}), - ...(row.displayName ? { displayName: row.displayName } : {}), - ...(row.picture ? { picture: row.picture } : {}), - ...(row.image ? { image: row.image } : {}), - ...(row.banner ? { banner: row.banner } : {}), - ...(row.about ? { about: row.about } : {}), - ...(row.nip05 ? { nip05: row.nip05 } : {}), - ...(typeof row.nip05Valid === 'boolean' ? { nip05Valid: row.nip05Valid } : {}), - ...(row.website ? { website: row.website } : {}), - ...(row.lud16 ? { lud16: row.lud16 } : {}), - ...(row.lud06 ? { lud06: row.lud06 } : {}), - ...(typeof row.followers === 'number' ? { followers: row.followers } : {}), - ...(typeof row.follows === 'number' ? { follows: row.follows } : {}), - ...(createdAt !== undefined ? { created_at: createdAt } : {}), - }; -} - // --------------------------------------------------------------------------- // Public API client functions // --------------------------------------------------------------------------- -export const searchUsers = async ({ - query, - limit = 10, - signal, -}: { - query: string; - limit?: number; - signal?: AbortSignal; -}): Promise> => { - const startedAt = Date.now(); - const route = describeRoute(backendConfig.nostrGraphqlEndpoint); - const logFields = { - ...route, - operationName: 'ProfileSearch', - queryLength: query.trim().length, - limit, - }; - - apiLog.info('api.profile_search.start', logFields); - const result = await nostrGraphqlClient.query({ - query: PROFILE_SEARCH_QUERY, - operationName: 'ProfileSearch', - variables: { input: profileSearchInput({ query, limit }) }, - dataSchema: NaggProfileSearchDataSchema, - signal, - }); - - if (result.isErr()) { - apiLog.warn('api.profile_search.failed', { - ...logFields, - durationMs: Date.now() - startedAt, - errorType: result.error.type, - message: result.error.message, - }); - return err(new Error(result.error.message)); - } - - const raw = { - query: result.value.profileSearch.query, - limit: result.value.profileSearch.limit, - sort: result.value.profileSearch.sort, - fromCache: result.value.profileSearch.fromCache, - results: result.value.profileSearch.nodes.map(mapProfileSearchResult), - }; - - const parsed = parseSearchUsers(raw); - if (parsed.isErr()) { - apiLog.warn('api.parse_failed', { - where: 'graphql.profileSearch', - issues: loggableIssues(parsed.error), - }); - apiLog.warn('api.profile_search.failed', { - ...logFields, - durationMs: Date.now() - startedAt, - errorType: 'parse', - }); - return err(toError(parsed.error)); - } - - apiLog.info('api.profile_search.done', { - ...logFields, - durationMs: Date.now() - startedAt, - resultCount: parsed.value.results.length, - fromCache: parsed.value.fromCache, - }); - return ok(parsed.value); -}; - export const auditMint = ({ mintUrl, signal }: { mintUrl: string; signal?: AbortSignal }) => fetchJson( `${API_BASE_URL}/cashu/mint/audit?mintUrl=${encodeURIComponent(mintUrl)}`, diff --git a/shared/lib/cashu/profileScopedStorage.ts b/shared/lib/cashu/profileScopedStorage.ts index 78eb126dc..d43c73783 100644 --- a/shared/lib/cashu/profileScopedStorage.ts +++ b/shared/lib/cashu/profileScopedStorage.ts @@ -217,9 +217,7 @@ async function rehydrateProfileStores(): Promise { contactsContent: '', contactsUpdatedAt: 0, followingPubkeys: {}, - likesByEventId: {}, - repostsByEventId: {}, - repliedByEventId: {}, + engagementByEventId: {}, deletedRepostOriginalIds: {}, optimisticFollowsByPubkey: {}, optimisticLikesByEventId: {}, diff --git a/shared/lib/loggerCore.ts b/shared/lib/loggerCore.ts index e49895eb2..674c239d4 100644 --- a/shared/lib/loggerCore.ts +++ b/shared/lib/loggerCore.ts @@ -285,6 +285,23 @@ const SECRET_STRING_PATTERNS: { name: string; test: (s: string) => boolean }[] = // their preview) when you need a readable identity in logs. Runs before the // base64/hex long-string patterns, which would otherwise preview it. { name: 'hex32', test: (s) => /^(0x)?[0-9a-fA-F]{64}$/.test(s) }, + // BIP32 extended PRIVATE keys (xprv/yprv/zprv + testnet tprv/uprv/vprv). + // base58, ~111 chars — without this they fall through to the base64 long + // pattern and (being ≤120) get logged in full. + { + name: 'xprv', + test: (s) => /^(xprv|yprv|zprv|tprv|uprv|vprv)[1-9A-HJ-NP-Za-km-z]{100,}$/.test(s), + }, + // WIF private keys: base58, mainnet 5/K/L, testnet 9/c, 51-52 chars. + // Otherwise classified as a generic long_string and logged in full. + { name: 'wif', test: (s) => /^[59cKL][1-9A-HJ-NP-Za-km-z]{50,51}$/.test(s) }, + // base64 that decodes to a 32- or 64-byte payload — the size of a private key + // or seed. Ambiguous (could be a 32-byte hash) but we hide rather than risk + // leaking key material; the length is still reported via `len`. + { + name: 'base64_key', + test: (s) => /^[A-Za-z0-9+/]{43}={0,1}$|^[A-Za-z0-9+/]{86,88}={0,2}$/.test(s), + }, ]; const EMBEDDED_SECRET_PATTERNS: { replacement: string; pattern: RegExp }[] = [ @@ -292,6 +309,10 @@ const EMBEDDED_SECRET_PATTERNS: { replacement: string; pattern: RegExp }[] = [ replacement: '', pattern: /\bnsec1[023456789acdefghjklmnpqrstuvwxyz]{58}\b/g, }, + { + replacement: '', + pattern: /\b(xprv|yprv|zprv|tprv|uprv|vprv)[1-9A-HJ-NP-Za-km-z]{100,}/g, + }, { replacement: '', pattern: /\bcashu[AB][A-Za-z0-9_-]{20,}/g, @@ -306,28 +327,38 @@ const EMBEDDED_SECRET_PATTERNS: { replacement: string; pattern: RegExp }[] = [ }, ]; -const LONG_STRING_PATTERNS: { name: string; test: (s: string) => boolean }[] = [ - { name: 'npub', test: (s) => /^npub1[023456789acdefghjklmnpqrstuvwxyz]{58}$/.test(s) }, - { name: 'base64', test: (s) => /^[A-Za-z0-9+/]{60,}={0,2}$/.test(s) }, - { name: 'hex', test: (s) => /^(0x)?[0-9a-fA-F]{40,}$/.test(s) }, - { - name: 'uuid', - test: (s) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s), - }, - { name: 'url', test: (s) => /^https?:\/\/.{80,}/.test(s) }, - { name: 'json_blob', test: (s) => s.length > 200 && (s[0] === '{' || s[0] === '[') }, - { name: 'xml_blob', test: (s) => s.length > 200 && s.trimStart().startsWith('<') }, - { name: 'solana_pubkey', test: (s) => /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(s) && s.length >= 32 }, -]; +// `noPreview` marks opaque encodings whose first 32 chars could themselves be +// key material — emit `{ _kind, len }` only, never the bytes (even when short +// enough to otherwise print in full). +const LONG_STRING_PATTERNS: { name: string; noPreview?: boolean; test: (s: string) => boolean }[] = + [ + { name: 'npub', test: (s) => /^npub1[023456789acdefghjklmnpqrstuvwxyz]{58}$/.test(s) }, + { name: 'base64', noPreview: true, test: (s) => /^[A-Za-z0-9+/]{60,}={0,2}$/.test(s) }, + { name: 'hex', noPreview: true, test: (s) => /^(0x)?[0-9a-fA-F]{40,}$/.test(s) }, + { + name: 'uuid', + test: (s) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s), + }, + { name: 'url', test: (s) => /^https?:\/\/.{80,}/.test(s) }, + { name: 'json_blob', test: (s) => s.length > 200 && (s[0] === '{' || s[0] === '[') }, + { name: 'xml_blob', test: (s) => s.length > 200 && s.trimStart().startsWith('<') }, + // Opaque base58 (>=32 chars): could be a Solana pubkey, a base58-encoded key, + // or other key material. Indistinguishable by value, so never show the bytes. + { + name: 'base58', + noPreview: true, + test: (s) => /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(s) && s.length >= 32, + }, + ]; type StringClass = | { kind: 'secret'; name: string } - | { kind: 'long'; name: string } - | { kind: 'long'; name: 'long_string' }; + | { kind: 'long'; name: string; noPreview?: boolean }; function classifyString(s: string): StringClass { for (const p of SECRET_STRING_PATTERNS) if (p.test(s)) return { kind: 'secret', name: p.name }; - for (const p of LONG_STRING_PATTERNS) if (p.test(s)) return { kind: 'long', name: p.name }; + for (const p of LONG_STRING_PATTERNS) + if (p.test(s)) return { kind: 'long', name: p.name, noPreview: p.noPreview }; return { kind: 'long', name: 'long_string' }; } @@ -349,6 +380,9 @@ function summarizeString(s: string, maxLen: number): Compact { if (redacted.length <= maxLen) return redacted; return { _kind: 'redacted_string', len: s.length, preview: redacted.slice(0, 32) + '…' }; } + // Opaque encodings (hex/base64/base58) could be key material — never show + // their bytes, even when short enough to otherwise print in full. + if (c.kind === 'long' && c.noPreview) return { _kind: c.name, len: s.length }; if (s.length <= maxLen) return s; return { _kind: c.name, len: s.length, preview: s.slice(0, 32) + '…' }; } diff --git a/shared/lib/nostr/buildNostrDataLayer.ts b/shared/lib/nostr/buildNostrDataLayer.ts new file mode 100644 index 000000000..9412d5d8c --- /dev/null +++ b/shared/lib/nostr/buildNostrDataLayer.ts @@ -0,0 +1,129 @@ +import { createNaggClient, setNostrLogger, facade, type NostrLogger } from '@sovranbitcoin/nagg-ts'; + +import { log } from '@/shared/lib/logger'; +import { getNostrTierConfig } from '@/shared/lib/nostr/nostrTierConfig'; +import { useProfileStore } from '@/shared/stores/global/profileStore'; + +// --------------------------------------------------------------------------- +// The tier-selecting Nostr data-layer facade — a PROFILE-SCOPED SINGLETON. +// +// One shared facade (and therefore one shared entity cache) is memoised per +// active profile + tier configuration. Every caller — feed, thread, DM, profile +// search — gets the SAME instance, so a profile fetched for search instantly +// renders in the feed, and a note seen in the feed opens as a thread with no +// refetch. (Previously each call built a fresh facade with a fresh cache, so the +// cache never accumulated across reads.) +// +// On identity switch the active pubkey changes, so the memo misses and the old +// profile-scoped cache is cleared and replaced — one account never serves +// another's cached data (ADR-0002). Tier toggles (Settings → Network) likewise +// rebuild, so a disabled tier drops out of the nagg → Primal → relay chain. +// +// NOTE (local-dev proof): the facade only exists in the LOCAL (symlinked) +// nagg-ts; the published registry 0.5.0 predates it, so an EAS/release build +// fails to resolve these symbols until nagg-ts is published. Local dev is fine. +// --------------------------------------------------------------------------- + +let loggerBridged = false; + +/** Route nagg-ts's structured logs into the app logger so the tier trace is visible. */ +function bridgeNostrLoggerOnce(): void { + if (loggerBridged) return; + loggerBridged = true; + const bridge: NostrLogger = { + debug: (event, data) => log.debug(event, data), + info: (event, data) => log.info(event, data), + warn: (event, data) => log.warn(event, data), + }; + setNostrLogger(bridge); +} + +type TierConfig = ReturnType; + +/** Assemble a fresh facade whose tiers reflect the enable/disable toggles. */ +function assembleLayer(config: TierConfig): facade.NostrDataLayer | null { + const tiers: facade.NostrTierStrategy[] = []; + + if (config.nagg.enabled) { + const client = createNaggClient({ + appView: { baseUrl: config.nagg.appViewBaseUrl, version: 'v1' }, + }); + tiers.push(facade.createNaggTier({ client })); + } + + if (config.primal.enabled) { + const connection = facade.primal.createPrimalWebSocketConnection({ url: config.primal.url }); + tiers.push(facade.primal.createPrimalTier({ connection })); + } + + if (config.relay.enabled) { + const connection = facade.relay.createRelayPoolConnection({ relays: [...config.relay.relays] }); + tiers.push(facade.relay.createRelayTier({ connection })); + } + + if (tiers.length === 0) { + log.warn('nostr.facade.no_tiers_enabled'); + return null; + } + return facade.createNostrDataLayer({ tiers }); +} + +type LayerMemo = { + layer: facade.NostrDataLayer; + configKey: string; + pubkey: string | undefined; +}; + +let memo: LayerMemo | null = null; + +function activeViewerPubkey(): string | undefined { + return useProfileStore.getState().getActiveProfile()?.pubkey; +} + +/** + * Seed the active profile's cached name/picture (from profileStore) into the + * entity cache at low confidence ('cache' rank, seenAt 0) so the viewer's own + * pfp/name render instantly — notably on the notifications screen, whose targets + * are the viewer's own posts — with zero fetch. A real kind-0 from any tier + * overrides it. Re-run on every (re)build so it survives an identity switch. + */ +function seedOwnProfile(layer: facade.NostrDataLayer): void { + const profile = useProfileStore.getState().getActiveProfile(); + if (!profile?.pubkey) return; + const { cachedDisplayName, cachedPicture } = profile; + if (!cachedDisplayName && !cachedPicture) return; + layer.cache.ingestProfileInfos( + { + [profile.pubkey]: { + name: cachedDisplayName ?? '', + ...(cachedPicture ? { picture: cachedPicture } : {}), + }, + }, + 'cache' + ); +} + +/** + * The profile-scoped Nostr data-layer singleton (see file header). Returns the + * memoised facade for the active profile + tier config, rebuilding only when the + * identity or the toggles change. Returns null when every tier is disabled. + */ +export function buildNostrDataLayer(): facade.NostrDataLayer | null { + bridgeNostrLoggerOnce(); + const config = getNostrTierConfig(); + const configKey = JSON.stringify(config); + const pubkey = activeViewerPubkey(); + + if (memo && memo.configKey === configKey && memo.pubkey === pubkey) return memo.layer; + + // Identity switch: drop the prior profile's cache before serving the new one. + if (memo && memo.pubkey !== pubkey) { + memo.layer.cache.clear(); + log.info('nostr.facade.profile_switch_cleared'); + } + + const layer = assembleLayer(config); + if (layer) seedOwnProfile(layer); + memo = layer ? { layer, configKey, pubkey } : null; + return layer; +} diff --git a/shared/lib/nostr/fetchProfiles.ts b/shared/lib/nostr/fetchProfiles.ts new file mode 100644 index 000000000..48eef0129 --- /dev/null +++ b/shared/lib/nostr/fetchProfiles.ts @@ -0,0 +1,47 @@ +import type { facade } from '@sovranbitcoin/nagg-ts'; + +import { buildNostrDataLayer } from '@/shared/lib/nostr/buildNostrDataLayer'; + +type ProfileMetadata = facade.ProfileMetadata; + +/** + * Fetch kind-0 metadata for a set of pubkeys through the tier-selecting facade + * (Primal `user_infos` → raw relays). Returns pubkey → metadata for the ones a + * tier resolved; never throws. Empty when every tier is disabled or exhausted. + */ +export async function fetchProfilesViaFacade( + pubkeys: string[] +): Promise> { + if (pubkeys.length === 0) return {}; + const layer = buildNostrDataLayer(); + if (!layer) return {}; + const result = await layer.getProfiles({ pubkeys }); + return result.match( + (resolved) => resolved.profiles, + () => ({}) + ); +} + +/** + * Fetch one profile's header (kind-0 metadata + follow/follower/note counts + + * joined date) through the tier-selecting facade. Primal serves it via + * `user_profile`; the relay floor derives metadata + following-count. Returns + * null when every tier is disabled/exhausted. Reputation is NOT here — it's + * nagg-only and stays on the REST path. + */ +export async function fetchProfileStatsViaFacade( + pubkey: string, + options: { viewerPubkey?: string; signal?: AbortSignal } = {} +): Promise { + const layer = buildNostrDataLayer(); + if (!layer) return null; + const result = await layer.getProfileStats({ + pubkey, + ...(options.viewerPubkey ? { viewerPubkey: options.viewerPubkey } : {}), + ...(options.signal ? { signal: options.signal } : {}), + }); + return result.match( + (resolved) => resolved, + () => null + ); +} diff --git a/shared/lib/nostr/nostrTierConfig.ts b/shared/lib/nostr/nostrTierConfig.ts new file mode 100644 index 000000000..d664d80e3 --- /dev/null +++ b/shared/lib/nostr/nostrTierConfig.ts @@ -0,0 +1,54 @@ +import { backendConfig } from '@/shared/config/backend'; +import { log } from '@/shared/lib/logger'; +import { DEFAULT_RELAYS } from '@/shared/lib/nostr/outbox/defaults'; +import { useSettingsStore } from '@/shared/stores/global/settingsStore'; + +/** + * Resolved configuration for the resilient Nostr data layer's three tiers + * (nagg → Primal cache → raw relays), combining the dev enable/disable toggles + * (Settings → Developer) with the endpoint config. This is the single seam the + * facade wiring reads when the read paths are routed through `@sovranbitcoin/ + * nagg-ts` — a disabled tier is simply omitted from the fallback chain, so a + * developer can simulate "nagg down" / "Primal down" / "relays down". + * + * It deliberately holds NO reference to the facade itself (only plain config), + * so it ships safely while the app still resolves nagg-ts to the published + * registry version that predates the facade. + */ +export type NostrTierConfig = { + nagg: { enabled: boolean; appViewBaseUrl: string }; + primal: { enabled: boolean; url: string }; + relay: { enabled: boolean; relays: readonly string[] }; +}; + +export function getNostrTierConfig(): NostrTierConfig { + const s = useSettingsStore.getState(); + const config: NostrTierConfig = { + nagg: { enabled: s.naggTierEnabled, appViewBaseUrl: backendConfig.nostrAppViewBaseUrl }, + primal: { enabled: s.primalTierEnabled, url: backendConfig.primalCacheUrl }, + relay: { enabled: s.relayTierEnabled, relays: DEFAULT_RELAYS }, + }; + log.info('nostr.tierConfig.resolved', { + enabled: [ + config.nagg.enabled ? 'nagg' : null, + config.primal.enabled ? 'primal' : null, + config.relay.enabled ? 'relay' : null, + ].filter(Boolean), + naggUrl: config.nagg.appViewBaseUrl, + primalUrl: config.primal.url, + relays: config.relay.relays.length, + }); + return config; +} + +/** Reactive hook form for components that want to display tier state. */ +export function useNostrTierConfig(): NostrTierConfig { + const naggTierEnabled = useSettingsStore((st) => st.naggTierEnabled); + const primalTierEnabled = useSettingsStore((st) => st.primalTierEnabled); + const relayTierEnabled = useSettingsStore((st) => st.relayTierEnabled); + return { + nagg: { enabled: naggTierEnabled, appViewBaseUrl: backendConfig.nostrAppViewBaseUrl }, + primal: { enabled: primalTierEnabled, url: backendConfig.primalCacheUrl }, + relay: { enabled: relayTierEnabled, relays: DEFAULT_RELAYS }, + }; +} diff --git a/shared/lib/nostr/ownsync/useOwnSocialGraphSeed.ts b/shared/lib/nostr/ownsync/useOwnSocialGraphSeed.ts new file mode 100644 index 000000000..04caffe12 --- /dev/null +++ b/shared/lib/nostr/ownsync/useOwnSocialGraphSeed.ts @@ -0,0 +1,67 @@ +/** + * @fileoverview `useOwnSocialGraphSeed` — one-shot seed of the viewer's follow + * set from the tiered nagg-ts facade (nagg → Primal → raw relays), the + * read-side companion to `useOwnEventsSync`. + * + * Per ADR 0003 the facade is the seed/backfill source; the relay subscription in + * `useOwnEventsSync` survives as the live-delta listener. Both feed the SAME + * `nostrSocialStore` through its last-writer-wins gate (kind-3 `created_at`), so + * a facade seed and a relay delta can't fight. This hook only seeds the read + * side (`followingPubkeys`); the raw kind-3 (`contactsTags`/`content`) that the + * follow/unfollow write path re-publishes stays owned by the relay sub, which + * preserves the relay hints + petnames the facade's parsed `follows` drop. + * + * Fires once per pubkey. Best-effort: a disabled/exhausted tier chain is a + * no-op (the relay sub still seeds the store the old way). + */ +import { useEffect, useRef } from 'react'; + +import { nostrLog } from '@/shared/lib/logger'; +import { buildNostrDataLayer } from '@/shared/lib/nostr/buildNostrDataLayer'; +import { useNostrKeysContext } from '@/shared/providers/NostrKeysProvider'; +import { useNostrSocialStore } from '@/shared/stores/profile/nostrSocialStore'; + +export function useOwnSocialGraphSeed(): void { + const { keys } = useNostrKeysContext(); + const pubkey = keys?.pubkey; + + // One seed per pubkey — a not-found / empty graph must not re-loop the effect. + const seededFor = useRef(null); + + useEffect(() => { + if (!pubkey || seededFor.current === pubkey) return; + seededFor.current = pubkey; + + const layer = buildNostrDataLayer(); + if (!layer) { + nostrLog.debug('nostr.ownsync.socialGraph.no_tiers'); + return; + } + + let cancelled = false; + void layer.getSocialGraph({ pubkey }).then((result) => { + if (cancelled) return; + result.match( + (graph) => { + nostrLog.info('nostr.ownsync.socialGraph.seeded', { + tier: graph.tier, + follows: graph.follows.length, + contactsUpdatedAt: graph.contactsUpdatedAt, + }); + useNostrSocialStore.getState().seedFollowsFromFacade({ + follows: graph.follows, + createdAt: graph.contactsUpdatedAt, + }); + }, + (error) => + nostrLog.debug('nostr.ownsync.socialGraph.exhausted', { + attempts: error.attempts.map((a) => `${a.tier}=${a.outcome}`), + }) + ); + }); + + return () => { + cancelled = true; + }; + }, [pubkey]); +} diff --git a/shared/lib/nostr/searchProfiles.ts b/shared/lib/nostr/searchProfiles.ts new file mode 100644 index 000000000..826159c49 --- /dev/null +++ b/shared/lib/nostr/searchProfiles.ts @@ -0,0 +1,92 @@ +import { nip19 } from 'nostr-tools'; +import { ok, type Result } from 'neverthrow'; +import type { facade } from '@sovranbitcoin/nagg-ts'; +import { NostrSearchResult, type SearchUsersResponse } from '@sovranbitcoin/schemas'; + +import { buildNostrDataLayer } from '@/shared/lib/nostr/buildNostrDataLayer'; + +// --------------------------------------------------------------------------- +// Profile search through the tier-selecting facade: nagg `/nostr/search` +// (Vertex-pagerank ranked, the gold path) → raw-relay NIP-50 floor. Returns the +// SAME `SearchUsersResponse` shape the old nagg-GraphQL `searchUsers` produced, +// so callers (useContactSearch + the split-bill picker) are unchanged. Which +// tiers run is gated by the Network settings toggles via buildNostrDataLayer. +// --------------------------------------------------------------------------- + +const EMPTY: SearchUsersResponse = { + query: '', + limit: 0, + sort: 'facade', + results: [], + fromCache: false, +}; + +/** Derive a bech32 npub, preferring the tier-supplied one; null if underivable. */ +function resolveNpub(hit: facade.ProfileSearchHit): string | null { + if (hit.npub) return hit.npub; + try { + return nip19.npubEncode(hit.pubkey); + } catch { + return null; + } +} + +function hitToSearchResult(hit: facade.ProfileSearchHit): NostrSearchResult | null { + const npub = resolveNpub(hit); + if (!npub) return null; + const m = hit.metadata; + // Per-hit parse bounds untrusted relay kind-0 content (field max-lengths, + // Hex64 pubkey) — a single oversized field drops just that hit, not the set. + const parsed = NostrSearchResult.safeParse({ + pubkey: hit.pubkey, + npub, + ...(typeof hit.rank === 'number' ? { rank: hit.rank } : {}), + ...(hit.score != null ? { score: hit.score } : {}), + ...(m.name ? { name: m.name } : {}), + ...(m.displayName ? { displayName: m.displayName } : {}), + ...(m.picture ? { picture: m.picture } : {}), + ...(m.banner ? { banner: m.banner } : {}), + ...(m.about ? { about: m.about } : {}), + ...(m.nip05 ? { nip05: m.nip05 } : {}), + ...(m.website ? { website: m.website } : {}), + ...(m.lud16 ? { lud16: m.lud16 } : {}), + ...(hit.followers != null ? { followers: hit.followers } : {}), + ...(hit.follows != null ? { follows: hit.follows } : {}), + }); + return parsed.success ? parsed.data : null; +} + +export async function searchProfilesViaFacade(args: { + query: string; + limit?: number; + signal?: AbortSignal; +}): Promise> { + const layer = buildNostrDataLayer(); + if (!layer) return ok({ ...EMPTY, query: args.query }); + + const result = await layer.searchProfiles({ + query: args.query, + ...(typeof args.limit === 'number' ? { limit: args.limit } : {}), + ...(args.signal ? { signal: args.signal } : {}), + }); + + // Exhaustion (every tier disabled/failed/no-match) is an empty answer, not an + // error — the search UI shows "no results" rather than a failure toast. + return result.match( + (resolved) => { + const results: NostrSearchResult[] = []; + for (const hit of resolved.hits) { + const mapped = hitToSearchResult(hit); + if (mapped) results.push(mapped); + } + return ok({ + query: args.query, + limit: args.limit ?? results.length, + sort: `facade:${resolved.tier}`, + results, + fromCache: false, + }); + }, + () => ok({ ...EMPTY, query: args.query }) + ); +} diff --git a/shared/lib/nostr/useEntityCache.ts b/shared/lib/nostr/useEntityCache.ts new file mode 100644 index 000000000..104cabf44 --- /dev/null +++ b/shared/lib/nostr/useEntityCache.ts @@ -0,0 +1,97 @@ +import { useCallback, useMemo, useSyncExternalStore } from 'react'; + +import { facade } from '@sovranbitcoin/nagg-ts'; + +import type { + FeedEvent, + NoteMetrics, + ProfileInfo, +} from '@/features/feed/components/nostr/feedTypes'; +import { buildNostrDataLayer } from '@/shared/lib/nostr/buildNostrDataLayer'; + +// --------------------------------------------------------------------------- +// Reactive bindings over the authoritative nagg-ts entity cache. +// +// A row reads its author/profile/metrics straight from the singleton cache and +// re-renders ONLY when that specific key changes (subscribeKey) — so a feed-wide +// ingest doesn't churn unaffected rows, and a profile that arrives later fills +// in place with no manual re-keying. `get(key)` returns a stable reference for an +// unchanged record (writes are idempotent in the store), which is exactly what +// useSyncExternalStore needs to avoid render loops. +// --------------------------------------------------------------------------- + +export type ProfileStatus = 'cached' | 'loading' | 'absent'; + +const NOOP_UNSUB = () => {}; + +function useCachedRecord( + store: facade.NormalizingStore | undefined, + key: string | undefined +): T | undefined { + const subscribe = useCallback( + (onChange: () => void) => (store && key ? store.subscribeKey(key, onChange) : NOOP_UNSUB), + [store, key] + ); + const getSnapshot = useCallback(() => (store && key ? store.get(key) : undefined), [store, key]); + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +function usePendingProfile( + pending: facade.PendingSet | undefined, + key: string | undefined +): boolean { + const subscribe = useCallback( + (onChange: () => void) => (pending && key ? pending.subscribeKey(key, onChange) : NOOP_UNSUB), + [pending, key] + ); + const getSnapshot = useCallback( + () => (pending && key ? pending.has(key) : false), + [pending, key] + ); + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +/** + * The author profile for a pubkey, plus a status that distinguishes a real + * cached record from a fetch-in-flight (skeleton) and a genuinely-absent profile + * (fallback). Reads the per-active-profile singleton cache. + */ +export function useProfile(pubkey: string | undefined): { + profile: ProfileInfo | undefined; + status: ProfileStatus; +} { + const cache = buildNostrDataLayer()?.cache; + const record = useCachedRecord(cache?.profiles, pubkey); + const pending = usePendingProfile(cache?.pendingProfiles, pubkey); + return useMemo(() => { + const profile: ProfileInfo | undefined = record + ? { name: record.name ?? '', ...(record.picture ? { picture: record.picture } : {}) } + : undefined; + const status: ProfileStatus = record ? 'cached' : pending ? 'loading' : 'absent'; + return { profile, status }; + }, [record, pending]); +} + +/** A cached note body by id (structurally a FeedEvent). */ +export function useNote(id: string | undefined): FeedEvent | undefined { + const cache = buildNostrDataLayer()?.cache; + return useCachedRecord(cache?.notes, id); +} + +/** Cached engagement metrics for a note id, mapped to the app's NoteMetrics shape. */ +export function useNoteStats(id: string | undefined): NoteMetrics | undefined { + const cache = buildNostrDataLayer()?.cache; + const record = useCachedRecord(cache?.noteStats, id); + return useMemo( + () => + record + ? { + likeCount: record.likes, + repostCount: record.reposts, + replyCount: record.replies, + satsZapped: record.satsZapped, + } + : undefined, + [record] + ); +} diff --git a/shared/lib/popup/popups/emojiPicker.tsx b/shared/lib/popup/popups/emojiPicker.tsx index b6ee8692f..3358cea09 100644 --- a/shared/lib/popup/popups/emojiPicker.tsx +++ b/shared/lib/popup/popups/emojiPicker.tsx @@ -24,24 +24,18 @@ import { StyleSheet } from 'react-native'; import { Pressable } from '@/shared/ui/primitives/Pressable'; import { BottomSheetScrollView, BottomSheetTextInput } from '@gorhom/bottom-sheet'; import { BottomSheet } from 'heroui-native'; -import { LegendList, type LegendListRef } from '@legendapp/list/react-native'; import * as Clipboard from 'expo-clipboard'; import opacity from 'hex-color-opacity'; import Icon, { CurrencyIcon } from 'assets/icons'; import { encode } from '@/shared/lib/third-party/emoji'; import { log, useRenderLogger } from '@/shared/lib/logger'; -import { - VISUAL_LIST_VIEWABILITY_CONFIG, - useVisualListLogger, - visualLayoutScopePart, -} from '@/shared/lib/contentShiftLog'; import { AnimatedEmoji } from '@/shared/ui/primitives/AnimatedEmoji'; import { Text } from '@/shared/ui/primitives/Text'; import { View } from '@/shared/ui/primitives/View/View'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { SectionAnchorList, type AnchorSection } from '@/shared/ui/composed/SectionAnchorList'; -import { VisualLayoutProbe } from '@/shared/ui/composed/VisualLayoutProbe'; +import { List } from '@/shared/ui/composed/List'; import { showActionSheet } from './bridge'; import { copyPopup } from './copy'; @@ -53,14 +47,9 @@ const emojiLog = log.child({ module: 'emojiPicker' }); const COLS = 6; const CELL_WIDTH_PCT = `${100 / COLS}%` as const; -/** Estimated height of one emoji row — six 28pt cells + paddingVertical:6 - * on each cell ≈ 40px. LegendList uses this to size the virtualization - * window before rows have measured. Rows are equal-height so this - * estimate is exact. */ -const EMOJI_ROW_HEIGHT = 40; /** - * Single emoji cell. Memoized so LegendList's row recycling can detect + * Single emoji cell. Memoized so the list's row recycling can detect * "same emoji + same onSelect identity = same content" and skip the * inner re-render. With ~6 cells per row and dozens of rows recycled * during a long scroll, this is the difference between a smooth jump @@ -92,10 +81,10 @@ const EmojiCell = React.memo(function EmojiCell({ /** * Single virtualized row of emojis (up to `COLS` cells). Memoized so - * LegendList row recycling skips the row's outer render when its - * `emojis` array reference is stable. Used both as the per-row - * renderer for category sections inside `SectionAnchorList` AND as - * the `renderItem` for the search-results `LegendList` override. + * list row recycling skips the row's outer render when its `emojis` + * array reference is stable. Used both as the per-row renderer for + * category sections inside `SectionAnchorList` AND as the `renderItem` + * for the search-results `List` override. */ const EmojiRow = React.memo(function EmojiRow({ emojis, @@ -343,7 +332,7 @@ export function EmojiPickerContent({ [] ); - // Pre-chunk search results so the override `LegendList` virtualizes + // Pre-chunk search results so the override `List` virtualizes // per row (not per cell) — matches the rowChunkSize=6 layout of the // sectioned mode, so cells stay on the same x-grid as the search bar. const searchRows = useMemo(() => chunkEmojis(searchResults), [searchResults]); @@ -352,55 +341,14 @@ export function EmojiPickerContent({ (items: EmojiEntry[]) => , [handleEmojiSelect] ); - const searchListRef = useRef(null); - const visualSearchList = useVisualListLogger({ - scope: 'emoji.search.list', - surface: 'composer', - component: 'EmojiSearchLegendList', - phase: isSearching ? 'searching' : 'idle', - extra: () => ({ - queryLength: searchQuery.length, - resultCount: searchResults.length, - rowCount: searchRows.length, - cols: COLS, - }), - getItemKey: (row, index) => visualLayoutScopePart(searchRowKeyExtractor(row, index)), - getItemContext: (row, index) => ({ - itemType: 'emoji-search-row', - index, - emojiCount: row.length, - firstKeyword: row[0]?.keywords[0] ?? null, - }), - getListState: () => searchListRef.current?.getState() ?? null, - }); const renderEmojiSearchRow = useCallback( - ({ item, index }: { item: EmojiEntry[]; index: number }) => { - const rowKey = searchRowKeyExtractor(item, index); - return ( - - - - ); - }, - [handleEmojiSelect, isSearching, searchQuery.length, searchResults.length] + ({ item }: { item: EmojiEntry[] }) => , + [handleEmojiSelect] ); // `overrideContent` swaps the body wholesale. Empty search → centered - // "No emoji found"; non-empty → its own virtualized `LegendList` so - // the override path stays cheap with hundreds of matches. + // "No emoji found"; non-empty → its own virtualized `List` so the + // override path stays cheap with hundreds of matches. const overrideContent = useMemo(() => { if (!isSearching) return null; if (searchResults.length === 0) { @@ -411,38 +359,21 @@ export function EmojiPickerContent({ ); } return ( - - ref={searchListRef} + data={searchRows} keyExtractor={searchRowKeyExtractor} renderItem={renderEmojiSearchRow} - estimatedItemSize={EMOJI_ROW_HEIGHT} - recycleItems - onItemSizeChanged={visualSearchList.onItemSizeChanged} - onLoad={visualSearchList.onLoad} - onMetricsChange={visualSearchList.onMetricsChange} - onStickyHeaderChange={visualSearchList.onStickyHeaderChange} - onViewableItemsChanged={visualSearchList.onViewableItemsChanged} - viewabilityConfig={VISUAL_LIST_VIEWABILITY_CONFIG} // Match the SectionAnchorList draw window so search and // sectioned mode have the same buffer behavior on fast scroll. drawDistance={150} contentContainerStyle={{ paddingBottom: 24 }} keyboardShouldPersistTaps="handled" - showsVerticalScrollIndicator={false} renderScrollComponent={({ children, ...props }) => ( {children} )} /> ); - }, [ - isSearching, - searchResults.length, - searchRows, - foreground, - renderEmojiSearchRow, - visualSearchList, - ]); + }, [isSearching, searchResults.length, searchRows, foreground, renderEmojiSearchRow]); return ( @@ -455,7 +386,6 @@ export function EmojiPickerContent({ renderRow={renderEmojiRow} renderItem={noopRenderItem} keyExtractor={emojiKeyExtractor} - estimatedItemSize={EMOJI_ROW_HEIGHT} ScrollComponent={BottomSheetScrollView as never} overrideContent={overrideContent} // Tapers to `overlay` so the top fade matches the BottomSheet's diff --git a/shared/lib/popup/popups/sendMemoSheet.tsx b/shared/lib/popup/popups/sendMemoSheet.tsx index 5c5f19482..05e680af3 100644 --- a/shared/lib/popup/popups/sendMemoSheet.tsx +++ b/shared/lib/popup/popups/sendMemoSheet.tsx @@ -15,7 +15,6 @@ import { type TextInputSelectionChangeEventData, } from 'react-native'; import { BottomSheetTextInput } from '@gorhom/bottom-sheet'; -import { LegendList, type LegendListRef } from '@legendapp/list/react-native'; import { BottomSheet } from 'heroui-native'; import opacity from 'hex-color-opacity'; import { ScrollView as GestureScrollView } from 'react-native-gesture-handler'; @@ -25,18 +24,14 @@ import Icon from 'assets/icons'; import { Button } from '@/shared/ui/primitives/Button'; import { Pressable } from '@/shared/ui/primitives/Pressable'; import { ContactRow, nostrIdentity } from '@/shared/ui/composed/ContactRow'; -import { VisualLayoutProbe } from '@/shared/ui/composed/VisualLayoutProbe'; +import { List } from '@/shared/ui/composed/List'; +import { SkeletonContentCrossfade } from '@/shared/ui/composed/SkeletonContentCrossfade'; import { Text } from '@/shared/ui/primitives/Text'; import { View } from '@/shared/ui/primitives/View/View'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { useNostrProfileMetadataMany } from '@/shared/hooks/useNostrProfileMetadata'; import { resolveIdentityName } from '@/shared/lib/identity'; import { relays as defaultRelays } from '@/shared/ndk'; -import { - VISUAL_LIST_VIEWABILITY_CONFIG, - useVisualListLogger, - visualLayoutScopePart, -} from '@/shared/lib/contentShiftLog'; import { CONTACT_SEARCH_MIN_LENGTH, useContactSearch, @@ -360,48 +355,16 @@ function MentionSearchResults({ trimmedQuery.length >= CONTACT_SEARCH_MIN_LENGTH && results.length === 0 && (searchLoading || !hasSearched); - const mentionListRef = useRef(null); - const visualList = useVisualListLogger({ - scope: 'composer.memo.mentions.list', - surface: 'composer', - component: 'SendMemoMentionList', - phase: showSkeletons ? 'loading' : 'results', - extra: () => ({ - queryLength: trimmedQuery.length, - resultCount: results.length, - panelHeight: height, - }), - getItemKey: (item) => item.pubkey, - getItemContext: (_item, index) => ({ - itemType: 'mention-result', - index, - }), - getListState: () => mentionListRef.current?.getState() ?? null, - }); - const renderResult = useCallback( - ({ item, index }: { item: MentionSearchResult; index: number }) => ( - - onSelectResult(item)} - testID={`send-memo-mention:${item.pubkey}`} - /> - + ({ item }: { item: MentionSearchResult }) => ( + onSelectResult(item)} + testID={`send-memo-mention:${item.pubkey}`} + /> ), - [onSelectResult, results.length, showSkeletons, trimmedQuery.length] + [onSelectResult] ); const keyExtractor = useCallback((item: MentionSearchResult) => item.pubkey, []); @@ -426,7 +389,16 @@ function MentionSearchResults({ /> ); } else if (showSkeletons) { - content = ; + content = ( + } + renderContent={() => null} + /> + ); } else if (results.length === 0) { content = ( - ref={mentionListRef} + data={results} keyExtractor={keyExtractor} renderItem={renderResult} - estimatedItemSize={68} - recycleItems - onItemSizeChanged={visualList.onItemSizeChanged} - onLoad={visualList.onLoad} - onMetricsChange={visualList.onMetricsChange} - onStickyHeaderChange={visualList.onStickyHeaderChange} - onViewableItemsChanged={visualList.onViewableItemsChanged} - viewabilityConfig={VISUAL_LIST_VIEWABILITY_CONFIG} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="always" - showsVerticalScrollIndicator={false} style={styles.mentionList} contentContainerStyle={styles.mentionListContent} renderScrollComponent={renderMentionScrollComponent} diff --git a/shared/stores/global/nostrMetadataCache.ts b/shared/stores/global/nostrMetadataCache.ts index 4fe7f751e..a02e2ebae 100644 --- a/shared/stores/global/nostrMetadataCache.ts +++ b/shared/stores/global/nostrMetadataCache.ts @@ -90,14 +90,22 @@ interface NostrMetadataCacheState { setManyProfiles: (entries: Record) => void; /** - * Low-confidence bulk seed for server-side search / recommendation - * responses. Two invariants vs `setManyProfiles`: - * - Never overwrites existing fields — relay-sourced kind-0 always - * wins because it's authoritative. - * - New entries get `fetchedAt: 0` so the next consumer treats them - * as immediately stale and triggers a real kind-0 fetch. The - * search snapshot is a first-paint hint, not a substitute. + * Low-confidence bulk seed (pubkey → partial metadata). Two invariants vs + * `setManyProfiles`: + * - Never overwrites existing fields — relay-sourced kind-0 always wins + * because it's authoritative; only fills gaps. + * - New entries get `fetchedAt: 0` so the next consumer treats them as + * immediately stale and triggers a real kind-0 fetch. The seed is a + * first-paint hint, not a substitute. + * + * Fed by any non-authoritative source — server search/recommendation + * snapshots AND the nagg feed's inline profiles — so the metadata cache is + * the SINGLE profile store instead of nagg profiles being a separate + * ephemeral map that the relay layer then re-fetches. */ + seedManyProfilesLowConfidence: (entries: Record) => void; + + /** Convenience over {@link seedManyProfilesLowConfidence} for search results. */ seedFromSearchResults: (results: SearchResultLike[]) => void; removeProfile: (pubkey: string) => void; @@ -146,7 +154,7 @@ const PersistedNostrMetadataCache = z.object({ export const useNostrMetadataCache = create()( persist( - (set) => ({ + (set, get) => ({ byPubkey: {}, setProfile: (pubkey, metadata) => { @@ -180,28 +188,28 @@ export const useNostrMetadataCache = create()( }); }, - seedFromSearchResults: (results) => { + seedManyProfilesLowConfidence: (entries) => { set((state) => { const next = { ...state.byPubkey }; let changed = 0; let inserted = 0; - for (const r of results) { - if (!r.pubkey) continue; - const existing = next[r.pubkey]; + for (const [pubkey, profile] of Object.entries(entries)) { + if (!pubkey) continue; + const existing = next[pubkey]; if (existing) { - const filled = fillMissing(existing, r.profile); + const filled = fillMissing(existing, profile); if (!fieldsEqual(filled, existing)) { - next[r.pubkey] = { ...filled, fetchedAt: existing.fetchedAt }; + next[pubkey] = { ...filled, fetchedAt: existing.fetchedAt }; changed++; } } else { - next[r.pubkey] = { ...r.profile, fetchedAt: 0 }; + next[pubkey] = { ...profile, fetchedAt: 0 }; inserted++; } } if (changed === 0 && inserted === 0) return state; evictIfOverCap(next); - storeLog.debug('store.nostr_metadata.seeded_from_search', { + storeLog.debug('store.nostr_metadata.seeded_low_confidence', { inserted, filled: changed, }); @@ -209,6 +217,12 @@ export const useNostrMetadataCache = create()( }); }, + seedFromSearchResults: (results) => { + const entries: Record = {}; + for (const r of results) if (r.pubkey) entries[r.pubkey] = r.profile; + get().seedManyProfilesLowConfidence(entries); + }, + removeProfile: (pubkey) => { set((state) => { if (!state.byPubkey[pubkey]) return state; diff --git a/shared/stores/global/settingsStore.ts b/shared/stores/global/settingsStore.ts index 7b7864339..b379b5f7e 100644 --- a/shared/stores/global/settingsStore.ts +++ b/shared/stores/global/settingsStore.ts @@ -72,6 +72,17 @@ interface SettingsState { * inert in production builds (see `loggerFile.ts`). */ fileLoggingEnabled: boolean; + /** + * Dev toggles: per-tier enablement for the resilient Nostr data layer + * (nagg → Primal cache → raw relays). Each flag, when false, removes that tier + * from the facade's fallback chain — so a developer can simulate "nagg is + * down", "Primal is down", or "relays are down" and watch the degradation. + * Default on. The facade reads these when it's wired into the read paths + * (see `nostrTierSettings`); until then they're inert. + */ + naggTierEnabled: boolean; + primalTierEnabled: boolean; + relayTierEnabled: boolean; avatarFallbackVariant: AvatarFallbackVariant; /** Minimum transfer amount in sats to include in a rebalance plan. */ minTransferThreshold: number; @@ -122,6 +133,9 @@ const PersistedSettings = z.object({ regenerateP2PKOnReceive: z.boolean().default(true), sendLocationEnabled: z.boolean().default(false), fileLoggingEnabled: z.boolean().default(false), + naggTierEnabled: z.boolean().default(true), + primalTierEnabled: z.boolean().default(true), + relayTierEnabled: z.boolean().default(true), avatarFallbackVariant: z.enum(AVATAR_FALLBACK_VARIANTS).default(DEFAULT_AVATAR_FALLBACK_VARIANT), minTransferThreshold: z.number().int().nonnegative().default(5), middlemanRouting: PersistedMiddlemanRouting.default({ @@ -152,6 +166,9 @@ const DEFAULT_SETTINGS: SettingsState = { regenerateP2PKOnReceive: true, sendLocationEnabled: false, fileLoggingEnabled: false, + naggTierEnabled: true, + primalTierEnabled: true, + relayTierEnabled: true, avatarFallbackVariant: DEFAULT_AVATAR_FALLBACK_VARIANT, minTransferThreshold: 5, middlemanRouting: DEFAULT_MIDDLEMAN_ROUTING, @@ -212,6 +229,11 @@ interface SettingsActions { setFileLoggingEnabled: (enabled: boolean) => void; getFileLoggingEnabled: () => boolean; + // Nostr data-layer per-tier enablement (dev) + setNaggTierEnabled: (enabled: boolean) => void; + setPrimalTierEnabled: (enabled: boolean) => void; + setRelayTierEnabled: (enabled: boolean) => void; + // Avatar fallback variation setAvatarFallbackVariant: (variant: AvatarFallbackVariant) => void; getAvatarFallbackVariant: () => AvatarFallbackVariant; @@ -346,6 +368,20 @@ export const useSettingsStore = create()( }, getFileLoggingEnabled: () => get().fileLoggingEnabled, + // Nostr data-layer tiers (dev) + setNaggTierEnabled: (enabled: boolean) => { + storeLog.info('store.settings.set_nagg_tier_enabled', { enabled }); + set({ naggTierEnabled: enabled }); + }, + setPrimalTierEnabled: (enabled: boolean) => { + storeLog.info('store.settings.set_primal_tier_enabled', { enabled }); + set({ primalTierEnabled: enabled }); + }, + setRelayTierEnabled: (enabled: boolean) => { + storeLog.info('store.settings.set_relay_tier_enabled', { enabled }); + set({ relayTierEnabled: enabled }); + }, + // Avatar fallback setAvatarFallbackVariant: (variant: AvatarFallbackVariant) => { storeLog.info('store.settings.set_avatar_fallback_variant', { variant }); diff --git a/shared/stores/profile/nostrSocialStore.ts b/shared/stores/profile/nostrSocialStore.ts index d35aba417..d37dce534 100644 --- a/shared/stores/profile/nostrSocialStore.ts +++ b/shared/stores/profile/nostrSocialStore.ts @@ -10,25 +10,28 @@ import { persistConfig } from '@/shared/lib/persist/persistConfig'; // Types // --------------------------------------------------------------------------- -type NostrReactionState = { - reactionEventId?: string; - updatedAt: number; -}; - -type NostrRepostState = { - repostEventId?: string; - updatedAt: number; -}; +/** One of our own actions on a target event (the `ownEventId` is OUR reaction / repost / reply). */ +type EngagementAction = { ownEventId?: string }; -type NostrRepliedState = { - replyEventId?: string; +/** + * Our viewer-state for a single target event: which of like / repost / reply we + * did, each with our own event id. ONE record per target replaces the former + * three parallel maps — adding a new engagement type (e.g. zap, bookmark) is a + * field here, not a whole new map. Confirmed state only; the optimistic overlay + * lives in the `optimistic*` maps and is merged by readers. + */ +type EngagementRecord = { + liked?: EngagementAction; + reposted?: EngagementAction; + replied?: EngagementAction; + /** Newest contributing action's `created_at`, for the recency cap. */ updatedAt: number; }; /** - * Recency cap on the canonical own-engagement maps. The global own-events sync + * Recency cap on the canonical own-engagement map. The global own-events sync * (`useOwnEventsSync`) can backfill thousands of our likes/reposts/replies; cap - * each map to the most-recent N by `updatedAt` so the persisted blob and + * the map to the most-recent N records by `updatedAt` so the persisted blob and * rehydrate stay bounded. Engagement older than the cap simply won't highlight * (rare, and the post would have to be re-encountered). */ @@ -55,10 +58,11 @@ interface NostrSocialState { contactsUpdatedAt: number; followingPubkeys: Record; - likesByEventId: Record; - repostsByEventId: Record; - /** Target event id → our reply to it. Drives the "you replied" highlight. */ - repliedByEventId: Record; + /** + * Target event id → our engagement record (liked / reposted / replied). One + * unified map; the `replied` field drives the "you replied" highlight. + */ + engagementByEventId: Record; deletedRepostOriginalIds: Record; optimisticFollowsByPubkey: Record; @@ -68,6 +72,16 @@ interface NostrSocialState { interface NostrSocialActions { setContactsFromRelay: (params: { tags: string[][]; content: string; createdAt: number }) => void; + /** + * Seed the read-side follow set from the tiered facade's social-graph bundle + * (nagg → Primal → relay). Read-only accelerator: it sets `followingPubkeys` + * and bumps the LWW gate but NEVER writes `contactsTags`/`content` — the raw + * kind-3 from `useOwnEventsSync`'s relay sub stays the write-authoritative + * source for follow/unfollow re-publish (it preserves relay hints + petnames + * the facade's parsed `follows` drop). LWW-gated by the kind-3 `created_at` so + * a facade seed and a relay delta can't fight — the newer contact list wins. + */ + seedFollowsFromFacade: (params: { follows: string[]; createdAt: number }) => void; setFollowOptimistic: (pubkey: string, value: boolean, pending: boolean) => void; clearFollowOptimistic: (pubkey: string) => void; clearSettledFollowOptimistic: () => void; @@ -162,15 +176,27 @@ function capByRecency( return next; } -/** Merge own-engagement rows (target → record) into a map, keeping the newest per target. */ -function upsertByTarget( - base: Record, - rows: { targetEventId: string; createdAt: number; record: V }[] -): Record { +type EngagementActionKind = 'liked' | 'reposted' | 'replied'; + +/** + * Merge one action type's own-engagement rows into the unified map: set the + * action on each target's record (preserving the record's other actions) and + * advance `updatedAt`. Recency-caps by record. Replaces the former per-map + * `upsertByTarget` — the merge is now additive across action types. + */ +function ingestEngagementAction( + base: Record, + action: EngagementActionKind, + rows: { targetEventId: string; ownEventId: string; createdAt: number }[] +): Record { const next = { ...base }; - for (const { targetEventId, createdAt, record } of rows) { + for (const { targetEventId, ownEventId, createdAt } of rows) { const existing = next[targetEventId]; - if (!existing || createdAt >= existing.updatedAt) next[targetEventId] = record; + next[targetEventId] = { + ...existing, + [action]: { ownEventId }, + updatedAt: Math.max(existing?.updatedAt ?? 0, createdAt), + }; } return capByRecency(next, MAX_ENGAGEMENT_ENTRIES); } @@ -184,9 +210,7 @@ const INITIAL_STATE: NostrSocialState = { contactsContent: '', contactsUpdatedAt: 0, followingPubkeys: {}, - likesByEventId: {}, - repostsByEventId: {}, - repliedByEventId: {}, + engagementByEventId: {}, deletedRepostOriginalIds: {}, optimisticFollowsByPubkey: {}, optimisticLikesByEventId: {}, @@ -197,16 +221,13 @@ const INITIAL_STATE: NostrSocialState = { // that grow unbounded if the user hammers reactions/follows offline. The .max() // caps below stop a runaway blob from hanging rehydrate; if the limits are hit // the merge falls back to defaults. -const PersistedReactionState = z.looseObject({ - reactionEventId: z.string().max(128).optional(), - updatedAt: z.number().int().nonnegative(), -}); -const PersistedRepostState = z.looseObject({ - repostEventId: z.string().max(128).optional(), - updatedAt: z.number().int().nonnegative(), +const PersistedEngagementAction = z.looseObject({ + ownEventId: z.string().max(128).optional(), }); -const PersistedRepliedState = z.looseObject({ - replyEventId: z.string().max(128).optional(), +const PersistedEngagementRecord = z.looseObject({ + liked: PersistedEngagementAction.optional(), + reposted: PersistedEngagementAction.optional(), + replied: PersistedEngagementAction.optional(), updatedAt: z.number().int().nonnegative(), }); const PersistedFollowOptimistic = z.looseObject({ @@ -231,9 +252,7 @@ const PersistedNostrSocialStore = z.object({ contactsContent: z.string().max(65_536).default(''), contactsUpdatedAt: z.number().int().nonnegative().default(0), followingPubkeys: z.record(z.string().max(128), z.literal(true)).default({}), - likesByEventId: z.record(z.string().max(128), PersistedReactionState).default({}), - repostsByEventId: z.record(z.string().max(128), PersistedRepostState).default({}), - repliedByEventId: z.record(z.string().max(128), PersistedRepliedState).default({}), + engagementByEventId: z.record(z.string().max(128), PersistedEngagementRecord).default({}), deletedRepostOriginalIds: z .record(z.string().max(128), z.number().int().nonnegative()) .default({}), @@ -277,6 +296,29 @@ export const useNostrSocialStore = create()( }); }, + seedFollowsFromFacade: ({ follows, createdAt }) => { + set((state) => { + // LWW: skip when the authoritative kind-3 (relay sub) already landed + // an equal-or-newer list. The relay path's own guard uses `<`, so an + // equal `created_at` there still fills the raw `contactsTags` we leave + // untouched here. + if (createdAt <= state.contactsUpdatedAt) { + storeLog.debug('social.contacts.seed.stale', { + createdAt, + current: state.contactsUpdatedAt, + }); + return state; + } + const following = extractFollowingFromTags(follows.map((pk) => ['p', pk])); + storeLog.info('social.contacts.seed', { + followingCount: Object.keys(following).length, + createdAt, + }); + // Read side only — no `contactsTags`/`content`; the relay sub owns those. + return { followingPubkeys: following, contactsUpdatedAt: createdAt }; + }); + }, + // ---- follow optimistic ---- setFollowOptimistic: (pubkey, value, pending) => { @@ -344,12 +386,13 @@ export const useNostrSocialStore = create()( if (likes.length === 0) return; storeLog.info('social.likes.ingest', { likeCount: likes.length }); set((state) => ({ - likesByEventId: upsertByTarget( - state.likesByEventId, + engagementByEventId: ingestEngagementAction( + state.engagementByEventId, + 'liked', likes.map((l) => ({ targetEventId: l.targetEventId, + ownEventId: l.reactionEventId, createdAt: l.createdAt, - record: { reactionEventId: l.reactionEventId, updatedAt: l.createdAt }, })) ), })); @@ -359,12 +402,13 @@ export const useNostrSocialStore = create()( if (reposts.length === 0) return; storeLog.info('social.reposts.ingest', { repostCount: reposts.length }); set((state) => ({ - repostsByEventId: upsertByTarget( - state.repostsByEventId, + engagementByEventId: ingestEngagementAction( + state.engagementByEventId, + 'reposted', reposts.map((r) => ({ targetEventId: r.targetEventId, + ownEventId: r.repostEventId, createdAt: r.createdAt, - record: { repostEventId: r.repostEventId, updatedAt: r.createdAt }, })) ), })); @@ -374,12 +418,13 @@ export const useNostrSocialStore = create()( if (replies.length === 0) return; storeLog.info('social.replies.ingest', { replyCount: replies.length }); set((state) => ({ - repliedByEventId: upsertByTarget( - state.repliedByEventId, + engagementByEventId: ingestEngagementAction( + state.engagementByEventId, + 'replied', replies.map((r) => ({ targetEventId: r.targetEventId, + ownEventId: r.replyEventId, createdAt: r.createdAt, - record: { replyEventId: r.replyEventId, updatedAt: r.createdAt }, })) ), })); @@ -389,32 +434,29 @@ export const useNostrSocialStore = create()( if (deletedEventIds.length === 0) return; const deleted = new Set(deletedEventIds); set((state) => { - const prune = ( - map: Record, - ownIdKey: keyof V - ): { next: Record; removed: number } => { - let removed = 0; - const next: Record = {}; - for (const [target, record] of Object.entries(map)) { - const ownId = record[ownIdKey]; - if (typeof ownId === 'string' && deleted.has(ownId)) { + let removed = 0; + const next: Record = {}; + const actions: EngagementActionKind[] = ['liked', 'reposted', 'replied']; + for (const [target, record] of Object.entries(state.engagementByEventId)) { + const kept: EngagementRecord = { updatedAt: record.updatedAt }; + let changed = false; + for (const action of actions) { + const entry = record[action]; + if (!entry) continue; + if (entry.ownEventId && deleted.has(entry.ownEventId)) { removed += 1; + changed = true; continue; } - next[target] = record; + kept[action] = entry; } - return { next, removed }; - }; - const likes = prune(state.likesByEventId, 'reactionEventId'); - const reposts = prune(state.repostsByEventId, 'repostEventId'); - const replies = prune(state.repliedByEventId, 'replyEventId'); - const removed = likes.removed + reposts.removed + replies.removed; + // Drop the record only when its last surviving action is gone. + if (kept.liked || kept.reposted || kept.replied) { + next[target] = changed ? kept : record; + } + } if (removed > 0) storeLog.info('social.own.deletions_applied', { removed }); - return { - likesByEventId: likes.next, - repostsByEventId: reposts.next, - repliedByEventId: replies.next, - }; + return { engagementByEventId: next }; }); }, @@ -474,9 +516,7 @@ export const useNostrSocialStore = create()( contactsContent: state.contactsContent, contactsUpdatedAt: state.contactsUpdatedAt, followingPubkeys: state.followingPubkeys, - likesByEventId: state.likesByEventId, - repostsByEventId: state.repostsByEventId, - repliedByEventId: state.repliedByEventId, + engagementByEventId: state.engagementByEventId, deletedRepostOriginalIds: state.deletedRepostOriginalIds, optimisticFollowsByPubkey: state.optimisticFollowsByPubkey, optimisticLikesByEventId: state.optimisticLikesByEventId, diff --git a/shared/stores/runtime/rollbackStore.ts b/shared/stores/runtime/rollbackStore.ts index 35cef5c83..e94274b89 100644 --- a/shared/stores/runtime/rollbackStore.ts +++ b/shared/stores/runtime/rollbackStore.ts @@ -12,7 +12,7 @@ import { create } from 'zustand'; // Failed reclaims skip `collapsing` entirely — the row stays as a normal // pending entry. // -// Both the row's height collapse and the LegendList sibling reflow are +// Both the row's height collapse and the FlashList sibling reflow are // driven from the same linear timing curve so they read as one continuous // rigid motion — no spring, no overshoot, no bounce. Hold the row in the // bucket until the timing curve has finished. diff --git a/shared/ui/composed/ContactRow.tsx b/shared/ui/composed/ContactRow.tsx index b53c2e3eb..2d44b252b 100644 --- a/shared/ui/composed/ContactRow.tsx +++ b/shared/ui/composed/ContactRow.tsx @@ -35,6 +35,7 @@ import { AmountFormatter } from '@/shared/ui/composed/AmountFormatter'; import { MintIcon } from '@/shared/ui/composed/MintIcon'; import { RowStatsAccent, + RowStatsAccentSkeleton, STAT_ICONS, STAT_COLOR_SOCIAL, STAT_COLOR_ERROR, @@ -776,26 +777,47 @@ export function ContactRow({ const statList: RowStat[] = hideMetadata || resolvedLoading ? [] : buildStats(identities, statKeys, { warning, success }); + // While loading, a row that will show an inline stats accent once loaded must + // reserve that line's height or the row grows when the pills arrive. Gate to + // mint rows (which reliably show a score/audit pill) and callers that asked + // for explicit stats — nostr rows often have no accent, so reserving theirs + // would over-shoot and shift the other way. Plain contact/ble/geohash + // skeletons are unaffected. + const reserveAccent = + resolvedLoading && + !hideMetadata && + (!!mint || (statsOverride != null && statsOverride.length > 0)); + const nip05 = showNip05 && !hideMetadata && !resolvedLoading && nostr?.profile?.nip05 ? { handle: nostr.profile.nip05 } : undefined; const hasNip05 = !!nip05; - const accentNode = ; + const accentNode = reserveAccent ? ( + + ) : ( + + ); // ---- Trailing --------------------------------------------------------- const chevronNode = ; const selectionNode = selectable ? ( - onToggle?.() : undefined} - size={24} - variant="success" - /> + resolvedLoading ? ( + // Reserve the control's exact 24px slot while loading without flashing an + // interactive checkbox (mirrors PostCardGutterHeader's empty-box approach). + + ) : ( + onToggle?.() : undefined} + size={24} + variant="success" + /> + ) ) : null; const logMintInteraction = ( diff --git a/shared/ui/composed/List.tsx b/shared/ui/composed/List.tsx new file mode 100644 index 000000000..16cd87cdd --- /dev/null +++ b/shared/ui/composed/List.tsx @@ -0,0 +1,37 @@ +/** + * @fileoverview The app's virtualized list. The single seam over the underlying + * list library (`@shopify/flash-list`). + * + * Every plain data list in the app renders through ``. It owns the only + * import of the underlying list library and applies the app's defaults (hidden + * scroll indicator, draw distance) so a future list-library swap touches this + * one file instead of every screen. + * + * Specialized surfaces that need full control of the underlying list — the + * thread reader (anchoring), the chat surface (bottom-stick), the + * section-anchored picker (sheet scroll injection) and the animated + * transaction timeline — render FlashList directly rather than through ``. + */ +import type { Ref } from 'react'; +import { FlashList, type FlashListProps, type FlashListRef } from '@shopify/flash-list'; + +/** + * Default over-render window (px). FlashList v2 measures synchronously, so this + * only governs how far ahead it renders; 250 keeps a couple of screens of + * lookahead without inflating the JS workload during fast scroll. + */ +const DEFAULT_DRAW_DISTANCE = 250; + +export function List({ + showsVerticalScrollIndicator = false, + drawDistance = DEFAULT_DRAW_DISTANCE, + ...props +}: FlashListProps & { ref?: Ref> }) { + return ( + + ); +} diff --git a/shared/ui/composed/MintIcon.tsx b/shared/ui/composed/MintIcon.tsx index 6c2776039..c9122b8a0 100644 --- a/shared/ui/composed/MintIcon.tsx +++ b/shared/ui/composed/MintIcon.tsx @@ -9,6 +9,12 @@ import { prefetchImage } from '@/shared/lib/imageCache'; const MINT_DEFAULT_ICON = 'mingcute:bank-fill'; +// Mirrors `AVATAR_IMAGE_FADE_MS`: a real network/disk load fades the icon in +// over its placeholder rather than popping. expo-image skips the transition for +// memory-cached images, so a recycled icon stays put. This is independent of any +// parent skeleton→content crossfade — the icon never blocks the data swap. +const MINT_ICON_IMAGE_FADE_MS = 200; + interface MintIconProps { iconUrl?: string | null; name?: string | null; @@ -87,6 +93,7 @@ export function MintIcon({ source={imageSource} cachePolicy="memory-disk" contentFit="cover" + transition={MINT_ICON_IMAGE_FADE_MS} style={StyleSheet.absoluteFill} accessibilityLabel={imageAlt} onError={handleImageError} diff --git a/shared/ui/composed/ModalLayoutWrapper.tsx b/shared/ui/composed/ModalLayoutWrapper.tsx index fcd4821a3..137301f56 100644 --- a/shared/ui/composed/ModalLayoutWrapper.tsx +++ b/shared/ui/composed/ModalLayoutWrapper.tsx @@ -121,7 +121,7 @@ interface ModalLayoutWrapperProps { disableHeaderSpacer?: boolean; /** * When true, children are rendered directly without wrapping in ScrollView. - * Use this when you need to provide your own scrollable component (e.g., FlatList, LegendList). + * Use this when you need to provide your own scrollable component (e.g., FlatList, FlashList). * You should add your own header spacer using the totalHeaderHeight value. */ useCustomScrollView?: boolean; diff --git a/shared/ui/composed/RowStatsAccent.tsx b/shared/ui/composed/RowStatsAccent.tsx index dac66cf3e..3396408a1 100644 --- a/shared/ui/composed/RowStatsAccent.tsx +++ b/shared/ui/composed/RowStatsAccent.tsx @@ -28,6 +28,7 @@ import opacity from 'hex-color-opacity'; import Icon from 'assets/icons'; import { Text } from '@/shared/ui/primitives/Text'; import { HStack } from '@/shared/ui/primitives/View/HStack'; +import { Skeleton } from '@/shared/ui/primitives/Skeleton'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; /** Canonical iconify glyphs for row-accent stats. Use these names so a star @@ -161,3 +162,33 @@ export function RowStatsAccent({ stats, note, noteColor, nip05 }: RowStatsAccent ); } + +/** + * Loading placeholder for the accent line. Rendered by `ContactRow` while a + * stats-bearing row (e.g. a mint result) is loading, so the accent's height is + * reserved and the row doesn't grow when the real `RowStatsAccent` pills arrive. + * + * Co-located with `RowStatsAccent` so the geometry can't drift: it mirrors the + * real pill's structure exactly — the same outer `HStack` (`gap: 4, marginTop: + * 2`) and per-pill `HStack` (`gap: 3`) of a 12px icon box + a `Text` at the + * same `size`/`bold`. The `Text loading` bar self-sizes to the real 12px line + * box, so the accent line is the same height whether loading or loaded (a fixed + * pixel bar previously under-shot the text line height by a couple of pixels). + */ +export function RowStatsAccentSkeleton({ seed }: { seed?: string }) { + return ( + + {[`accent:${seed ?? 'x'}`, `accent2:${seed ?? 'x'}`].map((key, i) => ( + + + + + ))} + + ); +} diff --git a/shared/ui/composed/Screen.tsx b/shared/ui/composed/Screen.tsx index ffd293ed1..deb2ad081 100644 --- a/shared/ui/composed/Screen.tsx +++ b/shared/ui/composed/Screen.tsx @@ -48,7 +48,7 @@ interface ScreenProps { * `animated` uses Animated.ScrollView and exposes scroll offset via `scrollY`. * `none` renders children directly with no scroll container. * `custom` also renders children directly — use when you supply your own - * scroller (FlatList / LegendList) and want the wrapper to hands-off. + * scroller (FlatList / FlashList) and want the wrapper to hands-off. */ scroll?: ScreenScrollMode; scrollY?: SharedValue; diff --git a/shared/ui/composed/SectionAnchorList.tsx b/shared/ui/composed/SectionAnchorList.tsx index 17166bc92..921df7720 100644 --- a/shared/ui/composed/SectionAnchorList.tsx +++ b/shared/ui/composed/SectionAnchorList.tsx @@ -15,10 +15,10 @@ * content clears it. * * Internals: - * - The body uses `@legendapp/list/react-native` (LegendList) under the hood for - * virtualization with item recycling. With `recycleItems` plus - * per-type estimated sizes, this handles 400+ equal-cell grids - * (emoji picker) at 60fps even on older devices. + * - The body uses `@shopify/flash-list` (FlashList v2) under the hood for + * virtualization with item recycling. FlashList recycles cells and + * measures synchronously, so this handles 400+ equal-cell grids + * (emoji picker) at 60fps even on older devices with no size hints. * - Sections are flattened into a single typed data array of * `{kind:'header'|'row', sectionId, ...}` rows. For grids, callers * pass `rowChunkSize > 1` and `renderRow` to lay N items per row; @@ -27,12 +27,12 @@ * visible index wins) with a ~400ms suppression window after a * programmatic `scrollToIndex` to avoid feedback loops. * - `overrideContent` lets callers swap the body wholesale (e.g. - * search-results mode). When non-null, the LegendList unmounts and + * search-results mode). When non-null, the FlashList unmounts and * the anchor bar hides — caller is responsible for any inner - * virtualization in this branch (typically another `LegendList`). + * virtualization in this branch (typically another list). * - `ScrollComponent` injection lets hosts pass * `BottomSheetScrollView` so gorhom's pan gestures + keyboard - * handling stay coherent inside a sheet. LegendList wires it via + * handling stay coherent inside a sheet. FlashList wires it via * its `renderScrollComponent` prop. */ @@ -55,13 +55,11 @@ import { } from 'react-native'; import { Pressable } from '@/shared/ui/primitives/Pressable'; import { - LegendList, - type LegendListRef, - type LegendListRenderItemProps, - type NativeScrollEvent, - type NativeSyntheticEvent, - type OnViewableItemsChangedInfo, -} from '@legendapp/list/react-native'; + FlashList, + type FlashListRef, + type ListRenderItemInfo, + type ViewToken, +} from '@shopify/flash-list'; import opacity from 'hex-color-opacity'; import { ScrollEdgeFade } from './ScrollEdgeFade'; @@ -70,17 +68,14 @@ import { View } from '@/shared/ui/primitives/View/View'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { log, useRenderLogger } from '@/shared/lib/logger'; import { zIndex } from '@/shared/styles/tokens'; -import { - remeasureVisualLayoutScope, - useVisualListLogger, - useVisualScrollMetricsLogger, - VISUAL_LIST_VIEWABILITY_CONFIG, - visualLayoutScopePart, -} from '@/shared/lib/contentShiftLog'; -import { VisualLayoutProbe } from '@/shared/ui/composed/VisualLayoutProbe'; const sectionListLog = log.child({ module: 'sectionAnchorList' }); +// "Any pixel of the row is visible" — flips the active anchor the moment a new +// section's first row enters the viewport. Module-level so the prop reference is +// stable across renders (RN warns on a changing viewabilityConfig). +const ANCHOR_VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 1, minimumViewTime: 0 } as const; + export interface AnchorSection { id: string; /** What renders in the anchor pill. `icon` is optional — label-only is fine. */ @@ -123,7 +118,7 @@ interface SectionAnchorListProps { * When non-null, the sections view is replaced wholesale by this node * and the anchor bar hides. Used for search-results mode in the emoji * picker. Callers that need virtualization in this branch should - * compose their own `LegendList` here. + * compose their own `FlashList` here. */ overrideContent?: ReactNode; /** Bottom content-container padding — clears a floating bottom bar. */ @@ -131,7 +126,7 @@ interface SectionAnchorListProps { /** * Injected scroll container. Defaults to react-native's `ScrollView`. * Pass `BottomSheetScrollView` when rendering inside @gorhom/bottom-sheet - * to preserve gesture + keyboard handling — LegendList uses this as + * to preserve gesture + keyboard handling — FlashList uses this as * its `renderScrollComponent` so the sheet sees the same scroll node * it would for a plain ``. */ @@ -144,22 +139,18 @@ interface SectionAnchorListProps { * left/right/top insets. */ anchorBarStyle?: StyleProp; - /** Estimated height of one chunked row (px). Defaults to 60. */ - estimatedItemSize?: number; - /** Estimated height of a section-header row (px). Defaults to 0. */ - estimatedHeaderSize?: number; /** - * Extra style on the LegendList's `contentContainerStyle`. Useful for + * Extra style on the FlashList's `contentContainerStyle`. Useful for * `paddingHorizontal` when the rendered rows shouldn't carry their * own inset. Merged with this component's own paddingTop/paddingBottom. */ listContentContainerStyle?: StyleProp; /** * External state that affects how rows render but isn't part of `sections`. - * LegendList recycles rows; with `recycleItems` on, it skips re-invoking - * `renderItem` for already-mounted rows when the `data` ref is unchanged. - * Pass anything that should force a re-render here (selection sets, - * filter flags, etc.) — same convention as FlatList / FlashList. + * FlashList recycles rows and skips re-invoking `renderItem` for mounted + * rows when the `data` ref is unchanged. Pass anything that should force a + * re-render here (selection sets, filter flags, etc.) — same convention as + * FlatList. */ extraData?: unknown; } @@ -175,17 +166,6 @@ type FlatRow = | { kind: 'header'; sectionId: string; render: () => ReactNode } | { kind: 'row'; sectionId: string; items: T[]; rowIndex: number; rowKey: string }; -function sectionAnchorVisualItemType(item: FlatRow): string { - if (item.kind === 'header') return 'header'; - return item.items.length > 1 ? 'grid-row' : 'row'; -} - -function sectionAnchorVisualItemKey(item: FlatRow, index: number): string { - const sectionId = visualLayoutScopePart(item.sectionId); - if (item.kind === 'header') return `header:${sectionId}:${index}`; - return `row:${sectionId}:${item.rowIndex}:${index}`; -} - export function SectionAnchorList({ sections, renderItem, @@ -198,8 +178,6 @@ export function SectionAnchorList({ ScrollComponent, topFadeColor, anchorBarStyle, - estimatedItemSize = 60, - estimatedHeaderSize = 0, listContentContainerStyle, extraData, }: SectionAnchorListProps) { @@ -213,7 +191,7 @@ export function SectionAnchorList({ const [foreground, surfaceTertiary] = useThemeColor(['foreground', 'surface-tertiary'] as const); const [activeAnchor, setActiveAnchor] = useState(sections[0]?.id); - const listRef = useRef(null); + const listRef = useRef>>(null); const tabScrollRef = useRef(null); const tabOffsets = useRef>({}); const programmaticScroll = useRef(false); @@ -320,22 +298,9 @@ export function SectionAnchorList({ return { flatItems: items, sectionFirstIndex: firstIndex }; }, [sections, rowChunkSize, keyExtractor]); - const flatRowStats = useMemo( - () => - flatItems.reduce( - (stats, item) => { - if (item.kind === 'header') stats.headers += 1; - else stats.rows += 1; - return stats; - }, - { headers: 0, rows: 0 } - ), - [flatItems] - ); - // One-shot mount log + the inverse for unmount. Captures the size of - // the dataset and the relevant LegendList tuning so log-doctor's - // `stats` mode can correlate later events to the picker config. + // the dataset so log-doctor's `stats` mode can correlate later events + // to the picker config. useEffect(() => { const totalDataItems = sections.reduce((acc, s) => acc + s.data.length, 0); sectionListLog.info('sectionList.mount', { @@ -343,8 +308,6 @@ export function SectionAnchorList({ totalDataItems, flatRows: flatItems.length, rowChunkSize, - estimatedItemSize, - estimatedHeaderSize, hasOverride: overrideContent != null, }); return () => { @@ -366,7 +329,7 @@ export function SectionAnchorList({ const showAnchors = sections.length > 0; // Viewport "top" for list-reading purposes is just below the chrome - // + headroom — same offset value drives the LegendList's scroll + // + headroom — same offset value drives the FlashList's scroll // content paddingTop AND the `viewOffset` on `scrollToIndex`. const viewportTopOffset = chromeHeight + HEADROOM; @@ -375,82 +338,19 @@ export function SectionAnchorList({ activeAnchorRef.current = activeAnchor; }, [activeAnchor]); - const visualScope = useMemo(() => { - const sectionSignature = sections - .slice(0, 4) - .map((section) => visualLayoutScopePart(section.id)) - .join('.'); - return `sectionAnchor.${sectionSignature || 'empty'}`; - }, [sections]); - const visualPhase = overrideContent != null ? 'override' : 'ready'; - const visualExtra = useCallback( - () => ({ - activeAnchor: activeAnchorRef.current ?? null, - sections: sections.length, - flatRows: flatItems.length, - rowRows: flatRowStats.rows, - headerRows: flatRowStats.headers, - rowChunkSize, - estimatedItemSize, - estimatedHeaderSize, - chromeHeight, - viewportTopOffset, - contentBottomInset, - hasOverride: overrideContent != null, - }), - [ - chromeHeight, - contentBottomInset, - estimatedHeaderSize, - estimatedItemSize, - flatItems.length, - flatRowStats.headers, - flatRowStats.rows, - overrideContent, - rowChunkSize, - sections.length, - viewportTopOffset, - ] - ); - const visualList = useVisualListLogger>({ - scope: visualScope, - surface: 'shared', - component: 'SectionAnchorList', - phase: visualPhase, - extra: visualExtra, - getItemKey: sectionAnchorVisualItemKey, - getItemContext: (item, index) => ({ - itemType: sectionAnchorVisualItemType(item), - sectionId: visualLayoutScopePart(item.sectionId), - rowIndex: item.kind === 'row' ? item.rowIndex : null, - rowItems: item.kind === 'row' ? item.items.length : null, - index, - }), - getListState: () => listRef.current?.getState() ?? null, - }); - const anchorScrollMetrics = useVisualScrollMetricsLogger({ - enabled: showAnchors, - scope: visualScope, - surface: 'shared', - component: 'SectionAnchorListAnchorScrollView', - axis: 'x', - phase: visualPhase, - extra: visualExtra, - }); - - // Active-anchor: pick the topmost visible row's sectionId. LegendList + // Active-anchor: pick the topmost visible row's sectionId. FlashList // sorts `viewableItems` by index, so [0] is the topmost in-view row. // Suppressed during programmatic scrolls so the animation doesn't // trip self-reinforcing setActiveAnchor() updates. // // Ref-pattern for `activeAnchor` so the callback identity stays // stable across renders. With `[activeAnchor]` as a dep, every - // anchor flip re-creates the callback → LegendList sees a new + // anchor flip re-creates the callback → FlashList sees a new // `onViewableItemsChanged` prop → re-runs viewability tracking. - // Empirically this added ~1 LegendList re-render per flip and + // Empirically this added ~1 FlashList re-render per flip and // amplified scroll-time JS thread blocks. const handleViewableItemsChanged = useCallback( - ({ viewableItems }: OnViewableItemsChangedInfo>) => { + ({ viewableItems }: { viewableItems: ViewToken>[] }) => { if (programmaticScroll.current) return; if (viewableItems.length === 0) return; const top = viewableItems[0]!; @@ -468,20 +368,6 @@ export function SectionAnchorList({ }, [] ); - const onVisualViewableItemsChanged = visualList.onViewableItemsChanged; - const handleViewableItemsChangedWithVisual = useCallback( - (info: OnViewableItemsChangedInfo>) => { - handleViewableItemsChanged(info); - onVisualViewableItemsChanged(info); - }, - [handleViewableItemsChanged, onVisualViewableItemsChanged] - ); - - // viewabilityConfig must be referentially stable across renders or - // RN warns. `itemVisiblePercentThreshold: 1` means "any pixel of the - // row is visible" — what we want for a quick activeAnchor flip the - // moment a new section's first row enters the viewport. - const viewabilityConfig = VISUAL_LIST_VIEWABILITY_CONFIG; const handleAnchorPress = useCallback( (id: string) => { @@ -530,17 +416,17 @@ export function SectionAnchorList({ [listContentContainerStyle, viewportTopOffset, contentBottomInset] ); - // Counter incremented every time LegendList invokes `renderListItem` + // Counter incremented every time FlashList invokes `renderListItem` // — i.e. every time a row enters the recycling pool with new data. // Logged in a throttled effect below so we can see "recycler invokes // per second" without flooding the log on each call. const recycleCount = useRef(0); const lastRecycleLog = useRef(0); - // Per-row renderer for LegendList. Headers render `section.renderHeader()` + // Per-row renderer for FlashList. Headers render `section.renderHeader()` // wholesale; rows dispatch to `renderRow` (chunked) or `renderItem` (single). const renderListItem = useCallback( - ({ item, index }: LegendListRenderItemProps>) => { + ({ item }: ListRenderItemInfo>) => { recycleCount.current += 1; const now = Date.now(); // Throttle to one log per second so a burst of recycling shows @@ -563,36 +449,9 @@ export function SectionAnchorList({ content = single === undefined ? null : renderItem(single, item.sectionId); } - return ( - ({ - activeAnchor: activeAnchorRef.current ?? null, - sectionId: visualLayoutScopePart(item.sectionId), - rowIndex: item.kind === 'row' ? item.rowIndex : null, - rowItems: item.kind === 'row' ? item.items.length : null, - viewportTopOffset, - contentBottomInset, - })}> - {content} - - ); + return <>{content}; }, - [ - contentBottomInset, - renderItem, - renderRow, - rowChunkSize, - viewportTopOffset, - visualPhase, - visualScope, - ] + [renderItem, renderRow, rowChunkSize] ); const listKeyExtractor = useCallback((item: FlatRow) => { @@ -602,13 +461,6 @@ export function SectionAnchorList({ const getItemType = useCallback((item: FlatRow) => item.kind, []); - const getFixedItemSize = useCallback( - (_item: FlatRow, _index: number, type: string | undefined) => { - return type === 'header' ? estimatedHeaderSize : estimatedItemSize; - }, - [estimatedHeaderSize, estimatedItemSize] - ); - // `renderScrollComponent` lets the host inject its scroll container // (e.g. `BottomSheetScrollView` for gorhom integration). Default to // the standard react-native ScrollView when no host wraps us. @@ -620,64 +472,35 @@ export function SectionAnchorList({ return Component; }, [ScrollComponent]); - const handleListScroll = useCallback( - (event: NativeSyntheticEvent) => { - remeasureVisualLayoutScope(visualScope, 'scroll', { - minIntervalMs: 500, - extra: { scrollY: Math.round(event.nativeEvent.contentOffset.y) }, - }); - }, - [visualScope] - ); - return ( - {/* Body — virtualized LegendList for sections, or wholesale + {/* Body — virtualized FlashList for sections, or wholesale `overrideContent` (e.g. search-results mode). Chrome is absolute-positioned over the top edge regardless. */} {overrideContent != null ? ( - // The override path needs the same paddingTop the LegendList + // The override path needs the same paddingTop the FlashList // would have applied, so caller-rendered content also clears // the chrome. - - {overrideContent} - + {overrideContent} ) : ( - ({ horizontal showsHorizontalScrollIndicator={false} style={styles.anchorBarWrapper} - onLayout={anchorScrollMetrics.onLayout} - onContentSizeChange={anchorScrollMetrics.onContentSizeChange} - onScroll={anchorScrollMetrics.onScroll} - scrollEventThrottle={250} contentContainerStyle={styles.anchorBarContent}> {sections.map((s) => { const isSelected = activeAnchor === s.id; diff --git a/shared/ui/composed/SkeletonContentCrossfade.tsx b/shared/ui/composed/SkeletonContentCrossfade.tsx new file mode 100644 index 000000000..1a7827e01 --- /dev/null +++ b/shared/ui/composed/SkeletonContentCrossfade.tsx @@ -0,0 +1,205 @@ +/** + * @fileoverview `SkeletonContentCrossfade` — the canonical skeleton → content + * transition for the app. + * + * While `loading`, it renders the skeleton tree and (for sizeable regions) a + * single `SkeletonLoadingShimmer` "wave" sweep over it. When data arrives it + * runs ONE consistent transition everywhere: the skeleton **fades out**, then + * the real content **fades in** — a clear two-step crossfade, fast enough not to + * feel like waiting (`SKELETON_CONTENT_FADE_MS` total). Images inside the + * content fade in **independently** on their own `expo-image` transition, so the + * swap never blocks on a slow image load. + * + * Why a region helper and not per-leaf animation: the loading primitives + * (`Text loading`, `Skeleton`, `Avatar state="loading"`, `MintIcon isLoading`) + * stay dumb geometry-preserving placeholders. Putting the fade here, at the + * region seam, keeps those primitives cheap, avoids replaying opacity tweens on + * FlashList row recycling, and preserves the "share the chrome" convention: + * pass the SAME component in both branches — + * `renderSkeleton={() => }` / `renderContent={() => }` — + * and the skeleton overlay lines up pixel-for-pixel with the content, so the + * swap shifts nothing. + * + * `exit="none"` skips the fade entirely (skeleton unmounts, content appears) for + * FlashList-recycled lists where an exiting overlay would render in a recycled + * cell's slot, and for footer-only skeletons that have no in-place content to + * fade into. + * + * Reduced-motion (system setting) drops both the wave and the fade for an + * instant swap. + */ + +import React, { useCallback, useEffect, useState, type ReactNode } from 'react'; +import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native'; +import Animated, { + cancelAnimation, + Easing, + Extrapolation, + interpolate, + ReduceMotion, + runOnJS, + useAnimatedStyle, + useReducedMotion, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; + +import { useThemeColor } from '@/shared/hooks/useThemeColor'; +import { SkeletonLoadingShimmer } from '@/shared/ui/composed/SkeletonExitShimmer'; + +/** Total skeleton→content transition: the skeleton fades out over the first + * half, the content fades in over the second. Kept short so the swap reads as a + * crisp crossfade, not a wait. */ +const SKELETON_CONTENT_FADE_MS = 300; + +type WaveMode = 'region' | 'none'; +type ExitMode = 'fade' | 'none'; +type Phase = 'loading' | 'exiting' | 'content'; + +interface SkeletonContentCrossfadeProps { + /** True while the real data is loading. The skeleton shows; on the + * true→false edge the crossfade runs. */ + loading: boolean; + /** The loading placeholder tree. Lazy so it isn't built once content shows. */ + renderSkeleton: () => ReactNode; + /** The real content tree. Lazy so it isn't built while still loading. */ + renderContent: () => ReactNode; + /** Total fade duration in ms. Defaults to {@link SKELETON_CONTENT_FADE_MS}. */ + durationMs?: number; + /** `'region'` (default) sweeps one shimmer wave over the skeleton; `'none'` + * keeps the cheaper static pulse for compact/inline placeholders. */ + wave?: WaveMode; + /** `'fade'` (default) runs the skeleton-out/content-in crossfade; `'none'` + * swaps instantly — use inside FlashList-recycled cells and for footer-only + * skeletons with no in-place content. */ + exit?: ExitMode; + /** The color of the surface the skeleton sits ON — the wave is painted in this + * color so it "erases" the skeleton as it sweeps (disappear/reappear), rather + * than reading as a stripe on top. Defaults to the screen `background`; pass + * the container's color when the skeleton is on a card/surface (e.g. a + * `surface-secondary` Card). This is the only thing that should set the wave + * color — never a brand/accent tint. */ + surfaceColor?: string; + style?: StyleProp; + testID?: string; + /** Telemetry passthrough for the region shimmer (see `contentShiftLog`). */ + visualKey?: string; + visualSurface?: string; + visualComponent?: string; + visualDisabled?: boolean; +} + +export function SkeletonContentCrossfade({ + loading, + renderSkeleton, + renderContent, + durationMs = SKELETON_CONTENT_FADE_MS, + wave = 'region', + exit = 'fade', + surfaceColor, + style, + testID, + visualKey, + visualSurface, + visualComponent = 'SkeletonContentCrossfade', + visualDisabled, +}: SkeletonContentCrossfadeProps) { + const reducedMotion = useReducedMotion(); + // The wave is painted in the color of the surface the skeleton sits on, so the + // band "erases" the skeleton as it sweeps (disappear/reappear) rather than + // reading as a brighter stripe on top. Defaults to the screen background; + // callers on a card/surface pass that surface's color. + const screenBackground = useThemeColor('background'); + const waveColor = surfaceColor ?? screenBackground; + const animatedExit = exit === 'fade' && !reducedMotion; + const showWave = wave === 'region' && !reducedMotion; + + // Phase is seeded from `loading`, so a row that mounts already-loaded (e.g. a + // recycled FlashList cell rebinding to loaded data) starts in 'content' and + // never plays a spurious fade. + const [phase, setPhase] = useState(loading ? 'loading' : 'content'); + // 0 → 1 across the whole transition: [0, 0.5] fades the skeleton out, then + // [0.5, 1] fades the content in. + const progress = useSharedValue(0); + + const finishExit = useCallback(() => setPhase('content'), []); + + // React to the loading edge. Entering loading always wins and cancels an + // in-flight exit (so rapid true→false→true never stacks overlays). + useEffect(() => { + if (loading) { + cancelAnimation(progress); + progress.set(0); + setPhase('loading'); + return; + } + setPhase((prev) => { + if (prev !== 'loading') return prev; + return animatedExit ? 'exiting' : 'content'; + }); + }, [loading, animatedExit, progress]); + + // Drive the one-shot crossfade while exiting. + useEffect(() => { + if (phase !== 'exiting') return; + progress.set(0); + progress.set( + withTiming( + 1, + { + duration: durationMs, + easing: Easing.inOut(Easing.quad), + reduceMotion: ReduceMotion.System, + }, + (finished) => { + if (finished) runOnJS(finishExit)(); + } + ) + ); + return () => cancelAnimation(progress); + }, [phase, durationMs, progress, finishExit]); + + const skeletonExitStyle = useAnimatedStyle(() => ({ + opacity: interpolate(progress.get(), [0, 0.5], [1, 0], Extrapolation.CLAMP), + })); + const contentEnterStyle = useAnimatedStyle(() => ({ + opacity: interpolate(progress.get(), [0.5, 1], [0, 1], Extrapolation.CLAMP), + })); + + if (phase === 'content') { + return ( + + {renderContent()} + + ); + } + + if (phase === 'loading') { + return ( + + {renderSkeleton()} + {showWave ? ( + + ) : null} + + ); + } + + // 'exiting' — content fades in (second half) in normal flow (it owns the + // height, so no shift); the skeleton fades out (first half) on top of it. + return ( + + {renderContent()} + + {renderSkeleton()} + + + ); +} diff --git a/shared/ui/composed/chat/ChatScreen.tsx b/shared/ui/composed/chat/ChatScreen.tsx index d6e32fab2..058d070ac 100644 --- a/shared/ui/composed/chat/ChatScreen.tsx +++ b/shared/ui/composed/chat/ChatScreen.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { Keyboard, ScrollView, @@ -13,20 +13,13 @@ import { useReanimatedKeyboardAnimation, } from 'react-native-keyboard-controller'; import Reanimated, { useAnimatedStyle } from 'react-native-reanimated'; -import { LegendList, type LegendListRef } from '@legendapp/list/react-native'; +import { FlashList } from '@shopify/flash-list'; import { Pressable } from '@/shared/ui/primitives/Pressable'; import { View } from '@/shared/ui/primitives/View/View'; import { useThemeColor } from '@/shared/hooks/useThemeColor'; import { useSingleFlight } from '@/shared/hooks/useSingleFlight'; import type { Logger } from '@/shared/lib/logger'; -import { - remeasureVisualLayoutScope, - useVisualListLogger, - VISUAL_LIST_VIEWABILITY_CONFIG, - visualLayoutScopePart, -} from '@/shared/lib/contentShiftLog'; -import { VisualLayoutProbe } from '@/shared/ui/composed/VisualLayoutProbe'; import { LiquidChatComposer } from './LiquidChatComposer'; import { ChatMessageBubble } from './ChatMessageBubble'; @@ -105,17 +98,17 @@ interface ChatScreenProps { onStartReachedThreshold?: number; } -/** Hint for LegendList's virtualization math. Real bubble heights are - * measured after layout; this value only affects first-render scroll - * position accuracy. */ -const ESTIMATED_BUBBLE_HEIGHT = 80; +// Horizontal gutter applied to every message row (FlashList rows have no +// padding of their own). Stable module ref so recycled cells don't re-create it. +const MESSAGE_ROW_STYLE = { paddingHorizontal: 16 } as const; /** - * Shared chat surface backed by `@legendapp/list/react-native`. Used by BitChat, Nostr + * Shared chat surface backed by `@shopify/flash-list`. Used by BitChat, Nostr * DM, WhiteNoise, geohash — every DM-like chat surface. Architecture mirrors - * `AiChatScreen` (which uses LegendList directly): forward-ordered data, - * `alignItemsAtEnd` for the chat-style bottom dock, `maintainScrollAtEnd` - * for stay-at-latest on append, a single shared Reanimated translate that + * `AiChatScreen` (which uses FlashList directly): forward-ordered data, + * FlashList `maintainVisibleContentPosition` with `startRenderingFromBottom` + * for the chat-style bottom dock and `autoscrollToBottomThreshold` for + * stay-at-latest on append, plus a single shared Reanimated translate that * lifts both the list wrapper and the (absolutely-positioned) composer in * lock-step with the keyboard. * @@ -123,7 +116,7 @@ const ESTIMATED_BUBBLE_HEIGHT = 80; * FlatList + internal KeyboardAvoidingView). Two reasons we switched: * 1. iOS 26 applies a soft `UIScrollEdgeEffect` to RN `FlatList` / * `VirtualizedList` instances by default — visible as a blur/fade band - * where the list meets the composer. LegendList's separate + * where the list meets the composer. FlashList's separate * virtualization sidesteps it entirely, matching the AI surface. * 2. GiftedChat's KAV interaction with our `position:'absolute'` composer * was unreliable across border-box positioning and modal-stack contexts @@ -174,11 +167,10 @@ export function ChatScreen({ // inset; doubling them up pushes content too far down). const resolvedBottomInset = bottomInset !== undefined ? bottomInset : safeAreaInsets.bottom; const resolvedTopInset = topInset > 0 ? topInset : headerHeight > 0 ? 0 : safeAreaInsets.top; - const chatVisualScope = useMemo(() => `chat.${visualLayoutScopePart(surface)}.list`, [surface]); const [draft, setDraft] = useState(''); - // Measured composer height. Used to pad the LegendList's content so the + // Measured composer height. Used to pad the FlashList's content so the // newest bubble rests just above the composer's top edge while the // composer itself is absolutely positioned over the chat — older bubbles // slide *under* the composer's translucent glass on scroll-up (the @@ -196,10 +188,10 @@ export function ChatScreen({ // the keyboard top when focused and back to its laid-out position // when dismissed. // - // - List: Reanimated `translateY` on the LegendList wrapper, same as + // - List: Reanimated `translateY` on the FlashList wrapper, same as // AiChatScreen — physically translates the list up so items // (which are positioned in absolute content coordinates inside - // LegendList) shift with the keyboard. Padding-based avoidance on the + // FlashList) shift with the keyboard. Padding-based avoidance on the // wrapper doesn't move the items (it just shrinks the viewport), so // the conversation stays put and only the composer rides up. // @@ -276,75 +268,13 @@ export function ChatScreen({ }); }, [draft, dispatchSend]); - const visualPhase = isLoading ? 'loading' : messages.length === 0 ? 'empty' : 'ready'; - const visualExtra = useCallback( - () => ({ - surface, - messageCount: messages.length, - composerHeight, - bottomInset: resolvedBottomInset, - topInset: resolvedTopInset, - isLoading: !!isLoading, - draftLength: draft.length, - }), - [ - composerHeight, - draft.length, - isLoading, - messages.length, - resolvedBottomInset, - resolvedTopInset, - surface, - ] - ); - const listRef = useRef(null); - const visualList = useVisualListLogger({ - scope: chatVisualScope, - surface: 'chat', - component: 'ChatLegendList', - phase: visualPhase, - extra: visualExtra, - getItemKey: (message, index) => - `message:${message.isOwn ? 'own' : 'other'}:${message.timestamp}:${index}`, - getItemContext: (message, index) => ({ - itemType: message.isOwn ? 'own-message' : 'counterparty-message', - deliveryStatus: message.deliveryStatus ?? null, - timestamp: message.timestamp, - isOwn: message.isOwn, - index, - }), - getListState: () => listRef.current?.getState() ?? null, - }); - - const handleListScroll = useCallback(() => { - remeasureVisualLayoutScope(chatVisualScope, 'scroll', { - extra: visualExtra(), - maxItems: 24, - minIntervalMs: 300, - }); - }, [chatVisualScope, visualExtra]); - const renderItem = useCallback( - ({ item, index }: { item: ChatBubbleMessage; index: number }) => { + ({ item }: { item: ChatBubbleMessage; index: number }) => { const group = groupingMap.get(item.id); const isFirstInGroup = group?.isFirst ?? true; const isLastInGroup = group?.isLast ?? true; return ( - ({ - ...visualExtra(), - deliveryStatus: item.deliveryStatus ?? null, - isFirstInGroup, - isLastInGroup, - })} - style={{ paddingHorizontal: 16 }}> + {renderBubble ? ( renderBubble({ message: item, isFirstInGroup, isLastInGroup }) ) : ( @@ -355,10 +285,10 @@ export function ChatScreen({ counterpartyAvatar={counterpartyAvatar} /> )} - + ); }, - [chatVisualScope, counterpartyAvatar, groupingMap, renderBubble, visualExtra, visualPhase] + [counterpartyAvatar, groupingMap, renderBubble] ); const keyExtractor = useCallback((m: ChatBubbleMessage) => m.id, []); @@ -384,7 +314,7 @@ export function ChatScreen({ // composer's top edge. No `paddingTop` here: adding one breaks // `alignItemsAtEnd`'s "content < viewport → dock to bottom" math (the // contentContainer's own paddingTop counts toward effective content - // height, so LegendList thinks the viewport is already filled and skips + // height, so FlashList thinks the viewport is already filled and skips // the auto-bottom padding it would otherwise insert). // `resolvedTopInset` clearance against a transparent floating header is // already accounted for at the screen level by consumers that need it @@ -417,7 +347,7 @@ export function ChatScreen({ ) : ( <> {/* List wrapper. Layout-based keyboard lift (`top: -keyboardH`) - shifts the whole wrapper up so LegendList's items — which it + shifts the whole wrapper up so FlashList's items — which it positions in absolute content coordinates — ride along. Padding-based avoidance only shrinks the viewport without moving items, and `translateY` creates a compositor-layer @@ -432,65 +362,34 @@ export function ChatScreen({ }, listKeyboardLiftStyle, ]}> - + {messages.length === 0 ? ( - - {wrappedEmptyContent} - + {wrappedEmptyContent} ) : ( - )} - + {/* Composer rides the keyboard via `` from @@ -507,15 +406,7 @@ export function ChatScreen({ - + {composerActions ? ( - + )} diff --git a/shared/ui/composed/search/SearchResultRows.tsx b/shared/ui/composed/search/SearchResultRows.tsx index 38c0dc51e..6267d8bd6 100644 --- a/shared/ui/composed/search/SearchResultRows.tsx +++ b/shared/ui/composed/search/SearchResultRows.tsx @@ -7,12 +7,10 @@ * the unified search surface can feed it different buckets (All, People, Groups, * Mints) from one `useSearchAggregates` call without duplicating queries. */ -import React, { useCallback, useMemo, useRef } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { View, StyleSheet } from 'react-native'; -import { LegendList, type LegendListRef } from '@legendapp/list/react-native'; -import { VisualLayoutProbe } from '@/shared/ui/composed/VisualLayoutProbe'; -import { useVisualListLogger, VISUAL_LIST_VIEWABILITY_CONFIG } from '@/shared/lib/contentShiftLog'; +import { List } from '@/shared/ui/composed/List'; import { guardedRouter as router } from '@/shared/hooks/useGuardedRouter'; import { useTabBarBottomPadding } from '@/shared/hooks/useTabBarBottomPadding'; import type { AllSearchResult } from '@/features/contacts/hooks/useAllSearchResults'; @@ -40,7 +38,6 @@ type SearchResultRowsProps = { }; const keyExtractor = (item: AllSearchResult) => item.id; -const SEARCH_RESULTS_VISUAL_SCOPE = 'search.results'; function searchResultItemType(item: AllSearchResult): string { return item.type === 'contact' && item.isLoadingProfile ? 'contact-loading' : item.type; @@ -182,62 +179,19 @@ export function SearchResultRows({ [] ); const listData = showPlaceholders ? placeholderData : showNoResults ? [] : results; - const listRef = useRef(null); - const visualList = useVisualListLogger({ - scope: SEARCH_RESULTS_VISUAL_SCOPE, - surface: 'search', - component: 'SearchResultRowsList', - phase: loading ? 'loading' : 'ready', - extra: { - loading, - placeholders: showPlaceholders, - queryLength: searchQuery.trim().length, - rows: listData.length, - }, - getItemKey: (item) => item.id, - getItemContext: (item) => ({ - rowKey: item.id, - rowLabel: searchResultItemType(item), - itemType: searchResultItemType(item), - }), - getListState: () => listRef.current?.getState() ?? null, - }); const renderItem = useCallback( - ({ item, index }: { item: AllSearchResult; index: number }) => ( - - {renderSearchResult(item)} - - ), - [loading, renderSearchResult, searchQuery, showPlaceholders] + ({ item }: { item: AllSearchResult }) => renderSearchResult(item), + [renderSearchResult] ); return ( -