11 changed files with 765 additions and 147 deletions
@ -0,0 +1,314 @@ |
|||||||
|
import RelayReviewCard from '@/components/RelayInfo/RelayReviewCard' |
||||||
|
import RelaySimpleInfo, { RelaySimpleInfoSkeleton } from '@/components/RelaySimpleInfo' |
||||||
|
import { Badge } from '@/components/ui/badge' |
||||||
|
import { Skeleton } from '@/components/ui/skeleton' |
||||||
|
import { ExtendedKind } from '@/constants' |
||||||
|
import { useFetchRelayInfo } from '@/hooks' |
||||||
|
import { |
||||||
|
buildExploreRelayDirectory, |
||||||
|
filterExploreRelayDirectory, |
||||||
|
type ExploreRelayEntry |
||||||
|
} from '@/lib/explore-relay-directory' |
||||||
|
import { |
||||||
|
dedupeRelayReviewsNewestFirst, |
||||||
|
groupRelayReviewsByUrl, |
||||||
|
loadCachedRelayReviews |
||||||
|
} from '@/lib/explore-relay-reviews' |
||||||
|
import { getRelayUrlFromRelayReviewEvent } from '@/lib/event-metadata' |
||||||
|
import { |
||||||
|
getRelayUrlsWithFavoritesFastReadAndInbox, |
||||||
|
userReadRelaysWithHttp |
||||||
|
} from '@/lib/favorites-feed-relays' |
||||||
|
import { toRelay } from '@/lib/link' |
||||||
|
import { normalizeAnyRelayUrl } from '@/lib/url' |
||||||
|
import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds' |
||||||
|
import { useSmartRelayNavigation } from '@/PageManager' |
||||||
|
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import client from '@/services/client.service' |
||||||
|
import indexedDb from '@/services/indexed-db.service' |
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
|
||||||
|
const SHOW_COUNT = 12 |
||||||
|
const REVIEW_QUERY_LIMIT = 100 |
||||||
|
const EXPLORE_REVIEWS_MAX_RELAYS = 12 |
||||||
|
const EXPLORE_REVIEWS_EOSE_TAIL_MS = 4500 |
||||||
|
const MAX_REVIEWS_PER_CARD = 3 |
||||||
|
|
||||||
|
function stableRelayInputsKey( |
||||||
|
favoriteRelays: string[], |
||||||
|
blockedRelays: string[], |
||||||
|
relayList: { read?: string[]; write?: string[]; httpRead?: string[] } | null | undefined |
||||||
|
): string { |
||||||
|
const normSortJoin = (urls: string[]) => |
||||||
|
[...urls] |
||||||
|
.map((u) => normalizeAnyRelayUrl(u) || u.trim()) |
||||||
|
.filter(Boolean) |
||||||
|
.sort((a, b) => a.localeCompare(b)) |
||||||
|
.join('|') |
||||||
|
return [ |
||||||
|
normSortJoin(favoriteRelays), |
||||||
|
normSortJoin(blockedRelays), |
||||||
|
normSortJoin([...(relayList?.httpRead ?? []), ...(relayList?.read ?? [])]), |
||||||
|
normSortJoin(relayList?.write ?? []) |
||||||
|
].join('::') |
||||||
|
} |
||||||
|
|
||||||
|
function ExploreRelayDirectoryCard({ entry }: { entry: ExploreRelayEntry }) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { navigateToRelay } = useSmartRelayNavigation() |
||||||
|
const { relayInfo } = useFetchRelayInfo(entry.url) |
||||||
|
const { sourceFlags, favoritedBy, reviews } = entry |
||||||
|
const visibleReviews = reviews.slice(0, MAX_REVIEWS_PER_CARD) |
||||||
|
|
||||||
|
const badges: { key: string; label: string }[] = [] |
||||||
|
if (sourceFlags.inMailboxRead || sourceFlags.inMailboxWrite || sourceFlags.inMailboxHttpRead) { |
||||||
|
badges.push({ key: 'inbox', label: t('Your inbox') }) |
||||||
|
} |
||||||
|
if (sourceFlags.inUserFavorites) { |
||||||
|
badges.push({ key: 'favorite', label: t('Favorite') }) |
||||||
|
} |
||||||
|
if (reviews.length > 0) { |
||||||
|
badges.push({ |
||||||
|
key: 'reviews', |
||||||
|
label: t('{{count}} reviews', { count: reviews.length }) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<article className="border-b px-4 py-4"> |
||||||
|
<RelaySimpleInfo |
||||||
|
relayInfo={relayInfo} |
||||||
|
users={favoritedBy.length > 0 ? favoritedBy : undefined} |
||||||
|
className="clickable min-h-0" |
||||||
|
onClick={(e) => { |
||||||
|
e.stopPropagation() |
||||||
|
navigateToRelay(toRelay(entry.url)) |
||||||
|
}} |
||||||
|
/> |
||||||
|
{badges.length > 0 ? ( |
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5"> |
||||||
|
{badges.map((b) => ( |
||||||
|
<Badge key={b.key} variant="secondary" className="text-xs font-normal"> |
||||||
|
{b.label} |
||||||
|
</Badge> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
) : null} |
||||||
|
{visibleReviews.length > 0 ? ( |
||||||
|
<div className="mt-3 grid min-w-0 gap-2 md:grid-cols-2"> |
||||||
|
{visibleReviews.map((event) => ( |
||||||
|
<RelayReviewCard |
||||||
|
key={event.id} |
||||||
|
event={event} |
||||||
|
showRelayInfo={false} |
||||||
|
className="border md:border-border" |
||||||
|
/> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
) : null} |
||||||
|
</article> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export default function ExploreRelayDirectory({ listFilter = '' }: { listFilter?: string }) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { pubkey, relayList } = useNostr() |
||||||
|
const { favoriteRelays, blockedRelays } = useFavoriteRelays() |
||||||
|
|
||||||
|
const relayInputsKey = useMemo( |
||||||
|
() => stableRelayInputsKey(favoriteRelays, blockedRelays, relayList), |
||||||
|
[favoriteRelays, blockedRelays, relayList] |
||||||
|
) |
||||||
|
|
||||||
|
const reviewRelayUrls = useMemo(() => { |
||||||
|
const stacked = appendCuratedReadOnlyRelays( |
||||||
|
getRelayUrlsWithFavoritesFastReadAndInbox( |
||||||
|
favoriteRelays, |
||||||
|
blockedRelays, |
||||||
|
userReadRelaysWithHttp(relayList), |
||||||
|
{ |
||||||
|
userWriteRelays: relayList?.write ?? [], |
||||||
|
maxRelays: EXPLORE_REVIEWS_MAX_RELAYS, |
||||||
|
applySocialKindBlockedFilter: false |
||||||
|
} |
||||||
|
), |
||||||
|
blockedRelays |
||||||
|
) |
||||||
|
return stacked |
||||||
|
.slice(0, EXPLORE_REVIEWS_MAX_RELAYS) |
||||||
|
.map((u) => normalizeAnyRelayUrl(u) || u.trim()) |
||||||
|
.filter((u): u is string => Boolean(u)) |
||||||
|
.sort((a, b) => a.localeCompare(b)) |
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- content hash of relay inputs
|
||||||
|
}, [relayInputsKey]) |
||||||
|
|
||||||
|
const [nip66Cached, setNip66Cached] = useState<string[]>([]) |
||||||
|
const [followingFavorites, setFollowingFavorites] = useState<[string, string[]][]>([]) |
||||||
|
const [followingLoading, setFollowingLoading] = useState(true) |
||||||
|
const [reviewEvents, setReviewEvents] = useState<import('nostr-tools').Event[]>([]) |
||||||
|
const [reviewsLoading, setReviewsLoading] = useState(true) |
||||||
|
const [showCount, setShowCount] = useState(SHOW_COUNT) |
||||||
|
const bottomRef = useRef<HTMLDivElement>(null) |
||||||
|
const fetchGenRef = useRef(0) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
client.scheduleNip66RelayDiscoveryFromExplore() |
||||||
|
}, []) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
let cancelled = false |
||||||
|
void indexedDb |
||||||
|
.getPublicLivelyRelayUrlsCache() |
||||||
|
.then((c) => { |
||||||
|
if (!cancelled && c?.urls?.length) setNip66Cached(c.urls) |
||||||
|
}) |
||||||
|
.catch(() => {}) |
||||||
|
return () => { |
||||||
|
cancelled = true |
||||||
|
} |
||||||
|
}, []) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
let cancelled = false |
||||||
|
setFollowingLoading(true) |
||||||
|
void (async () => { |
||||||
|
if (!pubkey) { |
||||||
|
setFollowingFavorites([]) |
||||||
|
return |
||||||
|
} |
||||||
|
const rows = (await client.fetchFollowingFavoriteRelays(pubkey)) ?? [] |
||||||
|
if (!cancelled) setFollowingFavorites(rows) |
||||||
|
})().finally(() => { |
||||||
|
if (!cancelled) setFollowingLoading(false) |
||||||
|
}) |
||||||
|
return () => { |
||||||
|
cancelled = true |
||||||
|
} |
||||||
|
}, [pubkey]) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const gen = ++fetchGenRef.current |
||||||
|
let cancelled = false |
||||||
|
setReviewsLoading(true) |
||||||
|
setReviewEvents([]) |
||||||
|
|
||||||
|
void (async () => { |
||||||
|
const cached = await loadCachedRelayReviews(REVIEW_QUERY_LIMIT) |
||||||
|
if (!cancelled && fetchGenRef.current === gen && cached.length > 0) { |
||||||
|
setReviewEvents(cached) |
||||||
|
} |
||||||
|
try { |
||||||
|
const raw = await client.fetchEvents( |
||||||
|
reviewRelayUrls, |
||||||
|
{ kinds: [ExtendedKind.RELAY_REVIEW], limit: REVIEW_QUERY_LIMIT }, |
||||||
|
{ |
||||||
|
onevent: (e) => { |
||||||
|
if (cancelled || fetchGenRef.current !== gen) return |
||||||
|
if (e.kind === ExtendedKind.RELAY_REVIEW && getRelayUrlFromRelayReviewEvent(e)) { |
||||||
|
setReviewEvents((prev) => dedupeRelayReviewsNewestFirst([...prev, e])) |
||||||
|
} |
||||||
|
}, |
||||||
|
firstRelayResultGraceMs: false, |
||||||
|
globalTimeout: 12_000, |
||||||
|
eoseTimeout: EXPLORE_REVIEWS_EOSE_TAIL_MS, |
||||||
|
cache: true |
||||||
|
} |
||||||
|
) |
||||||
|
if (cancelled || fetchGenRef.current !== gen) return |
||||||
|
const withRelay = raw.filter( |
||||||
|
(e) => e.kind === ExtendedKind.RELAY_REVIEW && getRelayUrlFromRelayReviewEvent(e) |
||||||
|
) |
||||||
|
setReviewEvents((prev) => dedupeRelayReviewsNewestFirst([...prev, ...withRelay])) |
||||||
|
} catch { |
||||||
|
if (!cancelled && fetchGenRef.current === gen) setReviewEvents([]) |
||||||
|
} finally { |
||||||
|
if (!cancelled && fetchGenRef.current === gen) setReviewsLoading(false) |
||||||
|
} |
||||||
|
})() |
||||||
|
|
||||||
|
return () => { |
||||||
|
cancelled = true |
||||||
|
} |
||||||
|
}, [relayInputsKey]) |
||||||
|
|
||||||
|
const reviewsByRelay = useMemo(() => groupRelayReviewsByUrl(reviewEvents), [reviewEvents]) |
||||||
|
|
||||||
|
const entries = useMemo( |
||||||
|
() => |
||||||
|
buildExploreRelayDirectory({ |
||||||
|
relayList, |
||||||
|
favoriteRelays, |
||||||
|
blockedRelays, |
||||||
|
nip66CachedUrls: nip66Cached, |
||||||
|
followingFavorites, |
||||||
|
reviewsByRelay |
||||||
|
}), |
||||||
|
[relayList, favoriteRelays, blockedRelays, nip66Cached, followingFavorites, reviewsByRelay] |
||||||
|
) |
||||||
|
|
||||||
|
const filtered = useMemo( |
||||||
|
() => filterExploreRelayDirectory(entries, listFilter), |
||||||
|
[entries, listFilter] |
||||||
|
) |
||||||
|
|
||||||
|
const visible = filtered.slice(0, showCount) |
||||||
|
const showInitialSkeleton = filtered.length === 0 && (followingLoading || reviewsLoading) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
setShowCount(SHOW_COUNT) |
||||||
|
}, [listFilter, relayInputsKey]) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const options = { root: null, rootMargin: '120px', threshold: 0 } |
||||||
|
const observer = new IntersectionObserver((entriesObs) => { |
||||||
|
if (entriesObs[0]?.isIntersecting && showCount < filtered.length) { |
||||||
|
setShowCount((prev) => prev + SHOW_COUNT) |
||||||
|
} |
||||||
|
}, options) |
||||||
|
const el = bottomRef.current |
||||||
|
if (el) observer.observe(el) |
||||||
|
return () => { |
||||||
|
if (el) observer.unobserve(el) |
||||||
|
} |
||||||
|
}, [showCount, filtered.length]) |
||||||
|
|
||||||
|
if (showInitialSkeleton) { |
||||||
|
return ( |
||||||
|
<section className="min-w-0 pb-8" aria-label={t('Relays')}> |
||||||
|
{Array.from({ length: 4 }).map((_, i) => ( |
||||||
|
<RelaySimpleInfoSkeleton key={i} className="border-b p-4" /> |
||||||
|
))} |
||||||
|
</section> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
if (filtered.length === 0) { |
||||||
|
return ( |
||||||
|
<p className="px-4 py-6 text-center text-sm text-muted-foreground"> |
||||||
|
{listFilter.trim() ? t('no relays found') : t('No relays in your lists yet.')} |
||||||
|
</p> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<section className="min-w-0 pb-8" aria-label={t('Relays')}> |
||||||
|
<p className="mb-3 px-4 text-sm text-muted-foreground"> |
||||||
|
{t('Your relays first, then those your network favors and reviews.')} |
||||||
|
</p> |
||||||
|
{visible.map((entry) => ( |
||||||
|
<ExploreRelayDirectoryCard key={entry.url} entry={entry} /> |
||||||
|
))} |
||||||
|
{reviewsLoading && entries.length > 0 ? ( |
||||||
|
<div className="px-4 py-2" aria-busy="true"> |
||||||
|
<Skeleton className="h-8 w-48" /> |
||||||
|
</div> |
||||||
|
) : null} |
||||||
|
{showCount < filtered.length ? <div ref={bottomRef} className="h-4" aria-hidden /> : null} |
||||||
|
{!followingLoading && !reviewsLoading && showCount >= filtered.length ? ( |
||||||
|
<p className="mt-3 text-center text-sm text-muted-foreground">{t('no more relays')}</p> |
||||||
|
) : null} |
||||||
|
</section> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,54 @@ |
|||||||
|
import { describe, expect, it } from 'vitest' |
||||||
|
import { buildExploreRelayDirectory, scoreExploreRelayEntry } from './explore-relay-directory' |
||||||
|
|
||||||
|
describe('scoreExploreRelayEntry', () => { |
||||||
|
it('ranks mailbox read above following-only social proof', () => { |
||||||
|
const mailboxOnly = scoreExploreRelayEntry( |
||||||
|
{ |
||||||
|
inMailboxRead: true, |
||||||
|
inMailboxWrite: false, |
||||||
|
inMailboxHttpRead: false, |
||||||
|
inUserFavorites: false, |
||||||
|
inAppDefaults: false, |
||||||
|
inFastRead: false, |
||||||
|
inNip66Cache: false |
||||||
|
}, |
||||||
|
0, |
||||||
|
0, |
||||||
|
1 |
||||||
|
) |
||||||
|
const socialOnly = scoreExploreRelayEntry( |
||||||
|
{ |
||||||
|
inMailboxRead: false, |
||||||
|
inMailboxWrite: false, |
||||||
|
inMailboxHttpRead: false, |
||||||
|
inUserFavorites: false, |
||||||
|
inAppDefaults: false, |
||||||
|
inFastRead: false, |
||||||
|
inNip66Cache: false |
||||||
|
}, |
||||||
|
25, |
||||||
|
0, |
||||||
|
1 |
||||||
|
) |
||||||
|
expect(mailboxOnly).toBeGreaterThan(socialOnly) |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
describe('buildExploreRelayDirectory', () => { |
||||||
|
it('dedupes URLs and sorts client inbox before following-only relays', () => { |
||||||
|
const relay = 'wss://inbox.example.com/' |
||||||
|
const entries = buildExploreRelayDirectory({ |
||||||
|
relayList: { read: [relay], write: [], httpRead: [] }, |
||||||
|
favoriteRelays: [], |
||||||
|
blockedRelays: [], |
||||||
|
followingFavorites: [['wss://social.example.com', ['aa', 'bb', 'cc']]], |
||||||
|
max: 50 |
||||||
|
}) |
||||||
|
expect(entries[0]?.url).toBe(relay) |
||||||
|
const social = entries.find((e) => e.url.includes('social.example.com')) |
||||||
|
expect(social).toBeDefined() |
||||||
|
expect(entries.find((e) => e.url === relay)?.favoritedBy).toEqual([]) |
||||||
|
expect(social?.favoritedBy).toHaveLength(3) |
||||||
|
}) |
||||||
|
}) |
||||||
@ -0,0 +1,188 @@ |
|||||||
|
import { DEFAULT_FAVORITE_RELAYS, FAST_READ_RELAY_URLS } from '@/constants' |
||||||
|
import { isExploreBrowsableRelayUrl } from '@/lib/explore-popular-relays' |
||||||
|
import { normalizeAnyRelayUrl } from '@/lib/url' |
||||||
|
import type { ViewerRelayListLike } from '@/lib/viewer-relay-defaults' |
||||||
|
import type { Event } from 'nostr-tools' |
||||||
|
|
||||||
|
export type ExploreRelaySourceFlags = { |
||||||
|
inMailboxRead: boolean |
||||||
|
inMailboxWrite: boolean |
||||||
|
inMailboxHttpRead: boolean |
||||||
|
inUserFavorites: boolean |
||||||
|
inAppDefaults: boolean |
||||||
|
inFastRead: boolean |
||||||
|
inNip66Cache: boolean |
||||||
|
} |
||||||
|
|
||||||
|
export type ExploreRelayEntry = { |
||||||
|
url: string |
||||||
|
score: number |
||||||
|
sourceFlags: ExploreRelaySourceFlags |
||||||
|
/** Pubkeys from people you follow who favorited this relay (most first in UI). */ |
||||||
|
favoritedBy: string[] |
||||||
|
/** Newest-first relay reviews for this URL. */ |
||||||
|
reviews: Event[] |
||||||
|
} |
||||||
|
|
||||||
|
const SCORE_MAILBOX_READ = 10_000 |
||||||
|
const SCORE_MAILBOX_WRITE = 8_000 |
||||||
|
const SCORE_MAILBOX_HTTP = 2_000 |
||||||
|
const SCORE_USER_FAVORITE = 5_000 |
||||||
|
const SCORE_PER_FOLLOWING_FAVORITER = 100 |
||||||
|
const SCORE_FOLLOWING_FAVORITERS_CAP = 2_000 |
||||||
|
const SCORE_PER_REVIEW = 50 |
||||||
|
const SCORE_REVIEWS_CAP = 500 |
||||||
|
const SCORE_STACK_BUMP = 10 |
||||||
|
|
||||||
|
export function scoreExploreRelayEntry( |
||||||
|
flags: ExploreRelaySourceFlags, |
||||||
|
followingFavoriteCount: number, |
||||||
|
reviewCount: number, |
||||||
|
stackFrequency: number |
||||||
|
): number { |
||||||
|
let score = 0 |
||||||
|
if (flags.inMailboxRead) score += SCORE_MAILBOX_READ |
||||||
|
if (flags.inMailboxWrite) score += SCORE_MAILBOX_WRITE |
||||||
|
if (flags.inMailboxHttpRead) score += SCORE_MAILBOX_HTTP |
||||||
|
if (flags.inUserFavorites) score += SCORE_USER_FAVORITE |
||||||
|
score += Math.min( |
||||||
|
followingFavoriteCount * SCORE_PER_FOLLOWING_FAVORITER, |
||||||
|
SCORE_FOLLOWING_FAVORITERS_CAP |
||||||
|
) |
||||||
|
score += Math.min(reviewCount * SCORE_PER_REVIEW, SCORE_REVIEWS_CAP) |
||||||
|
score += Math.min(stackFrequency * SCORE_STACK_BUMP, 60) |
||||||
|
return score |
||||||
|
} |
||||||
|
|
||||||
|
type MutableRelayRow = { |
||||||
|
url: string |
||||||
|
flags: ExploreRelaySourceFlags |
||||||
|
stackFrequency: number |
||||||
|
favoritedBy: string[] |
||||||
|
reviews: Event[] |
||||||
|
} |
||||||
|
|
||||||
|
export type BuildExploreRelayDirectoryOptions = { |
||||||
|
relayList: ViewerRelayListLike |
||||||
|
favoriteRelays: readonly string[] |
||||||
|
blockedRelays: readonly string[] |
||||||
|
nip66CachedUrls?: readonly string[] |
||||||
|
followingFavorites?: readonly (readonly [string, readonly string[]])[] |
||||||
|
reviewsByRelay?: ReadonlyMap<string, readonly Event[]> |
||||||
|
max?: number |
||||||
|
} |
||||||
|
|
||||||
|
function normalizeBlocked(blockedRelays: readonly string[]): Set<string> { |
||||||
|
return new Set( |
||||||
|
blockedRelays.map((b) => normalizeAnyRelayUrl(b) || b.trim()).filter(Boolean) |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
function getOrCreateRow( |
||||||
|
rows: Map<string, MutableRelayRow>, |
||||||
|
raw: string, |
||||||
|
blocked: Set<string> |
||||||
|
): MutableRelayRow | undefined { |
||||||
|
if (!isExploreBrowsableRelayUrl(raw)) return undefined |
||||||
|
const url = normalizeAnyRelayUrl(raw) || raw.trim() |
||||||
|
if (!url || blocked.has(url)) return undefined |
||||||
|
let row = rows.get(url) |
||||||
|
if (!row) { |
||||||
|
row = { |
||||||
|
url, |
||||||
|
flags: { |
||||||
|
inMailboxRead: false, |
||||||
|
inMailboxWrite: false, |
||||||
|
inMailboxHttpRead: false, |
||||||
|
inUserFavorites: false, |
||||||
|
inAppDefaults: false, |
||||||
|
inFastRead: false, |
||||||
|
inNip66Cache: false |
||||||
|
}, |
||||||
|
stackFrequency: 0, |
||||||
|
favoritedBy: [], |
||||||
|
reviews: [] |
||||||
|
} |
||||||
|
rows.set(url, row) |
||||||
|
} |
||||||
|
row.stackFrequency += 1 |
||||||
|
return row |
||||||
|
} |
||||||
|
|
||||||
|
/** Merge viewer lists, following favorites, and reviews into one scored directory. */ |
||||||
|
export function buildExploreRelayDirectory( |
||||||
|
options: BuildExploreRelayDirectoryOptions |
||||||
|
): ExploreRelayEntry[] { |
||||||
|
const blocked = normalizeBlocked(options.blockedRelays) |
||||||
|
const rows = new Map<string, MutableRelayRow>() |
||||||
|
const rl = options.relayList |
||||||
|
|
||||||
|
const touch = (raw: string, patch: Partial<ExploreRelaySourceFlags>) => { |
||||||
|
const row = getOrCreateRow(rows, raw, blocked) |
||||||
|
if (!row) return |
||||||
|
Object.assign(row.flags, patch) |
||||||
|
} |
||||||
|
|
||||||
|
for (const u of rl?.read ?? []) touch(u, { inMailboxRead: true }) |
||||||
|
for (const u of rl?.write ?? []) touch(u, { inMailboxWrite: true }) |
||||||
|
for (const u of rl?.httpRead ?? []) touch(u, { inMailboxHttpRead: true }) |
||||||
|
for (const u of options.favoriteRelays) touch(u, { inUserFavorites: true }) |
||||||
|
for (const u of DEFAULT_FAVORITE_RELAYS) touch(u, { inAppDefaults: true }) |
||||||
|
for (const u of FAST_READ_RELAY_URLS) touch(u, { inFastRead: true }) |
||||||
|
for (const u of options.nip66CachedUrls ?? []) touch(u, { inNip66Cache: true }) |
||||||
|
|
||||||
|
for (const [raw, pubkeys] of options.followingFavorites ?? []) { |
||||||
|
const row = getOrCreateRow(rows, raw, blocked) |
||||||
|
if (!row) continue |
||||||
|
const seen = new Set(row.favoritedBy) |
||||||
|
for (const pk of pubkeys) { |
||||||
|
if (!pk || seen.has(pk)) continue |
||||||
|
seen.add(pk) |
||||||
|
row.favoritedBy.push(pk) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
for (const [raw, events] of options.reviewsByRelay ?? []) { |
||||||
|
const row = getOrCreateRow(rows, raw, blocked) |
||||||
|
if (!row || !events.length) continue |
||||||
|
row.reviews = [...events] |
||||||
|
} |
||||||
|
|
||||||
|
const entries: ExploreRelayEntry[] = [] |
||||||
|
for (const row of rows.values()) { |
||||||
|
const followingCount = row.favoritedBy.length |
||||||
|
const reviewCount = row.reviews.length |
||||||
|
entries.push({ |
||||||
|
url: row.url, |
||||||
|
sourceFlags: row.flags, |
||||||
|
favoritedBy: row.favoritedBy, |
||||||
|
reviews: row.reviews, |
||||||
|
score: scoreExploreRelayEntry(row.flags, followingCount, reviewCount, row.stackFrequency) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
entries.sort((a, b) => { |
||||||
|
if (b.score !== a.score) return b.score - a.score |
||||||
|
if (b.favoritedBy.length !== a.favoritedBy.length) { |
||||||
|
return b.favoritedBy.length - a.favoritedBy.length |
||||||
|
} |
||||||
|
if (b.reviews.length !== a.reviews.length) return b.reviews.length - a.reviews.length |
||||||
|
return a.url.localeCompare(b.url) |
||||||
|
}) |
||||||
|
|
||||||
|
const max = options.max ?? 200 |
||||||
|
return entries.slice(0, max) |
||||||
|
} |
||||||
|
|
||||||
|
/** Case-insensitive filter for the directory list (URL / simplified host). */ |
||||||
|
export function filterExploreRelayDirectory( |
||||||
|
entries: ExploreRelayEntry[], |
||||||
|
rawQuery: string |
||||||
|
): ExploreRelayEntry[] { |
||||||
|
const q = rawQuery.trim().toLowerCase() |
||||||
|
if (!q) return entries |
||||||
|
return entries.filter((e) => { |
||||||
|
const n = e.url.toLowerCase() |
||||||
|
return n.includes(q) || n.replace(/^wss?:\/\//, '').includes(q) |
||||||
|
}) |
||||||
|
} |
||||||
@ -0,0 +1,53 @@ |
|||||||
|
import { ExtendedKind } from '@/constants' |
||||||
|
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' |
||||||
|
import { getRelayUrlFromRelayReviewEvent } from '@/lib/event-metadata' |
||||||
|
import { isExploreBrowsableRelayUrl } from '@/lib/explore-popular-relays' |
||||||
|
import client from '@/services/client.service' |
||||||
|
import indexedDb, { StoreNames } from '@/services/indexed-db.service' |
||||||
|
import type { Event } from 'nostr-tools' |
||||||
|
|
||||||
|
export function dedupeRelayReviewsNewestFirst(events: Event[]): Event[] { |
||||||
|
const sorted = [...events].sort((a, b) => b.created_at - a.created_at) |
||||||
|
const seen = new Set<string>() |
||||||
|
const out: Event[] = [] |
||||||
|
for (const evt of sorted) { |
||||||
|
const key = isReplaceableEvent(evt.kind) ? getReplaceableCoordinateFromEvent(evt) : evt.id |
||||||
|
if (seen.has(key)) continue |
||||||
|
seen.add(key) |
||||||
|
out.push(evt) |
||||||
|
} |
||||||
|
return out |
||||||
|
} |
||||||
|
|
||||||
|
export async function loadCachedRelayReviews(limit: number): Promise<Event[]> { |
||||||
|
const fromSession = client |
||||||
|
.getSessionEventsMatchingSearch('', Math.max(limit * 2, 200), [ExtendedKind.RELAY_REVIEW]) |
||||||
|
.filter((e) => e.kind === ExtendedKind.RELAY_REVIEW && !!getRelayUrlFromRelayReviewEvent(e)) |
||||||
|
if (fromSession.length >= limit) { |
||||||
|
return dedupeRelayReviewsNewestFirst(fromSession).slice(0, limit) |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const archiveRows = await indexedDb.getStoreItems(StoreNames.EVENT_ARCHIVE) |
||||||
|
const fromArchive = archiveRows |
||||||
|
.map((row) => row?.value as Event | undefined) |
||||||
|
.filter( |
||||||
|
(e): e is Event => |
||||||
|
!!e && e.kind === ExtendedKind.RELAY_REVIEW && !!getRelayUrlFromRelayReviewEvent(e) |
||||||
|
) |
||||||
|
return dedupeRelayReviewsNewestFirst([...fromSession, ...fromArchive]).slice(0, limit) |
||||||
|
} catch { |
||||||
|
return dedupeRelayReviewsNewestFirst(fromSession).slice(0, limit) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function groupRelayReviewsByUrl(events: Event[]): Map<string, Event[]> { |
||||||
|
const groups = new Map<string, Event[]>() |
||||||
|
for (const event of dedupeRelayReviewsNewestFirst(events)) { |
||||||
|
const url = getRelayUrlFromRelayReviewEvent(event) |
||||||
|
if (!url || !isExploreBrowsableRelayUrl(url)) continue |
||||||
|
if (!groups.has(url)) groups.set(url, []) |
||||||
|
groups.get(url)!.push(event) |
||||||
|
} |
||||||
|
return groups |
||||||
|
} |
||||||
Loading…
Reference in new issue