Implement NIP-85 fallback WOT ranking #221
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
|
||||
@@ -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.
|
||||
|
hodlbod
commented
Incorrect, we are not using nip 85 Incorrect, we are not using nip 85
|
||||
- `VITE_PLATFORM_ACCENT` - A hex color for the app's accent color
|
||||
- `VITE_PLATFORM_DESCRIPTION` - A description of the app
|
||||
|
||||
|
||||
@@ -1,21 +1,35 @@
|
||||
<style>
|
||||
.wot-background {
|
||||
fill: transparent;
|
||||
stroke: var(--color-base-content);
|
||||
opacity: 30%;
|
||||
stroke: var(--base-300);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wot-highlight {
|
||||
fill: transparent;
|
||||
stroke-width: 1.5;
|
||||
stroke-dasharray: 100 100;
|
||||
stroke-linecap: round;
|
||||
transform-origin: center;
|
||||
transition:
|
||||
stroke-dashoffset 160ms ease,
|
||||
stroke 160ms ease,
|
||||
stroke-width 160ms ease;
|
||||
}
|
||||
|
||||
.wot-score {
|
||||
font-size: 8px;
|
||||
font-weight: 800;
|
||||
fill: var(--base-content);
|
||||
paint-order: stroke;
|
||||
stroke: var(--base-100);
|
||||
stroke-width: 1.5px;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {clamp} from "@welshman/lib"
|
||||
import {pubkey, getFollows, deriveUserWotScore} from "@welshman/app"
|
||||
import {getPubkeyRank} from "@app/util/wot/getPubkeyRank"
|
||||
|
||||
interface Props {
|
||||
pubkey: string
|
||||
@@ -24,27 +38,69 @@
|
||||
const {pubkey: target}: Props = $props()
|
||||
|
||||
const max = 100
|
||||
const radius = 6
|
||||
const center = radius + 1
|
||||
const size = 22
|
||||
const radius = 8.25
|
||||
const center = size / 2
|
||||
const circumference = 2 * Math.PI * radius
|
||||
|
||||
const score = deriveUserWotScore(target)
|
||||
const active = $derived(getFollows($pubkey!).includes(target))
|
||||
const normalizedScore = $derived(clamp([0, max], $score) / max)
|
||||
const dashOffset = $derived(100 - 44 * normalizedScore)
|
||||
const style = $derived(`transform: rotate(${135 - normalizedScore * 180}deg)`)
|
||||
const stroke = $derived(active ? "var(--color-primary)" : "var(--color-base-content)")
|
||||
let score = $state(50)
|
||||
|
||||
onMount(() => {
|
||||
let cancelled = false
|
||||
|
||||
getPubkeyRank(target)
|
||||
.then(rank => {
|
||||
if (!cancelled) {
|
||||
score = rank ?? 50
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
score = 50
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
})
|
||||
|
||||
const normalizedScore = $derived(clamp([0, max], score) / max)
|
||||
const dashOffset = $derived(circumference * (1 - normalizedScore))
|
||||
const style = $derived(`transform: rotate(-90deg)`)
|
||||
const strokeWidth = $derived(2.2 + normalizedScore * 1.5)
|
||||
const stroke = $derived(
|
||||
score >= 90
|
||||
? "var(--success)"
|
||||
: score >= 75
|
||||
? "var(--info)"
|
||||
: score >= 50
|
||||
? "var(--warning)"
|
||||
: "var(--error)",
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class="relative h-[14px] w-[14px]">
|
||||
<svg height="14" width="14" class="absolute">
|
||||
<div class="relative h-[22px] w-[22px] shrink-0">
|
||||
<svg height={size} width={size} class="absolute">
|
||||
<circle class="wot-background" cx={center} cy={center} r={radius} />
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
class="wot-highlight"
|
||||
fill="none"
|
||||
stroke-width={strokeWidth}
|
||||
stroke-dasharray={circumference}
|
||||
stroke-dashoffset={dashOffset}
|
||||
{style}
|
||||
{stroke} />
|
||||
<text
|
||||
x={center}
|
||||
y={center + 0.15}
|
||||
text-anchor="middle"
|
||||
dominant-baseline="middle"
|
||||
class="wot-score">
|
||||
{Math.round(score)}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
import {request} from "@welshman/net"
|
||||
import {clamp} from "@welshman/lib"
|
||||
import {nip19} from "nostr-tools"
|
||||
import {getProfile} from "@welshman/app"
|
||||
import type {Filter, TrustedEvent} from "@welshman/util"
|
||||
|
||||
const DEFAULT_EXTENDED_SEARCH_RELAYS = [
|
||||
import.meta.env.VITE_DEFAULT_SEARCH_RELAYS,
|
||||
"relay.noswhere.com,search.nos.today,nostr.wine",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(",")
|
||||
|
||||
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 ""
|
||||
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 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(toRelayUrl)
|
||||
.filter(Boolean)
|
||||
.filter((url, index, urls) => urls.indexOf(url) === index)
|
||||
|
||||
type Nip11RelayInfo = {
|
||||
supportsNip50: boolean
|
||||
nip50Extensions: string[]
|
||||
}
|
||||
|
||||
type RelaySearchInfo = {
|
||||
relay: string
|
||||
extensions: string[]
|
||||
}
|
||||
|
||||
type ScoreResult = {
|
||||
score: number
|
||||
index: number
|
||||
relayHits: number
|
||||
activeRelays: number
|
||||
interleavedCount: number
|
||||
}
|
||||
|
||||
type TimedRequestOptions = {
|
||||
relays: string[]
|
||||
filters: Filter[]
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
const rankCache = new Map<string, number>()
|
||||
const pendingRankRequests = new Map<string, Promise<number>>()
|
||||
const relayInfoCache = new Map<string, Promise<Nip11RelayInfo>>()
|
||||
|
||||
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 decoded = nip19.decode(normalized)
|
||||
|
||||
if (decoded.type === "npub" && typeof decoded.data === "string" && isHexPubkey(decoded.data)) {
|
||||
return decoded.data.toLowerCase()
|
||||
}
|
||||
} catch {
|
||||
// Ignore decode errors and return an empty key for invalid values.
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
const requestWithTimeout = async ({
|
||||
relays,
|
||||
filters,
|
||||
timeout = RELAY_SEARCH_TIMEOUT_MS,
|
||||
}: TimedRequestOptions): Promise<TrustedEvent[]> => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeSearchTerm = (value: unknown): string => {
|
||||
if (typeof value !== "string") {
|
||||
return ""
|
||||
}
|
||||
|
||||
return value.trim().replace(/\s+/g, " ")
|
||||
}
|
||||
|
||||
const dedupeProfileTerms = (terms: string[]) => {
|
||||
const seen = new Set<string>()
|
||||
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>) =>
|
||||
[...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<string, unknown>) => {
|
||||
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<string, unknown>
|
||||
|
||||
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<Nip11RelayInfo> => {
|
||||
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<string, unknown>
|
||||
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 = fetchRelayInfo(relay)
|
||||
|
||||
relayInfoCache.set(relay, requestPromise)
|
||||
|
||||
return requestPromise
|
||||
}
|
||||
|
||||
const getNip50Relays = async (): Promise<RelaySearchInfo[]> => {
|
||||
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<string>()
|
||||
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<string>()
|
||||
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<string, unknown> | 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<string, unknown>
|
||||
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<TrustedEvent[]> => {
|
||||
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<number> => {
|
||||
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<number | null> => {
|
||||
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,25 +2,71 @@
|
||||
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"
|
||||
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<string[]>([])
|
||||
let element: Element | undefined = $state()
|
||||
let requestId = 0
|
||||
const rankByPubkey = new Map<string, number>()
|
||||
|
||||
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 = sortPubkeys(items)
|
||||
|
||||
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)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
Reference in New Issue
Block a user
This file should no longer exist, we switched to .env with overrides