From 064ab1e10172d74f2c528fdf73dbfe33f2e85f0f Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Tue, 7 Apr 2026 07:48:23 -0500 Subject: [PATCH] Address MR review: extract feed hook, fix cache key, add error handling, make curator configurable - Remove unused 'authors' parameter from useInfiniteHotFeed - Extract inline query from Feed.tsx into useCuratedDittoFeed hook - Use content-based fingerprint for query key instead of list length - Add error state handling so curator fetch failure shows empty state instead of infinite skeletons for first-time visitors - Move hardcoded curator pubkey to AppConfig (curatorPubkey) so it can be overridden via ditto.json without a code change - Remove LANDING_KINDS/LANDING_WEBXDC_FILTER from Feed.tsx (now in hook) --- src/App.tsx | 1 + src/components/Feed.tsx | 79 ++++---------------------- src/contexts/AppContext.ts | 2 + src/hooks/useCuratedDittoFeed.ts | 94 +++++++++++++++++++++++++++++++ src/hooks/useCuratorFollowList.ts | 23 ++++---- src/hooks/useTrending.ts | 5 +- src/lib/schemas.ts | 1 + 7 files changed, 124 insertions(+), 81 deletions(-) create mode 100644 src/hooks/useCuratedDittoFeed.ts diff --git a/src/App.tsx b/src/App.tsx index 1ef943aa..5e15b344 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -149,6 +149,7 @@ const hardcodedConfig: AppConfig = { plausibleEndpoint: import.meta.env.VITE_PLAUSIBLE_ENDPOINT || "", savedFeeds: [], imageQuality: 'compressed', + curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d', }; /** diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index add0a485..c9a983ec 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; import { useInView } from 'react-intersection-observer'; import { useNostr } from '@nostrify/react'; -import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { usePageRefresh } from '@/hooks/usePageRefresh'; import { ComposeBox } from '@/components/ComposeBox'; import { LandingHero } from '@/components/LandingHero'; @@ -23,6 +23,7 @@ import { useSavedFeeds } from '@/hooks/useSavedFeeds'; import { useStreamPosts } from '@/hooks/useStreamPosts'; import { useResolveTabFilter } from '@/hooks/useResolveTabFilter'; import { useCuratorFollowList } from '@/hooks/useCuratorFollowList'; +import { useCuratedDittoFeed } from '@/hooks/useCuratedDittoFeed'; import { getEnabledFeedKinds } from '@/lib/extraKinds'; import { diversifyFeedPages } from '@/lib/feedDiversity'; import { isRepostKind, shouldHideFeedEvent } from '@/lib/feedUtils'; @@ -37,29 +38,6 @@ import type { SavedFeed } from '@/contexts/AppContext'; type CoreFeedTab = 'follows' | 'global' | 'communities' | 'ditto'; type FeedTab = CoreFeedTab | string; // string = saved feed id -/** Curated kinds for the logged-out homepage and Ditto tab: unique Ditto content types. */ -const LANDING_KINDS = [ - 20, // Photos (NIP-68) - 21, // Videos (NIP-71) - 22, // Short Videos (NIP-71) - 34236, // Divines (addressable short videos) - 36787, // Music Tracks - 34139, // Music Playlists - 36767, // Themes - 37381, // Magic Decks - 3367, // Color Moments - 37516, // Treasures - 7516, // Treasures (Found Logs) - 30030, // Emoji Packs - 30009, // Badge Definitions - 10008, // Profile Badges - 30008, // Profile Badges (legacy) - 31124, // Blobbi -]; - -/** Webxdc needs a MIME-type tag filter, so it gets its own filter object. */ -const LANDING_WEBXDC_FILTER = { kinds: [1063], '#m': ['application/x-webxdc'] }; - interface FeedProps { /** Override the kinds list instead of using feed settings. */ kinds?: number[]; @@ -81,7 +59,7 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee const { savedFeeds } = useSavedFeeds(); const { hashtags } = useInterests(); const { hashtags: geotags } = useInterests('g'); - const { data: curatorFollowList } = useCuratorFollowList(); + const { data: curatorFollowList, isError: isCuratorError } = useCuratorFollowList(); // Tab settings from localStorage const showGlobalFeed = (() => { @@ -159,52 +137,17 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee ); // Curated Ditto feed: latest content from the curator's follow list. - // Standard NIP-01 reverse-chronological pagination (no sort:hot). - const { nostr } = useNostr(); - const dittoFeedEnabled = (useTopFeedForLoggedOut || !!useDittoTab) && (curatorFollowList ?? []).length > 0; - const curatorAuthorsKey = curatorFollowList ? curatorFollowList.length.toString() : ''; - - const topQuery = useInfiniteQuery({ - queryKey: ['ditto-curated-feed', curatorAuthorsKey], - queryFn: async ({ pageParam, signal }) => { - const base: Record = { - kinds: LANDING_KINDS, - authors: curatorFollowList, - limit: 20, - }; - if (pageParam) base.until = pageParam; - - // Webxdc needs a separate filter with MIME-type tag constraint - const webxdcFilter: Record = { - ...LANDING_WEBXDC_FILTER, - authors: curatorFollowList, - limit: 20, - }; - if (pageParam) webxdcFilter.until = pageParam; - - const ditto = nostr.group(DITTO_RELAYS); - return ditto.query( - [base, webxdcFilter] as Parameters[0], - { signal: AbortSignal.any([signal, AbortSignal.timeout(10000)]) }, - ); - }, - getNextPageParam: (lastPage) => { - if (lastPage.length === 0) return undefined; - return lastPage[lastPage.length - 1].created_at - 1; - }, - initialPageParam: undefined as number | undefined, - enabled: dittoFeedEnabled, - staleTime: 5 * 60 * 1000, - gcTime: 30 * 60 * 1000, - placeholderData: (prev) => prev, - }); + const topQuery = useCuratedDittoFeed( + curatorFollowList, + useTopFeedForLoggedOut || !!useDittoTab, + ); // Unify the two query shapes behind a single interface const useDittoQuery = useTopFeedForLoggedOut || useDittoTab; const activeQuery = useDittoQuery ? topQuery : feedQuery; const queryKey = useMemo( - () => useDittoQuery ? ['ditto-curated-feed', curatorAuthorsKey] : ['feed', activeTab], - [useDittoQuery, activeTab, curatorAuthorsKey], + () => useDittoQuery ? ['ditto-curated-feed'] : ['feed', activeTab], + [useDittoQuery, activeTab], ); const handleRefresh = usePageRefresh(queryKey); @@ -276,7 +219,9 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee }); }, [rawData?.pages, muteItems, useDittoQuery]); - const showSkeleton = isPending || (isLoading && !rawData); + // Show skeletons while loading, but not if the curator list query errored + // (that would leave logged-out users staring at infinite skeletons). + const showSkeleton = (isPending || (isLoading && !rawData)) && !(useDittoQuery && isCuratorError); // Kind-specific pages (e.g. Development, WebXDC) only show Follows + Global tabs. // Extra tabs (Ditto, Community, saved feeds, hashtags) are only for the home feed. diff --git a/src/contexts/AppContext.ts b/src/contexts/AppContext.ts index 492244ae..8df95d31 100644 --- a/src/contexts/AppContext.ts +++ b/src/contexts/AppContext.ts @@ -241,6 +241,8 @@ export interface AppConfig { savedFeeds: SavedFeed[]; /** Image upload quality: "compressed" resizes/optimizes, "original" uploads as-is. Default: "compressed". */ imageQuality: 'compressed' | 'original'; + /** Hex pubkey of the curator whose follow list defines the Ditto feed. */ + curatorPubkey?: string; } export interface AppContextType { diff --git a/src/hooks/useCuratedDittoFeed.ts b/src/hooks/useCuratedDittoFeed.ts new file mode 100644 index 00000000..d1cba5a8 --- /dev/null +++ b/src/hooks/useCuratedDittoFeed.ts @@ -0,0 +1,94 @@ +import { useNostr } from '@nostrify/react'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import type { NostrEvent } from '@nostrify/nostrify'; + +import { DITTO_RELAYS } from '@/lib/appRelays'; + +/** Curated kinds for the Ditto feed: unique Ditto content types. */ +const CURATED_KINDS = [ + 20, // Photos (NIP-68) + 21, // Videos (NIP-71) + 22, // Short Videos (NIP-71) + 34236, // Divines (addressable short videos) + 36787, // Music Tracks + 34139, // Music Playlists + 36767, // Themes + 37381, // Magic Decks + 3367, // Color Moments + 37516, // Treasures + 7516, // Treasures (Found Logs) + 30030, // Emoji Packs + 30009, // Badge Definitions + 10008, // Profile Badges + 30008, // Profile Badges (legacy) + 31124, // Blobbi +]; + +/** Webxdc needs a MIME-type tag filter, so it gets its own filter object. */ +const WEBXDC_FILTER = { kinds: [1063], '#m': ['application/x-webxdc'] }; + +/** + * Compute a short fingerprint of a string array for use in query keys. + * Produces a stable, content-dependent value so the query busts when + * the actual pubkey set changes (not just its length). + */ +function fingerprint(items: string[]): string { + // Simple djb2-style hash — fast and collision-resistant enough for a cache key. + let hash = 5381; + for (const item of items) { + for (let i = 0; i < item.length; i++) { + hash = ((hash << 5) + hash + item.charCodeAt(i)) | 0; + } + } + return (hash >>> 0).toString(36); +} + +/** + * Curated Ditto feed: latest content from the curator's follow list. + * Standard NIP-01 reverse-chronological pagination (no sort:hot). + * + * @param authors - Pubkeys whose content to include (from useCuratorFollowList). + * @param enabled - Whether the query should run. + */ +export function useCuratedDittoFeed(authors: string[] | undefined, enabled: boolean) { + const { nostr } = useNostr(); + const authorsKey = authors ? fingerprint(authors) : ''; + + return useInfiniteQuery({ + queryKey: ['ditto-curated-feed', authorsKey], + queryFn: async ({ pageParam, signal }) => { + const base: Record = { + kinds: CURATED_KINDS, + authors, + limit: 20, + }; + if (pageParam) base.until = pageParam; + + // Webxdc needs a separate filter with MIME-type tag constraint + const webxdcFilter: Record = { + ...WEBXDC_FILTER, + authors, + limit: 20, + }; + if (pageParam) webxdcFilter.until = pageParam; + + const ditto = nostr.group(DITTO_RELAYS); + return ditto.query( + [base, webxdcFilter] as Parameters[0], + { signal: AbortSignal.any([signal, AbortSignal.timeout(10000)]) }, + ); + }, + getNextPageParam: (lastPage) => { + if (lastPage.length === 0) return undefined; + return lastPage[lastPage.length - 1].created_at - 1; + }, + initialPageParam: undefined as number | undefined, + enabled: enabled && !!authors && authors.length > 0, + staleTime: 5 * 60 * 1000, + gcTime: 30 * 60 * 1000, + placeholderData: (prev) => prev, + }); +} + +/** Re-export for use in Feed.tsx landing hero / kind lists. */ +export { CURATED_KINDS, WEBXDC_FILTER }; diff --git a/src/hooks/useCuratorFollowList.ts b/src/hooks/useCuratorFollowList.ts index 3e2926c2..1d9dfe7c 100644 --- a/src/hooks/useCuratorFollowList.ts +++ b/src/hooks/useCuratorFollowList.ts @@ -1,11 +1,6 @@ import { useNostr } from '@nostrify/react'; import { useQuery } from '@tanstack/react-query'; - -/** - * The curator pubkey whose follow list curates the Ditto feed. - * npub1jvnpg4c6ljadf5t6ry0w9q0rnm4mksde87kglkrc993z46c39axsgq89sc - */ -export const CURATOR_PUBKEY = '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d'; +import { useAppContext } from '@/hooks/useAppContext'; /** localStorage key for cached curator follow list. */ const CACHE_KEY = 'ditto:curatorFollowList'; @@ -36,28 +31,36 @@ function setCached(pubkeys: string[]): void { * Fetches the follow list (kind 3 `p` tags) for the curator pubkey. * Returns the curator's pubkey + all pubkeys they follow. * Cached in localStorage for instant display on return visits. + * + * The curator pubkey is read from `config.curatorPubkey`. When unset the + * hook is disabled and returns `undefined`. */ export function useCuratorFollowList() { const { nostr } = useNostr(); + const { config } = useAppContext(); + const curatorPubkey = config.curatorPubkey; return useQuery({ - queryKey: ['curator-follow-list', CURATOR_PUBKEY], + queryKey: ['curator-follow-list', curatorPubkey], queryFn: async ({ signal }) => { + if (!curatorPubkey) return []; + const [event] = await nostr.query( - [{ kinds: [3], authors: [CURATOR_PUBKEY], limit: 1 }], + [{ kinds: [3], authors: [curatorPubkey], limit: 1 }], { signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) }, ); - if (!event) return [CURATOR_PUBKEY]; + if (!event) return [curatorPubkey]; const pubkeys = event.tags .filter(([name]) => name === 'p') .map(([, pk]) => pk); // Include the curator themselves - const allPubkeys = [...new Set([CURATOR_PUBKEY, ...pubkeys])]; + const allPubkeys = [...new Set([curatorPubkey, ...pubkeys])]; setCached(allPubkeys); return allPubkeys; }, + enabled: !!curatorPubkey, staleTime: 10 * 60 * 1000, // 10 minutes gcTime: 60 * 60 * 1000, // 1 hour placeholderData: getCached(), diff --git a/src/hooks/useTrending.ts b/src/hooks/useTrending.ts index 4c2d7dd3..bb8433cb 100644 --- a/src/hooks/useTrending.ts +++ b/src/hooks/useTrending.ts @@ -198,14 +198,12 @@ export function useInfiniteHotFeed( enabled = true, limit = SORTED_PAGE_SIZE, extraFilters?: Record[], - authors?: string[], ) { const { nostr } = useNostr(); const extraKey = extraFilters ? JSON.stringify(extraFilters) : ''; - const authorsKey = authors ? authors.length.toString() : ''; return useInfiniteQuery({ - queryKey: ['infinite-hot-feed', kinds.join(','), limit, extraKey, authorsKey], + queryKey: ['infinite-hot-feed', kinds.join(','), limit, extraKey], queryFn: async ({ pageParam, signal }) => { const ditto = nostr.group(DITTO_RELAYS); @@ -214,7 +212,6 @@ export function useInfiniteHotFeed( limit, }; if (pageParam) base.until = pageParam; - if (authors && authors.length > 0) base.authors = authors; // Primary filter for the main kinds list const filters: Record[] = [{ ...base, kinds }]; diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index ee034e12..ef63c9c1 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -245,6 +245,7 @@ export const AppConfigSchema = z.object({ }) ).optional().default([]), imageQuality: z.enum(['compressed', 'original']), + curatorPubkey: z.string().regex(/^[0-9a-f]{64}$/i).optional(), }); // ─── DittoConfigSchema (build-time ditto.json) ───────────────────────