mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-20 13:01:07 +02:00
add simple web-of-trust
This commit is contained in:
150
src/classes/pubkey-graph.ts
Normal file
150
src/classes/pubkey-graph.ts
Normal 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;
|
||||
}
|
||||
}
|
@@ -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,
|
||||
|
@@ -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" />
|
||||
|
@@ -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";
|
||||
|
@@ -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();
|
||||
|
73
src/services/web-of-trust.ts
Normal file
73
src/services/web-of-trust.ts
Normal 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;
|
||||
}
|
@@ -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();
|
||||
|
Reference in New Issue
Block a user