From a3e5b94e6975fa05bf3650ca8ad02688924f557e Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Sat, 14 Mar 2026 17:34:21 -0500 Subject: [PATCH] Fix profile tab editor missing people filter and tab feed using paginated query - ProfileTabEditModal: add 'People' scope option with AuthorFilterDropdown and ListPackPicker so users can pick specific pubkeys as tab authors, matching the filter options available on the normal search filter - useProfileFeed: add useTabFeed hook that runs a paginated useInfiniteQuery with full NostrFilter support (kinds, authors, NIP-50 search) - ProfileSavedFeedContent: replace useStreamPosts with useTabFeed so custom tab content uses cursor-based pagination (like the posts tab) instead of streaming, fixing search-keyed queries and enabling infinite scroll --- src/components/ProfileTabEditModal.tsx | 90 +++++++++++++++--- src/hooks/useProfileFeed.ts | 124 ++++++++++++++++++++++++- src/pages/ProfilePage.tsx | 54 +++++++---- 3 files changed, 236 insertions(+), 32 deletions(-) diff --git a/src/components/ProfileTabEditModal.tsx b/src/components/ProfileTabEditModal.tsx index aac3dbaa..fca06586 100644 --- a/src/components/ProfileTabEditModal.tsx +++ b/src/components/ProfileTabEditModal.tsx @@ -4,12 +4,12 @@ * Modal for adding or editing a custom profile tab (kind 16769). * Opens with an optional existing tab to edit; otherwise creates a new one. * - * Streamlined for profile tabs: only Search Query, Author Scope (Me / Contacts / Global), + * Streamlined for profile tabs: only Search Query, Author Scope (Me / Contacts / People / Global), * and multi-select Kind picker. */ import { useState, useMemo, useCallback } from 'react'; import { - Loader2, Check, Globe, Users, User, + Loader2, Check, Globe, Users, User, UserSearch, } from 'lucide-react'; import { Dialog, @@ -26,9 +26,14 @@ import { MultiKindPicker, ScopeToggle, parseSelectedKinds, + AuthorChip, + AuthorFilterDropdown, + ListPackPicker, } from '@/components/SavedFeedFiltersEditor'; import type { ScopeOption } from '@/components/SavedFeedFiltersEditor'; import { PortalContainerProvider } from '@/contexts/PortalContainerContext'; +import { useUserLists, useMatchedListId } from '@/hooks/useUserLists'; +import { useFollowPacks } from '@/hooks/useFollowPacks'; import type { ProfileTab, TabFilter } from '@/lib/profileTabsEvent'; @@ -44,18 +49,20 @@ interface ProfileTabEditModalProps { isPending?: boolean; } -// ─── Author scope type for the simplified 3-way toggle ──────────────────────── +// ─── Author scope type for the 4-way toggle ─────────────────────────────────── -type ProfileAuthorScope = 'me' | 'contacts' | 'global'; +type ProfileAuthorScope = 'me' | 'contacts' | 'people' | 'global'; -/** Map from simplified scope to filter fields. */ -function scopeToFilter(scope: ProfileAuthorScope, ownerPubkey: string): Partial { +/** Map from simplified scope to filter fields (excluding people's authors which are stored separately). */ +function scopeToFilter(scope: ProfileAuthorScope, ownerPubkey: string, peoplePubkeys: string[]): Partial { switch (scope) { case 'me': return { authors: [ownerPubkey] }; case 'contacts': // Uses $follows variable — handled at event level via var tags return { authors: ['$follows'] }; + case 'people': + return peoplePubkeys.length > 0 ? { authors: peoplePubkeys } : {}; case 'global': return {}; } @@ -66,10 +73,18 @@ function filterToScope(filter: TabFilter, ownerPubkey: string): ProfileAuthorSco const authors = Array.isArray(filter.authors) ? filter.authors as string[] : []; if (authors.length === 1 && authors[0] === ownerPubkey) return 'me'; if (authors.includes('$follows')) return 'contacts'; - if (authors.length > 0) return 'me'; // has specific authors + if (authors.length > 0) return 'people'; // has specific authors → people scope return 'global'; } +/** Extract people pubkeys from a TabFilter (non-variable, non-owner pubkeys). */ +function filterToPeoplePubkeys(filter: TabFilter, ownerPubkey: string): string[] { + const authors = Array.isArray(filter.authors) ? filter.authors as string[] : []; + if (authors.includes('$follows')) return []; + if (authors.length === 1 && authors[0] === ownerPubkey) return []; + return authors.filter((a) => a !== ownerPubkey && !a.startsWith('$')); +} + /** Serialize selected kind values into a kinds array for the filter. */ function serializeSelectedKinds(kinds: string[]): number[] { return kinds.map(Number).filter((n) => !isNaN(n) && n > 0); @@ -80,6 +95,7 @@ function serializeSelectedKinds(kinds: string[]): number[] { const PROFILE_SCOPE_OPTIONS: ScopeOption[] = [ { value: 'me', label: 'Me', icon: User }, { value: 'contacts', label: 'Contacts', icon: Users }, + { value: 'people', label: 'People', icon: UserSearch }, { value: 'global', label: 'Global', icon: Globe }, ]; @@ -94,6 +110,8 @@ export function ProfileTabEditModal({ isPending = false, }: ProfileTabEditModalProps) { const kindOptions = useMemo(() => buildKindOptions(), []); + const { lists } = useUserLists(); + const { data: followPacks = [] } = useFollowPacks(); const isNew = !tab; const initialFilter = useMemo(() => { @@ -108,15 +126,36 @@ export function ProfileTabEditModal({ const [authorScope, setAuthorScope] = useState( filterToScope(initialFilter, ownerPubkey), ); + const [peoplePubkeys, setPeoplePubkeys] = useState( + filterToPeoplePubkeys(initialFilter, ownerPubkey), + ); const [selectedKinds, setSelectedKinds] = useState( parseSelectedKinds(initialFilter), ); const [portalContainer, setPortalContainer] = useState(undefined); + const listPickerValue = useMatchedListId(peoplePubkeys); + const dialogContentRef = useCallback((node: HTMLElement | null) => { setPortalContainer(node ?? undefined); }, []); + const addPerson = useCallback((pubkey: string) => { + setPeoplePubkeys((prev) => prev.includes(pubkey) ? prev : [...prev, pubkey]); + setAuthorScope('people'); + }, []); + + const removePerson = useCallback((pubkey: string) => { + setPeoplePubkeys((prev) => prev.filter((p) => p !== pubkey)); + }, []); + + const handleAuthorScopeChange = useCallback((scope: ProfileAuthorScope) => { + setAuthorScope(scope); + if (scope !== 'people') { + setPeoplePubkeys([]); + } + }, []); + // Reset state when modal opens const handleOpenChange = (o: boolean) => { if (o) { @@ -124,6 +163,7 @@ export function ProfileTabEditModal({ const f = tab ? tab.filter : { authors: [ownerPubkey] }; setQuery(typeof f.search === 'string' ? f.search : ''); setAuthorScope(filterToScope(f, ownerPubkey)); + setPeoplePubkeys(filterToPeoplePubkeys(f, ownerPubkey)); setSelectedKinds(parseSelectedKinds(f)); } onOpenChange(o); @@ -133,7 +173,7 @@ export function ProfileTabEditModal({ if (!label.trim() || isPending) return; const filter: TabFilter = { - ...scopeToFilter(authorScope, ownerPubkey), + ...scopeToFilter(authorScope, ownerPubkey, peoplePubkeys), }; if (query.trim()) { @@ -201,12 +241,34 @@ export function ProfileTabEditModal({ {/* Author scope */}
Authors - value={authorScope} options={PROFILE_SCOPE_OPTIONS} onChange={setAuthorScope} size="md" /> -

- {authorScope === 'me' && 'Only show your own posts.'} - {authorScope === 'contacts' && 'Show posts from people you follow.'} - {authorScope === 'global' && 'Show posts from everyone.'} -

+ value={authorScope} options={PROFILE_SCOPE_OPTIONS} onChange={handleAuthorScopeChange} size="md" /> + {authorScope === 'people' ? ( +
+ {peoplePubkeys.length > 0 && ( +
+ {peoplePubkeys.map((pk) => ( + removePerson(pk)} /> + ))} +
+ )} + addPerson(pubkey)} /> + { + setPeoplePubkeys(pubkeys); + if (pubkeys.length > 0) setAuthorScope('people'); + }} + /> +
+ ) : ( +

+ {authorScope === 'me' && 'Only show your own posts.'} + {authorScope === 'contacts' && 'Show posts from people you follow.'} + {authorScope === 'global' && 'Show posts from everyone.'} +

+ )}
diff --git a/src/hooks/useProfileFeed.ts b/src/hooks/useProfileFeed.ts index 951ad913..607810fc 100644 --- a/src/hooks/useProfileFeed.ts +++ b/src/hooks/useProfileFeed.ts @@ -4,7 +4,7 @@ import { useFeedSettings } from './useFeedSettings'; import { getEnabledFeedKinds } from '@/lib/extraKinds'; import { getPaginationCursor, parseRepostContent, isRepostKind, type FeedItem } from '@/lib/feedUtils'; import { isReplyEvent } from '@/lib/nostrEvents'; -import type { NostrEvent } from '@nostrify/nostrify'; +import type { NostrEvent, NostrFilter } from '@nostrify/nostrify'; /** Extended FeedItem with pagination metadata. */ interface ProfileFeedPage { @@ -240,3 +240,125 @@ export function useProfileLikes(pubkey: string | undefined, active: boolean) { staleTime: 30 * 1000, }); } + +/** Page result for a custom tab feed query. */ +interface TabFeedPage { + items: FeedItem[]; + oldestQueryTimestamp: number; + rawCount: number; +} + +/** + * Infinite-scroll hook for a custom profile tab. + * + * Accepts a resolved NostrFilter (after variable substitution) and paginates + * through matching events using timestamp-based cursors — exactly like + * `useProfileFeed`, but with arbitrary filter parameters (kinds, authors, + * NIP-50 search, etc.). + * + * @param filter - The fully-resolved Nostr filter. May include `search` for NIP-50 queries. + * @param tabKey - A stable string key used to namespace the query cache (e.g. the tab label). + * @param enabled - Whether to start fetching (set to false while the filter is still resolving). + */ +export function useTabFeed( + filter: NostrFilter | null, + tabKey: string, + enabled = true, +) { + const { nostr } = useNostr(); + const { feedSettings } = useFeedSettings(); + + const defaultKinds = getEnabledFeedKinds(feedSettings); + const defaultKindsKey = [...defaultKinds].sort().join(','); + + // Stable string keys for query cache invalidation + const kindsKey = filter?.kinds ? [...filter.kinds].sort().join(',') : defaultKindsKey; + const authorsKey = filter?.authors ? [...filter.authors].sort().join(',') : ''; + const searchKey = filter?.search ?? ''; + + return useInfiniteQuery({ + queryKey: ['tab-feed', tabKey, kindsKey, authorsKey, searchKey], + queryFn: async ({ pageParam, signal }) => { + if (!filter) return { items: [], oldestQueryTimestamp: Math.floor(Date.now() / 1000), rawCount: 0 }; + + const querySignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]); + const now = Math.floor(Date.now() / 1000); + + const kinds = (filter.kinds && filter.kinds.length > 0) ? filter.kinds : defaultKinds; + + const queryFilter: Record = { + kinds, + limit: PAGE_SIZE, + }; + + if (filter.authors && filter.authors.length > 0) { + queryFilter.authors = filter.authors; + } + + if (filter.search) { + queryFilter.search = filter.search; + } + + if (pageParam) { + queryFilter.until = pageParam; + } + + const allEvents = await nostr.query( + [queryFilter as NostrFilter], + { signal: querySignal }, + ); + + const validEvents = allEvents.filter((ev) => ev.created_at <= now); + const oldestQueryTimestamp = getPaginationCursor(validEvents); + + // Process events into FeedItems, unwrapping reposts + const items: FeedItem[] = []; + const repostMissingIds: string[] = []; + const repostMap = new Map(); + + for (const ev of validEvents) { + if (isRepostKind(ev.kind)) { + const embedded = parseRepostContent(ev); + if (embedded && embedded.created_at <= now) { + items.push({ event: embedded, repostedBy: ev.pubkey, sortTimestamp: ev.created_at }); + } else { + const repostedId = ev.tags.find(([name]) => name === 'e')?.[1]; + if (repostedId) { + repostMissingIds.push(repostedId); + repostMap.set(repostedId, ev); + } + } + } else { + items.push({ event: ev, sortTimestamp: ev.created_at }); + } + } + + if (repostMissingIds.length > 0) { + try { + const originals = await nostr.query( + [{ ids: repostMissingIds, limit: repostMissingIds.length }], + { signal: querySignal }, + ); + for (const original of originals) { + const repost = repostMap.get(original.id); + if (repost && original.created_at <= now) { + items.push({ event: original, repostedBy: repost.pubkey, sortTimestamp: repost.created_at }); + } + } + } catch { + // timeout or abort — skip missing reposts + } + } + + const sorted = items.sort((a, b) => b.sortTimestamp - a.sortTimestamp); + return { items: sorted, oldestQueryTimestamp, rawCount: validEvents.length }; + }, + getNextPageParam: (lastPage) => { + if (lastPage.rawCount === 0) return undefined; + return lastPage.oldestQueryTimestamp - 1; + }, + initialPageParam: undefined as number | undefined, + enabled: enabled && !!filter, + staleTime: 30 * 1000, + }); +} diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 3b33fa19..7293e81c 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -33,7 +33,7 @@ import { usePinnedNotes } from '@/hooks/usePinnedNotes'; import { useFollowList, useFollowActions } from '@/hooks/useFollowActions'; import { useMuteList } from '@/hooks/useMuteList'; import { isEventMuted } from '@/lib/muteHelpers'; -import { useProfileFeed, useProfileLikes as useProfileLikesInfinite, filterByTab } from '@/hooks/useProfileFeed'; +import { useProfileFeed, useProfileLikes as useProfileLikesInfinite, useTabFeed, filterByTab } from '@/hooks/useProfileFeed'; import type { ProfileTab as CoreProfileTab } from '@/hooks/useProfileFeed'; import { useProfileMedia } from '@/hooks/useProfileMedia'; import { MediaCollage, MediaCollageSkeleton } from '@/components/MediaCollage'; @@ -65,7 +65,6 @@ import { useProfileTabs } from '@/hooks/useProfileTabs'; import { usePublishProfileTabs } from '@/hooks/usePublishProfileTabs'; import { ProfileTabEditModal } from '@/components/ProfileTabEditModal'; -import { useStreamPosts } from '@/hooks/useStreamPosts'; import { useResolveTabFilter } from '@/hooks/useResolveTabFilter'; import type { ProfileTab, ProfileTabsData, TabFilter, TabVarDef } from '@/lib/profileTabsEvent'; import { @@ -2681,21 +2680,30 @@ function ProfileSavedFeedContent({ feed, vars, ownerPubkey }: { }) { const { filter: resolvedFilter, isLoading: isResolving } = useResolveTabFilter(feed.filter, vars, ownerPubkey); - // Extract search query and kinds from the resolved filter for useStreamPosts - const search = typeof resolvedFilter?.search === 'string' ? resolvedFilter.search : ''; - const kindsOverride = Array.isArray(resolvedFilter?.kinds) ? resolvedFilter.kinds as number[] : undefined; - const authorPubkeys = Array.isArray(resolvedFilter?.authors) ? resolvedFilter.authors as string[] : undefined; + const { + data, + isPending, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useTabFeed(resolvedFilter, feed.label, !isResolving); - const { posts, isLoading: isStreamLoading } = useStreamPosts(search, { - includeReplies: true, - mediaType: 'all', - kindsOverride, - authorPubkeys: authorPubkeys && authorPubkeys.length > 0 ? authorPubkeys : undefined, - }); + const { ref: tabScrollRef, inView: tabInView } = useInView({ threshold: 0 }); - const isLoading = isResolving || isStreamLoading; + useEffect(() => { + if (tabInView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [tabInView, hasNextPage, isFetchingNextPage, fetchNextPage]); - if (isLoading && posts.length === 0) { + const items = useMemo( + () => data?.pages.flatMap((p) => p.items) ?? [], + [data], + ); + + const isLoading = isResolving || isPending; + + if (isLoading && items.length === 0) { return (
{Array.from({ length: 3 }).map((_, i) => ( @@ -2714,7 +2722,7 @@ function ProfileSavedFeedContent({ feed, vars, ownerPubkey }: { ); } - if (posts.length === 0) { + if (items.length === 0) { return (
No posts found for "{feed.label}". @@ -2724,9 +2732,21 @@ function ProfileSavedFeedContent({ feed, vars, ownerPubkey }: { return (
- {posts.map((event) => ( - + {items.map((item) => ( + ))} + + {hasNextPage && ( +
+ {isFetchingNextPage && ( + + )} +
+ )}
); }