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() {
) : (