diff --git a/src/classes/pubkey-graph.ts b/src/classes/pubkey-graph.ts new file mode 100644 index 000000000..eae72c5c3 --- /dev/null +++ b/src/classes/pubkey-graph.ts @@ -0,0 +1,150 @@ +import { NostrEvent } from "nostr-tools"; + +export class PubkeyGraph { + /** the pubkey at the center of it all */ + root: string; + + connections = new Map(); + distance = new Map(); + + // number of connections a key has at each level + connectionCount = new Map(); + + constructor(root: string) { + this.root = root; + } + + handleEvent(event: NostrEvent) { + const keys = event.tags.filter((t) => t[0] === "p" && t[1]).map((t) => t[1]); + for (const key of keys) this.changed.add(key); + this.setPubkeyConnections(event.pubkey, keys); + } + + setPubkeyConnections(pubkey: string, friends: string[]) { + this.connections.set(pubkey, friends); + } + + getByDistance() { + const dist: Record = {}; + + for (const [key, d] of this.distance) { + dist[d] = dist[d] || []; + + dist[d].push([key, this.connectionCount.get(key)]); + } + + // sort keys + for (const [d, keys] of Object.entries(dist)) { + keys.sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0)); + } + + return dist; + } + + getPubkeyDistance(pubkey: string) { + const distance = this.distance.get(pubkey); + if (!distance) return; + const count = this.connectionCount.get(pubkey); + return { distance, count }; + } + + sortByDistanceAndConnections(keys: string[]): string[]; + sortByDistanceAndConnections(keys: T[], getKey: (d: T) => string): T[]; + sortByDistanceAndConnections(keys: T[], getKey?: (d: T) => string): T[] { + return Array.from(keys).sort((a, b) => { + const aKey = typeof a === "string" ? a : getKey?.(a) || ""; + const bKey = typeof b === "string" ? b : getKey?.(b) || ""; + + const v = this.sortComparePubkeys(aKey, bKey); + if (v === 0) { + // tied break with original index + const ai = keys.indexOf(a); + const bi = keys.indexOf(b); + if (ai < bi) return -1; + else if (ai > bi) return 1; + return 0; + } + return v; + }); + } + + sortComparePubkeys(a: string, b: string) { + const aDist = this.distance.get(a); + const bDist = this.distance.get(b); + + if (!aDist && !bDist) return 0; + else if (aDist && (!bDist || aDist < bDist)) return -1; + else if (bDist && (!aDist || aDist > bDist)) return 1; + + // a and b are on the same level. compare connections + const aCount = this.connectionCount.get(a); + const bCount = this.connectionCount.get(b); + + if (aCount === bCount) return 0; + else if (aCount && (!bCount || aCount < bCount)) return -1; + else if (bCount && (!aCount || aCount > bCount)) return 1; + + return 0; + } + + changed = new Set(); + compute() { + this.distance.clear(); + + const next = new Set(); + const refCount = new Map(); + const walkLevel = (level = 0) => { + if (next.size === 0) return; + let keys = new Set(next); + next.clear(); + + for (const key of keys) { + this.distance.set(key, level); + const count = refCount.get(key); + if (count) this.connectionCount.set(key, count); + } + + for (const key of keys) { + const connections = this.connections.get(key); + if (connections) { + for (const child of connections) { + if (!this.distance.has(child)) { + next.add(child); + refCount.set(child, (refCount.get(child) ?? 0) + 1); + } + } + } + } + + walkLevel(level + 1); + }; + + console.time("walk"); + next.add(this.root); + walkLevel(0); + console.timeEnd("walk"); + } + + getPaths(pubkey: string, maxLength = 2) { + let paths: string[][] = []; + + const walk = (p: string, maxLvl = 0, path: string[] = []) => { + if (path.includes(p)) return; + + const connections = this.connections.get(p); + if (!connections) return; + + for (const friend of connections) { + if (friend === pubkey) { + paths.push([...path, p, friend]); + } else if (maxLvl > 0) { + walk(friend, maxLvl - 1, [...path, p]); + } + } + }; + + walk(this.root, maxLength); + + return paths; + } +} diff --git a/src/components/magic-textarea.tsx b/src/components/magic-textarea.tsx index 0276fa747..c3f25f1dc 100644 --- a/src/components/magic-textarea.tsx +++ b/src/components/magic-textarea.tsx @@ -14,6 +14,7 @@ import { Emoji, useContextEmojis } from "../providers/global/emoji-provider"; import { useUserSearchDirectoryContext } from "../providers/global/user-directory-provider"; import UserAvatar from "./user/user-avatar"; import UserDnsIdentity from "./user/user-dns-identity"; +import { getWebOfTrust } from "../services/web-of-trust"; export type PeopleToken = { pubkey: string; names: string[] }; type Token = Emoji | PeopleToken; @@ -73,7 +74,14 @@ function useAutocompleteTriggers() { "@": { dataProvider: async (token: string) => { const dir = await getDirectory(); - return matchSorter(dir, token.trim(), { keys: ["names"] }).slice(0, 10); + return matchSorter(dir, token.trim(), { + keys: ["names"], + sorter: (items) => + getWebOfTrust().sortByDistanceAndConnections( + items.sort((a, b) => b.rank - a.rank), + (i) => i.item.pubkey, + ), + }).slice(0, 10); }, component: Item, output, diff --git a/src/components/note/timeline-note/components/zap-bubbles.tsx b/src/components/note/timeline-note/components/zap-bubbles.tsx index d2cd8e740..c6cd9d14f 100644 --- a/src/components/note/timeline-note/components/zap-bubbles.tsx +++ b/src/components/note/timeline-note/components/zap-bubbles.tsx @@ -26,7 +26,7 @@ export default function ZapBubbles({ event }: { event: NostrEvent }) { return ( {sorted.map((zap) => ( - + {readablizeSats((zap.payment.amount ?? 0) / 1000)} diff --git a/src/index.tsx b/src/index.tsx index 2296dbbed..f8ce2188c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -4,6 +4,7 @@ import { App } from "./app"; import { GlobalProviders } from "./providers/global"; import "./services/user-event-sync"; import "./services/username-search"; +import "./services/web-of-trust"; // setup bitcoin connect import { init, onConnected } from "@getalby/bitcoin-connect-react"; diff --git a/src/services/user-event-sync.ts b/src/services/user-event-sync.ts index 0ba674edc..f6cf872ee 100644 --- a/src/services/user-event-sync.ts +++ b/src/services/user-event-sync.ts @@ -1,4 +1,6 @@ import { kinds } from "nostr-tools"; +import _throttle from "lodash.throttle"; + import { COMMON_CONTACT_RELAY } from "../const"; import { logger } from "../helpers/debug"; import accountService from "./account"; @@ -11,8 +13,14 @@ import userMetadataService from "./user-metadata"; const log = logger.extend("user-event-sync"); -function loadContactsList() { +function downloadEvents() { const account = accountService.current.value!; + const relays = clientRelaysService.readRelays.value; + + log("Loading user information"); + userMetadataService.requestMetadata(account.pubkey, [...relays, COMMON_CONTACT_RELAY], { alwaysRequest: true }); + userMailboxesService.requestMailboxes(account.pubkey, [...relays, COMMON_CONTACT_RELAY], { alwaysRequest: true }); + userAppSettings.requestAppSettings(account.pubkey, relays, { alwaysRequest: true }); log("Loading contacts list"); replaceableEventsService.requestEvent( @@ -26,18 +34,6 @@ function loadContactsList() { ); } -function downloadEvents() { - const account = accountService.current.value!; - const relays = clientRelaysService.readRelays.value; - - log("Loading user information"); - userMetadataService.requestMetadata(account.pubkey, [...relays, COMMON_CONTACT_RELAY], { alwaysRequest: true }); - userMailboxesService.requestMailboxes(account.pubkey, [...relays, COMMON_CONTACT_RELAY], { alwaysRequest: true }); - userAppSettings.requestAppSettings(account.pubkey, relays, { alwaysRequest: true }); - - loadContactsList(); -} - accountService.current.subscribe((account) => { if (!account) return; downloadEvents(); diff --git a/src/services/web-of-trust.ts b/src/services/web-of-trust.ts new file mode 100644 index 000000000..fdec01229 --- /dev/null +++ b/src/services/web-of-trust.ts @@ -0,0 +1,73 @@ +import { NostrEvent, kinds } from "nostr-tools"; +import _throttle from "lodash.throttle"; + +import { PubkeyGraph } from "../classes/pubkey-graph"; +import { COMMON_CONTACT_RELAY } from "../const"; +import { logger } from "../helpers/debug"; +import accountService from "./account"; +import replaceableEventsService from "./replaceable-events"; +import { getPubkeysFromList } from "../helpers/nostr/lists"; + +const log = logger.extend("web-of-trust"); +let webOfTrust: PubkeyGraph; + +let newEvents = 0; +const throttleUpdateWebOfTrust = _throttle(() => { + log("Computing web-of-trust with", newEvents, "new events"); + webOfTrust.compute(); + newEvents = 0; +}, 5_000); + +export function loadSocialGraph( + web: PubkeyGraph, + kind: number, + pubkey: string, + relay?: string, + maxLvl = 0, + walked: Set = new Set(), +) { + const contacts = replaceableEventsService.requestEvent( + relay ? [relay, COMMON_CONTACT_RELAY] : [COMMON_CONTACT_RELAY], + kind, + pubkey, + ); + + walked.add(pubkey); + + const handleEvent = (event: NostrEvent) => { + web.handleEvent(event); + newEvents++; + throttleUpdateWebOfTrust(); + + if (maxLvl > 0) { + for (const person of getPubkeysFromList(event)) { + if (walked.has(person.pubkey)) continue; + + loadSocialGraph(web, kind, person.pubkey, person.relay, maxLvl - 1, walked); + } + } + }; + + if (contacts.value) { + handleEvent(contacts.value); + } else { + contacts.once((event) => handleEvent(event)); + } +} + +accountService.current.subscribe((account) => { + if (!account) return; + + webOfTrust = new PubkeyGraph(account.pubkey); + + if (import.meta.env.DEV) { + //@ts-expect-error + window.webOfTrust = webOfTrust; + } + + loadSocialGraph(webOfTrust, kinds.Contacts, account.pubkey, undefined, 1); +}); + +export function getWebOfTrust() { + return webOfTrust; +} diff --git a/src/views/launchpad/components/search-form.tsx b/src/views/launchpad/components/search-form.tsx index 8198cc5fd..b95f1a673 100644 --- a/src/views/launchpad/components/search-form.tsx +++ b/src/views/launchpad/components/search-form.tsx @@ -1,16 +1,6 @@ import { FormEventHandler, useCallback, useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { - Box, - Card, - Code, - Flex, - FlexProps, - Input, - InputGroup, - InputRightElement, - useDisclosure, -} from "@chakra-ui/react"; +import { Card, Flex, FlexProps, Input, InputGroup, InputRightElement, useDisclosure } from "@chakra-ui/react"; import { matchSorter } from "match-sorter"; import { useAsync, useKeyPressEvent, useThrottle } from "react-use"; import { nip19 } from "nostr-tools"; @@ -20,6 +10,7 @@ import { useUserSearchDirectoryContext } from "../../../providers/global/user-di import UserAvatar from "../../../components/user/user-avatar"; import UserName from "../../../components/user/user-name"; import KeyboardShortcut from "../../../components/keyboard-shortcut"; +import { getWebOfTrust } from "../../../services/web-of-trust"; function UserOption({ pubkey }: { pubkey: string }) { return ( @@ -41,8 +32,16 @@ export default function SearchForm({ ...props }: Omit) { const { value: localUsers = [] } = useAsync(async () => { if (queryThrottle.trim().length < 2) return []; + const webOfTrust = getWebOfTrust(); const dir = await getDirectory(); - return matchSorter(dir, queryThrottle.trim(), { keys: ["names"] }).slice(0, 10); + return matchSorter(dir, queryThrottle.trim(), { + keys: ["names"], + sorter: (items) => + webOfTrust.sortByDistanceAndConnections( + items.sort((a, b) => b.rank - a.rank), + (i) => i.item.pubkey, + ), + }).slice(0, 10); }, [queryThrottle]); useEffect(() => { if (localUsers.length > 0 && !autoComplete.isOpen) autoComplete.onOpen();