diff --git a/src/components/VideoFeed.test.tsx b/src/components/VideoFeed.test.tsx index cfada2b2..a2e801de 100644 --- a/src/components/VideoFeed.test.tsx +++ b/src/components/VideoFeed.test.tsx @@ -13,6 +13,7 @@ const { mockSetVideosForFullscreen, mockUpdateVideos, mockUseVideoPrefetch, + mockInfiniteScroll, mockTrackEvent, } = vi.hoisted(() => ({ mockTrackEvent: vi.fn(), @@ -22,6 +23,7 @@ const { mockSetVideosForFullscreen: vi.fn(), mockUpdateVideos: vi.fn(), mockUseVideoPrefetch: vi.fn(), + mockInfiniteScroll: vi.fn(), })); vi.mock('@/hooks/useVideoProvider', () => ({ @@ -111,7 +113,10 @@ vi.mock('@/components/ui/card', () => ({ })); vi.mock('react-infinite-scroll-component', () => ({ - default: ({ children }: { children: ReactNode }) =>
{children}
, + default: ({ children, dataLength }: { children: ReactNode; dataLength: number }) => { + mockInfiniteScroll({ dataLength }); + return
{children}
; + }, })); describe('VideoFeed', () => { @@ -143,6 +148,7 @@ describe('VideoFeed', () => { isLoading: false, error: null, refetch: vi.fn(), + fetchedCount: 1, dataSource: 'funnelcake', }); }); @@ -205,6 +211,7 @@ describe('VideoFeed', () => { isLoading: false, error: null, refetch: vi.fn(), + fetchedCount: 1, dataSource: 'funnelcake', }); @@ -227,6 +234,7 @@ describe('VideoFeed', () => { isLoading: false, error: null, refetch: vi.fn(), + fetchedCount: 3, dataSource: 'funnelcake', }); @@ -252,4 +260,45 @@ describe('VideoFeed', () => { { prefetchVideos: false }, ); }); + + it('keys infinite scroll from fetched count instead of filtered rendered count', () => { + mockUseVideoProvider.mockReturnValue({ + data: { + pages: [{ + videos: [ + { + id: 'video-1', + pubkey: 'a'.repeat(64), + kind: 34236, + vineId: 'same-address', + videoUrl: 'https://example.com/video-1.mp4', + }, + { + id: 'video-2', + pubkey: 'a'.repeat(64), + kind: 34236, + vineId: 'same-address', + videoUrl: 'https://example.com/video-2.mp4', + }, + ], + }], + }, + fetchNextPage: vi.fn(), + hasNextPage: true, + isLoading: false, + error: null, + refetch: vi.fn(), + fetchedCount: 2, + dataSource: 'funnelcake', + }); + + render( + + + + ); + + expect(screen.getAllByTestId('video-card')).toHaveLength(1); + expect(mockInfiniteScroll).toHaveBeenCalledWith({ dataLength: 2 }); + }); }); diff --git a/src/components/VideoFeed.tsx b/src/components/VideoFeed.tsx index 8efc8f45..bb7ea5de 100644 --- a/src/components/VideoFeed.tsx +++ b/src/components/VideoFeed.tsx @@ -98,6 +98,7 @@ export function VideoFeed({ isLoading, error, refetch, + fetchedCount, dataSource, } = useVideoProvider({ feedType, @@ -478,7 +479,7 @@ export function VideoFeed({ > {compilationLauncher} {compilationLauncher} ({ + const queryResult = useInfiniteQuery({ queryKey: ['infinite-search-videos', debouncedQuery, searchType, sortMode, pageSize], queryFn: async ({ pageParam, signal }) => { if (!debouncedQuery.trim()) { @@ -311,4 +311,11 @@ export function useInfiniteSearchVideos({ staleTime: 60_000, gcTime: 300_000, }); + + const fetchedCount = queryResult.data?.pages.reduce((sum, page) => sum + page.videos.length, 0) ?? 0; + + return { + ...queryResult, + fetchedCount, + }; } diff --git a/src/hooks/useInfiniteVideos.test.ts b/src/hooks/useInfiniteVideos.test.ts new file mode 100644 index 00000000..55911fea --- /dev/null +++ b/src/hooks/useInfiniteVideos.test.ts @@ -0,0 +1,374 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { NostrEvent } from '@nostrify/nostrify'; +import type { ParsedVideoData } from '@/types/video'; + +const mockNostrQuery = vi.fn(); +const mockParseVideoEvents = vi.fn(); + +vi.mock('@nostrify/react', () => ({ + useNostr: () => ({ + nostr: { + query: mockNostrQuery, + }, + }), +})); + +vi.mock('@/hooks/useCurrentUser', () => ({ + useCurrentUser: () => ({ + user: { pubkey: 'a'.repeat(64) }, + }), +})); + +vi.mock('@/hooks/useFollowList', () => ({ + useFollowList: () => ({ + data: ['b'.repeat(64)], + isLoading: false, + }), +})); + +vi.mock('@/hooks/useAppContext', () => ({ + useAppContext: () => ({ + config: { + relayUrl: 'wss://relay.divine.video', + }, + }), +})); + +vi.mock('@/hooks/useRelayCapabilities', () => ({ + useVideoSortSupport: () => true, +})); + +vi.mock('@/lib/videoParser', () => ({ + parseVideoEvents: mockParseVideoEvents, +})); + +vi.mock('@/lib/debug', () => ({ + debugLog: vi.fn(), +})); + +vi.mock('@/lib/performanceMonitoring', () => ({ + performanceMonitor: { + recordQuery: vi.fn(), + recordFeedLoad: vi.fn(), + }, +})); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + return function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(QueryClientProvider, { client: queryClient }, children); + }; +} + +function makeEvent(index: number, createdAt = 1700000000 - index): NostrEvent { + return { + id: `event-${String(index).padStart(2, '0')}`, + pubkey: `pubkey-${String(index).padStart(2, '0')}`, + created_at: createdAt, + kind: 34236, + tags: [['d', `vine-${index}`]], + content: '', + sig: 'sig', + }; +} + +function makeEvents(count: number, startIndex = 0): NostrEvent[] { + return Array.from({ length: count }, (_, index) => makeEvent(startIndex + index)); +} + +function makeVideo(index: number, overrides: Partial = {}): ParsedVideoData { + return { + id: `event-${String(index).padStart(2, '0')}`, + pubkey: `pubkey-${String(index).padStart(2, '0')}`, + kind: 34236, + createdAt: 1700000000 - index, + content: '', + videoUrl: `https://example.com/video-${index}.mp4`, + hashtags: [], + vineId: `vine-${index}`, + isVineMigrated: false, + reposts: [], + ...overrides, + }; +} + +let useInfiniteVideos: typeof import('./useInfiniteVideos').useInfiniteVideos; + +beforeEach(async () => { + vi.clearAllMocks(); + ({ useInfiniteVideos } = await import('./useInfiniteVideos')); +}); + +describe('useInfiniteVideos sorted relay pagination', () => { + it('continues when a full raw page parses short', async () => { + mockNostrQuery.mockResolvedValueOnce(makeEvents(20)); + mockParseVideoEvents.mockReturnValueOnce(Array.from({ length: 15 }, (_, index) => makeVideo(index))); + + const { result } = renderHook( + () => useInfiniteVideos({ feedType: 'trending', sortMode: 'hot', pageSize: 20 }), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data?.pages[0].videos).toHaveLength(15); + expect(result.current.hasNextPage).toBe(true); + }); + + it('parses page 2 from a disjoint raw event window', async () => { + const firstPageEvents = makeEvents(20); + const secondPrefixEvents = makeEvents(40); + + mockNostrQuery + .mockResolvedValueOnce(firstPageEvents) + .mockResolvedValueOnce(secondPrefixEvents); + mockParseVideoEvents + .mockReturnValueOnce(Array.from({ length: 20 }, (_, index) => makeVideo(index))) + .mockReturnValueOnce(Array.from({ length: 20 }, (_, index) => makeVideo(20 + index))); + + const { result } = renderHook( + () => useInfiniteVideos({ feedType: 'trending', sortMode: 'hot', pageSize: 20 }), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + await waitFor(() => { + expect(result.current.hasNextPage).toBe(true); + }); + + let nextPageResult: Awaited> | undefined; + await act(async () => { + nextPageResult = await result.current.fetchNextPage(); + }); + + await waitFor(() => { + expect(mockNostrQuery).toHaveBeenCalledTimes(2); + }); + + expect(mockNostrQuery.mock.calls[1]?.[0]).toEqual([ + expect.objectContaining({ limit: 40 }), + ]); + expect(mockParseVideoEvents.mock.calls[1]?.[0].map((event: NostrEvent) => event.id)).toEqual( + secondPrefixEvents.slice(20).map(event => event.id) + ); + expect(nextPageResult?.data?.pages[1].videos.map(video => video.id)).toEqual( + Array.from({ length: 20 }, (_, index) => `event-${20 + index}`) + ); + }); + + it('backfills a zero-video sorted window before returning a page', async () => { + mockNostrQuery + .mockResolvedValueOnce(makeEvents(20)) + .mockResolvedValueOnce(makeEvents(40)); + mockParseVideoEvents + .mockReturnValueOnce([]) + .mockReturnValueOnce([makeVideo(25)]); + + const { result } = renderHook( + () => useInfiniteVideos({ feedType: 'trending', sortMode: 'hot', pageSize: 20 }), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockNostrQuery).toHaveBeenCalledTimes(2); + expect(mockNostrQuery.mock.calls[0]?.[0]).toEqual([ + expect.objectContaining({ limit: 20 }), + ]); + expect(mockNostrQuery.mock.calls[1]?.[0]).toEqual([ + expect.objectContaining({ limit: 40 }), + ]); + expect(result.current.data?.pages[0].videos).toEqual([makeVideo(25)]); + expect(result.current.hasNextPage).toBe(true); + }); + + it('stops when the relay returns fewer raw events than requested', async () => { + mockNostrQuery.mockResolvedValueOnce(makeEvents(18)); + mockParseVideoEvents.mockReturnValueOnce(Array.from({ length: 18 }, (_, index) => makeVideo(index))); + + const { result } = renderHook( + () => useInfiniteVideos({ feedType: 'trending', sortMode: 'hot', pageSize: 20 }), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data?.pages[0].videos).toHaveLength(18); + expect(result.current.hasNextPage).toBe(false); + }); + + it('stops after exhausting zero-video sorted backfill attempts', async () => { + mockNostrQuery + .mockResolvedValueOnce(makeEvents(20)) + .mockResolvedValueOnce(makeEvents(40)) + .mockResolvedValueOnce(makeEvents(60)); + mockParseVideoEvents + .mockReturnValueOnce([]) + .mockReturnValueOnce([]) + .mockReturnValueOnce([]); + + const { result } = renderHook( + () => useInfiniteVideos({ feedType: 'trending', sortMode: 'hot', pageSize: 20 }), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockNostrQuery).toHaveBeenCalledTimes(3); + expect(mockNostrQuery.mock.calls.map(call => call[0][0].limit)).toEqual([20, 40, 60]); + expect(result.current.data?.pages[0].videos).toEqual([]); + expect(result.current.hasNextPage).toBe(false); + }); + + it('stops when an expanded sorted window hits the relay cap before parsing videos', async () => { + mockNostrQuery + .mockResolvedValueOnce(makeEvents(20)) + .mockResolvedValueOnce(makeEvents(39)); + mockParseVideoEvents + .mockReturnValueOnce([]) + .mockReturnValueOnce([]); + + const { result } = renderHook( + () => useInfiniteVideos({ feedType: 'trending', sortMode: 'hot', pageSize: 20 }), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockNostrQuery).toHaveBeenCalledTimes(2); + expect(result.current.data?.pages[0].videos).toEqual([]); + expect(result.current.hasNextPage).toBe(false); + }); + + it('sorts only the current raw window for client-side Classic ordering', async () => { + mockNostrQuery + .mockResolvedValueOnce(makeEvents(2)) + .mockResolvedValueOnce(makeEvents(4)); + mockParseVideoEvents + .mockReturnValueOnce([ + makeVideo(0, { loopCount: 20 }), + makeVideo(1, { loopCount: 10 }), + ]) + .mockReturnValueOnce([ + makeVideo(2, { loopCount: 5 }), + makeVideo(3, { loopCount: 50 }), + ]); + + const { result } = renderHook( + () => useInfiniteVideos({ feedType: 'trending', sortMode: 'top', pageSize: 2 }), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + await waitFor(() => { + expect(result.current.hasNextPage).toBe(true); + }); + + let nextPageResult: Awaited> | undefined; + await act(async () => { + nextPageResult = await result.current.fetchNextPage(); + }); + + expect(mockParseVideoEvents.mock.calls[1]?.[0].map((event: NostrEvent) => event.id)).toEqual( + ['event-02', 'event-03'] + ); + expect(nextPageResult?.data?.pages[1].videos.map(video => video.id)).toEqual([ + 'event-03', + 'event-02', + ]); + }); +}); + +describe('useInfiniteVideos chronological relay pagination', () => { + it('backfills an empty parsed page with the raw event timestamp cursor', async () => { + mockNostrQuery + .mockResolvedValueOnce([ + makeEvent(1, 100), + makeEvent(2, 99), + ]) + .mockResolvedValueOnce([makeEvent(3, 80)]); + mockParseVideoEvents + .mockReturnValueOnce([]) + .mockReturnValueOnce([makeVideo(3)]); + + const { result } = renderHook( + () => useInfiniteVideos({ feedType: 'recent', pageSize: 2 }), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockNostrQuery).toHaveBeenCalledTimes(2); + expect(mockNostrQuery.mock.calls[1]?.[0]).toEqual([ + expect.objectContaining({ until: 98 }), + ]); + expect(result.current.data?.pages[0].videos).toEqual([makeVideo(3)]); + }); + + it('stops after exhausting zero-video chronological backfill attempts', async () => { + mockNostrQuery + .mockResolvedValueOnce([ + makeEvent(1, 100), + makeEvent(2, 99), + ]) + .mockResolvedValueOnce([ + makeEvent(3, 80), + makeEvent(4, 79), + ]) + .mockResolvedValueOnce([ + makeEvent(5, 60), + makeEvent(6, 59), + ]); + mockParseVideoEvents + .mockReturnValueOnce([]) + .mockReturnValueOnce([]) + .mockReturnValueOnce([]); + + const { result } = renderHook( + () => useInfiniteVideos({ feedType: 'recent', pageSize: 2 }), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockNostrQuery).toHaveBeenCalledTimes(3); + expect(mockNostrQuery.mock.calls.map(call => call[0][0].until)).toEqual([ + undefined, + 98, + 78, + ]); + expect(result.current.data?.pages[0].videos).toEqual([]); + expect(result.current.hasNextPage).toBe(false); + }); +}); diff --git a/src/hooks/useInfiniteVideos.ts b/src/hooks/useInfiniteVideos.ts index da636759..e4039f6e 100644 --- a/src/hooks/useInfiniteVideos.ts +++ b/src/hooks/useInfiniteVideos.ts @@ -12,6 +12,12 @@ import type { NIP50Filter, SortMode } from '@/types/nostr'; import { parseVideoEvents } from '@/lib/videoParser'; import { debugLog } from '@/lib/debug'; import { performanceMonitor } from '@/lib/performanceMonitoring'; +import { + nextSortedOffset, + RAW_FEED_BACKFILL_ATTEMPTS, + sortedFeedHasMore, + sortedFeedWindowSize, +} from '@/lib/sortedFeedWindow'; interface UseInfiniteVideosOptions { feedType: 'discovery' | 'home' | 'trending' | 'hashtag' | 'profile' | 'recent'; @@ -72,19 +78,17 @@ export function useInfiniteVideos({ const offset = isOffsetParam ? (pageParam as { offset: number }).offset : 0; const cursor = !isOffsetParam ? (pageParam as number | undefined) : undefined; - // Build filter based on feed type + const useSortedPagination = !!(effectiveSortMode && ['top', 'hot', 'rising', 'controversial'].includes(effectiveSortMode)); + + // Build filter based on feed type. Sorted relay pagination uses a + // growing prefix because NIP-50 has no offset parameter. That means + // relay traffic grows with depth and will stop at the relay's limit cap. const filter: NIP50Filter = { kinds: VIDEO_KINDS, - limit: pageSize + limit: useSortedPagination ? offset + pageSize : pageSize }; - // For sorted feeds, we need to request all items up to current offset + pageSize - // and then slice to get just the new page (since NIP-50 doesn't support offset) - const useSortedPagination = effectiveSortMode && ['top', 'hot', 'rising', 'controversial'].includes(effectiveSortMode); - - if (useSortedPagination && offset > 0) { - // Request enough to cover offset + new page - filter.limit = offset + pageSize; + if (useSortedPagination) { debugLog(`[useInfiniteVideos] Sorted pagination: requesting ${filter.limit} to get offset ${offset}`); } else if (cursor) { // Use timestamp cursor for chronological pagination @@ -169,88 +173,117 @@ export function useInfiniteVideos({ debugLog(`[useInfiniteVideos] 📡 Fetching ${feedType} feed, cursor: ${cursor || 'none'}, sort: ${effectiveSortMode || 'none'}, filter:`, filter); - // Fetch events with performance tracking - const queryStart = performance.now(); - const events = await nostr.query([filter], { - signal: AbortSignal.any([ - signal, - AbortSignal.timeout(10000) - ]) - }); - const queryTime = performance.now() - queryStart; - - // Record query performance - performanceMonitor.recordQuery({ - relayUrl: config.relayUrl, - queryType: `infinite-${feedType}`, - duration: queryTime, - eventCount: events.length, - filters: JSON.stringify(filter) - }); + let events: Awaited> = []; + let pageVideos: ParsedVideoData[] = []; + let queryTime = 0; + let parseTime = 0; + let requestedLimit = pageSize; + + for (let attempt = 0; attempt < RAW_FEED_BACKFILL_ATTEMPTS; attempt++) { + if (useSortedPagination) { + requestedLimit = offset + sortedFeedWindowSize(pageSize, attempt); + filter.limit = requestedLimit; + } else { + requestedLimit = pageSize; + filter.limit = requestedLimit; + } + + const queryFilter = { ...filter }; + const queryStart = performance.now(); + events = await nostr.query([queryFilter], { + signal: AbortSignal.any([ + signal, + AbortSignal.timeout(10000) + ]) + }); + const attemptQueryTime = performance.now() - queryStart; + queryTime += attemptQueryTime; + + performanceMonitor.recordQuery({ + relayUrl: config.relayUrl, + queryType: `infinite-${feedType}`, + duration: attemptQueryTime, + eventCount: events.length, + filters: JSON.stringify(queryFilter) + }); - debugLog(`[useInfiniteVideos] Got ${events.length} events for ${feedType} in ${queryTime.toFixed(0)}ms`); - - // Log the first few events to see what we're getting - if (events.length > 0) { - debugLog(`[useInfiniteVideos] First 3 events timestamps:`, - events.slice(0, 3).map(e => ({ - created_at: e.created_at, - date: new Date(e.created_at * 1000).toISOString(), - id: e.id.substring(0, 8) - })) - ); - } + debugLog(`[useInfiniteVideos] Got ${events.length} events for ${feedType} in ${attemptQueryTime.toFixed(0)}ms`); + + if (events.length > 0) { + debugLog(`[useInfiniteVideos] First 3 events timestamps:`, + events.slice(0, 3).map(e => ({ + created_at: e.created_at, + date: new Date(e.created_at * 1000).toISOString(), + id: e.id.substring(0, 8) + })) + ); + } + + const parseStart = performance.now(); + const eventsToParse = useSortedPagination ? events.slice(offset) : events; + let videos = parseVideoEvents(eventsToParse); + + // Sort Classic Vines by loop count (original Vine popularity metric) + // NIP-50's 'top' sort uses Nostr engagement, not original Vine loops + if (effectiveSortMode === 'top' && feedType === 'trending') { + videos = videos.sort((a, b) => { + const aLoops = a.loopCount || 0; + const bLoops = b.loopCount || 0; + return bLoops - aLoops; // Descending order (most loops first) + }); + debugLog(`[useInfiniteVideos] 🔄 Sorted ${videos.length} Classic Vines by loop count`); + } + + // Client-side sorting for hashtag feeds (relay doesn't support #t + search combo) + if (feedType === 'hashtag' && sortMode && videos.length > 1) { + debugLog(`[useInfiniteVideos] 🔄 Applying client-side sort:${sortMode} for hashtag feed`); + videos = videos.sort((a, b) => { + switch (sortMode) { + case 'top': + // Sort by loop count (popularity) + return (b.loopCount || 0) - (a.loopCount || 0); + case 'hot': { + // Hot = engagement weighted by recency (loop count + time decay) + const aScore = (a.loopCount || 0) / Math.pow((Date.now() / 1000 - a.createdAt) / 3600 + 1, 1.5); + const bScore = (b.loopCount || 0) / Math.pow((Date.now() / 1000 - b.createdAt) / 3600 + 1, 1.5); + return bScore - aScore; + } + case 'rising': { + // Rising = recent with some engagement + const aRising = (a.loopCount || 0) * Math.max(0, 1 - (Date.now() / 1000 - a.createdAt) / 86400); + const bRising = (b.loopCount || 0) * Math.max(0, 1 - (Date.now() / 1000 - b.createdAt) / 86400); + return bRising - aRising; + } + default: + // Default: chronological (newest first) + return b.createdAt - a.createdAt; + } + }); + } - // Parse and filter - const parseStart = performance.now(); - let videos = parseVideoEvents(events); + parseTime += performance.now() - parseStart; + pageVideos = videos; - // Sort Classic Vines by loop count (original Vine popularity metric) - // NIP-50's 'top' sort uses Nostr engagement, not original Vine loops - if (effectiveSortMode === 'top' && feedType === 'trending') { - videos = videos.sort((a, b) => { - const aLoops = a.loopCount || 0; - const bLoops = b.loopCount || 0; - return bLoops - aLoops; // Descending order (most loops first) - }); - debugLog(`[useInfiniteVideos] 🔄 Sorted ${videos.length} Classic Vines by loop count`); - } + if (pageVideos.length > 0 || events.length < requestedLimit) { + break; + } - // Client-side sorting for hashtag feeds (relay doesn't support #t + search combo) - if (feedType === 'hashtag' && sortMode && videos.length > 1) { - debugLog(`[useInfiniteVideos] 🔄 Applying client-side sort:${sortMode} for hashtag feed`); - videos = videos.sort((a, b) => { - switch (sortMode) { - case 'top': - // Sort by loop count (popularity) - return (b.loopCount || 0) - (a.loopCount || 0); - case 'hot': { - // Hot = engagement weighted by recency (loop count + time decay) - const aScore = (a.loopCount || 0) / Math.pow((Date.now() / 1000 - a.createdAt) / 3600 + 1, 1.5); - const bScore = (b.loopCount || 0) / Math.pow((Date.now() / 1000 - b.createdAt) / 3600 + 1, 1.5); - return bScore - aScore; - } - case 'rising': { - // Rising = recent with some engagement - const aRising = (a.loopCount || 0) * Math.max(0, 1 - (Date.now() / 1000 - a.createdAt) / 86400); - const bRising = (b.loopCount || 0) * Math.max(0, 1 - (Date.now() / 1000 - b.createdAt) / 86400); - return bRising - aRising; - } - default: - // Default: chronological (newest first) - return b.createdAt - a.createdAt; - } - }); - } + if (useSortedPagination) { + debugLog(`[useInfiniteVideos] Sorted page at offset ${offset} parsed empty; expanding raw window`); + continue; + } - // For sorted pagination, slice to get only the new page - let pageVideos = videos; - if (useSortedPagination && offset > 0) { - pageVideos = videos.slice(offset, offset + pageSize); - debugLog(`[useInfiniteVideos] Sliced sorted results: ${videos.length} total -> ${pageVideos.length} for page (offset ${offset})`); + const lastEvent = events[events.length - 1]; + if (!lastEvent) { + break; + } + filter.until = lastEvent.created_at - 1; + debugLog(`[useInfiniteVideos] Chronological page parsed empty; advancing raw cursor to ${filter.until}`); } - const parseTime = performance.now() - parseStart; + if (useSortedPagination) { + debugLog(`[useInfiniteVideos] Sliced sorted raw results: ${events.length} total -> ${pageVideos.length} parsed for page (offset ${offset})`); + } const totalTime = performance.now() - totalStart; @@ -266,9 +299,9 @@ export function useInfiniteVideos({ // Determine next cursor/offset if (useSortedPagination) { - // For sorted feeds, use offset-based pagination - const newOffset = offset + pageVideos.length; - const hasMore = pageVideos.length === pageSize; // If we got a full page, there might be more + // For sorted feeds, advance by raw events consumed, not parsed videos. + const newOffset = nextSortedOffset(events.length, offset); + const hasMore = pageVideos.length > 0 && sortedFeedHasMore(events.length, requestedLimit); debugLog(`[useInfiniteVideos] Sorted pagination: offset ${offset} -> ${newOffset}, hasMore: ${hasMore}`); return { videos: pageVideos, @@ -276,10 +309,16 @@ export function useInfiniteVideos({ offset: hasMore ? newOffset : undefined }; } else { - // For chronological feeds, use timestamp cursor - const nextCursor = pageVideos.length > 0 - ? pageVideos[pageVideos.length - 1].createdAt - 1 + // For chronological feeds, use timestamp cursor. Advancing by the last + // raw event's timestamp (not the last parsed video's) is what unsticks + // pagination when a full page parses short. This relies on nostr.query + // returning non-search results newest-first: NPool sorts non-search + // filters by created_at desc and video feeds bypass the profile/contact + // cache, so events[last] is the oldest event fetched. + const rawCursor = events.length > 0 + ? events[events.length - 1].created_at - 1 : undefined; + const nextCursor = pageVideos.length > 0 ? rawCursor : undefined; return { videos: pageVideos, nextCursor diff --git a/src/hooks/useInfiniteVideosFunnelcake.test.ts b/src/hooks/useInfiniteVideosFunnelcake.test.ts index 11c97696..a1b2dc37 100644 --- a/src/hooks/useInfiniteVideosFunnelcake.test.ts +++ b/src/hooks/useInfiniteVideosFunnelcake.test.ts @@ -275,6 +275,32 @@ describe('useInfiniteVideosFunnelcake', () => { expect(result.current.hasNextPage).toBe(true); }); + it('stops v2 cursor pagination when a cursor page transforms to zero videos', async () => { + mockFetchVideosV2.mockResolvedValueOnce({ + videos: [{}], + has_more: true, + next_cursor: 'o:12', + }); + mockTransformToVideoPage.mockReturnValueOnce({ + videos: [], + nextCursor: undefined, + rawCursor: 'o:12', + hasMore: true, + }); + + const { result } = renderHook( + () => useInfiniteVideosFunnelcake({ feedType: 'trending', pageSize: 12 }), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data?.pages[0].videos).toEqual([]); + expect(result.current.hasNextPage).toBe(false); + }); + it('uses cursor-based pagination for recommendations', async () => { // Page 1: server returns cursor for next page mockFetchRecommendations diff --git a/src/hooks/useInfiniteVideosFunnelcake.ts b/src/hooks/useInfiniteVideosFunnelcake.ts index 72adc276..8790defc 100644 --- a/src/hooks/useInfiniteVideosFunnelcake.ts +++ b/src/hooks/useInfiniteVideosFunnelcake.ts @@ -504,7 +504,7 @@ export function useInfiniteVideosFunnelcake({ } // Trending uses opaque v2 cursors — pass through as a string page param if (feedType === 'trending' || feedType === 'popular') { - return lastPage.v2Cursor; + return lastPage.videos.length > 0 ? lastPage.v2Cursor : undefined; } // Use offset for sorted pagination, timestamp for chronological if (lastPage.offset !== undefined) { diff --git a/src/hooks/useVideoProvider.blocklist.test.ts b/src/hooks/useVideoProvider.blocklist.test.ts index 3a3b0100..8f43d9fe 100644 --- a/src/hooks/useVideoProvider.blocklist.test.ts +++ b/src/hooks/useVideoProvider.blocklist.test.ts @@ -90,6 +90,7 @@ describe('useVideoProvider blocklist filtering', () => { ); const videos = result.current.data?.pages.flatMap(p => p.videos) ?? []; expect(videos.map(v => v.id)).toEqual(['ok-1', 'ok-2']); + expect(result.current.fetchedCount).toBe(4); }); it("drops a blocked author's videos from the discovery feed", () => { diff --git a/src/hooks/useVideoProvider.test.ts b/src/hooks/useVideoProvider.test.ts index cc51fc04..c85a3ad6 100644 --- a/src/hooks/useVideoProvider.test.ts +++ b/src/hooks/useVideoProvider.test.ts @@ -355,4 +355,25 @@ describe('useVideoProvider', () => { })); expect(result.current.dataSource).toBe('websocket'); }); + + it('reports fetched count from unfiltered active query pages', () => { + mockUseInfiniteVideosFunnelcake.mockReturnValue({ + ...queryResult, + data: { + pages: [ + { videos: [{ pubkey: 'a' }, { pubkey: 'b' }], nextCursor: undefined }, + { videos: [{ pubkey: 'a' }], nextCursor: undefined }, + ], + pageParams: [undefined, 2], + }, + }); + + const { result } = renderHook(() => + useVideoProvider({ + feedType: 'discovery', + }) + ); + + expect(result.current.fetchedCount).toBe(3); + }); }); diff --git a/src/hooks/useVideoProvider.ts b/src/hooks/useVideoProvider.ts index 8ef95ad0..5209e159 100644 --- a/src/hooks/useVideoProvider.ts +++ b/src/hooks/useVideoProvider.ts @@ -40,6 +40,7 @@ interface VideoProviderResult { isLoading: boolean; error: Error | null; refetch: () => void; + fetchedCount: number; // Additional metadata dataSource: 'funnelcake' | 'websocket'; apiUrl?: string; @@ -300,6 +301,7 @@ export function useVideoProvider({ // unfiltered pages inside the underlying query hooks. const blockedPubkeys = useFeedBlocklist(); const rawData = activeQuery.data; + const fetchedCount = rawData?.pages.reduce((sum, page) => sum + page.videos.length, 0) ?? 0; const filteredData = useMemo( () => filterBlockedVideoPages(rawData, blockedPubkeys), [rawData, blockedPubkeys] @@ -312,6 +314,7 @@ export function useVideoProvider({ isLoading: activeQuery.isLoading, error: activeQuery.error, refetch: activeQuery.refetch, + fetchedCount, dataSource: activeDataSource, apiUrl: shouldUseFunnelcake ? decision.apiUrl : undefined, }; diff --git a/src/lib/sortedFeedWindow.test.ts b/src/lib/sortedFeedWindow.test.ts new file mode 100644 index 00000000..b47d7bbe --- /dev/null +++ b/src/lib/sortedFeedWindow.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import { + nextSortedOffset, + sortedFeedHasMore, + sortedFeedWindowSize, +} from '@/lib/sortedFeedWindow'; + +describe('sortedFeedWindow', () => { + it('advances sorted offsets by raw events consumed', () => { + expect(nextSortedOffset(20, 0)).toBe(20); + expect(nextSortedOffset(40, 20)).toBe(40); + }); + + it('does not move a sorted offset backwards for short capped responses', () => { + expect(nextSortedOffset(18, 20)).toBe(20); + }); + + it('continues while the relay fills the requested limit', () => { + expect(sortedFeedHasMore(20, 20)).toBe(true); + expect(sortedFeedHasMore(19, 20)).toBe(false); + }); + + it('expands the raw window by one page per backfill attempt', () => { + expect(sortedFeedWindowSize(20, 0)).toBe(20); + expect(sortedFeedWindowSize(20, 1)).toBe(40); + expect(sortedFeedWindowSize(20, 2)).toBe(60); + }); +}); diff --git a/src/lib/sortedFeedWindow.ts b/src/lib/sortedFeedWindow.ts new file mode 100644 index 00000000..4e4e633b --- /dev/null +++ b/src/lib/sortedFeedWindow.ts @@ -0,0 +1,21 @@ +// ABOUTME: Raw-event pagination helpers for relay feed backfill +// ABOUTME: Keeps feed windows advancing even when parsing drops events + +// When a raw window parses zero videos we expand and retry, but only this many +// times. This bound doubles as the feed's reachability ceiling: after roughly +// RAW_FEED_BACKFILL_ATTEMPTS * pageSize consecutive unparseable events, +// pagination stops even if valid videos exist deeper. The cap is deliberate: +// it prevents an unbounded relay-hammering loop on a bad data cluster. +export const RAW_FEED_BACKFILL_ATTEMPTS = 3; + +export function nextSortedOffset(eventCount: number, offset: number): number { + return Math.max(eventCount, offset); +} + +export function sortedFeedHasMore(eventCount: number, requestedLimit: number): boolean { + return eventCount >= requestedLimit; +} + +export function sortedFeedWindowSize(pageSize: number, attempt: number): number { + return pageSize * (attempt + 1); +} diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 02204471..84a99c99 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -107,6 +107,7 @@ export function ProfilePage({ pubkeyOverride }: { pubkeyOverride?: string } = {} error: videosError, fetchNextPage, hasNextPage, + fetchedCount, } = useVideoProvider({ feedType: 'profile', sortMode, @@ -503,7 +504,7 @@ export function ProfilePage({ pubkeyOverride }: { pubkeyOverride?: string } = {} ) : viewMode === 'grid' ? ( ({ mockNavigate: vi.fn(), mockFetchEventById: vi.fn(), @@ -38,6 +39,7 @@ const { mockEnterFullscreen: vi.fn(), mockSetVideosForFullscreen: vi.fn(), mockUpdateVideos: vi.fn(), + mockInfiniteScroll: vi.fn(), })); vi.mock('@unhead/react', () => ({ @@ -124,7 +126,10 @@ vi.mock('@/components/VideoCardWithMetrics', () => ({ })); vi.mock('react-infinite-scroll-component', () => ({ - default: ({ children }: { children: ReactNode }) =>
{children}
, + default: ({ children, dataLength }: { children: ReactNode; dataLength: number }) => { + mockInfiniteScroll({ dataLength }); + return
{children}
; + }, })); vi.mock('@/hooks/useSubdomainNavigate', () => ({ @@ -224,6 +229,7 @@ describe('SearchPage', () => { hasNextPage: false, isLoading: false, error: null, + fetchedCount: 0, }); mockUseSearchUsers.mockReturnValue({ data: [], @@ -416,6 +422,7 @@ describe('SearchPage', () => { hasNextPage: false, isLoading: false, error: null, + fetchedCount: 2, }); renderPage(['/search?q=twerking&filter=videos&sort=top']); @@ -441,6 +448,7 @@ describe('SearchPage', () => { hasNextPage: false, isLoading: false, error: null, + fetchedCount: 1, }); renderPage(['/search?q=twerking&filter=videos']); @@ -467,6 +475,7 @@ describe('SearchPage', () => { hasNextPage: false, isLoading: false, error: null, + fetchedCount: 1, }); renderPage(['/search?q=vine&filter=videos']); @@ -493,6 +502,7 @@ describe('SearchPage', () => { hasNextPage: false, isLoading: false, error: null, + fetchedCount: 1, }); renderPage(['/search?q=twerking&filter=videos&play=compilation&start=0']); @@ -519,6 +529,7 @@ describe('SearchPage', () => { hasNextPage: false, isLoading: false, error: null, + fetchedCount: 1, }); renderPage(['/search?q=twerking&filter=videos&play=compilation&video=video-1']); @@ -542,6 +553,7 @@ describe('SearchPage', () => { hasNextPage: false, isLoading: false, error: null, + fetchedCount: 1, }); renderPage(['/search?q=dance']); @@ -557,6 +569,7 @@ describe('SearchPage', () => { hasNextPage: false, isLoading: false, error: null, + fetchedCount: 1, }); renderPage(['/search?q=dance&filter=videos']); @@ -655,6 +668,7 @@ describe('SearchPage', () => { hasNextPage: false, isLoading: false, error: null, + fetchedCount: 0, }); renderPage(['/search?q=dogs']); @@ -667,6 +681,29 @@ describe('SearchPage', () => { expect(screen.getByTestId('search-sort-relevance')).toHaveAttribute('aria-checked', 'false'); }); + it('keys video infinite scroll from fetched count instead of deduped rendered count', () => { + mockUseInfiniteSearchVideos.mockReturnValue({ + data: { + pages: [{ + videos: [ + { id: 'video-1', pubkey: 'a'.repeat(64), videoUrl: 'https://example.com/video-1.mp4' }, + { id: 'video-1', pubkey: 'a'.repeat(64), videoUrl: 'https://example.com/video-1-copy.mp4' }, + ], + }], + }, + fetchNextPage: vi.fn(), + hasNextPage: true, + isLoading: false, + error: null, + fetchedCount: 2, + }); + + renderPage(['/search?q=dogs&filter=videos']); + + expect(screen.getAllByTestId('video-card-video-1')).toHaveLength(2); + expect(mockInfiniteScroll).toHaveBeenCalledWith({ dataLength: 2 }); + }); + it('honors the sort URL param on initial load', () => { mockUseInfiniteSearchVideos.mockReturnValue({ data: { pages: [{ videos: [] }] }, @@ -674,6 +711,7 @@ describe('SearchPage', () => { hasNextPage: false, isLoading: false, error: null, + fetchedCount: 0, }); renderPage(['/search?q=dogs&sort=top']); @@ -690,6 +728,7 @@ describe('SearchPage', () => { hasNextPage: false, isLoading: false, error: null, + fetchedCount: 0, }); renderPage(['/search?q=dogs&sort=garbage']); @@ -711,6 +750,7 @@ describe('SearchPage', () => { hasNextPage: false, isLoading: false, error: null, + fetchedCount: 0, }); renderPage(['/search?q=dogs']); @@ -750,6 +790,7 @@ describe('SearchPage', () => { hasNextPage: false, isLoading: false, error: null, + fetchedCount: 0, }); renderPage(['/search?q=dogs']); diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index 201185aa..667d73ab 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -93,6 +93,7 @@ export function SearchPage() { hasNextPage: hasNextVideos, isLoading: isLoadingVideos, error: videoError, + fetchedCount: fetchedVideoCount, } = useInfiniteSearchVideos({ query: searchQuery, sortMode, @@ -663,7 +664,7 @@ export function SearchPage() { ) : (