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