Use dedicated media:true search query for profile media tab and sidebar

Replaces client-side media URL extraction with a dedicated NIP-50
search query (media:true) routed to relay.ditto.pub. This provides
more accurate media detection using the relay's index-time analysis.

Also fixes a bug where the Posts tab could flash 'No posts yet' when
navigating to a profile — filterByTab was a dependency of the feedItems
memo, so switching tabs could momentarily empty the list when the first
page was mostly replies.
This commit is contained in:
Chad Curtis
2026-02-22 01:43:45 -06:00
parent ea0f01da98
commit d746ffaab3
3 changed files with 125 additions and 25 deletions
+9 -11
View File
@@ -19,12 +19,10 @@ interface ProfileField {
interface ProfileRightSidebarProps {
fields?: ProfileField[];
/** Events from the profile feed — media is extracted client-side instead of a separate query. */
events?: NostrEvent[];
/** Whether the feed events are still loading. */
eventsLoading?: boolean;
/** Whether all feed pages have been loaded (no more pages to fetch). */
eventsComplete?: boolean;
/** Media events fetched via a dedicated search query (video:true image:true). */
mediaEvents?: NostrEvent[];
/** Whether the media events are still loading. */
mediaLoading?: boolean;
}
interface MediaItem {
@@ -243,13 +241,13 @@ function ProfileFieldRow({ field }: { field: ProfileField }) {
);
}
export function ProfileRightSidebar({ fields, events, eventsLoading, eventsComplete }: ProfileRightSidebarProps) {
export function ProfileRightSidebar({ fields, mediaEvents, mediaLoading: mediaLoadingProp }: ProfileRightSidebarProps) {
const { config } = useAppContext();
const media = useMemo(
() => extractMedia(events ?? [], config.contentWarningPolicy),
[events, config.contentWarningPolicy],
() => extractMedia(mediaEvents ?? [], config.contentWarningPolicy),
[mediaEvents, config.contentWarningPolicy],
);
const mediaLoading = (eventsLoading ?? false) || (media.length === 0 && !eventsComplete);
const mediaLoading = mediaLoadingProp ?? false;
return (
<aside className="w-[300px] shrink-0 hidden xl:flex flex-col sticky top-0 h-screen overflow-y-auto pt-5 pb-3 px-5">
@@ -258,7 +256,7 @@ export function ProfileRightSidebar({ fields, events, eventsLoading, eventsCompl
<h2 className="text-xl font-bold mb-3">Media</h2>
{mediaLoading ? (
<div className="grid grid-cols-3 gap-0.5">
{Array.from({ length: 6 }).map((_, i) => (
{Array.from({ length: 9 }).map((_, i) => (
<Skeleton key={i} className="aspect-square rounded-lg" />
))}
</div>
+70
View File
@@ -0,0 +1,70 @@
import { useNostr } from '@nostrify/react';
import { useInfiniteQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
const PAGE_SIZE = 20;
/** Result page from the profile media query. */
interface ProfileMediaPage {
events: NostrEvent[];
/** Oldest timestamp in this page, used as cursor for pagination. */
oldestTimestamp: number | undefined;
/** Number of events returned (for exhaustion detection). */
count: number;
}
/**
* Queries media events for a profile directly from relay.ditto.pub
* using the NIP-50 search extension `media:true`.
*
* `media:true` covers images, videos, and mixed attachments — it's
* set on any event that has at least one media URL detected at index
* time. There is no separate `image:true` extension; multiple search
* extensions are ANDed, so `media:true` alone is the correct filter.
*/
export function useProfileMedia(pubkey: string | undefined, enabled = true) {
const { nostr } = useNostr();
return useInfiniteQuery<ProfileMediaPage, Error>({
queryKey: ['profile-media', pubkey ?? ''],
queryFn: async ({ pageParam, signal }) => {
if (!pubkey) return { events: [], oldestTimestamp: undefined, count: 0 };
const querySignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
const filter: Record<string, unknown> = {
kinds: [1, 20, 34236],
authors: [pubkey],
search: 'media:true',
limit: PAGE_SIZE,
};
if (pageParam) {
filter.until = pageParam;
}
const events = await nostr.query(
[filter as { kinds: number[]; authors: string[]; limit: number; search: string; until?: number }],
{ signal: querySignal },
);
const now = Math.floor(Date.now() / 1000);
const valid = events.filter((e) => e.created_at <= now);
const sorted = valid.sort((a, b) => b.created_at - a.created_at);
const oldestTimestamp = sorted.length > 0
? sorted[sorted.length - 1].created_at
: undefined;
return { events: sorted, oldestTimestamp, count: sorted.length };
},
getNextPageParam: (lastPage) => {
if (lastPage.count === 0 || lastPage.oldestTimestamp === undefined) {
return undefined;
}
return lastPage.oldestTimestamp - 1;
},
initialPageParam: undefined as number | undefined,
enabled: !!pubkey && enabled,
staleTime: 30 * 1000,
});
}
+46 -14
View File
@@ -28,6 +28,7 @@ import { useMuteList } from '@/hooks/useMuteList';
import { isEventMuted } from '@/lib/muteHelpers';
import { useProfileFeed, useProfileLikes as useProfileLikesInfinite, filterByTab } from '@/hooks/useProfileFeed';
import type { ProfileTab } from '@/hooks/useProfileFeed';
import { useProfileMedia } from '@/hooks/useProfileMedia';
import { useProfileSupplementary } from '@/hooks/useProfileData';
import { useNip05Resolve } from '@/hooks/useNip05Resolve';
import { genUserName } from '@/lib/genUserName';
@@ -646,6 +647,15 @@ export function ProfilePage() {
description: metadata?.about || 'Nostr profile',
});
// Profile media — dedicated search query via relay.ditto.pub (video:true image:true)
const {
data: mediaData,
isPending: mediaPending,
fetchNextPage: fetchNextMediaPage,
hasNextPage: hasNextMediaPage,
isFetchingNextPage: isFetchingNextMediaPage,
} = useProfileMedia(pubkey);
// Infinite-scroll likes
const {
data: likesData,
@@ -703,7 +713,9 @@ export function ProfilePage() {
}
};
// Flatten feed pages, deduplicate, and filter muted content
// Flatten feed pages, deduplicate, and filter muted content.
// Tab filtering is applied downstream in `currentItems` so the base
// list stays stable across tab switches and doesn't momentarily empty.
const feedItems = useMemo(() => {
if (!feedData?.pages) return [];
const seen = new Set<string>();
@@ -718,14 +730,24 @@ export function ProfilePage() {
}
}
}
return activeTab === 'likes' ? items : filterByTab(items, activeTab);
}, [feedData?.pages, muteItems, activeTab]);
return items;
}, [feedData?.pages, muteItems]);
// Extract raw events for the media sidebar, excluding reposts (other users' content)
const feedEvents = useMemo(
() => feedItems.filter((item) => !item.repostedBy).map((item) => item.event),
[feedItems],
);
// Flatten media pages for the sidebar and media tab
const mediaEvents = useMemo(() => {
if (!mediaData?.pages) return [];
const seen = new Set<string>();
const events: NostrEvent[] = [];
for (const page of mediaData.pages) {
for (const event of page.events) {
if (!seen.has(event.id)) {
seen.add(event.id);
events.push(event);
}
}
}
return events;
}, [mediaData?.pages]);
// Flatten likes pages and deduplicate
const likedItems = useMemo(() => {
@@ -765,12 +787,16 @@ export function ProfilePage() {
if (hasNextLikesPage && !isFetchingNextLikesPage) {
fetchNextLikesPage();
}
} else if (activeTab === 'media') {
if (hasNextMediaPage && !isFetchingNextMediaPage) {
fetchNextMediaPage();
}
} else {
if (hasNextFeedPage && !isFetchingNextFeedPage) {
fetchNextFeedPage();
}
}
}, [inView, activeTab, hasNextFeedPage, isFetchingNextFeedPage, fetchNextFeedPage, hasNextLikesPage, isFetchingNextLikesPage, fetchNextLikesPage]);
}, [inView, activeTab, hasNextFeedPage, isFetchingNextFeedPage, fetchNextFeedPage, hasNextLikesPage, isFetchingNextLikesPage, fetchNextLikesPage, hasNextMediaPage, isFetchingNextMediaPage, fetchNextMediaPage]);
const authorEvent = metadataEvent;
@@ -780,12 +806,18 @@ export function ProfilePage() {
[likedItems]
);
const currentItems = activeTab === 'likes' ? likedFeedItems : feedItems;
const currentLoading = activeTab === 'likes' ? likesPending : feedPending;
const hasMore = activeTab === 'likes' ? hasNextLikesPage : hasNextFeedPage;
const isFetchingMore = activeTab === 'likes' ? isFetchingNextLikesPage : isFetchingNextFeedPage;
// For media, convert media events to FeedItems
const mediaFeedItems: FeedItem[] = useMemo(() =>
mediaEvents.map(event => ({ event, sortTimestamp: event.created_at })),
[mediaEvents]
);
useLayoutOptions(pubkey ? { rightSidebar: <ProfileRightSidebar fields={fields} events={feedEvents} eventsLoading={feedPending} eventsComplete={!hasNextFeedPage && !isFetchingNextFeedPage} /> } : {});
const currentItems = activeTab === 'likes' ? likedFeedItems : activeTab === 'media' ? mediaFeedItems : filterByTab(feedItems, activeTab);
const currentLoading = activeTab === 'likes' ? likesPending : activeTab === 'media' ? mediaPending : feedPending;
const hasMore = activeTab === 'likes' ? hasNextLikesPage : activeTab === 'media' ? hasNextMediaPage : hasNextFeedPage;
const isFetchingMore = activeTab === 'likes' ? isFetchingNextLikesPage : activeTab === 'media' ? isFetchingNextMediaPage : isFetchingNextFeedPage;
useLayoutOptions(pubkey ? { rightSidebar: <ProfileRightSidebar fields={fields} mediaEvents={mediaEvents} mediaLoading={mediaPending} /> } : {});
if (!pubkey) {
// If we're resolving a NIP-05, show loading state