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)
This commit is contained in:
Chad Curtis
2026-04-07 07:48:23 -05:00
parent e6efdc3539
commit 064ab1e101
7 changed files with 124 additions and 81 deletions
+1
View File
@@ -149,6 +149,7 @@ const hardcodedConfig: AppConfig = {
plausibleEndpoint: import.meta.env.VITE_PLAUSIBLE_ENDPOINT || "",
savedFeeds: [],
imageQuality: 'compressed',
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
};
/**
+12 -67
View File
@@ -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<NostrEvent[], Error>({
queryKey: ['ditto-curated-feed', curatorAuthorsKey],
queryFn: async ({ pageParam, signal }) => {
const base: Record<string, unknown> = {
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<string, unknown> = {
...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<typeof ditto.query>[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.
+2
View File
@@ -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 {
+94
View File
@@ -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<NostrEvent[], Error>({
queryKey: ['ditto-curated-feed', authorsKey],
queryFn: async ({ pageParam, signal }) => {
const base: Record<string, unknown> = {
kinds: CURATED_KINDS,
authors,
limit: 20,
};
if (pageParam) base.until = pageParam;
// Webxdc needs a separate filter with MIME-type tag constraint
const webxdcFilter: Record<string, unknown> = {
...WEBXDC_FILTER,
authors,
limit: 20,
};
if (pageParam) webxdcFilter.until = pageParam;
const ditto = nostr.group(DITTO_RELAYS);
return ditto.query(
[base, webxdcFilter] as Parameters<typeof ditto.query>[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 };
+13 -10
View File
@@ -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<string[]>({
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(),
+1 -4
View File
@@ -198,14 +198,12 @@ export function useInfiniteHotFeed(
enabled = true,
limit = SORTED_PAGE_SIZE,
extraFilters?: Record<string, unknown>[],
authors?: string[],
) {
const { nostr } = useNostr();
const extraKey = extraFilters ? JSON.stringify(extraFilters) : '';
const authorsKey = authors ? authors.length.toString() : '';
return useInfiniteQuery<NostrEvent[], Error>({
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<string, unknown>[] = [{ ...base, kinds }];
+1
View File
@@ -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) ───────────────────────