Fix search page filter UX: persist to URL, add author scope, debounce, streaming fixes

- Persist all filters (replies, media, language, platform, kind, author) to URL params so they survive refresh and can be shared
- Add author scope filter: accepts npub or hex pubkey, filters both initial batch and live streaming
- Add reset all filters button in popover header + chip strip with clear link
- Show NIP-50 search string block below the input so users can see exactly what's sent to the relay
- Show active filter chips; empty state now lists active filters with a 'Clear all' shortcut
- Add kind + media type conflict warning when both are set simultaneously
- Fix Vines streaming: hardcoded 'return false' for kind 1 changed to only reject kind 1/1111, letting 22/34236 through
- Fix streaming language/protocol gap: protocol filter is now applied client-side to live streaming events via proxy tag detection
- Wire up useContentFilters: shouldFilterEvent() now applied in useStreamPosts useMemo so user-defined content rules affect search
- Add useDebounce hook (300ms) and apply to useSearchProfiles to stop hammering relays on every keystroke
This commit is contained in:
Chad Curtis
2026-03-05 20:11:23 -06:00
parent 0bcd21f877
commit 699b4bc32a
4 changed files with 589 additions and 249 deletions
+21
View File
@@ -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<T>(value: T, delay = 300): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}
+11 -7
View File
@@ -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<SearchProfile[]>({
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,
+73 -14
View File
@@ -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', '<uri>', '<protocol>']
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<NostrEvent[]>([]);
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 };
}
+484 -228
View File
@@ -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<string>('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 (
<main className="">
{/* Tabs — sticky at top */}
<div className={cn(STICKY_HEADER_CLASS, 'bg-background/80 backdrop-blur-md z-10 border-b border-border')}>
<div className="flex">
<TabButton label="Posts" active={activeTab === 'posts'} onClick={() => setActiveTab('posts')} />
<TabButton label="Accounts" active={activeTab === 'accounts'} onClick={() => setActiveTab('accounts')} />
</div>
<main className="">
{/* Tabs — sticky at top */}
<div className={cn(STICKY_HEADER_CLASS, 'bg-background/80 backdrop-blur-md z-10 border-b border-border')}>
<div className="flex">
<TabButton label="Posts" active={activeTab === 'posts'} onClick={() => setActiveTab('posts')} />
<TabButton label="Accounts" active={activeTab === 'accounts'} onClick={() => setActiveTab('accounts')} />
</div>
</div>
{/* ─── Posts Tab ─── */}
{activeTab === 'posts' && (
<>
{/* Search input + filter icon */}
<div className="px-4 pt-5 pb-3">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Input
type="text"
placeholder="Search"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pr-10 bg-secondary/50 border-border focus-visible:ring-1 rounded-lg"
/>
<SearchIcon className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
</div>
{/* Filter popover */}
<Popover open={filtersOpen} onOpenChange={setFiltersOpen}>
<PopoverTrigger asChild>
<button
className={cn(
'shrink-0 h-10 w-10 rounded-lg border bg-secondary/50 hover:bg-secondary flex items-center justify-center transition-colors',
filtersOpen
? 'border-2 border-primary bg-secondary text-primary'
: (includeReplies !== true || mediaType !== 'all' || language !== 'global' || platform !== 'nostr' || kindFilter !== 'all')
? 'border-primary text-primary'
: 'border-border',
)}
style={{ outline: 'none' }}
aria-label="Search filters"
>
<SlidersHorizontal className="size-4" />
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72 p-4 space-y-4">
{/* Including replies */}
<div className="flex items-center justify-between">
<span className="font-medium text-sm">Include replies</span>
<Switch
checked={includeReplies}
onCheckedChange={setIncludeReplies}
/>
</div>
<Separator />
{/* Media type */}
<div className="space-y-2">
<span className="font-medium text-sm">Media type</span>
<Select value={mediaType} onValueChange={(v) => setMediaType(v as typeof mediaType)}>
<SelectTrigger className="w-full bg-secondary/50">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[
{ value: 'all', label: 'All media' },
{ value: 'images', label: 'Images', icon: Image },
{ value: 'videos', label: 'Videos', icon: Video },
{ value: 'vines', label: 'Shorts & Vines', icon: Film },
{ value: 'none', label: 'No media' },
].map(({ value, label, icon: Icon }) => (
<SelectItem key={value} value={value}>
<span className="flex items-center gap-2">
{Icon && <Icon className="size-3.5 shrink-0 text-muted-foreground" />}
{label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Separator />
{/* Language */}
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Languages className="size-3.5 text-muted-foreground" />
<span className="font-medium text-sm">Language</span>
</div>
<Select value={language} onValueChange={setLanguage}>
<SelectTrigger className="w-full bg-secondary/50">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">Global</SelectItem>
<SelectItem value="en">English</SelectItem>
<SelectItem value="es">Spanish</SelectItem>
<SelectItem value="fr">French</SelectItem>
<SelectItem value="de">German</SelectItem>
<SelectItem value="ja">Japanese</SelectItem>
<SelectItem value="zh">Chinese</SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
{/* Event kind */}
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Hash className="size-3.5 text-muted-foreground" />
<span className="font-medium text-sm">Event kind</span>
</div>
<Select value={kindFilter} onValueChange={(v) => { setKindFilter(v); if (v !== 'custom') setCustomKindText(''); }}>
<SelectTrigger className="w-full bg-secondary/50">
<SelectValue placeholder="All kinds" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All kinds</SelectItem>
{kindOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<span className="flex items-center gap-2">
{opt.icon && <opt.icon className="size-3.5 shrink-0 text-muted-foreground" />}
{opt.label}
</span>
</SelectItem>
))}
<SelectItem value="custom">Custom kind</SelectItem>
</SelectContent>
</Select>
{kindFilter === 'custom' && (
<Input
type="text"
inputMode="numeric"
placeholder="e.g. 1, 30023"
value={customKindText}
onChange={(e) => setCustomKindText(e.target.value)}
className="bg-secondary/50 border-border focus-visible:ring-1 rounded-lg text-sm"
/>
)}
</div>
<Separator />
{/* Platform */}
<div className="space-y-2">
<span className="font-medium text-sm">Show posts from</span>
<Select value={platform} onValueChange={(v) => setPlatform(v as typeof platform)}>
<SelectTrigger className="w-full bg-secondary/50">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="nostr">Nostr</SelectItem>
<SelectItem value="activitypub">Mastodon</SelectItem>
<SelectItem value="atproto">Bluesky</SelectItem>
</SelectContent>
</Select>
</div>
</PopoverContent>
</Popover>
</div>
</div>
{/* Post results — stream */}
{postsLoading && posts.length === 0 ? (
<div className="divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<PostSkeleton key={i} />
))}
</div>
) : posts.length > 0 ? (
<div>
{posts.map((event) => (
<NoteCard key={event.id} event={event} />
))}
</div>
) : searchQuery.trim() ? (
<EmptyState message="No posts found matching your search." />
) : (
<EmptyState message="Enter a search query to find posts." />
)}
</>
)}
{/* ─── Accounts Tab ─── */}
{activeTab === 'accounts' && (
<>
{/* Search input for accounts */}
<div className="px-4 pt-5 pb-2">
<div className="relative">
{/* ─── Posts Tab ─── */}
{activeTab === 'posts' && (
<>
{/* Search input + filter icon */}
<div className="px-4 pt-5 pb-3">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Input
type="text"
placeholder="Search"
@@ -364,32 +323,301 @@ export function SearchPage() {
/>
<SearchIcon className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
</div>
{/* Filter popover */}
<Popover open={filtersOpen} onOpenChange={setFiltersOpen}>
<PopoverTrigger asChild>
<button
className={cn(
'shrink-0 h-10 w-10 rounded-lg border bg-secondary/50 hover:bg-secondary flex items-center justify-center transition-colors',
filtersOpen
? 'border-2 border-primary bg-secondary text-primary'
: hasActiveFilters
? 'border-primary text-primary'
: 'border-border',
)}
style={{ outline: 'none' }}
aria-label="Search filters"
>
<SlidersHorizontal className="size-4" />
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72 p-4 space-y-4">
{/* Header with reset button */}
<div className="flex items-center justify-between">
<span className="font-semibold text-sm">Filters</span>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground gap-1"
onClick={resetFilters}
>
<RotateCcw className="size-3" />
Reset all
</Button>
)}
</div>
<Separator />
{/* Including replies */}
<div className="flex items-center justify-between">
<span className="font-medium text-sm">Include replies</span>
<Switch
checked={includeReplies}
onCheckedChange={setIncludeReplies}
/>
</div>
<Separator />
{/* Author scope */}
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<User className="size-3.5 text-muted-foreground" />
<span className="font-medium text-sm">Author</span>
</div>
<div className="relative">
<Input
type="text"
placeholder="npub1… or hex pubkey"
value={authorQuery}
onChange={(e) => setAuthorQuery(e.target.value)}
className="bg-secondary/50 border-border focus-visible:ring-1 rounded-lg text-sm pr-7"
/>
{authorQuery && (
<button
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setAuthorQuery('')}
aria-label="Clear author"
>
<X className="size-3.5" />
</button>
)}
</div>
</div>
<Separator />
{/* Media type */}
<div className="space-y-2">
<span className="font-medium text-sm">Media type</span>
<Select value={mediaType} onValueChange={setMediaType}>
<SelectTrigger className="w-full bg-secondary/50">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[
{ value: 'all', label: 'All media' },
{ value: 'images', label: 'Images', icon: Image },
{ value: 'videos', label: 'Videos', icon: Video },
{ value: 'vines', label: 'Shorts & Vines', icon: Film },
{ value: 'none', label: 'No media' },
].map(({ value, label, icon: Icon }) => (
<SelectItem key={value} value={value}>
<span className="flex items-center gap-2">
{Icon && <Icon className="size-3.5 shrink-0 text-muted-foreground" />}
{label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Separator />
{/* Language */}
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Languages className="size-3.5 text-muted-foreground" />
<span className="font-medium text-sm">Language</span>
</div>
<Select value={language} onValueChange={setLanguage}>
<SelectTrigger className="w-full bg-secondary/50">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">Global</SelectItem>
<SelectItem value="en">English</SelectItem>
<SelectItem value="es">Spanish</SelectItem>
<SelectItem value="fr">French</SelectItem>
<SelectItem value="de">German</SelectItem>
<SelectItem value="ja">Japanese</SelectItem>
<SelectItem value="zh">Chinese</SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
{/* Event kind */}
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Hash className="size-3.5 text-muted-foreground" />
<span className="font-medium text-sm">Event kind</span>
</div>
<Select value={kindFilter} onValueChange={setKindFilter}>
<SelectTrigger className="w-full bg-secondary/50">
<SelectValue placeholder="All kinds" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All kinds</SelectItem>
{kindOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<span className="flex items-center gap-2">
{opt.icon && <opt.icon className="size-3.5 shrink-0 text-muted-foreground" />}
{opt.label}
</span>
</SelectItem>
))}
<SelectItem value="custom">Custom kind</SelectItem>
</SelectContent>
</Select>
{kindFilter === 'custom' && (
<Input
type="text"
inputMode="numeric"
placeholder="e.g. 1, 30023"
value={customKindText}
onChange={(e) => setCustomKindText(e.target.value)}
className="bg-secondary/50 border-border focus-visible:ring-1 rounded-lg text-sm"
/>
)}
{/* Conflict warning */}
{hasKindMediaConflict && (
<p className="text-xs text-amber-600 dark:text-amber-400 flex items-start gap-1.5">
<Info className="size-3.5 shrink-0 mt-0.5" />
Media type and Event kind filters may conflict. Kind filter takes precedence.
</p>
)}
</div>
<Separator />
{/* Platform */}
<div className="space-y-2">
<span className="font-medium text-sm">Show posts from</span>
<Select value={platform} onValueChange={setPlatform}>
<SelectTrigger className="w-full bg-secondary/50">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="nostr">Nostr</SelectItem>
<SelectItem value="activitypub">Mastodon</SelectItem>
<SelectItem value="atproto">Bluesky</SelectItem>
</SelectContent>
</Select>
</div>
</PopoverContent>
</Popover>
</div>
<div>
{searchQuery.trim() ? (
profilesLoading ? (
<div className="divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<AccountSkeleton key={i} />
))}
</div>
) : profiles && profiles.length > 0 ? (
<div className="divide-y divide-border">
{profiles.map((profile) => (
<AccountItem key={profile.pubkey} profile={profile} isFollowed={followedPubkeys.has(profile.pubkey)} />
))}
</div>
) : (
<EmptyState message="No accounts found matching your search." />
)
) : (
<FollowsList />
)}
{/* Active filter summary chips */}
{activeFilterLabels.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{activeFilterLabels.map((label) => (
<Badge key={label} variant="secondary" className="text-xs font-normal">
{label}
</Badge>
))}
<button
onClick={resetFilters}
className="text-xs text-muted-foreground hover:text-foreground underline underline-offset-2 transition-colors"
>
Clear
</button>
</div>
)}
{/* NIP-50 search query debug block */}
{searchQuery.trim() && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="mt-2 px-3 py-2 rounded-md bg-secondary/40 border border-border cursor-default">
<p className="text-xs text-muted-foreground font-mono truncate">
<span className="text-muted-foreground/60 mr-1">search:</span>
{nip50SearchString}
</p>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs text-xs font-mono break-all">
{nip50SearchString}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
{/* Post results — stream */}
{postsLoading && posts.length === 0 ? (
<div className="divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<PostSkeleton key={i} />
))}
</div>
</>
)}
</main>
) : posts.length > 0 ? (
<div>
{posts.map((event) => (
<NoteCard key={event.id} event={event} />
))}
</div>
) : searchQuery.trim() ? (
<EmptyState
message="No posts found matching your search."
activeFilters={activeFilterLabels}
onResetFilters={hasActiveFilters ? resetFilters : undefined}
/>
) : (
<EmptyState message="Enter a search query to find posts." />
)}
</>
)}
{/* ─── Accounts Tab ─── */}
{activeTab === 'accounts' && (
<>
{/* Search input for accounts */}
<div className="px-4 pt-5 pb-2">
<div className="relative">
<Input
type="text"
placeholder="Search"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pr-10 bg-secondary/50 border-border focus-visible:ring-1 rounded-lg"
/>
<SearchIcon className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
</div>
</div>
<div>
{searchQuery.trim() ? (
profilesLoading ? (
<div className="divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<AccountSkeleton key={i} />
))}
</div>
) : profiles && profiles.length > 0 ? (
<div className="divide-y divide-border">
{profiles.map((profile) => (
<AccountItem key={profile.pubkey} profile={profile} isFollowed={followedPubkeys.has(profile.pubkey)} />
))}
</div>
) : (
<EmptyState message="No accounts found matching your search." />
)
) : (
<FollowsList />
)}
</div>
</>
)}
</main>
);
}
@@ -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 (
<div className="py-16 px-8 text-center">
<p className="text-muted-foreground">{message}</p>
{activeFilters && activeFilters.length > 0 && (
<div className="mt-3">
<p className="text-xs text-muted-foreground/70 mb-2">Active filters:</p>
<div className="flex flex-wrap justify-center gap-1.5">
{activeFilters.map((label) => (
<Badge key={label} variant="secondary" className="text-xs font-normal">
{label}
</Badge>
))}
</div>
{onResetFilters && (
<button
onClick={onResetFilters}
className="mt-3 text-xs text-primary hover:underline underline-offset-2 transition-colors"
>
Clear all filters
</button>
)}
</div>
)}
</div>
);
}