You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
188 lines
5.8 KiB
188 lines
5.8 KiB
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) |
|
}) |
|
}
|
|
|