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:
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user