import Explore from '@/components/Explore' import ExploreFavoriteRelays from '@/components/Explore/ExploreFavoriteRelays' import ExploreRelayReviews from '@/components/Explore/ExploreRelayReviews' import FollowingFavoriteRelayList from '@/components/FollowingFavoriteRelayList' import Tabs from '@/components/Tabs' import VersionUpdateBanner from '@/components/VersionUpdateBanner' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { toRelay } from '@/lib/link' import { cn } from '@/lib/utils' import { isWebsocketUrl, normalizeUrl, simplifyUrl } from '@/lib/url' import { RefreshButton } from '@/components/RefreshButton' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions' import { useSmartRelayNavigation } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' import nip66Service from '@/services/nip66.service' import { TPageRef } from '@/types' import { ArrowRight, Compass, Plus } from 'lucide-react' import { forwardRef, FormEvent, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' const RELAY_SUGGESTION_LIMIT = 20 function dedupeNormalizedRelayUrls(urls: string[]): string[] { const seen = new Set() const out: string[] = [] for (const u of urls) { const k = normalizeUrl(u) || u if (!k || seen.has(k)) continue seen.add(k) out.push(k) } return out } /** Lower rank = better match for ordering suggestions. */ function relaySuggestionRank(normalizedUrl: string, queryLower: string): number { const n = normalizedUrl.toLowerCase() const simple = simplifyUrl(n).toLowerCase() if (!queryLower) return 99 if (n === queryLower || simple === queryLower) return 0 if (simple.startsWith(queryLower) || n.startsWith(`wss://${queryLower}`) || n.startsWith(`ws://${queryLower}`)) return 1 if (simple.includes(queryLower) || n.includes(queryLower)) return 2 return 99 } function filterMonitoringRelaySuggestions(urls: string[], rawQuery: string): string[] { const q = rawQuery.trim().toLowerCase() if (!q) return [] const matches = urls.filter((url) => relaySuggestionRank(url, q) < 99) matches.sort((a, b) => { const ra = relaySuggestionRank(a, q) const rb = relaySuggestionRank(b, q) if (ra !== rb) return ra - rb return simplifyUrl(a).localeCompare(simplifyUrl(b), undefined, { sensitivity: 'base' }) }) return matches.slice(0, RELAY_SUGGESTION_LIMIT) } type TExploreTabs = 'explore' | 'reviews' | 'following' function normalizeHomeTab(restored: string): TExploreTabs { if (restored === 'following') return 'following' if (restored === 'reviews') return 'reviews' // Removed "favorites" tab — treat saved state as Explore return 'explore' } const ExplorePage = forwardRef((_, ref) => { const { t } = useTranslation() const { pubkey, relayList } = useNostr() const [tab, setTab] = useState('explore') const layoutRef = useRef(null) const [contentRefreshKey, setContentRefreshKey] = useState(0) const bumpExploreContent = useCallback(() => { void (async () => { await syncUserDeletionTombstones(pubkey, relayList) setContentRefreshKey((k) => k + 1) })() }, [pubkey, relayList]) useImperativeHandle( ref, () => ({ scrollToTop: (behavior?: ScrollBehavior) => layoutRef.current?.scrollToTop(behavior), refresh: bumpExploreContent }), [bumpExploreContent] ) // Listen for tab restoration from PageManager useEffect(() => { const handleRestore = (e: CustomEvent<{ page: string; tab: string }>) => { if (e.detail.page === 'explore' && e.detail.tab) { setTab(normalizeHomeTab(e.detail.tab)) } } window.addEventListener('restorePageTab', handleRestore as EventListener) return () => window.removeEventListener('restorePageTab', handleRestore as EventListener) }, []) return ( } subHeader={ { setTab(next as TExploreTabs) window.dispatchEvent( new CustomEvent('pageTabChanged', { detail: { page: 'explore', tab: next } }) ) }} /> } displayScrollToTopButton >
{tab === 'explore' && (
)} {tab === 'reviews' && (
)} {tab === 'following' && (
)}
) }) ExplorePage.displayName = 'ExplorePage' export default ExplorePage function ExplorePageTitlebar({ onRefresh }: { onRefresh: () => void }) { const { t } = useTranslation() return (
{t('Explore')}
) } function ExploreRelaySearchSection() { const { t } = useTranslation() const { navigateToRelay } = useSmartRelayNavigation() const [relayQuery, setRelayQuery] = useState('') const [monitoringRelays, setMonitoringRelays] = useState([]) const [suggestOpen, setSuggestOpen] = useState(false) const blurCloseTimer = useRef | null>(null) useEffect(() => { nip66Service.getPublicLivelyRelayUrls().then((urls) => { setMonitoringRelays(dedupeNormalizedRelayUrls(urls ?? [])) }) }, []) useEffect(() => { return () => { if (blurCloseTimer.current != null) clearTimeout(blurCloseTimer.current) } }, []) const relaySuggestions = useMemo( () => filterMonitoringRelaySuggestions(monitoringRelays, relayQuery), [monitoringRelays, relayQuery] ) const clearBlurTimer = () => { if (blurCloseTimer.current != null) { clearTimeout(blurCloseTimer.current) blurCloseTimer.current = null } } const openRelayAndReset = (normalizedUrl: string) => { navigateToRelay(toRelay(normalizedUrl)) setRelayQuery('') setSuggestOpen(false) } const tryOpenRelay = () => { const trimmed = relayQuery.trim() if (!trimmed) return const normalized = normalizeUrl(trimmed) if (!normalized || !isWebsocketUrl(normalized)) { toast.error(t('invalid relay URL')) return } openRelayAndReset(normalized) } const onSubmitRelay = (e: FormEvent) => { e.preventDefault() tryOpenRelay() } return (

{t('Search for Relays')}

setRelayQuery(e.target.value)} aria-label={t('Relay URL…')} aria-autocomplete="list" aria-expanded={suggestOpen && relaySuggestions.length > 0} aria-controls="explore-relay-suggestions" role="combobox" onFocus={() => { clearBlurTimer() setSuggestOpen(true) }} onBlur={() => { clearBlurTimer() blurCloseTimer.current = setTimeout(() => setSuggestOpen(false), 200) }} /> {suggestOpen && relaySuggestions.length > 0 ? (
    e.preventDefault()} > {relaySuggestions.map((url) => (
  • ))}
) : null}
) }