mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-22 15:19:47 +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 { useUserSearchDirectoryContext } from "../providers/global/user-directory-provider";
|
||||||
import UserAvatar from "./user/user-avatar";
|
import UserAvatar from "./user/user-avatar";
|
||||||
import UserDnsIdentity from "./user/user-dns-identity";
|
import UserDnsIdentity from "./user/user-dns-identity";
|
||||||
|
import { getWebOfTrust } from "../services/web-of-trust";
|
||||||
|
|
||||||
export type PeopleToken = { pubkey: string; names: string[] };
|
export type PeopleToken = { pubkey: string; names: string[] };
|
||||||
type Token = Emoji | PeopleToken;
|
type Token = Emoji | PeopleToken;
|
||||||
@@ -73,7 +74,14 @@ function useAutocompleteTriggers() {
|
|||||||
"@": {
|
"@": {
|
||||||
dataProvider: async (token: string) => {
|
dataProvider: async (token: string) => {
|
||||||
const dir = await getDirectory();
|
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,
|
component: Item,
|
||||||
output,
|
output,
|
||||||
|
@@ -26,7 +26,7 @@ export default function ZapBubbles({ event }: { event: NostrEvent }) {
|
|||||||
return (
|
return (
|
||||||
<HiddenScrollbar overflowY="hidden" overflowX="auto" gap="2">
|
<HiddenScrollbar overflowY="hidden" overflowX="auto" gap="2">
|
||||||
{sorted.map((zap) => (
|
{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" />
|
<LightningIcon mr="1" />
|
||||||
<TagLabel fontWeight="bold">{readablizeSats((zap.payment.amount ?? 0) / 1000)}</TagLabel>
|
<TagLabel fontWeight="bold">{readablizeSats((zap.payment.amount ?? 0) / 1000)}</TagLabel>
|
||||||
<UserAvatar pubkey={zap.request.pubkey} size="xs" square={false} ml="2" />
|
<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 { GlobalProviders } from "./providers/global";
|
||||||
import "./services/user-event-sync";
|
import "./services/user-event-sync";
|
||||||
import "./services/username-search";
|
import "./services/username-search";
|
||||||
|
import "./services/web-of-trust";
|
||||||
|
|
||||||
// setup bitcoin connect
|
// setup bitcoin connect
|
||||||
import { init, onConnected } from "@getalby/bitcoin-connect-react";
|
import { init, onConnected } from "@getalby/bitcoin-connect-react";
|
||||||
|
@@ -1,4 +1,6 @@
|
|||||||
import { kinds } from "nostr-tools";
|
import { kinds } from "nostr-tools";
|
||||||
|
import _throttle from "lodash.throttle";
|
||||||
|
|
||||||
import { COMMON_CONTACT_RELAY } from "../const";
|
import { COMMON_CONTACT_RELAY } from "../const";
|
||||||
import { logger } from "../helpers/debug";
|
import { logger } from "../helpers/debug";
|
||||||
import accountService from "./account";
|
import accountService from "./account";
|
||||||
@@ -11,8 +13,14 @@ import userMetadataService from "./user-metadata";
|
|||||||
|
|
||||||
const log = logger.extend("user-event-sync");
|
const log = logger.extend("user-event-sync");
|
||||||
|
|
||||||
function loadContactsList() {
|
function downloadEvents() {
|
||||||
const account = accountService.current.value!;
|
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");
|
log("Loading contacts list");
|
||||||
replaceableEventsService.requestEvent(
|
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) => {
|
accountService.current.subscribe((account) => {
|
||||||
if (!account) return;
|
if (!account) return;
|
||||||
downloadEvents();
|
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 { FormEventHandler, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import { Card, Flex, FlexProps, Input, InputGroup, InputRightElement, useDisclosure } from "@chakra-ui/react";
|
||||||
Box,
|
|
||||||
Card,
|
|
||||||
Code,
|
|
||||||
Flex,
|
|
||||||
FlexProps,
|
|
||||||
Input,
|
|
||||||
InputGroup,
|
|
||||||
InputRightElement,
|
|
||||||
useDisclosure,
|
|
||||||
} from "@chakra-ui/react";
|
|
||||||
import { matchSorter } from "match-sorter";
|
import { matchSorter } from "match-sorter";
|
||||||
import { useAsync, useKeyPressEvent, useThrottle } from "react-use";
|
import { useAsync, useKeyPressEvent, useThrottle } from "react-use";
|
||||||
import { nip19 } from "nostr-tools";
|
import { nip19 } from "nostr-tools";
|
||||||
@@ -20,6 +10,7 @@ import { useUserSearchDirectoryContext } from "../../../providers/global/user-di
|
|||||||
import UserAvatar from "../../../components/user/user-avatar";
|
import UserAvatar from "../../../components/user/user-avatar";
|
||||||
import UserName from "../../../components/user/user-name";
|
import UserName from "../../../components/user/user-name";
|
||||||
import KeyboardShortcut from "../../../components/keyboard-shortcut";
|
import KeyboardShortcut from "../../../components/keyboard-shortcut";
|
||||||
|
import { getWebOfTrust } from "../../../services/web-of-trust";
|
||||||
|
|
||||||
function UserOption({ pubkey }: { pubkey: string }) {
|
function UserOption({ pubkey }: { pubkey: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -41,8 +32,16 @@ export default function SearchForm({ ...props }: Omit<FlexProps, "children">) {
|
|||||||
const { value: localUsers = [] } = useAsync(async () => {
|
const { value: localUsers = [] } = useAsync(async () => {
|
||||||
if (queryThrottle.trim().length < 2) return [];
|
if (queryThrottle.trim().length < 2) return [];
|
||||||
|
|
||||||
|
const webOfTrust = getWebOfTrust();
|
||||||
const dir = await getDirectory();
|
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]);
|
}, [queryThrottle]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (localUsers.length > 0 && !autoComplete.isOpen) autoComplete.onOpen();
|
if (localUsers.length > 0 && !autoComplete.isOpen) autoComplete.onOpen();
|
||||||
|
Reference in New Issue
Block a user