diff --git a/.changeset/pink-apples-battle.md b/.changeset/pink-apples-battle.md new file mode 100644 index 000000000..66e2524f0 --- /dev/null +++ b/.changeset/pink-apples-battle.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add network dm graph diff --git a/src/app.tsx b/src/app.tsx index 6028beb4c..7adc384a6 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -73,7 +73,8 @@ const UserTracksTab = lazy(() => import("./views/user/tracks")); const ToolsHomeView = lazy(() => import("./views/tools")); const NetworkView = lazy(() => import("./views/tools/network")); const StreamModerationView = lazy(() => import("./views/tools/stream-moderation")); -const NetworkGraphView = lazy(() => import("./views/tools/network-mute-graph")); +const NetworkMuteGraphView = lazy(() => import("./views/tools/network-mute-graph")); +const NetworkDMGraphView = lazy(() => import("./views/tools/network-dm-graph")); const UserStreamsTab = lazy(() => import("./views/user/streams")); const StreamsView = lazy(() => import("./views/streams")); @@ -224,7 +225,8 @@ const router = createHashRouter([ children: [ { path: "", element: }, { path: "network", element: }, - { path: "network-graph", element: }, + { path: "network-mute-graph", element: }, + { path: "network-dm-graph", element: }, ], }, { diff --git a/src/views/tools/index.tsx b/src/views/tools/index.tsx index 353436819..f4cb5e62f 100644 --- a/src/views/tools/index.tsx +++ b/src/views/tools/index.tsx @@ -15,9 +15,12 @@ export default function ToolsHomeView() { - + diff --git a/src/views/tools/network-dm-graph.tsx b/src/views/tools/network-dm-graph.tsx new file mode 100644 index 000000000..fc153747e --- /dev/null +++ b/src/views/tools/network-dm-graph.tsx @@ -0,0 +1,172 @@ +import { useEffect, useMemo, useState } from "react"; +import { Box, Flex, Input, Text } from "@chakra-ui/react"; +import AutoSizer from "react-virtualized-auto-sizer"; +import ForceGraph, { LinkObject, NodeObject } from "react-force-graph-3d"; +import { Kind } from "nostr-tools"; +import dayjs from "dayjs"; +import { + Group, + Mesh, + MeshBasicMaterial, + SRGBColorSpace, + SphereGeometry, + Sprite, + SpriteMaterial, + TextureLoader, +} from "three"; + +import { useCurrentAccount } from "../../hooks/use-current-account"; +import RequireCurrentAccount from "../../providers/require-current-account"; +import { useUsersMetadata } from "../../hooks/use-user-network"; +import { getPubkeysFromList } from "../../helpers/nostr/lists"; +import useUserContactList from "../../hooks/use-user-contact-list"; +import { useUserMetadata } from "../../hooks/use-user-metadata"; +import EventStore from "../../classes/event-store"; +import NostrRequest from "../../classes/nostr-request"; +import { isPTag } from "../../types/nostr-event"; +import RelaySelectionProvider, { useRelaySelectionContext } from "../../providers/relay-selection-provider"; +import RelaySelectionButton from "../../components/relay-selection/relay-selection-button"; +import { useDebounce } from "react-use"; +import useSubject from "../../hooks/use-subject"; + +type NodeType = { id: string; image?: string; name?: string }; + +function NetworkDMGraphPage() { + const account = useCurrentAccount()!; + const { relays } = useRelaySelectionContext(); + + const contacts = useUserContactList(account.pubkey); + const contactsPubkeys = useMemo( + () => (contacts ? getPubkeysFromList(contacts).map((p) => p.pubkey) : []), + [contacts], + ); + + const [until, setUntil] = useState(dayjs().unix()); + const [since, setSince] = useState(dayjs().subtract(1, "week").unix()); + + const store = useMemo(() => new EventStore(), []); + const [fetchData] = useDebounce( + () => { + if (!contacts) return; + + store.clear(); + const request = new NostrRequest(relays); + request.onEvent.subscribe(store.addEvent, store); + request.start({ + authors: contactsPubkeys, + kinds: [Kind.EncryptedDirectMessage], + since, + until, + }); + }, + 2 * 1000, + [relays, store, contactsPubkeys, since, until], + ); + useEffect(() => { + fetchData(); + }, [relays, store, contactsPubkeys, since, until]); + + const selfMetadata = useUserMetadata(account.pubkey); + const usersMetadata = useUsersMetadata(contactsPubkeys); + + const newEventTrigger = useSubject(store.onEvent); + const graphData = useMemo(() => { + if (store.events.size === 0) return { nodes: [], links: [] }; + + const nodes: Record> = {}; + const links: Record> = {}; + + const getOrCreateNode = (pubkey: string) => { + if (!nodes[pubkey]) { + const node: NodeType = { + id: pubkey, + }; + + const metadata = usersMetadata[pubkey]; + if (metadata) { + node.image = metadata.picture; + node.name = metadata.name; + } + + nodes[pubkey] = node; + } + return nodes[pubkey]; + }; + + for (const [_, dm] of store.events) { + const author = dm.pubkey; + const receiver = dm.tags.find(isPTag)?.[1]; + if (!receiver) continue; + + if (contactsPubkeys.includes(receiver) && (contactsPubkeys.includes(author) || author === account.pubkey)) { + const keyA = [author, receiver].join("|"); + links[keyA] = { source: getOrCreateNode(author), target: getOrCreateNode(receiver) }; + } + } + + return { nodes: Object.values(nodes), links: Object.values(links) }; + }, [contactsPubkeys, account.pubkey, usersMetadata, selfMetadata, newEventTrigger]); + + return ( + + + setSince(dayjs(e.target.value).unix())} + /> + Showing all direct messages between contacts in the last {dayjs.unix(since).fromNow(true)} + + + + + {({ height, width }) => ( + + graphData={graphData} + enableNodeDrag={false} + width={width} + height={height} + linkDirectionalArrowLength={3.5} + linkDirectionalArrowRelPos={1} + linkCurvature={0.25} + nodeThreeObject={(node: NodeType) => { + if (!node.image) { + return new Mesh(new SphereGeometry(5, 12, 6), new MeshBasicMaterial({ color: 0xaa0f0f })); + } + + const group = new Group(); + + const imgTexture = new TextureLoader().load(node.image); + imgTexture.colorSpace = SRGBColorSpace; + const material = new SpriteMaterial({ map: imgTexture }); + const sprite = new Sprite(material); + sprite.scale.set(10, 10, 10); + + group.children.push(sprite); + + // if (node.name) { + // const text = new SpriteText(node.name, 8, "ffffff"); + // text.position.set(0, 0, 16); + // group.children.push(text); + // } + + return sprite; + }} + /> + )} + + + + ); +} + +export default function NetworkDMGraphView() { + return ( + + + + + + ); +}