From 318baf64b53434c6a60d06859175920f0fb656f7 Mon Sep 17 00:00:00 2001 From: Priyanshubhartistm Date: Fri, 17 Apr 2026 18:54:05 +0530 Subject: [PATCH 1/3] Implement NIP-85 fallback WOT ranking --- .env | 1 + .env.example | 24 ++++++++ README.md | 3 +- src/app/components/WotScore.svelte | 86 +++++++++++++++++++++----- src/app/core/state.ts | 11 ---- src/app/util/wot/getPubkeyRank.ts | 96 ++++++++++++++++++++++++++++++ src/routes/people/+page.svelte | 31 +++++++--- 7 files changed, 218 insertions(+), 34 deletions(-) create mode 100644 .env.example create mode 100644 src/app/util/wot/getPubkeyRank.ts diff --git a/.env b/.env index 517b5fd2..d08e5793 100644 --- a/.env +++ b/.env @@ -14,6 +14,7 @@ VITE_PUSH_SERVER=https://nps.flotilla.social/ VITE_PUSH_BRIDGE=wss://npb.coracle.social/ VITE_BLOCKED_RELAYS=brb.io,relay.nostr.band,nostr.mutinywallet.com,feeds.nostr.band,nostr.zbd.gg,wot.utxo.one,blastr.f7z.xyz,relay.current.fyi VITE_INDEXER_RELAYS=purplepag.es,relay.damus.io,indexer.coracle.social +VITE_ASSERTION_RELAYS=nip85.brainstorm.world,nip85.nosfabrica.com,nip85.uid.ovh VITE_DEFAULT_RELAYS=relay.damus.io,relay.primal.net,nostr.mom VITE_DEFAULT_SEARCH_RELAYS=relay.ditto.pub,antiprimal.net,relay.vertexlab.io VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com,relay.keychat.io,relay.ditto.pub diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..d4f9d10b --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +VITE_DEFAULT_PUBKEYS=06639a386c9c1014217622ccbcf40908c4f1a0c33e23f8d6d68f4abf655f8f71,266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5,391819e2f2f13b90cac7209419eb574ef7c0d1f4e81867fc24c47a3ce5e8a248,3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d,3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24,55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185,58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196,61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9,6389be6491e7b693e9f368ece88fcd145f07c068d2c1bbae4247b9b5ef439d32,63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed,6e75f7972397ca3295e0f4ca0fbc6eb9cc79be85bafdd56bd378220ca8eee74e,76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa,7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194,82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2,84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240,97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322,b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0,dace63b00c42e6e017d00dd190a9328386002ff597b841eb5ef91de4f1ce8491,eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e,fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3 +VITE_DEFAULT_BLOSSOM_SERVERS=https://blossom.primal.net/ +VITE_POMADE_SIGNERS=https://pomade.coracle.social,https://pomade.fiatjaf.com,https://pomade.nostrver.se,https://pomade.scuttle.works +VITE_PLATFORM_URL=https://app.flotilla.social +VITE_PLATFORM_TERMS=https://flotilla.social/terms +VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy +VITE_PLATFORM_NAME=Flotilla +VITE_PLATFORM_LOGO=static/logo.png +VITE_PLATFORM_RELAYS= +VITE_PLATFORM_ACCENT="#7161FF" +VITE_PLATFORM_SECONDARY="#EB5E28" +VITE_PLATFORM_DESCRIPTION="Flotilla is nostr - for communities." +VITE_PUSH_SERVER=https://nps.flotilla.social/ +VITE_PUSH_BRIDGE=wss://npb.coracle.social/ +VITE_BLOCKED_RELAYS=brb.io,relay.nostr.band,nostr.mutinywallet.com,feeds.nostr.band,nostr.zbd.gg,wot.utxo.one,blastr.f7z.xyz,relay.current.fyi +VITE_INDEXER_RELAYS=purplepag.es,relay.damus.io,indexer.coracle.social +VITE_ASSERTION_RELAYS=nip85.brainstorm.world,nip85.nosfabrica.com,nip85.uid.ovh +VITE_DEFAULT_RELAYS=relay.damus.io,relay.primal.net,nostr.mom +VITE_DEFAULT_SEARCH_RELAYS=relay.ditto.pub,antiprimal.net,relay.vertexlab.io +VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com,relay.keychat.io,relay.ditto.pub +VITE_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social +VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y +VITE_GLITCHTIP_API_KEY= +GLITCHTIP_AUTH_TOKEN= \ No newline at end of file diff --git a/README.md b/README.md index 6092fd32..aac85bc0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ + # Flotilla A discord-like nostr client based on the idea of "relays as groups". @@ -8,11 +9,11 @@ If you would like to be interoperable with Flotilla, please check out this guide You can also optionally create an `.env.local` file and populate it with the following environment variables (see `.env.template` for examples): -- `VITE_DEFAULT_PUBKEYS` - A comma-separated list of hex pubkeys for bootstrapping web of trust - `VITE_PLATFORM_URL` - The url where the app will be hosted - `VITE_PLATFORM_NAME` - The name of the app - `VITE_PLATFORM_LOGO` - A logo url for the app. Can be a local path or https link. Must be a PNG file. - `VITE_PLATFORM_RELAYS` - A list of comma-separated relay urls that will make flotilla operate in "platform mode". Disables all space browse/add/select functionality and makes the first platform relay the home page. +- `VITE_ASSERTION_RELAYS` - A list of comma-separated relays used to fetch NIP-85 assertion ranks (kind 30382) for fallback trust scores. - `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color - `VITE_PLATFORM_DESCRIPTION` - A description of the app diff --git a/src/app/components/WotScore.svelte b/src/app/components/WotScore.svelte index 23a22bbc..c07552fb 100644 --- a/src/app/components/WotScore.svelte +++ b/src/app/components/WotScore.svelte @@ -1,21 +1,35 @@ -
- +
+ + + {Math.round(score)} +
diff --git a/src/app/core/state.ts b/src/app/core/state.ts index 0630385d..b0b07d86 100644 --- a/src/app/core/state.ts +++ b/src/app/core/state.ts @@ -18,7 +18,6 @@ import { uniq, indexBy, partition, - shuffle, parseJson, memoize, addToMapKey, @@ -139,7 +138,6 @@ import { tracker, createSearch, userMuteList, - userFollowList, ensurePlaintext, makeOutboxLoader, appContext, @@ -205,8 +203,6 @@ export const POMADE_SIGNERS = fromCsv(import.meta.env.VITE_POMADE_SIGNERS) export const DEFAULT_BLOSSOM_SERVERS = fromCsv(import.meta.env.VITE_DEFAULT_BLOSSOM_SERVERS) -export const DEFAULT_PUBKEYS = import.meta.env.VITE_DEFAULT_PUBKEYS - export const DUFFLEPUD_URL = "https://dufflepud.onrender.com" export const THUMBNAIL_URL = import.meta.env.VITE_THUMBNAIL_URL @@ -257,13 +253,6 @@ export const entityLink = (entity: string) => `https://coracle.social/${entity}` export const pubkeyLink = (pubkey: string, relays = Router.get().FromPubkeys([pubkey]).getUrls()) => entityLink(nip19.nprofileEncode({pubkey, relays})) -export const bootstrapPubkeys = derived(userFollowList, $userFollowList => { - const appPubkeys = DEFAULT_PUBKEYS.split(",") - const userPubkeys = shuffle(getPubkeyTagValues(getListTags($userFollowList))) - - return userPubkeys.length > 5 ? userPubkeys : [...userPubkeys, ...appPubkeys] -}) - export const deriveEvent = makeDeriveEvent({ repository, includeDeleted: true, diff --git a/src/app/util/wot/getPubkeyRank.ts b/src/app/util/wot/getPubkeyRank.ts new file mode 100644 index 00000000..5ada634e --- /dev/null +++ b/src/app/util/wot/getPubkeyRank.ts @@ -0,0 +1,96 @@ +import {request} from "@welshman/net" +import {clamp} from "@welshman/lib" +import type {TrustedEvent} from "@welshman/util" + +const DEFAULT_ASSERTION_RELAYS = + "wss://nip85.brainstorm.world,wss://nip85.nosfabrica.com,wss://nip85.uid.ovh" + +const toAssertionRelayUrl = (url: string) => { + const trimmed = url.trim() + + if (!trimmed) return "" + if (trimmed.startsWith("http://")) return trimmed.replace(/^http:/, "ws:") + if (trimmed.startsWith("https://")) return trimmed.replace(/^https:/, "wss:") + if (!/^[a-z]+:\/\//i.test(trimmed)) return `wss://${trimmed}` + + return trimmed +} + +const ASSERTION_RELAYS = String(import.meta.env.VITE_ASSERTION_RELAYS || DEFAULT_ASSERTION_RELAYS) + .split(",") + .map(toAssertionRelayUrl) + .filter(Boolean) + +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 +} + +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) +} + +const fetchPubkeyRank = async (pubkey: string): Promise => { + if (ASSERTION_RELAYS.length === 0) { + return 50 + } + + try { + const events = await request({ + relays: ASSERTION_RELAYS, + autoClose: true, + filters: [{kinds: [30382], "#d": [pubkey], limit: 8}], + }) + + const ranks = events.map(getRankFromEvent).filter((rank): rank is number => rank !== null) + + if (ranks.length === 0) { + return 50 + } + + const median = getMedian(ranks) + + return median + } catch { + return 50 + } +} + +export const getPubkeyRank = (pubkey: string): Promise => { + if (rankCache.has(pubkey)) { + return Promise.resolve(rankCache.get(pubkey) ?? 50) + } + + const pending = pendingRankRequests.get(pubkey) + + if (pending) { + return pending + } + + const requestPromise = fetchPubkeyRank(pubkey).then(rank => { + rankCache.set(pubkey, rank ?? 50) + pendingRankRequests.delete(pubkey) + + return rank ?? 50 + }) + + pendingRankRequests.set(pubkey, requestPromise) + + return requestPromise +} diff --git a/src/routes/people/+page.svelte b/src/routes/people/+page.svelte index 66d90acf..57631c04 100644 --- a/src/routes/people/+page.svelte +++ b/src/routes/people/+page.svelte @@ -8,19 +8,36 @@ import Page from "@lib/components/Page.svelte" import ContentSearch from "@lib/components/ContentSearch.svelte" import PeopleItem from "@app/components/PeopleItem.svelte" - import {bootstrapPubkeys} from "@app/core/state" + import {getPubkeyRank} from "@app/util/wot/getPubkeyRank" + + const FALLBACK_RANK = 50 let term = $state("") let limit = $state(10) - let pubkeys = $state($bootstrapPubkeys) + let pubkeys = $state([]) let element: Element | undefined = $state() + let requestId = 0 + + const rankPubkeys = async (items: string[]) => { + const currentRequestId = ++requestId + + pubkeys = items + + const scoredPubkeys = await Promise.all( + items.map(async pubkey => ({pubkey, rank: (await getPubkeyRank(pubkey)) ?? FALLBACK_RANK})), + ) + + if (currentRequestId !== requestId) { + return + } + + pubkeys = scoredPubkeys.sort((a, b) => b.rank - a.rank).map(item => item.pubkey) + } const search = debounce(200, (term: string) => { - if (term) { - pubkeys = $profileSearch.searchValues(term) - } else { - pubkeys = $bootstrapPubkeys - } + const searchTerm = term.trim() + + void rankPubkeys($profileSearch.searchValues(searchTerm)) }) $effect(() => search(term)) -- 2.52.0 From c39292ae1c603b410701f1354f11c10783bbc327 Mon Sep 17 00:00:00 2001 From: Priyanshubhartistm Date: Fri, 17 Apr 2026 20:35:10 +0530 Subject: [PATCH 2/3] Fix people rank sorting stability --- src/routes/people/+page.svelte | 49 +++++++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/src/routes/people/+page.svelte b/src/routes/people/+page.svelte index 57631c04..651ee3ef 100644 --- a/src/routes/people/+page.svelte +++ b/src/routes/people/+page.svelte @@ -2,7 +2,7 @@ import {onMount} from "svelte" import {debounce} from "throttle-debounce" import {createScroller, isMobile} from "@lib/html" - import {profileSearch} from "@welshman/app" + import {displayProfileByPubkey, profileSearch} from "@welshman/app" import Magnifier from "@assets/icons/magnifier.svg?dataurl" import Icon from "@lib/components/Icon.svelte" import Page from "@lib/components/Page.svelte" @@ -17,21 +17,50 @@ let pubkeys = $state([]) let element: Element | undefined = $state() let requestId = 0 + const rankByPubkey = new Map() + + const sortPubkeys = (items: string[]) => { + const indexed = items.map((pubkey, index) => ({ + pubkey, + index, + rank: rankByPubkey.get(pubkey) ?? FALLBACK_RANK, + name: displayProfileByPubkey(pubkey).toLowerCase(), + })) + + indexed.sort((a, b) => { + if (b.rank !== a.rank) { + return b.rank - a.rank + } + + const byName = a.name.localeCompare(b.name) + + if (byName !== 0) { + return byName + } + + return a.index - b.index + }) + + return indexed.map(item => item.pubkey) + } const rankPubkeys = async (items: string[]) => { const currentRequestId = ++requestId - pubkeys = items + pubkeys = sortPubkeys(items) - const scoredPubkeys = await Promise.all( - items.map(async pubkey => ({pubkey, rank: (await getPubkeyRank(pubkey)) ?? FALLBACK_RANK})), + await Promise.all( + items.map(async pubkey => { + const rank = (await getPubkeyRank(pubkey)) ?? FALLBACK_RANK + + if (currentRequestId !== requestId) { + return + } + + rankByPubkey.set(pubkey, rank) + pubkeys = sortPubkeys(items) + }), ) - - if (currentRequestId !== requestId) { - return - } - - pubkeys = scoredPubkeys.sort((a, b) => b.rank - a.rank).map(item => item.pubkey) } const search = debounce(200, (term: string) => { -- 2.52.0 From 9dc247641fc6f9543c658b54c1269bdb0c80438d Mon Sep 17 00:00:00 2001 From: Priyanshubhartistm Date: Sun, 19 Apr 2026 18:29:21 +0530 Subject: [PATCH 3/3] fix: switch fallback score calc to NIP-11 + NIP-50 --- src/app/util/wot/getPubkeyRank.ts | 536 +++++++++++++++++++++++++++--- 1 file changed, 481 insertions(+), 55 deletions(-) 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 } -- 2.52.0