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 (
+
+
+
+
+
+ );
+}