path display overhaul

This commit is contained in:
ajvpot
2025-09-23 04:04:17 +02:00
parent 665b91f1fa
commit b173351011
10 changed files with 191 additions and 142 deletions
@@ -5,7 +5,6 @@ import Link from "next/link";
import moment from "moment";
import { formatPublicKey } from "@/lib/meshcore";
import { getNameIconLabel } from "@/lib/meshcore-map-nodeutils";
import PathDisplay from "@/components/PathDisplay";
import AdvertDetails from "@/components/AdvertDetails";
import ContactQRCode from "@/components/ContactQRCode";
import { useConfig, LAST_SEEN_OPTIONS } from "@/components/ConfigContext";
+25 -2
View File
@@ -13,11 +13,14 @@ import { ChevronDownIcon } from '@heroicons/react/24/outline';
function SearchPageContent() {
const { config } = useConfig();
const { query, setQuery, setLimit, setExact } = useSearchQuery();
const { query, setQuery, setLimit, setExact, setIsRepeater } = useSearchQuery();
const [showFilters, setShowFilters] = useState(false);
// Helper function to check if exact search is enabled
const isExactEnabled = query.exact === true || (typeof query.exact === 'string' && (query.exact === 'true' || query.exact === ''));
// Helper function to check if is_repeater search is enabled
const isRepeaterEnabled = query.is_repeater === true || (typeof query.is_repeater === 'string' && (query.is_repeater === 'true' || query.is_repeater === ''));
// Always use config values for region and lastSeen
const searchParams = {
@@ -26,6 +29,7 @@ function SearchPageContent() {
lastSeen: config.lastSeen,
limit: query.limit || 50,
exact: isExactEnabled,
is_repeater: isRepeaterEnabled,
};
const { data, isLoading, error } = useMeshcoreSearch({
@@ -82,7 +86,7 @@ function SearchPageContent() {
{showFilters && (
<div className="mt-4 p-4 bg-white dark:bg-neutral-800 rounded-lg border border-gray-200 dark:border-neutral-700">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
{/* Region Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
@@ -155,6 +159,25 @@ function SearchPageContent() {
</div>
</div>
{/* Repeater Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Node Type
</label>
<div className="flex items-center">
<input
type="checkbox"
id="is-repeater"
checked={isRepeaterEnabled}
onChange={(e) => setIsRepeater(e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="is-repeater" className="ml-2 text-sm text-gray-700 dark:text-gray-300">
Repeaters only
</label>
</div>
</div>
</div>
</div>
)}
+2 -2
View File
@@ -30,9 +30,9 @@ export async function POST(req: Request) {
});
}
if (body.queries.length > 50) {
if (body.queries.length > 500) {
return NextResponse.json({
error: "Maximum 50 queries allowed per batch",
error: "Maximum 500 queries allowed per batch",
code: "TOO_MANY_QUERIES"
}, { status: 400 });
}
+2 -1
View File
@@ -2,7 +2,8 @@
import { useState } from "react";
import moment from "moment";
import PathVisualization, { PathData } from "./PathVisualization";
import PathVisualization from "./PathVisualization";
import { PathData } from "@/lib/pathUtils";
interface AdvertDetailsProps {
advert: {
+4 -1
View File
@@ -2,7 +2,8 @@
import React, { useMemo } from "react";
import { useConfig } from "./ConfigContext";
import { useMessageDecryption } from "@/hooks/useMessageDecryption";
import PathVisualization, { PathData } from "./PathVisualization";
import PathVisualization from "./PathVisualization";
import { PathData } from "@/lib/pathUtils";
import NodeLinkWithHover from "./NodeLinkWithHover";
import { findNodeMentions } from "@/lib/node-utils";
@@ -90,6 +91,7 @@ function ChatMessageContent({ text }: { text: string }) {
<NodeLinkWithHover
key={index}
nodeName={nodeName}
exact={true}
>
@{nodeName}
</NodeLinkWithHover>
@@ -149,6 +151,7 @@ function ChatMessageItem({ msg, showErrorRow }: { msg: ChatMessage, showErrorRow
{parsed.sender ? (
<NodeLinkWithHover
nodeName={parsed.sender}
exact={true}
>
{parsed.sender}
</NodeLinkWithHover>
+20 -5
View File
@@ -11,11 +11,15 @@ import NodeCard from '@/components/NodeCard';
interface NodeLinkWithHoverProps {
nodeName: string;
children: React.ReactNode;
exact?: boolean;
is_repeater?: boolean;
}
export default function NodeLinkWithHover({
nodeName,
children
children,
exact = false,
is_repeater = false
}: NodeLinkWithHoverProps) {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [isWaitingForSearch, setIsWaitingForSearch] = useState(false);
@@ -32,7 +36,8 @@ export default function NodeLinkWithHover({
region: config.selectedRegion,
lastSeen: config.lastSeen,
limit: 10,
exact: true,
exact: exact,
is_repeater: is_repeater,
enabled: !!nodeName
});
@@ -49,7 +54,11 @@ export default function NodeLinkWithHover({
if (foundNode) return `/meshcore/node/${foundNode.public_key}`;
// If no results or multiple results, link to search page
return `/search?q=${encodeURIComponent(nodeName)}&exact`;
const searchUrl = `/search?q=${encodeURIComponent(nodeName)}`;
const params = [];
if (exact) params.push('exact');
if (is_repeater) params.push('is_repeater');
return searchUrl + (params.length > 0 ? '&' + params.join('&') : '');
})();
// Handle click behavior
@@ -75,11 +84,17 @@ export default function NodeLinkWithHover({
// Calculate navigation URL directly here since linkHref might still be "#"
const navigationUrl = foundNode
? `/meshcore/node/${foundNode.public_key}`
: `/search?q=${encodeURIComponent(nodeName)}&exact`;
: (() => {
const searchUrl = `/search?q=${encodeURIComponent(nodeName)}`;
const params = [];
if (exact) params.push('exact');
if (is_repeater) params.push('is_repeater');
return searchUrl + (params.length > 0 ? '&' + params.join('&') : '');
})();
router.push(navigationUrl);
}
}, [isWaitingForSearch, isSearchLoading, foundNode, router, nodeName, searchData]);
}, [isWaitingForSearch, isSearchLoading, foundNode, router, nodeName, searchData, exact, is_repeater]);
// Popover content component
const PopoverContent = () => {
-29
View File
@@ -1,29 +0,0 @@
"use client";
import React from "react";
interface PathDisplayProps {
path: string;
origin_pubkey: string;
className?: string;
}
export default function PathDisplay({
path,
origin_pubkey,
className = ""
}: PathDisplayProps) {
// Parse path into 2-character slices
const pathSlices = path.match(/.{1,2}/g) || [];
const formattedPath = pathSlices.join(' ');
// Get first 2 characters of the pubkey for display
const pubkeyPrefix = origin_pubkey.substring(0, 2);
return (
<div className={`flex items-center gap-2 ${className}`}>
<span className="font-mono text-sm">{formattedPath}</span>
<span className="text-blue-600 dark:text-blue-400 text-sm">({pubkeyPrefix})</span>
</div>
);
}
+41 -100
View File
@@ -6,27 +6,18 @@ import Link from "next/link";
import Tree from 'react-d3-tree';
import { ArrowsPointingOutIcon, ArrowsPointingInIcon } from "@heroicons/react/24/outline";
import { ExternalLink } from "lucide-react";
import PathDisplay from "./PathDisplay";
import NodeLinkWithHover from "./NodeLinkWithHover";
import { useMeshcoreSearches } from "@/hooks/useMeshcoreSearch";
import type { MeshcoreSearchResult } from "@/hooks/useMeshcoreSearch";
import { useConfigWithRegion } from "@/hooks/useConfigWithRegion";
export interface PathData {
origin: string;
pubkey: string;
path: string;
}
interface PathGroup {
path: string;
pathSlices: string[];
indices: number[];
}
interface TreeNode {
name: string;
children?: TreeNode[];
}
import {
PathData,
PathGroup,
TreeNode,
groupPathsByStructure,
buildTreeFromPathGroups,
extractUniquePrefixes
} from "@/lib/pathUtils";
interface PathVisualizationProps {
paths: PathData[];
@@ -53,77 +44,23 @@ export default function PathVisualization({
const { config } = useConfigWithRegion();
const pathsCount = paths.length;
// Group paths by structure
const pathGroups = useMemo(() =>
groupPathsByStructure(paths),
[paths]
);
// Process data for tree visualization
const treeData = useMemo(() => {
if (!showGraph || pathsCount === 0) return null;
// Group messages by path similarity
const pathGroups: PathGroup[] = [];
paths.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 rootName = initiatingNodeKey ? initiatingNodeKey.substring(0, 2) : "??";
const root: TreeNode = { name: rootName, 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, paths, pathsCount, initiatingNodeKey]);
return buildTreeFromPathGroups(pathGroups, initiatingNodeKey);
}, [showGraph, pathsCount, pathGroups, initiatingNodeKey]);
// Extract unique prefixes from tree data for name lookups
const uniquePrefixes = useMemo(() => {
if (!treeData) return [];
const prefixes = new Set<string>();
const extractPrefixes = (node: TreeNode) => {
prefixes.add(node.name);
node.children?.forEach(extractPrefixes);
};
extractPrefixes(treeData);
return Array.from(prefixes);
}, [treeData]);
const uniquePrefixes = useMemo(() =>
extractUniquePrefixes(treeData),
[treeData]
);
// Use the new useMeshcoreSearches hook to handle multiple prefix searches
// Filter out "??" prefix and only search for valid hex prefixes
@@ -187,23 +124,27 @@ export default function PathVisualization({
const PathsList = useCallback(() => (
<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">
{paths.map(({ origin, pubkey, path }, index) => (
<div key={index} className="flex items-center gap-2">
<Link
href={`/meshcore/node/${pubkey}`}
className="hover:underline cursor-pointer"
>
{origin}
</Link>
<PathDisplay
path={path}
origin_pubkey={pubkey}
className="text-xs"
/>
{pathGroups.map((group, groupIndex) => (
<div key={groupIndex} className="mb-2 last:mb-0">
<div className="flex flex-wrap gap-1 ml-2 items-center">
{group.pathSlices.map((slice, sliceIndex) => (
<NodeLinkWithHover key={sliceIndex} nodeName={slice} exact={false} is_repeater={true}>
<span className="text-gray-800 dark:text-gray-200 text-xs">
{slice}
</span>
</NodeLinkWithHover>
))}
{group.count > 1 && (
<span className="text-gray-500 dark:text-gray-400 text-xs ml-1">
(x{group.count})
{/* TODO: this doesnt work? */}
</span>
)}
</div>
</div>
))}
</div>
), [paths]);
), [pathGroups]);
// Memoize the render function to prevent unnecessary re-renders
const renderCustomNodeElement = useCallback(({ nodeDatum, toggleNode }: any) => {
@@ -333,7 +274,7 @@ export default function PathVisualization({
<div className={className}>
<div className="flex items-center gap-2 mb-2">
<span className="text-sm text-gray-600 dark:text-gray-300">
{pathsCount} path{pathsCount !== 1 ? 's' : ''}
Heard {pathsCount} time{pathsCount !== 1 ? 's' : ''}
</span>
{pathsCount > 0 && (
<button
@@ -368,7 +309,7 @@ export default function PathVisualization({
onClick={handleToggle}
className="flex items-center gap-1 hover:text-gray-800 dark:hover:text-gray-100 transition-colors"
>
<span>{pathsCount} path{pathsCount !== 1 ? 's' : ''}</span>
<span>Heard {pathsCount} time{pathsCount !== 1 ? 's' : ''}</span>
<svg
className={`w-3 h-3 transition-transform ${expanded ? 'rotate-180' : ''}`}
fill="none"
+3 -1
View File
@@ -109,16 +109,18 @@ export interface SearchQuery {
q: string;
limit?: number;
exact?: boolean;
is_repeater?: boolean;
}
export function useSearchQuery() {
const { query, setParam } = useQueryParams<SearchQuery>({ q: '', limit: 50, exact: false });
const { query, setParam } = useQueryParams<SearchQuery>({ q: '', limit: 50, exact: false, is_repeater: false });
return {
query,
setQuery: (q: string) => setParam('q', q),
setLimit: (limit: number) => setParam('limit', limit),
setExact: (exact: boolean) => setParam('exact', exact),
setIsRepeater: (is_repeater: boolean) => setParam('is_repeater', is_repeater),
updateQuery: (updates: Partial<SearchQuery>) => {
Object.entries(updates).forEach(([key, value]) => {
setParam(key as keyof SearchQuery, value as any);
+94
View File
@@ -0,0 +1,94 @@
export interface PathData {
origin: string;
pubkey: string;
path: string;
}
export interface PathGroup {
path: string;
pathSlices: string[];
indices: number[];
count: number;
}
export interface TreeNode {
name: string;
children?: TreeNode[];
}
/**
* Groups paths by their structure similarity
*/
export function groupPathsByStructure(paths: PathData[]): PathGroup[] {
const pathGroups: PathGroup[] = [];
paths.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);
existingGroup.count++;
} else {
pathGroups.push({
path: path + pubkeyPrefix,
pathSlices: fullPathSlices,
indices: [index],
count: 1
});
}
});
return pathGroups;
}
/**
* Builds a tree structure from path groups for visualization
*/
export function buildTreeFromPathGroups(pathGroups: PathGroup[], initiatingNodeKey?: string): TreeNode {
const rootName = initiatingNodeKey ? initiatingNodeKey.substring(0, 2) : "??";
const root: TreeNode = { name: rootName, children: [] };
pathGroups.forEach(group => {
let currentNode = root;
group.pathSlices.forEach((slice) => {
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;
}
/**
* Extracts all unique prefixes from a tree structure
*/
export function extractUniquePrefixes(treeData: TreeNode | null): string[] {
if (!treeData) return [];
const prefixes = new Set<string>();
const extractPrefixes = (node: TreeNode) => {
prefixes.add(node.name);
node.children?.forEach(extractPrefixes);
};
extractPrefixes(treeData);
return Array.from(prefixes);
}