diff --git a/src/hooks/useStreamPosts.ts b/src/hooks/useStreamPosts.ts index 04a4c0b0..e0bdfc08 100644 --- a/src/hooks/useStreamPosts.ts +++ b/src/hooks/useStreamPosts.ts @@ -12,6 +12,8 @@ interface StreamPostsOptions { includeReplies: boolean; mediaType: 'all' | 'images' | 'videos' | 'vines' | 'none'; language?: string; + /** Protocol strings to pass as protocol: search terms. Defaults to ['nostr']. */ + protocols?: string[]; } /** Check if an event has imeta tags with image MIME types. */ @@ -97,6 +99,9 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) { const enabledKinds = getEnabledFeedKinds(feedSettings); const kindsKey = [...enabledKinds].sort().join(','); + // Stable key for protocols so it can be a useEffect dependency + const protocolsKey = [...(options.protocols ?? ['nostr'])].sort().join(','); + useEffect(() => { const ac = new AbortController(); let alive = true; @@ -135,7 +140,14 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) { const streamFilter: NostrFilter = { kinds }; // Search filter for initial query (includes NIP-50 extensions) - const searchParts: string[] = ['protocol:nostr']; + // protocol:nostr = native Nostr only (no bridged events). + // When bridged protocols are selected, omit protocol:nostr so the relay + // returns both native and bridged events matching the selected protocols. + const protocols = options.protocols ?? ['nostr']; + const bridged = protocols.filter(p => p !== 'nostr'); + const searchParts: string[] = bridged.length > 0 + ? bridged.map(p => `protocol:${p}`) + : ['protocol:nostr']; if (query.trim()) { searchParts.push(query.trim()); @@ -215,7 +227,7 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) { alive = false; ac.abort(); }; - }, [nostr, query, isVines, kindsKey, options.language, options.mediaType]); + }, [nostr, query, isVines, kindsKey, options.language, options.mediaType, protocolsKey]); // Apply client-side filters (including mute filtering) without restarting the stream const posts = useMemo(() => { @@ -223,7 +235,7 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) { if (muteItems.length > 0 && isEventMuted(event, muteItems)) return false; return filterEvent(event, options, query); }); - }, [allEvents, options.includeReplies, options.mediaType, query, muteItems]); + }, [allEvents, options.includeReplies, options.mediaType, protocolsKey, query, muteItems]); return { posts, isLoading }; } diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index 20e77fe8..8fd979d1 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -1,16 +1,17 @@ import { useSeoMeta } from '@unhead/react'; -import { ChevronUp, ChevronDown, Search as SearchIcon, Image, Video, Film, Languages, UserRoundCheck } from 'lucide-react'; +import { SlidersHorizontal, Search as SearchIcon, Image, Video, Film, Languages, UserRoundCheck } 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 { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; 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 { Checkbox } from '@/components/ui/checkbox'; import { EmojifiedText } from '@/components/CustomEmoji'; import { useSearchProfiles } from '@/hooks/useSearchProfiles'; import { useStreamPosts } from '@/hooks/useStreamPosts'; @@ -45,7 +46,7 @@ export function SearchPage() { // Local input state for the search field (avoids trimming while typing) const [searchQuery, setSearchQuery] = useState(searchParams.get('q') ?? ''); - const [filtersOpen, setFiltersOpen] = useState(true); + const [filtersOpen, setFiltersOpen] = useState(false); // Update tab in URL without a feedback loop const setActiveTab = useCallback((tab: TabType) => { @@ -97,30 +98,14 @@ export function SearchPage() { const [includeReplies, setIncludeReplies] = useState(true); const [mediaType, setMediaType] = useState<'all' | 'images' | 'videos' | 'vines' | 'none'>('all'); const [language, setLanguage] = useState('global'); - const [showNostr, setShowNostr] = useState(true); - const [showMastodon, setShowMastodon] = useState(false); + const [platform, setPlatform] = useState<'nostr' | 'activitypub' | 'atproto'>('nostr'); + + const protocols = [platform]; // Hooks - const { posts: allPosts, isLoading: postsLoading } = useStreamPosts(searchQuery, { includeReplies, mediaType, language }); + const { posts, isLoading: postsLoading } = useStreamPosts(searchQuery, { includeReplies, mediaType, language, protocols }); const { data: profiles, isLoading: profilesLoading, followedPubkeys } = useSearchProfiles(activeTab === 'accounts' ? searchQuery : ''); - // Filter by platform (Nostr/Mastodon) client-side - const posts = useMemo(() => { - return allPosts.filter(event => { - const hasActivityPubProxy = event.tags.some( - tag => tag[0] === 'proxy' && tag.length > 2 && tag[2] === 'activitypub' - ); - - const isMastodon = hasActivityPubProxy; - const isNostr = !hasActivityPubProxy; - - if (isMastodon && !showMastodon) return false; - if (isNostr && !showNostr) return false; - - return true; - }); - }, [allPosts, showNostr, showMastodon]); - return (
{/* Tabs — sticky at top */} @@ -134,121 +119,127 @@ export function SearchPage() { {/* ─── Posts Tab ─── */} {activeTab === 'posts' && ( <> - {/* Search input */} -
-
- setSearchQuery(e.target.value)} - className="pr-10 bg-secondary/50 border-border focus-visible:ring-1 rounded-lg" - /> - -
-
- - {/* Search filters — collapsible */} -
- {/* Header row */} - - - {/* Filter controls */} - {filtersOpen && ( -
- {/* Including replies */} -
- Including replies - -
- - {/* Media type — horizontal wrap */} -
- With ONLY the media type: - setMediaType(v as typeof mediaType)} - className="flex flex-wrap gap-x-4 gap-y-2" - > - {[ - { value: 'all', label: 'All media' }, - { value: 'images', label: 'Images', icon: Image }, - { value: 'videos', label: 'Videos', icon: Video }, - { value: 'vines', label: 'Vines', icon: Film }, - { value: 'none', label: 'No media' }, - ].map(({ value, label, icon: Icon }) => ( -
- - -
- ))} -
-
- - {/* Language — inline */} -
-
- - In the language: -
- -
- - {/* Platform filter */} -
- Show posts from: -
-
- setShowNostr(!!checked)} - /> - -
-
- setShowMastodon(!!checked)} - /> - -
-
-
+ {/* 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 + setMediaType(v as typeof mediaType)} + className="space-y-1.5" + > + {[ + { value: 'all', label: 'All media' }, + { value: 'images', label: 'Images', icon: Image }, + { value: 'videos', label: 'Videos', icon: Video }, + { value: 'vines', label: 'Vines', icon: Film }, + { value: 'none', label: 'No media' }, + ].map(({ value, label, icon: Icon }) => ( +
+ + +
+ ))} +
+
+ + + + {/* Language */} +
+
+ + Language +
+ +
+ + + + {/* Platform */} +
+ Show posts from + setPlatform(v as typeof platform)} + className="space-y-1.5" + > + {[ + { value: 'nostr', label: 'Nostr' }, + { value: 'activitypub', label: 'Mastodon' }, + { value: 'atproto', label: 'Bluesky' }, + ].map(({ value, label }) => ( +
+ + +
+ ))} +
+
+
+
+
{/* Post results — stream */}