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
This commit is contained in:
Chad Curtis
2026-03-14 17:34:21 -05:00
parent bf9cef5bb5
commit a3e5b94e69
3 changed files with 236 additions and 32 deletions
+76 -14
View File
@@ -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<TabFilter> {
/** Map from simplified scope to filter fields (excluding people's authors which are stored separately). */
function scopeToFilter(scope: ProfileAuthorScope, ownerPubkey: string, peoplePubkeys: string[]): Partial<TabFilter> {
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<ProfileAuthorScope>[] = [
{ 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<TabFilter>(() => {
@@ -108,15 +126,36 @@ export function ProfileTabEditModal({
const [authorScope, setAuthorScope] = useState<ProfileAuthorScope>(
filterToScope(initialFilter, ownerPubkey),
);
const [peoplePubkeys, setPeoplePubkeys] = useState<string[]>(
filterToPeoplePubkeys(initialFilter, ownerPubkey),
);
const [selectedKinds, setSelectedKinds] = useState<string[]>(
parseSelectedKinds(initialFilter),
);
const [portalContainer, setPortalContainer] = useState<HTMLElement | undefined>(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 */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Authors</span>
<ScopeToggle<ProfileAuthorScope> value={authorScope} options={PROFILE_SCOPE_OPTIONS} onChange={setAuthorScope} size="md" />
<p className="text-xs text-muted-foreground leading-relaxed">
{authorScope === 'me' && 'Only show your own posts.'}
{authorScope === 'contacts' && 'Show posts from people you follow.'}
{authorScope === 'global' && 'Show posts from everyone.'}
</p>
<ScopeToggle<ProfileAuthorScope> value={authorScope} options={PROFILE_SCOPE_OPTIONS} onChange={handleAuthorScopeChange} size="md" />
{authorScope === 'people' ? (
<div className="space-y-1.5">
{peoplePubkeys.length > 0 && (
<div className="flex flex-wrap gap-1">
{peoplePubkeys.map((pk) => (
<AuthorChip key={pk} pubkey={pk} onRemove={() => removePerson(pk)} />
))}
</div>
)}
<AuthorFilterDropdown onCommit={(pubkey) => addPerson(pubkey)} />
<ListPackPicker
lists={lists}
followPacks={followPacks}
value={listPickerValue}
onSelectPubkeys={(pubkeys) => {
setPeoplePubkeys(pubkeys);
if (pubkeys.length > 0) setAuthorScope('people');
}}
/>
</div>
) : (
<p className="text-xs text-muted-foreground leading-relaxed">
{authorScope === 'me' && 'Only show your own posts.'}
{authorScope === 'contacts' && 'Show posts from people you follow.'}
{authorScope === 'global' && 'Show posts from everyone.'}
</p>
)}
</div>
</div>
+123 -1
View File
@@ -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<TabFeedPage, Error>({
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<string, unknown> = {
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<string, NostrEvent>();
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,
});
}
+37 -17
View File
@@ -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 (
<div className="space-y-0">
{Array.from({ length: 3 }).map((_, i) => (
@@ -2714,7 +2722,7 @@ function ProfileSavedFeedContent({ feed, vars, ownerPubkey }: {
);
}
if (posts.length === 0) {
if (items.length === 0) {
return (
<div className="py-12 text-center text-muted-foreground text-sm">
No posts found for "{feed.label}".
@@ -2724,9 +2732,21 @@ function ProfileSavedFeedContent({ feed, vars, ownerPubkey }: {
return (
<div>
{posts.map((event) => (
<NoteCard key={event.id} event={event} />
{items.map((item) => (
<NoteCard
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
event={item.event}
repostedBy={item.repostedBy}
/>
))}
{hasNextPage && (
<div ref={tabScrollRef} className="flex justify-center py-6">
{isFetchingNextPage && (
<Loader2 className="size-5 animate-spin text-muted-foreground" />
)}
</div>
)}
</div>
);
}