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..ec4c1e07
--- /dev/null
+++ b/src/app/util/wot/getPubkeyRank.ts
@@ -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()
+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 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 => {
+ 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()
+
+ 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 = fetchRelayInfo(relay)
+
+ 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
+}
diff --git a/src/routes/people/+page.svelte b/src/routes/people/+page.svelte
index 66d90acf..651ee3ef 100644
--- a/src/routes/people/+page.svelte
+++ b/src/routes/people/+page.svelte
@@ -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([])
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 = 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))