diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 00000000..afb30f9c --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,21 @@ +import { useState, useEffect } from 'react'; + +/** + * Returns a debounced version of `value` that only updates after `delay` ms + * of inactivity. Useful for reducing relay queries on keystroke. + */ +export function useDebounce(value: T, delay = 300): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/hooks/useSearchProfiles.ts b/src/hooks/useSearchProfiles.ts index cdca4df4..badc6037 100644 --- a/src/hooks/useSearchProfiles.ts +++ b/src/hooks/useSearchProfiles.ts @@ -4,6 +4,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { NSchema as n } from '@nostrify/nostrify'; import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify'; import { useFollowList } from '@/hooks/useFollowActions'; +import { useDebounce } from '@/hooks/useDebounce'; export interface SearchProfile { pubkey: string; @@ -63,14 +64,17 @@ export function useSearchProfiles(query: string) { [followData?.pubkeys], ); + // Debounce the query so we don't hammer the relay on every keystroke + const debouncedQuery = useDebounce(query, 300); + const relayResults = useQuery({ - queryKey: ['search-profiles', query], + queryKey: ['search-profiles', debouncedQuery], queryFn: async ({ signal }) => { - if (!query.trim()) return []; + if (!debouncedQuery.trim()) return []; // NIP-50 profile search (uses pool, reuses existing connections) const events = await nostr.query( - [{ kinds: [0], search: query.trim(), limit: 10 }], + [{ kinds: [0], search: debouncedQuery.trim(), limit: 10 }], { signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) }, ); @@ -96,7 +100,7 @@ export function useSearchProfiles(query: string) { return Array.from(seen.values()); }, - enabled: query.trim().length >= 1, + enabled: debouncedQuery.trim().length >= 1, staleTime: 30 * 1000, placeholderData: (prev) => prev, }); @@ -115,12 +119,12 @@ export function useSearchProfiles(query: string) { } // Relay returned nothing — search the local cache instead - if (query.trim().length >= 1) { - return searchCachedProfiles(queryClient, query.trim(), followedPubkeys); + if (debouncedQuery.trim().length >= 1) { + return searchCachedProfiles(queryClient, debouncedQuery.trim(), followedPubkeys); } return relayData; - }, [relayResults.data, followedPubkeys, query, queryClient]); + }, [relayResults.data, followedPubkeys, debouncedQuery, queryClient]); return { ...relayResults, diff --git a/src/hooks/useStreamPosts.ts b/src/hooks/useStreamPosts.ts index 6f3617d7..e1010a4e 100644 --- a/src/hooks/useStreamPosts.ts +++ b/src/hooks/useStreamPosts.ts @@ -2,12 +2,14 @@ import { useNostr } from '@nostrify/react'; import { useState, useEffect, useMemo } from 'react'; import { useFeedSettings } from './useFeedSettings'; import { useMuteList } from './useMuteList'; +import { useContentFilters } from './useContentFilters'; import { getEnabledFeedKinds } from '@/lib/extraKinds'; import { isRepostKind } from '@/lib/feedUtils'; import { isReplyEvent } from '@/lib/nostrEvents'; import { isEventMuted } from '@/lib/muteHelpers'; import type { NostrEvent, NostrFilter } from '@nostrify/nostrify'; import { DITTO_RELAY } from '@/lib/appRelays'; +import { nip19 } from 'nostr-tools'; interface StreamPostsOptions { includeReplies: boolean; @@ -20,6 +22,11 @@ interface StreamPostsOptions { * The search will only query these specific kind numbers. */ kindsOverride?: number[]; + /** + * When set, limits results to events authored by this pubkey (hex). + * Accepts both raw hex and npub-encoded pubkeys. + */ + authorPubkey?: string; } /** Check if an event has imeta tags with image MIME types. */ @@ -41,11 +48,42 @@ function hasVideoImeta(event: NostrEvent): boolean { * Initial query uses relay-level filters (NIP-50 search), but streaming * events need client-side filtering since relays don't support streaming search. */ -function filterEvent(event: NostrEvent, options: StreamPostsOptions, searchQuery: string): boolean { +function filterEvent( + event: NostrEvent, + options: StreamPostsOptions, + searchQuery: string, + resolvedAuthorPubkey: string | undefined, +): boolean { const now = Math.floor(Date.now() / 1000); if (event.created_at > now) return false; - // Non-text events (extra kinds) pass through without filtering + // Author filter — applies to all kinds + if (resolvedAuthorPubkey && event.pubkey !== resolvedAuthorPubkey) return false; + + // Protocol filter — streaming events carry a 'proxy' tag for bridged protocols. + // A missing proxy tag = native Nostr. Filter based on selected protocol. + const protocols = options.protocols ?? ['nostr']; + if (!protocols.includes('nostr') || protocols.length > 1) { + const proxyTag = event.tags.find(([name]) => name === 'proxy'); + if (protocols.includes('nostr') && !protocols.some(p => p !== 'nostr')) { + // nostr only: reject events with a proxy tag + if (proxyTag) return false; + } else { + // bridged protocol selected: only keep events that have a matching proxy tag + // and optionally native nostr events if 'nostr' is also in protocols + const hasProxy = !!proxyTag; + const isNative = !hasProxy; + if (isNative && !protocols.includes('nostr')) return false; + if (hasProxy) { + // proxy tag format: ['proxy', '', ''] + const proxyProtocol = proxyTag?.[2]?.toLowerCase(); + const wantedBridged = protocols.filter(p => p !== 'nostr'); + if (!wantedBridged.some(p => proxyProtocol?.includes(p))) return false; + } + } + } + + // Non-text events (extra kinds) pass through without further content filtering // Kind 1111 (NIP-22 comments) are treated like kind 1 for filtering purposes if (event.kind !== 1 && event.kind !== 1111) return true; @@ -66,23 +104,24 @@ function filterEvent(event: NostrEvent, options: StreamPostsOptions, searchQuery const hasImages = hasImageImeta(event); const hasVideos = hasVideoImeta(event); switch (options.mediaType) { - case 'images': + case 'images': if (!hasImages || hasVideos) return false; break; - case 'videos': + case 'videos': if (!hasVideos) return false; break; - case 'vines': - return false; // kind 1 posts aren't vines - case 'none': + case 'vines': + // Vines are kinds 22/34236; kind 1 posts aren't vines — filter them out + // (streaming for vines uses kind 22/34236 in streamFilter, so kind 1 events + // that slip through from cache should be rejected) + if (event.kind === 1 || event.kind === 1111) return false; + break; + case 'none': if (hasImages || hasVideos) return false; break; } } - // Note: Language filtering is only done at relay-level (NIP-50 language:) - // We can't reliably detect language client-side for streaming events - return true; } @@ -96,9 +135,22 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) { const { nostr } = useNostr(); const { feedSettings } = useFeedSettings(); const { muteItems } = useMuteList(); + const { shouldFilterEvent } = useContentFilters(); const [allEvents, setAllEvents] = useState([]); const [isLoading, setIsLoading] = useState(true); + // Resolve authorPubkey: accept both hex and npub + const resolvedAuthorPubkey = useMemo(() => { + const raw = options.authorPubkey?.trim(); + if (!raw) return undefined; + if (/^[0-9a-f]{64}$/i.test(raw)) return raw; + try { + const decoded = nip19.decode(raw); + if (decoded.type === 'npub') return decoded.data; + } catch { /* ignore */ } + return undefined; + }, [options.authorPubkey]); + // These mediaTypes query dedicated event kinds rather than filtering kind 1 const isDedicatedKindQuery = !options.kindsOverride && (options.mediaType === 'vines' || options.mediaType === 'images' || options.mediaType === 'videos'); @@ -197,6 +249,12 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) { initialFilter.search = searchParts.join(' '); } + // Author filter — scopes both the initial batch and streaming subscription + if (resolvedAuthorPubkey) { + initialFilter.authors = [resolvedAuthorPubkey]; + streamFilter.authors = [resolvedAuthorPubkey]; + } + // 1. Fetch initial batch with search filters (uses pool, reuses existing connections) (async () => { try { @@ -248,16 +306,17 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) { ac.abort(); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- enabledKinds is stabilized via kindsKey; options.protocols is stabilized via protocolsKey; kindsOverride is stabilized via kindsOverrideKey - }, [nostr, query, isDedicatedKindQuery, kindsKey, options.language, options.mediaType, protocolsKey, kindsOverrideKey]); + }, [nostr, query, isDedicatedKindQuery, kindsKey, options.language, options.mediaType, protocolsKey, kindsOverrideKey, resolvedAuthorPubkey]); - // Apply client-side filters (including mute filtering) without restarting the stream + // Apply client-side filters (including mute filtering and content filters) without restarting the stream const posts = useMemo(() => { return allEvents.filter((event) => { if (muteItems.length > 0 && isEventMuted(event, muteItems)) return false; - return filterEvent(event, options, query); + if (shouldFilterEvent(event)) return false; + return filterEvent(event, options, query, resolvedAuthorPubkey); }); // eslint-disable-next-line react-hooks/exhaustive-deps -- using specific options fields instead of the whole object for granular reactivity - }, [allEvents, options.includeReplies, options.mediaType, protocolsKey, query, muteItems]); + }, [allEvents, options.includeReplies, options.mediaType, protocolsKey, query, muteItems, resolvedAuthorPubkey, shouldFilterEvent]); return { posts, isLoading }; } diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index 844ffd91..10b208f4 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -1,16 +1,32 @@ import { useSeoMeta } from '@unhead/react'; import { useAppContext } from '@/hooks/useAppContext'; -import { SlidersHorizontal, Search as SearchIcon, Image, Video, Film, Languages, UserRoundCheck, Hash } from 'lucide-react'; +import { + SlidersHorizontal, + Search as SearchIcon, + Image, + Video, + Film, + Languages, + UserRoundCheck, + Hash, + User, + RotateCcw, + X, + Info, +} from 'lucide-react'; import { useState, useMemo, useEffect, useCallback } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { NoteCard } from '@/components/NoteCard'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Separator } from '@/components/ui/separator'; import { Skeleton } from '@/components/ui/skeleton'; import { Switch } from '@/components/ui/switch'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { EmojifiedText } from '@/components/CustomEmoji'; import { useSearchProfiles } from '@/hooks/useSearchProfiles'; import { useStreamPosts } from '@/hooks/useStreamPosts'; @@ -33,6 +49,22 @@ function parseTab(value: string | null): TabType { return VALID_TABS.includes(value as TabType) ? (value as TabType) : 'posts'; } +const DEFAULT_FILTERS = { + includeReplies: true, + mediaType: 'all' as const, + language: 'global', + platform: 'nostr' as const, + kindFilter: 'all', + customKindText: '', + authorQuery: '', +}; + +/** Parse a boolean from a URL param, returning defaultVal if absent/invalid. */ +function parseBoolParam(value: string | null, defaultVal: boolean): boolean { + if (value === null) return defaultVal; + return value !== 'false'; +} + export function SearchPage() { const { config } = useAppContext(); @@ -44,14 +76,62 @@ export function SearchPage() { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); - // Derive tab directly from URL — single source of truth (no separate state) + // Derive tab directly from URL — single source of truth const activeTab = parseTab(searchParams.get('tab')); // Local input state for the search field (avoids trimming while typing) const [searchQuery, setSearchQuery] = useState(searchParams.get('q') ?? ''); const [filtersOpen, setFiltersOpen] = useState(false); - // Update tab in URL without a feedback loop + // ── Filter state — all derived from URL params ────────────────────────── + const includeReplies = parseBoolParam(searchParams.get('replies'), DEFAULT_FILTERS.includeReplies); + const VALID_MEDIA_TYPES = ['all', 'images', 'videos', 'vines', 'none'] as const; + type MediaType = typeof VALID_MEDIA_TYPES[number]; + const rawMedia = searchParams.get('media') ?? DEFAULT_FILTERS.mediaType; + const mediaType: MediaType = (VALID_MEDIA_TYPES as readonly string[]).includes(rawMedia) ? (rawMedia as MediaType) : DEFAULT_FILTERS.mediaType; + const language = searchParams.get('lang') ?? DEFAULT_FILTERS.language; + const VALID_PLATFORMS = ['nostr', 'activitypub', 'atproto'] as const; + type PlatformType = typeof VALID_PLATFORMS[number]; + const rawPlatform = searchParams.get('platform') ?? DEFAULT_FILTERS.platform; + const platform: PlatformType = (VALID_PLATFORMS as readonly string[]).includes(rawPlatform) ? (rawPlatform as PlatformType) : DEFAULT_FILTERS.platform; + const kindFilter = searchParams.get('kind') ?? DEFAULT_FILTERS.kindFilter; + const customKindText = searchParams.get('customKind') ?? DEFAULT_FILTERS.customKindText; + const authorQuery = searchParams.get('author') ?? DEFAULT_FILTERS.authorQuery; + // ──────────────────────────────────────────────────────────────────────── + + // Helper to update a single URL param + const setParam = useCallback((key: string, value: string, defaultValue: string) => { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + if (value === defaultValue) { + next.delete(key); + } else { + next.set(key, value); + } + return next; + }, { replace: true }); + }, [setSearchParams]); + + const setIncludeReplies = useCallback((v: boolean) => setParam('replies', String(v), String(DEFAULT_FILTERS.includeReplies)), [setParam]); + const setMediaType = useCallback((v: string) => setParam('media', v, DEFAULT_FILTERS.mediaType), [setParam]); + const setLanguage = useCallback((v: string) => setParam('lang', v, DEFAULT_FILTERS.language), [setParam]); + const setPlatform = useCallback((v: string) => setParam('platform', v, DEFAULT_FILTERS.platform), [setParam]); + const setKindFilter = useCallback((v: string) => { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + if (v === DEFAULT_FILTERS.kindFilter) { + next.delete('kind'); + } else { + next.set('kind', v); + } + if (v !== 'custom') next.delete('customKind'); + return next; + }, { replace: true }); + }, [setSearchParams]); + const setCustomKindText = useCallback((v: string) => setParam('customKind', v, DEFAULT_FILTERS.customKindText), [setParam]); + const setAuthorQuery = useCallback((v: string) => setParam('author', v, DEFAULT_FILTERS.authorQuery), [setParam]); + + // Update tab in URL const setActiveTab = useCallback((tab: TabType) => { setSearchParams((prev) => { const next = new URLSearchParams(prev); @@ -64,7 +144,7 @@ export function SearchPage() { }, { replace: true }); }, [setSearchParams]); - // Sync search query state → URL (only when the trimmed value actually differs) + // Sync search query state → URL useEffect(() => { const currentQ = searchParams.get('q') ?? ''; const trimmed = searchQuery.trim(); @@ -97,16 +177,7 @@ export function SearchPage() { } }, [searchQuery, navigate]); - // Search filters - const [includeReplies, setIncludeReplies] = useState(true); - const [mediaType, setMediaType] = useState<'all' | 'images' | 'videos' | 'vines' | 'none'>('all'); - const [language, setLanguage] = useState('global'); - const [platform, setPlatform] = useState<'nostr' | 'activitypub' | 'atproto'>('nostr'); - // Kind filter: 'all' = no override, 'custom' = user types a number, otherwise a kind number string - const [kindFilter, setKindFilter] = useState('all'); - const [customKindText, setCustomKindText] = useState(''); - - const protocols = [platform]; + const protocols = useMemo(() => [platform], [platform]); // Build a flat list of (kind, label, icon) options from EXTRA_KINDS for the dropdown const kindOptions = useMemo(() => { @@ -159,202 +230,90 @@ export function SearchPage() { return Number.isInteger(n) && n > 0 ? [n] : undefined; }, [kindFilter, customKindText]); + // Detect kind + media type conflict: a specific kind is selected AND a media type is set + const hasKindMediaConflict = kindsOverride !== undefined && mediaType !== 'all'; + + // Determine if any filter differs from the default + const hasActiveFilters = !includeReplies || mediaType !== DEFAULT_FILTERS.mediaType || + language !== DEFAULT_FILTERS.language || platform !== DEFAULT_FILTERS.platform || + kindFilter !== DEFAULT_FILTERS.kindFilter || authorQuery !== DEFAULT_FILTERS.authorQuery; + + const resetFilters = useCallback(() => { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + next.delete('replies'); + next.delete('media'); + next.delete('lang'); + next.delete('platform'); + next.delete('kind'); + next.delete('customKind'); + next.delete('author'); + return next; + }, { replace: true }); + }, [setSearchParams]); + + // Build the NIP-50 search string that will be sent to the relay (for display) + const nip50SearchString = useMemo(() => { + const bridged = protocols.filter(p => p !== 'nostr'); + const parts: string[] = bridged.length > 0 + ? bridged.map(p => `protocol:${p}`) + : ['protocol:nostr']; + if (searchQuery.trim()) parts.push(searchQuery.trim()); + if (language !== 'global') parts.push(`language:${language}`); + const isDedicatedKindQuery = !kindsOverride && (mediaType === 'vines' || mediaType === 'images' || mediaType === 'videos'); + if (!isDedicatedKindQuery && !hasKindMediaConflict) { + if (mediaType === 'images') { parts.push('media:true'); parts.push('video:false'); } + else if (mediaType === 'videos') parts.push('video:true'); + else if (mediaType === 'none') parts.push('media:false'); + } + return parts.join(' '); + }, [searchQuery, language, mediaType, protocols, kindsOverride, hasKindMediaConflict]); + + // Active filter labels for the summary / empty state hints + const activeFilterLabels = useMemo(() => { + const labels: string[] = []; + if (!includeReplies) labels.push('No replies'); + if (mediaType !== 'all') labels.push({ images: 'Images', videos: 'Videos', vines: 'Shorts & Vines', none: 'No media' }[mediaType] ?? mediaType); + if (language !== 'global') labels.push(language.toUpperCase()); + if (platform !== 'nostr') labels.push({ activitypub: 'Mastodon', atproto: 'Bluesky' }[platform] ?? platform); + if (kindFilter !== 'all' && kindFilter !== 'custom') { + const opt = kindOptions.find(o => o.value === kindFilter); + if (opt) labels.push(opt.label); + } else if (kindFilter === 'custom' && customKindText) { + labels.push(`Kind: ${customKindText}`); + } + if (authorQuery) labels.push(`Author: ${authorQuery.slice(0, 12)}…`); + return labels; + }, [includeReplies, mediaType, language, platform, kindFilter, customKindText, authorQuery, kindOptions]); + // Hooks - const { posts, isLoading: postsLoading } = useStreamPosts(searchQuery, { includeReplies, mediaType, language, protocols, kindsOverride }); + const { posts, isLoading: postsLoading } = useStreamPosts(searchQuery, { + includeReplies, + mediaType, + language, + protocols, + kindsOverride, + authorPubkey: authorQuery || undefined, + }); const { data: profiles, isLoading: profilesLoading, followedPubkeys } = useSearchProfiles(activeTab === 'accounts' ? searchQuery : ''); return ( -
- {/* Tabs — sticky at top */} -
-
- setActiveTab('posts')} /> - setActiveTab('accounts')} /> -
+
+ {/* Tabs — sticky at top */} +
+
+ setActiveTab('posts')} /> + setActiveTab('accounts')} />
+
- {/* ─── Posts Tab ─── */} - {activeTab === 'posts' && ( - <> - {/* Search input + filter icon */} -
-
-
- setSearchQuery(e.target.value)} - className="pr-10 bg-secondary/50 border-border focus-visible:ring-1 rounded-lg" - /> - -
- - {/* Filter popover */} - - - - - - {/* Including replies */} -
- Include replies - -
- - - - {/* Media type */} -
- Media type - -
- - - - {/* Language */} -
-
- - Language -
- -
- - - - {/* Event kind */} -
-
- - Event kind -
- - {kindFilter === 'custom' && ( - setCustomKindText(e.target.value)} - className="bg-secondary/50 border-border focus-visible:ring-1 rounded-lg text-sm" - /> - )} -
- - - - {/* Platform */} -
- Show posts from - -
-
-
-
-
- - {/* Post results — stream */} - {postsLoading && posts.length === 0 ? ( -
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} -
- ) : posts.length > 0 ? ( -
- {posts.map((event) => ( - - ))} -
- ) : searchQuery.trim() ? ( - - ) : ( - - )} - - )} - - {/* ─── Accounts Tab ─── */} - {activeTab === 'accounts' && ( - <> - {/* Search input for accounts */} -
-
+ {/* ─── Posts Tab ─── */} + {activeTab === 'posts' && ( + <> + {/* Search input + filter icon */} +
+
+
+ + {/* Filter popover */} + + + + + + + {/* Header with reset button */} +
+ Filters + {hasActiveFilters && ( + + )} +
+ + + + {/* Including replies */} +
+ Include replies + +
+ + + + {/* Author scope */} +
+
+ + Author +
+
+ setAuthorQuery(e.target.value)} + className="bg-secondary/50 border-border focus-visible:ring-1 rounded-lg text-sm pr-7" + /> + {authorQuery && ( + + )} +
+
+ + + + {/* Media type */} +
+ Media type + +
+ + + + {/* Language */} +
+
+ + Language +
+ +
+ + + + {/* Event kind */} +
+
+ + Event kind +
+ + {kindFilter === 'custom' && ( + setCustomKindText(e.target.value)} + className="bg-secondary/50 border-border focus-visible:ring-1 rounded-lg text-sm" + /> + )} + {/* Conflict warning */} + {hasKindMediaConflict && ( +

+ + Media type and Event kind filters may conflict. Kind filter takes precedence. +

+ )} +
+ + + + {/* Platform */} +
+ Show posts from + +
+
+
-
- {searchQuery.trim() ? ( - profilesLoading ? ( -
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} -
- ) : profiles && profiles.length > 0 ? ( -
- {profiles.map((profile) => ( - - ))} -
- ) : ( - - ) - ) : ( - - )} + {/* Active filter summary chips */} + {activeFilterLabels.length > 0 && ( +
+ {activeFilterLabels.map((label) => ( + + {label} + + ))} + +
+ )} + + {/* NIP-50 search query debug block */} + {searchQuery.trim() && ( + + + +
+

+ search: + {nip50SearchString} +

+
+
+ + {nip50SearchString} + +
+
+ )} +
+ + {/* Post results — stream */} + {postsLoading && posts.length === 0 ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))}
- - )} -
+ ) : posts.length > 0 ? ( +
+ {posts.map((event) => ( + + ))} +
+ ) : searchQuery.trim() ? ( + + ) : ( + + )} + + )} + + {/* ─── Accounts Tab ─── */} + {activeTab === 'accounts' && ( + <> + {/* Search input for accounts */} +
+
+ setSearchQuery(e.target.value)} + className="pr-10 bg-secondary/50 border-border focus-visible:ring-1 rounded-lg" + /> + +
+
+ +
+ {searchQuery.trim() ? ( + profilesLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : profiles && profiles.length > 0 ? ( +
+ {profiles.map((profile) => ( + + ))} +
+ ) : ( + + ) + ) : ( + + )} +
+ + )} +
); } @@ -526,10 +754,38 @@ function FollowItem({ pubkey }: { pubkey: string }) { ); } -function EmptyState({ message }: { message: string }) { +function EmptyState({ + message, + activeFilters, + onResetFilters, +}: { + message: string; + activeFilters?: string[]; + onResetFilters?: () => void; +}) { return (

{message}

+ {activeFilters && activeFilters.length > 0 && ( +
+

Active filters:

+
+ {activeFilters.map((label) => ( + + {label} + + ))} +
+ {onResetFilters && ( + + )} +
+ )}
); }