message prop graph

This commit is contained in:
ajvpot
2025-08-23 02:17:27 +02:00
parent 4e8af83e41
commit 3d73e51ad7
4 changed files with 393 additions and 27 deletions
+217 -7
View File
@@ -23,6 +23,7 @@
"next": "15.3.4",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-d3-tree": "^3.6.6",
"react-dom": "^19.1.0",
"react-leaflet": "^5.0.0",
"tailwind-merge": "^3.3.1"
@@ -66,6 +67,31 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz",
"integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@bkrem/react-transition-group": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@bkrem/react-transition-group/-/react-transition-group-1.3.5.tgz",
"integrity": "sha512-lbBYhC42sxAeFEopxzd9oWdkkV0zirO5E9WyeOBxOrpXsf7m30Aj8vnbayZxFOwD9pvUQ2Pheb1gO79s0Qap3Q==",
"dependencies": {
"chain-function": "^1.0.0",
"dom-helpers": "^3.3.1",
"loose-envify": "^1.3.1",
"prop-types": "^15.5.6",
"react-lifecycles-compat": "^3.0.4",
"warning": "^3.0.0"
},
"peerDependencies": {
"react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@clickhouse/client": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@clickhouse/client/-/client-1.11.2.tgz",
@@ -1430,6 +1456,11 @@
"integrity": "sha512-v3D66IptpUqh+pHKVNRxY8yvp2ESSZXe0rTzsGdzUhEwag7ljVfgCllkWv2YgiYXDhWFBrEywll4A5JToyTNFA==",
"dev": true
},
"node_modules/@types/d3-hierarchy": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-1.1.11.tgz",
"integrity": "sha512-lnQiU7jV+Gyk9oQYk0GGYccuexmQPTp08E0+4BidgFdiJivjEvf+esPSdZqCZ2C7UwTWejWpqetVaU8A+eX3FA=="
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -2442,6 +2473,11 @@
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
"integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="
},
"node_modules/chain-function": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/chain-function/-/chain-function-1.0.1.tgz",
"integrity": "sha512-SxltgMwL9uCko5/ZCLiyG2B7R9fY4pDZUw7hJ4MhirdjBLosoDqkWABi3XMucddHdLiFJMb7PD2MZifZriuMTg=="
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -2498,6 +2534,14 @@
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="
},
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -2589,6 +2633,120 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-hierarchy": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz",
"integrity": "sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ=="
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz",
"integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz",
"integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==",
"dependencies": {
"d3-path": "1"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -2722,6 +2880,14 @@
"node": ">=0.4.0"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"engines": {
"node": ">=6"
}
},
"node_modules/detect-libc": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
@@ -2743,6 +2909,14 @@
"node": ">=0.10.0"
}
},
"node_modules/dom-helpers": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz",
"integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==",
"dependencies": {
"@babel/runtime": "^7.1.2"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -4309,8 +4483,7 @@
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
"node_modules/js-yaml": {
"version": "4.1.0",
@@ -4731,7 +4904,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
@@ -5016,7 +5188,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5299,7 +5470,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
@@ -5370,6 +5540,34 @@
"node": ">=0.10.0"
}
},
"node_modules/react-d3-tree": {
"version": "3.6.6",
"resolved": "https://registry.npmjs.org/react-d3-tree/-/react-d3-tree-3.6.6.tgz",
"integrity": "sha512-E9ByUdeqvlxLlF9BSL7KWQH3ikYHtHO+g1rAPcVgj6mu92tjRUCan2AWxoD4eTSzzAATf8BZtf+CXGSoSd6ioQ==",
"dependencies": {
"@bkrem/react-transition-group": "^1.3.5",
"@types/d3-hierarchy": "^1.1.8",
"clone": "^2.1.1",
"d3-hierarchy": "^1.1.9",
"d3-selection": "^3.0.0",
"d3-shape": "^1.3.7",
"d3-zoom": "^3.0.0",
"dequal": "^2.0.2",
"uuid": "^8.3.1"
},
"peerDependencies": {
"react": "16.x || 17.x || 18.x || 19.x",
"react-dom": "16.x || 17.x || 18.x || 19.x"
}
},
"node_modules/react-d3-tree/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/react-dom": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
@@ -5384,8 +5582,7 @@
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/react-leaflet": {
"version": "5.0.0",
@@ -5400,6 +5597,11 @@
"react-dom": "^19.0.0"
}
},
"node_modules/react-lifecycles-compat": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -6440,6 +6642,14 @@
"extsprintf": "^1.2.0"
}
},
"node_modules/warning": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz",
"integrity": "sha512-jMBt6pUrKn5I+OGgtQ4YZLdhIeJmObddh6CsibPxyQ5yPZm1XExSyzC1LCNX7BzhxWgiHmizBWJTHJIjMjTQYQ==",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+1
View File
@@ -24,6 +24,7 @@
"next": "15.3.4",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-d3-tree": "^3.6.6",
"react-dom": "^19.1.0",
"react-leaflet": "^5.0.0",
"tailwind-merge": "^3.3.1"
+1 -1
View File
@@ -269,7 +269,7 @@ export default function ChatBox({ showAllMessagesTab = false, className = "", st
)}
{(startExpanded ? messages : messages.toReversed()).map((msg, i) => (
<ChatMessageItem
key={msg.ingest_timestamp + (msg.origins?.join(',') ?? '') + i}
key={`${msg.ingest_timestamp}-${msg.origin_key_path_array?.length || 0}`}
msg={msg}
showErrorRow={selectedKey.isAllMessages}
/>
+174 -19
View File
@@ -1,7 +1,8 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import React, { useState, useEffect, useMemo, useCallback } from "react";
import { useConfig } from "./ConfigContext";
import { decryptMeshcoreGroupMessage } from "../lib/meshcore";
import Tree from 'react-d3-tree';
export interface ChatMessage {
ingest_timestamp: string;
@@ -15,6 +16,17 @@ export interface ChatMessage {
origin_key_path_array: Array<[string, string, string]>; // Array of [origin, pubkey, path] tuples
}
interface PathGroup {
path: string;
pathSlices: string[]; // Includes origin pubkey as the final hop
indices: number[];
}
interface TreeNode {
name: string;
children?: TreeNode[];
}
function formatHex(hex: string): string {
return hex.replace(/(.{2})/g, "$1 ").trim();
}
@@ -24,7 +36,7 @@ function formatLocalTime(utcString: string): string {
return utcDate.toLocaleString();
}
export default function ChatMessageItem({ msg, showErrorRow }: { msg: ChatMessage, showErrorRow?: boolean }) {
function ChatMessageItem({ msg, showErrorRow }: { msg: ChatMessage, showErrorRow?: boolean }) {
const { config } = useConfig();
const knownKeys = useMemo(() => [
...(config?.meshcoreKeys?.map((k: any) => k.privateKey) || []),
@@ -34,6 +46,7 @@ export default function ChatMessageItem({ msg, showErrorRow }: { msg: ChatMessag
const [parsed, setParsed] = useState<any | null>(null);
const [error, setError] = useState<string | null>(null);
const [originsExpanded, setOriginsExpanded] = useState(false);
const [showGraph, setShowGraph] = useState(false);
useEffect(() => {
let cancelled = false;
@@ -65,27 +78,107 @@ export default function ChatMessageItem({ msg, showErrorRow }: { msg: ChatMessag
return () => { cancelled = true; };
}, [msg.encrypted_message, msg.mac, msg.channel_hash, knownKeysString, knownKeys]);
const originKeyPathArray = msg.origin_key_path_array && msg.origin_key_path_array.length > 0 ? msg.origin_key_path_array : [];
const originKeyPathArray = useMemo(() =>
msg.origin_key_path_array && msg.origin_key_path_array.length > 0 ? msg.origin_key_path_array : [],
[msg.origin_key_path_array]
);
const originsCount = originKeyPathArray.length;
const OriginsBox = () => (
// Process data for tree visualization
const treeData = useMemo(() => {
if (!showGraph || originsCount === 0) return null;
// Group messages by path similarity
const pathGroups: PathGroup[] = [];
originKeyPathArray.forEach(([origin, pubkey, path], index) => {
// Parse path into 2-character slices and include pubkey as final hop
const pathSlices = path.match(/.{1,2}/g) || [];
const pubkeyPrefix = pubkey.substring(0, 2);
const fullPathSlices = [...pathSlices, pubkeyPrefix];
// Find existing group with same path structure
const existingGroup = pathGroups.find(group =>
group.pathSlices.length === fullPathSlices.length &&
group.pathSlices.every((slice, i) => slice === fullPathSlices[i])
);
if (existingGroup) {
existingGroup.indices.push(index);
} else {
pathGroups.push({
path: path + pubkeyPrefix,
pathSlices: fullPathSlices,
indices: [index]
});
}
});
// Build tree structure for react-d3-tree
const buildTree = (): TreeNode => {
const root: TreeNode = { name: "??", children: [] };
pathGroups.forEach(group => {
let currentNode = root;
group.pathSlices.forEach((slice, level) => {
let child = currentNode.children?.find(c => c.name === slice);
if (!child) {
child = { name: slice, children: [] };
if (!currentNode.children) currentNode.children = [];
currentNode.children.push(child);
}
currentNode = child;
});
});
return root;
};
return buildTree();
}, [showGraph, originKeyPathArray, originsCount]);
const handleOriginsToggle = useCallback(() => {
setOriginsExpanded(prev => !prev);
}, []);
const handleGraphToggle = useCallback(() => {
setShowGraph(prev => !prev);
}, []);
const OriginsBox = useCallback(() => (
<div className="text-xs text-gray-600 dark:text-gray-300">
<button
onClick={() => setOriginsExpanded(!originsExpanded)}
className="flex items-center gap-1 hover:text-gray-800 dark:hover:text-gray-100 transition-colors"
>
<span>Heard {originsCount > 0 ? `${originsCount} repeat${originsCount !== 1 ? 's' : ''}` : '0 repeats'}</span>
<svg
className={`w-3 h-3 transition-transform ${originsExpanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
<div className="flex items-center gap-2">
<button
onClick={handleOriginsToggle}
className="flex items-center gap-1 hover:text-gray-800 dark:hover:text-gray-100 transition-colors"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<span>Heard {originsCount > 0 ? `${originsCount} repeat${originsCount !== 1 ? 's' : ''}` : '0 repeats'}</span>
<svg
className={`w-3 h-3 transition-transform ${originsExpanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{originsCount > 0 && (
<button
onClick={handleGraphToggle}
className="px-2 py-1 text-xs rounded bg-gray-200 dark:bg-neutral-700 hover:bg-gray-300 dark:hover:bg-neutral-600 transition-colors"
>
{showGraph ? 'Hide Graph' : 'Show Graph'}
</button>
)}
</div>
{originsExpanded && originsCount > 0 && (
<div className="mt-1 p-2 bg-gray-100 dark:bg-neutral-700 rounded text-xs break-all text-gray-800 dark:text-gray-200">
{/* List view (original) */}
{originKeyPathArray.map(([origin, pubkey, path], index: number) => {
// Parse path into 2-character slices
const pathSlices = path.match(/.{1,2}/g) || [];
@@ -104,7 +197,54 @@ export default function ChatMessageItem({ msg, showErrorRow }: { msg: ChatMessag
</div>
)}
</div>
);
), [originsExpanded, originsCount, showGraph, originKeyPathArray, handleOriginsToggle, handleGraphToggle]);
const GraphView = useCallback(() => (
showGraph && originsCount > 0 && (
<div key="graph-container" className="mt-2 p-3 bg-gray-100 dark:bg-neutral-700 rounded text-xs text-gray-800 dark:text-gray-200">
<div key="graph-title" className="mb-2 font-medium text-gray-700 dark:text-gray-300">Message Propagation Tree</div>
<div key="graph-content" className="w-full h-80">
{treeData && (
<Tree
key={`tree-${msg.ingest_timestamp}-${originsCount}`}
data={treeData}
orientation="vertical"
pathFunc="step"
translate={{ x: 200, y: 50 }}
separation={{ siblings: 1.2, nonSiblings: 1.5 }}
nodeSize={{ x: 60, y: 60 }}
renderCustomNodeElement={({ nodeDatum, toggleNode }) => {
const isRoot = nodeDatum.name === "??";
// Check if this node represents an origin pubkey (final 2-char hex from pubkey)
const isOriginPubkey = originKeyPathArray.some(([origin, pubkey, path]) => {
const pubkeyPrefix = pubkey.substring(0, 2);
return nodeDatum.name === pubkeyPrefix;
});
return (
<g key={`node-${nodeDatum.name}`}>
<circle
r={15}
fill={isRoot ? "#3b82f6" : "#6b7280"}
stroke={isOriginPubkey && !isRoot ? "#10b981" : "none"}
strokeWidth={isOriginPubkey && !isRoot ? 2 : 0}
/>
<text
textAnchor="middle"
y="5"
style={{ fontSize: "12px", fill: "white", fontWeight: "bold", stroke: "none" }}
>
{nodeDatum.name}
</text>
</g>
);
}}
/>
)}
</div>
</div>
)
), [showGraph, originsCount, treeData, msg.ingest_timestamp]);
if (parsed) {
return (
@@ -120,6 +260,7 @@ export default function ChatMessageItem({ msg, showErrorRow }: { msg: ChatMessag
<span>{parsed.text}</span>
</div>
<OriginsBox />
<GraphView />
</div>
);
}
@@ -133,6 +274,7 @@ export default function ChatMessageItem({ msg, showErrorRow }: { msg: ChatMessag
<span className="text-xs text-gray-500 ml-2">channel: {msg.channel_hash}</span>
</div>
<OriginsBox />
<GraphView />
</div>
);
}else{
@@ -148,6 +290,19 @@ export default function ChatMessageItem({ msg, showErrorRow }: { msg: ChatMessag
</div>
<div className="w-full h-5 bg-gray-200 dark:bg-neutral-800 rounded animate-pulse my-2" />
<OriginsBox />
<GraphView />
</div>
);
}
}
export default React.memo(ChatMessageItem, (prevProps, nextProps) => {
// Only re-render if these key properties change
return (
prevProps.msg.ingest_timestamp === nextProps.msg.ingest_timestamp &&
prevProps.msg.encrypted_message === nextProps.msg.encrypted_message &&
prevProps.msg.mac === nextProps.msg.mac &&
prevProps.msg.channel_hash === nextProps.msg.channel_hash &&
prevProps.msg.origin_key_path_array?.length === nextProps.msg.origin_key_path_array?.length &&
prevProps.showErrorRow === nextProps.showErrorRow
);
});