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:
@@ -149,6 +149,7 @@ const hardcodedConfig: AppConfig = {
|
||||
plausibleEndpoint: import.meta.env.VITE_PLAUSIBLE_ENDPOINT || "",
|
||||
savedFeeds: [],
|
||||
imageQuality: 'compressed',
|
||||
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+12
-67
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 }];
|
||||
|
||||
@@ -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) ───────────────────────
|
||||
|
||||
Reference in New Issue
Block a user