8 changed files with 209 additions and 57 deletions
@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
|
||||
import { buildExplorePopularRelayUrls } from '@/lib/explore-popular-relays' |
||||
import { toRelay } from '@/lib/link' |
||||
import { normalizeAnyRelayUrl, simplifyUrl } from '@/lib/url' |
||||
import { useSmartRelayNavigation } from '@/PageManager' |
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import indexedDb from '@/services/indexed-db.service' |
||||
import { useEffect, useMemo, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
/** |
||||
* Lightweight Explore relay list: URLs from the viewer's NIP-65 / favorites / defaults and optional |
||||
* cached NIP-66 data — no GitHub collections fetch and no NIP-11 storm on mount. |
||||
*/ |
||||
export default function ExplorePopularRelays() { |
||||
const { t } = useTranslation() |
||||
const { relayList } = useNostr() |
||||
const { favoriteRelays, blockedRelays } = useFavoriteRelays() |
||||
const { navigateToRelay } = useSmartRelayNavigation() |
||||
const [nip66Cached, setNip66Cached] = useState<string[]>([]) |
||||
|
||||
useEffect(() => { |
||||
let cancelled = false |
||||
void indexedDb |
||||
.getPublicLivelyRelayUrlsCache() |
||||
.then((c) => { |
||||
if (!cancelled && c?.urls?.length) setNip66Cached(c.urls) |
||||
}) |
||||
.catch(() => {}) |
||||
return () => { |
||||
cancelled = true |
||||
} |
||||
}, []) |
||||
|
||||
const urls = useMemo( |
||||
() => |
||||
buildExplorePopularRelayUrls({ |
||||
relayList, |
||||
favoriteRelays, |
||||
blockedRelays, |
||||
nip66CachedUrls: nip66Cached |
||||
}), |
||||
[relayList, favoriteRelays, blockedRelays, nip66Cached] |
||||
) |
||||
|
||||
if (urls.length === 0) { |
||||
return ( |
||||
<p className="px-4 py-6 text-sm text-muted-foreground">{t('No relays in your lists yet.')}</p> |
||||
) |
||||
} |
||||
|
||||
return ( |
||||
<section className="min-w-0 pb-6" aria-label={t('Popular relays')}> |
||||
<h2 className="mb-2 px-4 text-base font-semibold tracking-tight">{t('Popular relays')}</h2> |
||||
<p className="mb-3 px-4 text-sm text-muted-foreground"> |
||||
{t('From your mailbox, favorites, and cached relay lists on this device.')} |
||||
</p> |
||||
<ul className="grid min-w-0 gap-2 px-2 md:grid-cols-2 md:px-4"> |
||||
{urls.map((url) => { |
||||
const key = normalizeAnyRelayUrl(url) || url |
||||
return ( |
||||
<li key={key}> |
||||
<button |
||||
type="button" |
||||
className="flex w-full min-w-0 flex-col rounded-lg border bg-card px-3 py-2.5 text-left shadow-sm transition-colors hover:bg-accent/40" |
||||
onClick={() => navigateToRelay(toRelay(url))} |
||||
> |
||||
<span className="truncate font-mono text-sm font-semibold">{simplifyUrl(url)}</span> |
||||
<span className="mt-0.5 truncate text-xs text-muted-foreground">{url}</span> |
||||
</button> |
||||
</li> |
||||
) |
||||
})} |
||||
</ul> |
||||
</section> |
||||
) |
||||
} |
||||
@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
|
||||
import { DEFAULT_FAVORITE_RELAYS, FAST_READ_RELAY_URLS } from '@/constants' |
||||
import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize' |
||||
import { normalizeAnyRelayUrl } from '@/lib/url' |
||||
import type { ViewerRelayListLike } from '@/lib/viewer-relay-defaults' |
||||
|
||||
/** Public Explore UI: no loopback/LAN and no plain `ws://` (local dev / cache relays). */ |
||||
export function isExploreBrowsableRelayUrl(raw: string): boolean { |
||||
if (!urlIsNonLocalForRemoteViewer(raw)) return false |
||||
const n = (normalizeAnyRelayUrl(raw) || raw.trim()).toLowerCase() |
||||
return !n.startsWith('ws://') |
||||
} |
||||
|
||||
export type BuildExplorePopularRelayUrlsOptions = { |
||||
relayList: ViewerRelayListLike |
||||
favoriteRelays: readonly string[] |
||||
blockedRelays: readonly string[] |
||||
/** Cached NIP-66 lively list from IndexedDB (no network required). */ |
||||
nip66CachedUrls?: readonly string[] |
||||
max?: number |
||||
} |
||||
|
||||
/** |
||||
* Relay URLs for Explore: merge the viewer's lists + small defaults, rank by how often each URL |
||||
* appears across sources (proxy for "popular in your stack"). |
||||
*/ |
||||
export function buildExplorePopularRelayUrls(options: BuildExplorePopularRelayUrlsOptions): string[] { |
||||
const blocked = new Set( |
||||
options.blockedRelays.map((b) => normalizeAnyRelayUrl(b) || b.trim()).filter(Boolean) |
||||
) |
||||
const counts = new Map<string, number>() |
||||
|
||||
const bump = (raw: string) => { |
||||
if (!isExploreBrowsableRelayUrl(raw)) return |
||||
const k = normalizeAnyRelayUrl(raw) || raw.trim() |
||||
if (!k || blocked.has(k)) return |
||||
counts.set(k, (counts.get(k) ?? 0) + 1) |
||||
} |
||||
|
||||
const rl = options.relayList |
||||
for (const u of [...(rl?.read ?? []), ...(rl?.write ?? []), ...(rl?.httpRead ?? [])]) { |
||||
bump(u) |
||||
} |
||||
for (const u of options.favoriteRelays) bump(u) |
||||
for (const u of DEFAULT_FAVORITE_RELAYS) bump(u) |
||||
for (const u of FAST_READ_RELAY_URLS) bump(u) |
||||
for (const u of options.nip66CachedUrls ?? []) bump(u) |
||||
|
||||
const ranked = [...counts.entries()] |
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) |
||||
.map(([url]) => url) |
||||
|
||||
const max = options.max ?? 48 |
||||
return ranked.slice(0, max) |
||||
} |
||||
Loading…
Reference in new issue