11 changed files with 765 additions and 147 deletions
@ -0,0 +1,314 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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