diff --git a/src/app/util/wot/getPubkeyRank.ts b/src/app/util/wot/getPubkeyRank.ts index 5ada634e..ec4c1e07 100644 --- a/src/app/util/wot/getPubkeyRank.ts +++ b/src/app/util/wot/getPubkeyRank.ts @@ -1,11 +1,26 @@ import {request} from "@welshman/net" import {clamp} from "@welshman/lib" -import type {TrustedEvent} from "@welshman/util" +import {nip19} from "nostr-tools" +import {getProfile} from "@welshman/app" +import type {Filter, TrustedEvent} from "@welshman/util" -const DEFAULT_ASSERTION_RELAYS = - "wss://nip85.brainstorm.world,wss://nip85.nosfabrica.com,wss://nip85.uid.ovh" +const DEFAULT_EXTENDED_SEARCH_RELAYS = [ + import.meta.env.VITE_DEFAULT_SEARCH_RELAYS, + "relay.noswhere.com,search.nos.today,nostr.wine", +] + .filter(Boolean) + .join(",") -const toAssertionRelayUrl = (url: string) => { +const DEFAULT_SCORE = 50 +const SEARCH_LIMIT = 40 +const MAX_PROFILE_TERMS = 3 +const MIN_PROFILE_TERM_LENGTH = 3 +const REQUEST_THRESHOLD = 0.5 +const PROFILE_LOOKUP_TIMEOUT_MS = 6_000 +const RELAY_SEARCH_TIMEOUT_MS = 6_000 +const NIP11_TIMEOUT_MS = 4_000 + +const toRelayUrl = (url: string) => { const trimmed = url.trim() if (!trimmed) return "" @@ -16,81 +31,492 @@ const toAssertionRelayUrl = (url: string) => { return trimmed } -const ASSERTION_RELAYS = String(import.meta.env.VITE_ASSERTION_RELAYS || DEFAULT_ASSERTION_RELAYS) +const toNip11Url = (url: string) => { + const normalized = toRelayUrl(url) + + if (normalized.startsWith("wss://")) return normalized.replace(/^wss:/, "https:") + if (normalized.startsWith("ws://")) return normalized.replace(/^ws:/, "http:") + + return normalized +} + +const EXTENDED_SEARCH_RELAYS = String( + import.meta.env.VITE_EXTENDED_SEARCH_RELAYS || DEFAULT_EXTENDED_SEARCH_RELAYS, +) .split(",") - .map(toAssertionRelayUrl) + .map(toRelayUrl) .filter(Boolean) + .filter((url, index, urls) => urls.indexOf(url) === index) -const rankCache = new Map() -const pendingRankRequests = new Map>() - -const getMedian = (values: number[]) => { - const sorted = [...values].sort((a, b) => a - b) - const middle = Math.floor(sorted.length / 2) - - if (sorted.length % 2 === 1) { - return sorted[middle] - } - - return (sorted[middle - 1] + sorted[middle]) / 2 +type Nip11RelayInfo = { + supportsNip50: boolean + nip50Extensions: string[] } -const getRankFromEvent = (event: TrustedEvent) => { - const rankTag = event.tags?.find((tag: string[]) => tag[0] === "rank") - const rankValue = rankTag ? Number(rankTag[1]) : null - - if (rankValue === null || !Number.isFinite(rankValue)) { - return null - } - - return clamp([0, 100], rankValue) +type RelaySearchInfo = { + relay: string + extensions: string[] } -const fetchPubkeyRank = async (pubkey: string): Promise => { - if (ASSERTION_RELAYS.length === 0) { - return 50 +type ScoreResult = { + score: number + index: number + relayHits: number + activeRelays: number + interleavedCount: number +} + +type TimedRequestOptions = { + relays: string[] + filters: Filter[] + timeout?: number +} + +const rankCache = new Map() +const pendingRankRequests = new Map>() +const relayInfoCache = new Map>() + +const toStringArray = (value: unknown): string[] => { + if (Array.isArray(value)) { + return value + .filter(item => typeof item === "string" || typeof item === "number") + .map(item => String(item)) + } + + if (typeof value === "string") { + return [value] + } + + if (value && typeof value === "object") { + return Object.keys(value) + } + + return [] +} + +const isHexPubkey = (value: string) => /^[0-9a-f]{64}$/i.test(value) + +const normalizePubkey = (value: string) => { + const normalized = value.trim().toLowerCase() + + if (!normalized) { + return "" + } + + if (isHexPubkey(normalized)) { + return normalized } try { - const events = await request({ - relays: ASSERTION_RELAYS, - autoClose: true, - filters: [{kinds: [30382], "#d": [pubkey], limit: 8}], - }) + const decoded = nip19.decode(normalized) - const ranks = events.map(getRankFromEvent).filter((rank): rank is number => rank !== null) - - if (ranks.length === 0) { - return 50 + if (decoded.type === "npub" && typeof decoded.data === "string" && isHexPubkey(decoded.data)) { + return decoded.data.toLowerCase() } - - const median = getMedian(ranks) - - return median } catch { - return 50 + // Ignore decode errors and return an empty key for invalid values. + } + + return "" +} + +const requestWithTimeout = async ({ + relays, + filters, + timeout = RELAY_SEARCH_TIMEOUT_MS, +}: TimedRequestOptions): Promise => { + if (relays.length === 0) { + return [] + } + + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), timeout) + + try { + return await request({ + relays, + autoClose: true, + threshold: REQUEST_THRESHOLD, + signal: controller.signal, + filters, + }) + } catch { + return [] + } finally { + clearTimeout(timeoutId) } } -export const getPubkeyRank = (pubkey: string): Promise => { - if (rankCache.has(pubkey)) { - return Promise.resolve(rankCache.get(pubkey) ?? 50) +const normalizeSearchTerm = (value: unknown): string => { + if (typeof value !== "string") { + return "" } - const pending = pendingRankRequests.get(pubkey) + return value.trim().replace(/\s+/g, " ") +} + +const dedupeProfileTerms = (terms: string[]) => { + const seen = new Set() + + return terms.filter(term => { + if (term.length < MIN_PROFILE_TERM_LENGTH) { + return false + } + + const key = term.toLowerCase() + + if (seen.has(key)) { + return false + } + + seen.add(key) + + return true + }) +} + +const getTermsFromProfileRecord = (profile: Record) => { + const terms = [ + normalizeSearchTerm(profile.name), + normalizeSearchTerm(profile.display_name), + normalizeSearchTerm(profile.nip05), + ] + + const nip05 = normalizeSearchTerm(profile.nip05) + + if (nip05.includes("@")) { + terms.push(normalizeSearchTerm(nip05.split("@")[0])) + } + + return dedupeProfileTerms(terms).slice(0, MAX_PROFILE_TERMS) +} + +const parseSupportedNips = (nip11: Record) => + [...toStringArray(nip11.supported_nips), ...toStringArray(nip11.supportedNips)] + .map(nip => nip.trim()) + .filter(Boolean) + .filter((nip, index, nips) => nips.indexOf(nip) === index) + +const parseNip50Extensions = (nip11: Record) => { + const extensions = [ + ...toStringArray(nip11.nip50), + ...toStringArray(nip11.nip_50), + ...toStringArray(nip11["nip-50"]), + ] + + if (nip11.extensions && typeof nip11.extensions === "object") { + const extensionObject = nip11.extensions as Record + + extensions.push(...toStringArray(extensionObject.nip50)) + extensions.push(...toStringArray(extensionObject.nip_50)) + extensions.push(...toStringArray(extensionObject["nip-50"])) + } + + return extensions + .map(extension => extension.trim()) + .filter(Boolean) + .filter((extension, index, items) => items.indexOf(extension) === index) +} + +const fetchRelayInfo = async (relay: string): Promise => { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), NIP11_TIMEOUT_MS) + + try { + const response = await fetch(toNip11Url(relay), { + headers: {Accept: "application/nostr+json"}, + signal: controller.signal, + }) + + if (!response.ok) { + return {supportsNip50: false, nip50Extensions: []} + } + + const nip11 = (await response.json()) as Record + const supportedNips = parseSupportedNips(nip11) + const nip50Extensions = parseNip50Extensions(nip11) + + return { + supportsNip50: supportedNips.includes("50") || nip50Extensions.length > 0, + nip50Extensions, + } + } catch { + return {supportsNip50: false, nip50Extensions: []} + } finally { + clearTimeout(timeoutId) + } +} + +const getRelayInfo = (relay: string) => { + const pending = relayInfoCache.get(relay) if (pending) { return pending } - const requestPromise = fetchPubkeyRank(pubkey).then(rank => { - rankCache.set(pubkey, rank ?? 50) - pendingRankRequests.delete(pubkey) + const requestPromise = fetchRelayInfo(relay) - return rank ?? 50 - }) - - pendingRankRequests.set(pubkey, requestPromise) + relayInfoCache.set(relay, requestPromise) + + return requestPromise +} + +const getNip50Relays = async (): Promise => { + if (EXTENDED_SEARCH_RELAYS.length === 0) { + return [] + } + + const relayInfo = await Promise.all( + EXTENDED_SEARCH_RELAYS.map(async relay => { + const info = await getRelayInfo(relay) + + if (!info.supportsNip50) { + return + } + + return {relay, extensions: info.nip50Extensions} + }), + ) + + const supportedRelays = relayInfo.filter( + (info): info is RelaySearchInfo => Boolean(info && info.extensions) && Boolean(info?.relay), + ) + + if (supportedRelays.length > 0) { + return supportedRelays + } + + // Some relays block cross-origin NIP-11 fetches in browser contexts. + // If that happens, still run NIP-50 requests against configured relays. + return EXTENDED_SEARCH_RELAYS.map(relay => ({relay, extensions: []})) +} + +const dedupeByPubkey = (events: TrustedEvent[]) => { + const seen = new Set() + const deduped: TrustedEvent[] = [] + + for (const event of events) { + if (seen.has(event.pubkey)) { + continue + } + + seen.add(event.pubkey) + deduped.push(event) + } + + return deduped +} + +const interleaveByPubkey = (eventLists: TrustedEvent[][]) => { + const offsets = new Array(eventLists.length).fill(0) + const seen = new Set() + const interleaved: TrustedEvent[] = [] + let added = true + + while (added) { + added = false + + for (let index = 0; index < eventLists.length; index += 1) { + const events = eventLists[index] + + while (offsets[index] < events.length) { + const event = events[offsets[index]] + offsets[index] += 1 + + if (seen.has(event.pubkey)) { + continue + } + + seen.add(event.pubkey) + interleaved.push(event) + added = true + break + } + } + } + + return interleaved +} + +const getSearchQuery = (pubkey: string, extensions: string[]) => { + const hasExactPhraseMatch = extensions.some(extension => + extension.toLowerCase().includes("exact-phrase"), + ) + + if (hasExactPhraseMatch) { + return `"${pubkey}"` + } + + return pubkey +} + +const getProfileSearchTerms = async (pubkey: string, relays: string[]) => { + const localProfile = getProfile(pubkey) as Record | undefined + const localTerms = localProfile ? getTermsFromProfileRecord(localProfile) : [] + + if (localTerms.length >= MAX_PROFILE_TERMS || relays.length === 0) { + return localTerms.slice(0, MAX_PROFILE_TERMS) + } + + const relayResults = await Promise.all( + relays.map(relay => + requestWithTimeout({ + relays: [relay], + timeout: PROFILE_LOOKUP_TIMEOUT_MS, + filters: [{kinds: [0], authors: [pubkey], limit: 1}], + }), + ), + ) + + const newestFirst = relayResults.flat().sort((a, b) => b.created_at - a.created_at) + + for (const event of newestFirst) { + try { + const profile = JSON.parse(event.content || "{}") as Record + const mergedTerms = dedupeProfileTerms([...localTerms, ...getTermsFromProfileRecord(profile)]) + + if (mergedTerms.length > 0) { + return mergedTerms.slice(0, MAX_PROFILE_TERMS) + } + } catch { + continue + } + } + + return localTerms.slice(0, MAX_PROFILE_TERMS) +} + +const getSearchTerms = (pubkey: string, extensions: string[], profileTerms: string[]) => { + const terms = [...profileTerms, getSearchQuery(pubkey, extensions)] + + try { + terms.push(nip19.npubEncode(pubkey)) + } catch { + // If pubkey is malformed, keep using the raw search term only. + } + + return terms.filter((term, index, items) => Boolean(term) && items.indexOf(term) === index) +} + +const fetchRelayResults = async ( + relay: string, + pubkey: string, + extensions: string[], + profileTerms: string[], +): Promise => { + const terms = getSearchTerms(pubkey, extensions, profileTerms) + + const results = await Promise.all( + terms.map(async term => { + const filters: Filter[] = [{kinds: [0], search: term, limit: SEARCH_LIMIT}] + + return requestWithTimeout({ + relays: [relay], + filters, + }) + }), + ) + + return dedupeByPubkey(results.flat()) +} + +const getScoreFromResults = (pubkey: string, relayResults: TrustedEvent[][]): ScoreResult => { + if (relayResults.length === 0) { + return { + score: DEFAULT_SCORE, + index: -1, + relayHits: 0, + activeRelays: 0, + interleavedCount: 0, + } + } + + const activeRelays = relayResults.filter(results => results.length > 0).length + const interleaved = interleaveByPubkey(relayResults) + + if (activeRelays === 0 || interleaved.length === 0) { + return { + score: DEFAULT_SCORE, + index: -1, + relayHits: 0, + activeRelays, + interleavedCount: 0, + } + } + + const index = interleaved.findIndex(event => event.pubkey === pubkey) + const relayHits = relayResults.filter(results => + results.some(event => event.pubkey === pubkey), + ).length + const coverage = relayHits / Math.max(1, activeRelays) + const position = index === -1 ? 0 : 1 - index / Math.max(1, interleaved.length - 1) + const weightedScore = (coverage * 0.55 + position * 0.45) * 100 + + return { + score: clamp([0, 100], Math.round(weightedScore)), + index, + relayHits, + activeRelays, + interleavedCount: interleaved.length, + } +} + +const fetchPubkeyRank = async (inputPubkey: string): Promise => { + const pubkey = normalizePubkey(inputPubkey) + + if (!pubkey) { + return DEFAULT_SCORE + } + + const relays = await getNip50Relays() + + if (relays.length === 0) { + return DEFAULT_SCORE + } + + const profileTerms = await getProfileSearchTerms( + pubkey, + relays.map(({relay}) => relay), + ) + + const relayResults = await Promise.all( + relays.map(({relay, extensions}) => fetchRelayResults(relay, pubkey, extensions, profileTerms)), + ) + + const scoreResult = getScoreFromResults(pubkey, relayResults) + + console.log(`[Score Calc] Pubkey: ${pubkey} → Score: ${scoreResult.score}`) + + return scoreResult.score +} + +export const getPubkeyRank = (pubkey: string): Promise => { + const normalizedPubkey = normalizePubkey(pubkey) + + if (!normalizedPubkey) { + return Promise.resolve(DEFAULT_SCORE) + } + + const cached = rankCache.get(normalizedPubkey) + + if (cached !== undefined) { + return Promise.resolve(cached) + } + + const pending = pendingRankRequests.get(normalizedPubkey) + + if (pending) { + return pending + } + + const requestPromise = fetchPubkeyRank(normalizedPubkey).then(rank => { + rankCache.set(normalizedPubkey, rank) + pendingRankRequests.delete(normalizedPubkey) + + return rank + }) + + pendingRankRequests.set(normalizedPubkey, requestPromise) return requestPromise }