add simple web-of-trust

This commit is contained in:
hzrd149
2024-04-04 15:14:27 -05:00
parent 7aed7b982d
commit a22d0d63ec
7 changed files with 254 additions and 27 deletions

150
src/classes/pubkey-graph.ts Normal file
View File

@@ -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<string, string[]>();
distance = new Map<string, number>();
// number of connections a key has at each level
connectionCount = new Map<string, number>();
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<number, [string, number | undefined][]> = {};
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<T>(keys: T[], getKey: (d: T) => string): T[];
sortByDistanceAndConnections<T>(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<string>();
compute() {
this.distance.clear();
const next = new Set<string>();
const refCount = new Map<string, number>();
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;
}
}

View File

@@ -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,

View File

@@ -26,7 +26,7 @@ export default function ZapBubbles({ event }: { event: NostrEvent }) {
return (
<HiddenScrollbar overflowY="hidden" overflowX="auto" gap="2">
{sorted.map((zap) => (
<Tag borderRadius="full" py="1" flexShrink={0} variant="outline">
<Tag key={zap.event.id} borderRadius="full" py="1" flexShrink={0} variant="outline">
<LightningIcon mr="1" />
<TagLabel fontWeight="bold">{readablizeSats((zap.payment.amount ?? 0) / 1000)}</TagLabel>
<UserAvatar pubkey={zap.request.pubkey} size="xs" square={false} ml="2" />

View File

@@ -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";

View File

@@ -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();

View File

@@ -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<string> = 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;
}

View File

@@ -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<FlexProps, "children">) {
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();