diff --git a/package-lock.json b/package-lock.json index c88bf5c..af6a757 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@tanstack/react-query": "^5.87.1", "@types/leaflet": "^1.9.19", "@types/qrcode": "^1.5.5", + "@yornaath/batshit": "^0.10.1", "aes-js": "^3.1.2", "class-variance-authority": "^0.7.1", "clickhouse": "^2.6.0", @@ -29,6 +30,7 @@ "react-d3-tree": "^3.6.6", "react-dom": "^19.1.0", "react-leaflet": "^5.0.0", + "react-tiny-popover": "^8.1.6", "tailwind-merge": "^3.3.1" }, "devDependencies": { @@ -2075,6 +2077,19 @@ "win32" ] }, + "node_modules/@yornaath/batshit": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@yornaath/batshit/-/batshit-0.10.1.tgz", + "integrity": "sha512-WGZ1WNoiVN6CLf28O73+6SCf+2lUn4U7TLGM9f4zOad0pn9mdoXIq8cwu3Kpf7N2OTYgWGK4eQPTflwFlduDGA==", + "dependencies": { + "@yornaath/batshit-devtools": "^1.7.1" + } + }, + "node_modules/@yornaath/batshit-devtools": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@yornaath/batshit-devtools/-/batshit-devtools-1.7.1.tgz", + "integrity": "sha512-AyttV1Njj5ug+XqEWY1smV45dTWMlWKtj1B8jcFYgBKUFyUlF/qEhD+iP1E5UaRYW6hQRYD9T2WNDwFTrOMWzQ==" + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -5719,6 +5734,15 @@ "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" }, + "node_modules/react-tiny-popover": { + "version": "8.1.6", + "resolved": "https://registry.npmjs.org/react-tiny-popover/-/react-tiny-popover-8.1.6.tgz", + "integrity": "sha512-jeZnGqHxb5TX7pCzpqLoVJned7DTVnLrLoCQQGFTyvlxXB/QUaet7O0krG22t5FReMBH035SLnzThKvk8tIfsg==", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", diff --git a/package.json b/package.json index 8f31ce3..a98cb90 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@tanstack/react-query": "^5.87.1", "@types/leaflet": "^1.9.19", "@types/qrcode": "^1.5.5", + "@yornaath/batshit": "^0.10.1", "aes-js": "^3.1.2", "class-variance-authority": "^0.7.1", "clickhouse": "^2.6.0", @@ -30,6 +31,7 @@ "react-d3-tree": "^3.6.6", "react-dom": "^19.1.0", "react-leaflet": "^5.0.0", + "react-tiny-popover": "^8.1.6", "tailwind-merge": "^3.3.1" }, "devDependencies": { diff --git a/src/app/api/meshcore/search/route.ts b/src/app/api/meshcore/search/route.ts index b07841d..3a4650d 100644 --- a/src/app/api/meshcore/search/route.ts +++ b/src/app/api/meshcore/search/route.ts @@ -1,70 +1,100 @@ import { NextResponse } from "next/server"; import { searchMeshcoreNodes } from "@/lib/clickhouse/actions"; -export async function GET(req: Request) { +interface SearchQueryParams { + query?: string; + region?: string; + lastSeen?: string | null; + limit: number; + exact: boolean; + is_repeater?: boolean; +} + +export async function POST(req: Request) { try { - const { searchParams } = new URL(req.url); - const query = searchParams.get("q"); - const region = searchParams.get("region"); - const lastSeen = searchParams.get("lastSeen"); - const limit = parseInt(searchParams.get("limit") || "50", 10); + const body = await req.json(); - // Validate limit - if (limit < 1 || limit > 200) { + // Validate that body contains an array of queries + if (!Array.isArray(body.queries)) { return NextResponse.json({ - error: "Limit must be between 1 and 200", - code: "INVALID_LIMIT" + error: "Body must contain a 'queries' array", + code: "INVALID_BODY" }, { status: 400 }); } - // If no query provided, return empty results - if (!query || query.trim().length === 0) { + // Validate queries array length + if (body.queries.length === 0) { return NextResponse.json({ results: [], - total: 0, - query: query || "", - region: region || null + total: 0 }); } - // Validate query length - if (query.length > 100) { + if (body.queries.length > 50) { return NextResponse.json({ - error: "Query too long (max 100 characters)", - code: "QUERY_TOO_LONG" + error: "Maximum 50 queries allowed per batch", + code: "TOO_MANY_QUERIES" }, { status: 400 }); } - // Validate lastSeen parameter - let lastSeenValue: string | null = null; - if (lastSeen !== null) { - const lastSeenNum = parseInt(lastSeen, 10); - if (isNaN(lastSeenNum) || lastSeenNum < 0) { - return NextResponse.json({ - error: "lastSeen must be a positive number (seconds)", - code: "INVALID_LAST_SEEN" - }, { status: 400 }); + // Validate and normalize each query + const normalizedQueries: SearchQueryParams[] = body.queries.map((queryObj: any, index: number) => { + // Validate limit for each query + const limit = parseInt(queryObj.limit || "50", 10); + if (limit < 1 || limit > 200) { + throw new Error(`Query ${index}: Limit must be between 1 and 200`); } - lastSeenValue = lastSeen; - } + + // Validate query length + if (queryObj.query && queryObj.query.length > 100) { + throw new Error(`Query ${index}: Query too long (max 100 characters)`); + } + + // Validate lastSeen parameter + let lastSeenValue: string | null = null; + if (queryObj.lastSeen !== null && queryObj.lastSeen !== undefined) { + const lastSeenNum = parseInt(queryObj.lastSeen, 10); + if (isNaN(lastSeenNum) || lastSeenNum < 0) { + throw new Error(`Query ${index}: lastSeen must be a positive number (seconds)`); + } + lastSeenValue = queryObj.lastSeen.toString(); + } + + return { + query: queryObj.query?.trim() || undefined, + region: queryObj.region || undefined, + lastSeen: lastSeenValue, + limit, + exact: Boolean(queryObj.exact), + is_repeater: queryObj.is_repeater !== undefined ? Boolean(queryObj.is_repeater) : undefined + }; + }); - const results = await searchMeshcoreNodes({ - query: query.trim(), - region: region || undefined, - lastSeen: lastSeenValue, - limit + // Execute batch search + const results = await searchMeshcoreNodes(normalizedQueries); + + // Format response - array of arrays, one per query + const formattedResults = normalizedQueries.map((queryParams: SearchQueryParams, index: number) => { + const queryResults = Array.isArray(results) + ? (Array.isArray(results[index]) ? results[index] : []) + : []; + + return queryResults; }); return NextResponse.json({ - results, - total: results.length, - query: query.trim(), - region: region || null, - lastSeen: lastSeenValue, - limit + results: formattedResults }); } catch (error) { - console.error("Error searching meshcore nodes:", error); + console.error("Error in batch search:", error); + + // Handle validation errors + if (error instanceof Error && error.message.includes('Query ')) { + return NextResponse.json({ + error: error.message, + code: "VALIDATION_ERROR" + }, { status: 400 }); + } // Check if it's a ClickHouse connection error if (error instanceof Error && error.message.includes('ClickHouse')) { @@ -75,7 +105,7 @@ export async function GET(req: Request) { } return NextResponse.json({ - error: "Failed to search nodes", + error: "Failed to execute batch search", code: "INTERNAL_ERROR" }, { status: 500 }); } diff --git a/src/app/api/stats/total-nodes/route.ts b/src/app/api/stats/total-nodes/route.ts index 53f3dcd..f859122 100644 --- a/src/app/api/stats/total-nodes/route.ts +++ b/src/app/api/stats/total-nodes/route.ts @@ -10,7 +10,7 @@ export async function GET(req: Request) { const regionFilter = generateRegionWhereClause(region); const whereClause = regionFilter.whereClause ? `WHERE ${regionFilter.whereClause}` : ''; - const query = `SELECT count() AS total_nodes FROM meshcore_adverts_latest ${whereClause}`; + const query = `SELECT count(DISTINCT public_key) AS total_nodes FROM meshcore_adverts ${whereClause}`; const resultSet = await clickhouse.query({ query, format: 'JSONEachRow' }); const rows = await resultSet.json() as Array<{ total_nodes: number }>; diff --git a/src/app/globals.css b/src/app/globals.css index 9ef44fb..c6441cd 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -153,6 +153,32 @@ html { position: relative; } +.custom-node-marker--loading { + position: relative; +} + +.custom-node-marker--loading::after { + content: ''; + position: absolute; + top: -4px; + left: -4px; + right: -4px; + bottom: -4px; + border: 2px solid transparent; + border-top: 2px solid #2563eb; /* blue-600 */ + border-radius: 50%; + animation: node-spinner 1s linear infinite; +} + +@keyframes node-spinner { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + .map-spinner { width: 28px; height: 28px; diff --git a/src/app/meshcore/node/[publicKey]/page.tsx b/src/app/meshcore/node/[publicKey]/page.tsx index dbd7787..2d52d01 100644 --- a/src/app/meshcore/node/[publicKey]/page.tsx +++ b/src/app/meshcore/node/[publicKey]/page.tsx @@ -1,6 +1,5 @@ "use client"; -import { useEffect, useState } from "react"; import { useParams } from "next/navigation"; import Link from "next/link"; import moment from "moment"; @@ -11,59 +10,10 @@ import AdvertDetails from "@/components/AdvertDetails"; import ContactQRCode from "@/components/ContactQRCode"; import { useConfig, LAST_SEEN_OPTIONS } from "@/components/ConfigContext"; import { useNeighbors, type Neighbor } from "@/hooks/useNeighbors"; +import { useNodeData, type NodeData, type NodeInfo, type Advert, type LocationHistory, type MqttInfo, type NodeError } from "@/hooks/useNodeData"; +import { ArrowRightEndOnRectangleIcon, ArrowRightStartOnRectangleIcon } from "@heroicons/react/24/outline"; -interface NodeInfo { - public_key: string; - node_name: string; - latitude: number | null; - longitude: number | null; - has_location: number; - is_repeater: number; - is_chat_node: number; - is_room_server: number; - has_name: number; -} - -interface Advert { - group_id: number; - origin_path_pubkey_tuples: Array<[string, string, string]>; // Array of [origin, path, origin_pubkey] tuples - advert_count: number; - earliest_timestamp: string; - latest_timestamp: string; - latitude: number | null; - longitude: number | null; - is_repeater: number; - is_chat_node: number; - is_room_server: number; - has_location: number; -} - -interface LocationHistory { - mesh_timestamp: string; - latitude: number; - longitude: number; -} - -interface MqttTopic { - topic: string; - broker: string; - last_packet_time: string; - is_recent: boolean; -} - -interface MqttInfo { - is_uplinked: boolean; - has_packets: boolean; - topics: MqttTopic[]; -} - - -interface NodeData { - node: NodeInfo; - recentAdverts: Advert[]; - locationHistory: LocationHistory[]; - mqtt: MqttInfo; -} +// Interfaces are now imported from useNodeData hook // Function to determine node type based on capabilities function getNodeType(node: NodeInfo): number { @@ -77,72 +27,30 @@ export default function MeshcoreNodePage() { const params = useParams(); const publicKey = params.publicKey as string; const { config } = useConfig(); - const [nodeData, setNodeData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [errorCode, setErrorCode] = useState(null); - // Use TanStack Query for neighbors data - only fetch if node is uplinked + // Use TanStack Query for node data + const { + data: nodeData, + isLoading: loading, + error: queryError + } = useNodeData({ + publicKey: publicKey, + enabled: !!publicKey + }); + + // Use TanStack Query for neighbors data - fetch for all nodes const { data: neighbors = [], isLoading: neighborsLoading } = useNeighbors({ nodeId: publicKey, lastSeen: config.lastSeen, - enabled: !!nodeData?.mqtt?.is_uplinked + enabled: !!publicKey }); - // Fetch node data only when publicKey changes - useEffect(() => { - if (!publicKey) return; - - const fetchNodeData = async () => { - try { - setLoading(true); - setError(null); - setErrorCode(null); - - // Fetch node data - const nodeResponse = await fetch(`/api/meshcore/node/${publicKey}`); - - if (nodeResponse.status === 404) { - const errorData = await nodeResponse.json().catch(() => ({})); - setError(errorData.error || "Node not found"); - setErrorCode(errorData.code || "NODE_NOT_FOUND"); - return; - } - - if (nodeResponse.status === 400) { - const errorData = await nodeResponse.json().catch(() => ({})); - setError(errorData.error || "Invalid request"); - setErrorCode(errorData.code || "BAD_REQUEST"); - return; - } - - if (nodeResponse.status === 503) { - const errorData = await nodeResponse.json().catch(() => ({})); - setError(errorData.error || "Service temporarily unavailable"); - setErrorCode(errorData.code || "SERVICE_UNAVAILABLE"); - return; - } - - if (!nodeResponse.ok) { - const errorData = await nodeResponse.json().catch(() => ({})); - throw new Error(errorData.error || "Failed to fetch node data"); - } - - const nodeData = await nodeResponse.json(); - setNodeData(nodeData); - } catch (err) { - setError(err instanceof Error ? err.message : "An error occurred"); - setErrorCode("NETWORK_ERROR"); - } finally { - setLoading(false); - } - }; - - fetchNodeData(); - }, [publicKey]); + // Extract error information from TanStack Query error + const error = queryError?.error || null; + const errorCode = queryError?.code || null; if (loading) { @@ -354,6 +262,32 @@ export default function MeshcoreNodePage() { {node.public_key} +
+
First Seen
+
+
+
+ {moment.utc(node.first_seen).format('YYYY-MM-DD HH:mm:ss')} UTC +
+
+ {moment.utc(node.first_seen).local().fromNow()} +
+
+
+
+
+
Last Seen
+
+
+
+ {moment.utc(node.last_seen).format('YYYY-MM-DD HH:mm:ss')} UTC +
+
+ {moment.utc(node.last_seen).local().fromNow()} +
+
+
+
Current Location
@@ -463,9 +397,8 @@ export default function MeshcoreNodePage() {
- {/* Neighbors Section - Only show if MQTT uplink is connected */} - {mqtt.is_uplinked && ( -
+ {/* Neighbors Section - Show for all nodes */} +

Neighbors ({neighborsLoading ? "..." : neighbors.length}) @@ -545,11 +478,8 @@ export default function MeshcoreNodePage() { {neighbor.directions && neighbor.directions.length > 0 && (
Direction: - {neighbor.directions.includes('incoming') && 📥} - {neighbor.directions.includes('outgoing') && 📤} - {neighbor.directions.includes('incoming') && neighbor.directions.includes('outgoing') && ( - ↔️ Bidirectional - )} + {neighbor.directions.includes('incoming') && } + {neighbor.directions.includes('outgoing') && }
)}

@@ -559,7 +489,6 @@ export default function MeshcoreNodePage() { )}
- )} ); diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 9f4e2c2..22e884a 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -7,20 +7,25 @@ import SearchInput from '@/components/SearchInput'; import SearchResults from '@/components/SearchResults'; import RegionSelector from '@/components/RegionSelector'; import { LAST_SEEN_OPTIONS } from '@/components/ConfigContext'; -import { useState, Suspense } from 'react'; +import { useState, Suspense, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; import { ChevronDownIcon } from '@heroicons/react/24/outline'; function SearchPageContent() { const { config } = useConfig(); - const { query, setQuery, setLimit } = useSearchQuery(); + const { query, setQuery, setLimit, setExact } = 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 === '')); + // Always use config values for region and lastSeen const searchParams = { query: query.q, region: config.selectedRegion, lastSeen: config.lastSeen, limit: query.limit || 50, + exact: isExactEnabled, }; const { data, isLoading, error } = useMeshcoreSearch({ @@ -77,7 +82,7 @@ function SearchPageContent() { {showFilters && (
-
+
{/* Region Filter */}
+ + {/* Exact Match Filter */} +
+ +
+ setExact(e.target.checked)} + className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" + /> + +
+
+
)} diff --git a/src/components/ChatBox.tsx b/src/components/ChatBox.tsx index 36d15e3..2e35ef1 100644 --- a/src/components/ChatBox.tsx +++ b/src/components/ChatBox.tsx @@ -1,27 +1,15 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState } from "react"; import { MinusIcon, PlusIcon } from "@heroicons/react/24/outline"; import { useConfig } from "./ConfigContext"; -import { decryptMeshcoreGroupMessage } from "../lib/meshcore"; import { getChannelIdFromKey } from "../lib/meshcore"; -import ChatMessageItem, { ChatMessage } from "./ChatMessageItem"; +import ChatMessageItem from "./ChatMessageItem"; import RefreshButton from "./RefreshButton"; -import { buildApiUrl } from "../lib/api"; import RegionSelector from "./RegionSelector"; import { getRegionConfig } from "../lib/regions"; +import { useChatMessages } from "../hooks/useChatMessages"; +import { useIntersectionObserver } from "../hooks/useIntersectionObserver"; -const PAGE_SIZE = 20; - -function formatHex(hex: string): string { - // Add a space every 2 characters for readability - return hex.replace(/(.{2})/g, "$1 ").trim(); -} - -function formatLocalTime(utcString: string): string { - // Parse as UTC and display in local time - const utcDate = new Date(utcString + (utcString.endsWith("Z") ? "" : "Z")); - return utcDate.toLocaleString(); -} interface ChatBoxProps { showAllMessagesTab?: boolean; @@ -53,112 +41,53 @@ export default function ChatBox({ const [selectedTab, setSelectedTab] = useState(showAllMessagesTab ? 1 : 0); const [minimized, setMinimized] = useState(!startExpanded); // Use startExpanded as default for minimized state - const [messages, setMessages] = useState([]); - const [loading, setLoading] = useState(false); - const [hasMore, setHasMore] = useState(true); - const [lastBefore, setLastBefore] = useState(undefined); const selectedKey = allTabs[selectedTab]; const channelId = selectedKey.isAllMessages ? undefined : getChannelIdFromKey(selectedKey.privateKey).toUpperCase(); + + // Use the new chat messages hook + const { + messages, + loading, + hasMore, + loadMore, + isLoadingMore, + refresh, + isRefreshing + } = useChatMessages({ + channelId, + region: config?.selectedRegion, + enabled: !minimized, + autoRefreshEnabled: !minimized, + }); // Only show tabs if more than one channel (or if we have all messages tab) const showTabs = allTabs.length > 1; - const fetchMessages = useCallback( - async (before?: string, replace = false, after?: string) => { - if (!config?.selectedRegion) return; - - setLoading(true); - try { - let url = `/api/chat?limit=${PAGE_SIZE}®ion=${encodeURIComponent( - config.selectedRegion! - )}`; - if (channelId) url += `&channel_id=${channelId}`; - - if (after) { - // Fetch newer messages using the after parameter - url += `&after=${encodeURIComponent(after)}`; - } else if (before) { - // Fetch older messages using before parameter - url += `&before=${encodeURIComponent(before)}`; - } - - const res = await fetch(buildApiUrl(url)); - const data = await res.json(); - if (Array.isArray(data)) { - if (after) { - // Add newer messages to the beginning (most recent first) - if (data.length > 0) { - setMessages((prev) => [...data, ...prev]); - } - } else { - setMessages((prev) => (replace ? data : [...prev, ...data])); - setHasMore(data.length === PAGE_SIZE); - if (data.length > 0) { - setLastBefore(data[data.length - 1].ingest_timestamp); - } - } - } else { - // Only set hasMore to false if this is not an auto-refresh request - if (!after) { - setHasMore(false); - } - } - } catch (error) { - console.error("Load failed:", error); - } finally { - setLoading(false); + // Set up intersection observer for infinite scrolling + const loadMoreTriggerRef = useIntersectionObserver( + () => { + if (hasMore && !isLoadingMore && !loading) { + loadMore(); } }, - [channelId, config.selectedRegion] + { + threshold: 0.1, + rootMargin: '100px', + enabled: hasMore && !isLoadingMore && !loading + } ); - useEffect(() => { - if (!minimized) { - setMessages([]); - setHasMore(true); - setLastBefore(undefined); - fetchMessages(undefined, true); - } - }, [minimized, selectedTab, config?.selectedRegion, fetchMessages]); - - // Auto-refresh effect - useEffect(() => { - if (!minimized && messages.length > 0) { - const interval = setInterval(() => { - // Auto-refresh should only fetch newer messages, not replace all - // Pass the most recent timestamp directly to avoid ref issues - const mostRecentTimestamp = messages[0].ingest_timestamp; - fetchMessages(undefined, false, mostRecentTimestamp); - }, 5000); - - return () => clearInterval(interval); - } - }, [minimized, channelId, messages, fetchMessages]); - - const handleLoadMore = () => { - if (lastBefore) { - fetchMessages(lastBefore); - } - }; - const handleRefresh = () => { - setMessages([]); - setHasMore(true); - setLastBefore(undefined); - fetchMessages(undefined, true); + refresh(); }; - const LoadMoreButton = () => ( - + const LoadingIndicator = () => ( +
+
+
); return ( @@ -187,7 +116,7 @@ export default function ChatBox({ {!minimized && config?.selectedRegion && ( )} -
-
- {messages.length === 0 && !loading && ( -
- No chat messages found. -
- )} - {hasMore && !startExpanded && } - {(startExpanded ? messages : messages.toReversed()).map((msg, i) => ( - - ))} - {hasMore && startExpanded && } -
-
+
+
+ {messages.length === 0 && !loading && ( +
+ No chat messages found. +
+ )} + + {/* Messages */} + {(startExpanded ? messages : messages.toReversed()).map((msg, i) => ( + + ))} + + {/* Loading indicator */} + {isLoadingMore && } + + + {/* Load more trigger always at the bottom */} + {hasMore && ( +
+ )} +
+
)} {!minimized && !config?.selectedRegion && (
{ - setMessages([]); - setHasMore(true); - setLastBefore(undefined); - fetchMessages(undefined, true); - }} className="w-full" />
diff --git a/src/components/ChatMessageItem.tsx b/src/components/ChatMessageItem.tsx index 16cd068..b3eaa69 100644 --- a/src/components/ChatMessageItem.tsx +++ b/src/components/ChatMessageItem.tsx @@ -3,6 +3,7 @@ import React, { useState, useEffect, useMemo, useCallback } from "react"; import { useConfig } from "./ConfigContext"; import { decryptMeshcoreGroupMessage } from "../lib/meshcore"; import PathVisualization, { PathData } from "./PathVisualization"; +import NodeLinkWithHover from "./NodeLinkWithHover"; export interface ChatMessage { ingest_timestamp: string; @@ -115,7 +116,13 @@ function ChatMessageItem({ msg, showErrorRow }: { msg: ChatMessage, showErrorRow channel: {msg.channel_hash}
- {parsed.sender} + {parsed.sender ? ( + + {parsed.sender} + + ) : null} {parsed.sender && ": "} {linkifyText(parsed.text)}
diff --git a/src/components/ContactQRCode.tsx b/src/components/ContactQRCode.tsx index 0147606..354a58f 100644 --- a/src/components/ContactQRCode.tsx +++ b/src/components/ContactQRCode.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import QRCode from "qrcode"; interface ContactQRCodeProps { @@ -12,15 +12,17 @@ interface ContactQRCodeProps { export default function ContactQRCode({ name, publicKey, type, size = 200 }: ContactQRCodeProps) { const canvasRef = useRef(null); + const [contactUrl, setContactUrl] = useState(""); useEffect(() => { if (!canvasRef.current) return; const generateQR = async () => { try { - const contactUrl = `meshcore://contact/add?name=${encodeURIComponent(name)}&public_key=${encodeURIComponent(publicKey)}&type=${type}`; + const url = `meshcore://contact/add?name=${encodeURIComponent(name)}&public_key=${encodeURIComponent(publicKey)}&type=${type}`; + setContactUrl(url); - await QRCode.toCanvas(canvasRef.current, contactUrl, { + await QRCode.toCanvas(canvasRef.current, url, { width: size, margin: 2, color: { @@ -38,10 +40,17 @@ export default function ContactQRCode({ name, publicKey, type, size = 200 }: Con return (
- + + +
); } diff --git a/src/components/MapIcons.tsx b/src/components/MapIcons.tsx index bf7d892..57ea568 100644 --- a/src/components/MapIcons.tsx +++ b/src/components/MapIcons.tsx @@ -7,6 +7,8 @@ import { NodePosition } from '../types/map'; interface NodeMarkerProps { node: NodePosition; showNodeNames?: boolean; + isSelected?: boolean; + isLoadingNeighbors?: boolean; } interface ClusterMarkerProps { @@ -18,14 +20,22 @@ interface PopupContentProps { } // Individual node marker component -export function NodeMarker({ node, showNodeNames = true }: NodeMarkerProps) { +export function NodeMarker({ node, showNodeNames = true, isSelected = false, isLoadingNeighbors = false }: NodeMarkerProps) { const getMarkerClass = () => { + let baseClass = "custom-node-marker"; + if (node.type === "meshtastic") { - return "custom-node-marker custom-node-marker--green"; + baseClass += " custom-node-marker--green"; } else if (node.type === "meshcore") { - return "custom-node-marker custom-node-marker--blue custom-node-marker--top"; + baseClass += " custom-node-marker--blue custom-node-marker--top"; } - return "custom-node-marker"; + + // Only add loading class when actually loading neighbors + if (isLoadingNeighbors) { + baseClass += " custom-node-marker--loading"; + } + + return baseClass; }; return ( diff --git a/src/components/MapView.tsx b/src/components/MapView.tsx index 3163a44..5b56ac5 100644 --- a/src/components/MapView.tsx +++ b/src/components/MapView.tsx @@ -26,6 +26,7 @@ type ClusteredMarkersProps = { nodes: NodePosition[]; selectedNodeId: string | null; onNodeClick: (nodeId: string | null) => void; + isLoadingNeighbors?: boolean; }; // Individual marker component @@ -33,12 +34,14 @@ function IndividualMarker({ node, showNodeNames, selectedNodeId, - onNodeClick + onNodeClick, + isLoadingNeighbors = false }: { node: NodePosition; showNodeNames: boolean; selectedNodeId: string | null; onNodeClick: (nodeId: string | null) => void; + isLoadingNeighbors?: boolean; }) { const map = useMap(); const markerRef = useRef(null); @@ -52,11 +55,19 @@ function IndividualMarker({ useEffect(() => { if (!map) return; + const isSelected = selectedNodeId === node.node_id; const icon = L.divIcon({ className: 'custom-node-marker-container', iconSize: [16, 32], iconAnchor: [8, 8], - html: renderToString(), + html: renderToString( + + ), }); const marker = L.marker([node.latitude, node.longitude], { icon }); @@ -78,7 +89,7 @@ function IndividualMarker({ map.removeLayer(markerRef.current); } }; - }, [map, node, showNodeNames]); + }, [map, node, showNodeNames, selectedNodeId, isLoadingNeighbors]); // Update marker when node data changes useEffect(() => { @@ -89,16 +100,24 @@ function IndividualMarker({ } // Update icon and popup + const isSelected = selectedNodeId === node.node_id; const icon = L.divIcon({ className: 'custom-node-marker-container', iconSize: [16, 32], iconAnchor: [8, 8], - html: renderToString(), + html: renderToString( + + ), }); markerRef.current.setIcon(icon); markerRef.current.getPopup()?.setContent(renderToString()); } - }, [node, showNodeNames]); + }, [node, showNodeNames, selectedNodeId, isLoadingNeighbors]); return null; } @@ -108,12 +127,14 @@ function ClusteredMarkersGroup({ nodes, showNodeNames, selectedNodeId, - onNodeClick + onNodeClick, + isLoadingNeighbors = false }: { nodes: NodePosition[]; showNodeNames: boolean; selectedNodeId: string | null; onNodeClick: (nodeId: string | null) => void; + isLoadingNeighbors?: boolean; }) { const map = useMap(); const clusterGroupRef = useRef(null); @@ -143,11 +164,19 @@ function ClusteredMarkersGroup({ }); nodes.forEach((node: NodePosition) => { + const isSelected = selectedNodeId === node.node_id; const icon = L.divIcon({ className: 'custom-node-marker-container', iconSize: [16, 32], iconAnchor: [8, 8], - html: renderToString(), + html: renderToString( + + ), }); const marker = L.marker([node.latitude, node.longitude], { icon }); (marker as any).options.nodeData = node; @@ -172,12 +201,12 @@ function ClusteredMarkersGroup({ map.removeLayer(clusterGroupRef.current); } }; - }, [map, nodes, showNodeNames]); + }, [map, nodes, showNodeNames, selectedNodeId, isLoadingNeighbors]); return null; } -function ClusteredMarkers({ nodes, selectedNodeId, onNodeClick }: ClusteredMarkersProps) { +function ClusteredMarkers({ nodes, selectedNodeId, onNodeClick, isLoadingNeighbors = false }: ClusteredMarkersProps) { const configResult = useConfig(); const config = configResult?.config; const showNodeNames = config?.showNodeNames !== false; @@ -193,6 +222,7 @@ function ClusteredMarkers({ nodes, selectedNodeId, onNodeClick }: ClusteredMarke showNodeNames={showNodeNames} selectedNodeId={selectedNodeId} onNodeClick={onNodeClick} + isLoadingNeighbors={isLoadingNeighbors} /> ))} @@ -205,6 +235,7 @@ function ClusteredMarkers({ nodes, selectedNodeId, onNodeClick }: ClusteredMarke showNodeNames={showNodeNames} selectedNodeId={selectedNodeId} onNodeClick={onNodeClick} + isLoadingNeighbors={isLoadingNeighbors} /> ); } @@ -502,6 +533,7 @@ export default function MapView() { nodes={nodePositions} selectedNodeId={selectedNodeId} onNodeClick={handleNodeClick} + isLoadingNeighbors={neighborsLoading} />
+ {/* ChatBox temporarily disabled - infinite scroll is broken in reverse view
+ */}
); diff --git a/src/components/NodeCard.tsx b/src/components/NodeCard.tsx new file mode 100644 index 0000000..72cce0f --- /dev/null +++ b/src/components/NodeCard.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { MapPinIcon, WifiIcon, ChatBubbleLeftRightIcon, ServerIcon } from '@heroicons/react/24/outline'; +import Link from 'next/link'; +import moment from 'moment'; +import { formatPublicKey } from '@/lib/meshcore'; +import { MeshcoreSearchResult } from '@/hooks/useMeshcoreSearch'; + +export interface NodeCardData { + public_key: string; + node_name: string | null; + latitude: number | null; + longitude: number | null; + has_location: number; + is_repeater: number; + is_chat_node: number; + is_room_server: number; + last_seen: string; + topic?: string; + broker?: string; +} + +interface NodeCardProps { + node: NodeCardData | MeshcoreSearchResult; + className?: string; + showTopicInfo?: boolean; +} + +export default function NodeCard({ node, className = "", showTopicInfo = true }: NodeCardProps) { + const hasLocation = node.has_location === 1; + const isRepeater = node.is_repeater === 1; + const isChatNode = node.is_chat_node === 1; + const isRoomServer = node.is_room_server === 1; + + return ( + +
+
+
+

+ {node.node_name || 'Unnamed Node'} +

+
+ {isRepeater && ( + + + Repeater + + )} + {isChatNode && ( + + + Chat + + )} + {isRoomServer && ( + + + Room Server + + )} +
+
+ +
+ {hasLocation && node.latitude && node.longitude && ( +
+ + + {node.latitude.toFixed(4)}, {node.longitude.toFixed(4)} + +
+ )} + +
+ Last seen: {moment.utc(node.last_seen).local().fromNow()} + + {formatPublicKey(node.public_key)} + +
+ + {showTopicInfo && node.topic && node.broker && ( +
+ Topic: {node.topic} • Broker: {node.broker.split('://')[1]} +
+ )} +
+
+
+ + ); +} diff --git a/src/components/NodeLinkWithHover.tsx b/src/components/NodeLinkWithHover.tsx new file mode 100644 index 0000000..7360a08 --- /dev/null +++ b/src/components/NodeLinkWithHover.tsx @@ -0,0 +1,173 @@ +"use client"; + +import React, { useState, useRef, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { Popover } from 'react-tiny-popover'; +import { useMeshcoreSearch } from '@/hooks/useMeshcoreSearch'; +import { useConfig } from '@/components/ConfigContext'; +import NodeCard from '@/components/NodeCard'; + +interface NodeLinkWithHoverProps { + nodeName: string; + children: React.ReactNode; +} + +export default function NodeLinkWithHover({ + nodeName, + children +}: NodeLinkWithHoverProps) { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [isWaitingForSearch, setIsWaitingForSearch] = useState(false); + const { config } = useConfig(); + const router = useRouter(); + + // Search query - enabled immediately when component mounts + const { + data: searchData, + isLoading: isSearchLoading, + error: searchError + } = useMeshcoreSearch({ + query: nodeName, + region: config.selectedRegion, + lastSeen: config.lastSeen, + limit: 10, + exact: true, + enabled: !!nodeName + }); + + const searchResults = searchData?.results || []; + const foundNode = searchResults.length === 1 ? searchResults[0] : null; + const noResultsFound = searchData && searchResults.length === 0; + + // Determine the href for the Link component - handles all cases + const linkHref = (() => { + // If search is loading or hasn't completed, use placeholder + if (isSearchLoading) return "#"; + + // If exactly one result found, link directly to node + if (foundNode) return `/meshcore/node/${foundNode.public_key}`; + + // If no results or multiple results, link to search page + return `/search?q=${encodeURIComponent(nodeName)}&exact`; + })(); + + // Handle click behavior + const handleClick = (e: React.MouseEvent) => { + // If href is "#", prevent navigation and handle waiting + if (linkHref === "#") { + e.preventDefault(); + + // If search is in progress, wait for it + if (isSearchLoading) { + setIsWaitingForSearch(true); + } + } + // Otherwise, let Next.js Link handle the navigation + }; + + // Effect to handle navigation after search completes + useEffect(() => { + // Only trigger navigation when we were waiting AND search just completed + if (isWaitingForSearch && !isSearchLoading && searchData) { + setIsWaitingForSearch(false); + + // Calculate navigation URL directly here since linkHref might still be "#" + const navigationUrl = foundNode + ? `/meshcore/node/${foundNode.public_key}` + : `/search?q=${encodeURIComponent(nodeName)}&exact`; + + router.push(navigationUrl); + } + }, [isWaitingForSearch, isSearchLoading, foundNode, router, nodeName, searchData]); + + // Popover content component + const PopoverContent = () => { + return ( +
+ {isSearchLoading ? ( +
+
+

Searching for {nodeName}...

+
+ ) : searchError ? ( +
+

Search error

+

Click to search manually

+
+ ) : foundNode ? ( + + ) : searchResults.length === 0 ? ( +
+

No node found for "{nodeName}"

+

The node may need to advert to appear

+
+ ) : ( +
+

+ {searchResults.length} nodes found for "{nodeName}" +

+

Click to see all results

+
+ )} + + {isWaitingForSearch && ( +
+
+
+

Navigating...

+
+
+ )} +
+ ); + }; + + + // If search completed and no results found, render gray text instead of link + if (noResultsFound) { + return ( + } + onClickOutside={() => setIsPopoverOpen(false)} + containerStyle={{ zIndex: "1000" }} + > + setIsPopoverOpen(true)} + onMouseLeave={() => setIsPopoverOpen(false)} + > + {children} + + + ); + } + + return ( + } + onClickOutside={() => setIsPopoverOpen(false)} + containerStyle={{ zIndex: "1000" }} + > + setIsPopoverOpen(true)} + onMouseLeave={() => setIsPopoverOpen(false)} + onClick={handleClick} + > + {children} + + + ); +} diff --git a/src/components/PathVisualization.tsx b/src/components/PathVisualization.tsx index 7760605..b91d55c 100644 --- a/src/components/PathVisualization.tsx +++ b/src/components/PathVisualization.tsx @@ -6,6 +6,8 @@ import Link from "next/link"; import Tree from 'react-d3-tree'; import { ArrowsPointingOutIcon, ArrowsPointingInIcon } from "@heroicons/react/24/outline"; import PathDisplay from "./PathDisplay"; +import { useMeshcoreSearches } from "../hooks/useMeshcoreSearch"; +import type { MeshcoreSearchResult } from "../hooks/useMeshcoreSearch"; export interface PathData { origin: string; @@ -32,6 +34,7 @@ interface PathVisualizationProps { initiatingNodeKey?: string; } + export default function PathVisualization({ paths, title = "Paths", @@ -102,6 +105,67 @@ export default function PathVisualization({ return buildTree(); }, [showGraph, paths, pathsCount, initiatingNodeKey]); + // Extract unique prefixes from tree data for name lookups + const uniquePrefixes = useMemo(() => { + if (!treeData) return []; + + const prefixes = new Set(); + + const extractPrefixes = (node: TreeNode) => { + prefixes.add(node.name); + node.children?.forEach(extractPrefixes); + }; + + extractPrefixes(treeData); + return Array.from(prefixes); + }, [treeData]); + + // Use the new useMeshcoreSearches hook to handle multiple prefix searches + // Filter out "??" prefix and only search for valid hex prefixes + const searches = useMemo(() => + uniquePrefixes + .filter(prefix => prefix !== "??") // Don't search for placeholder prefix + .map(prefix => ({ + query: prefix, + exact: false, + limit: 20, + is_repeater: true, // Filter for repeaters only + enabled: showGraph && prefix.length > 0 + })) + , [uniquePrefixes, showGraph]); + + const searchResults = useMeshcoreSearches({ searches }); + + // Create mapping from prefix to node data (name + public key) + const prefixToNodes = useMemo(() => { + const mapping = new Map>(); + + // Create searchable prefixes (excluding "??") + const searchablePrefixes = uniquePrefixes.filter(prefix => prefix !== "??"); + + searchablePrefixes.forEach((prefix, index) => { + const searchResult = searchResults[index]; + if (searchResult?.data?.results) { + const matchingNodes = searchResult.data.results + .filter(result => result.public_key.toLowerCase().startsWith(prefix.toLowerCase()) && result.node_name) + .map(result => ({ + name: result.node_name, + publicKey: result.public_key + })) + .filter(node => node.name.length > 0); + + if (matchingNodes.length > 0) { + mapping.set(prefix, matchingNodes); + } + } + }); + + return mapping; + }, [searchResults, uniquePrefixes]); + + // Fixed node spacing optimized for ~3 lines of text + const fixedNodeSize = { x: 140, y: 100 }; + const handleToggle = useCallback(() => { setExpanded(prev => !prev); }, []); @@ -134,6 +198,65 @@ export default function PathVisualization({ ), [paths]); + // Memoize the render function to prevent unnecessary re-renders + const renderCustomNodeElement = useCallback(({ nodeDatum, toggleNode }: any) => { + const rootName = initiatingNodeKey ? initiatingNodeKey.substring(0, 2) : "??"; + const isRoot = nodeDatum.name === rootName; + // Check if this node represents an origin pubkey (final 2-char hex from pubkey) + const isOriginPubkey = paths.some(({ pubkey }) => { + const pubkeyPrefix = pubkey.substring(0, 2); + return nodeDatum.name === pubkeyPrefix; + }); + + // Get node data for this prefix + const nodeData = prefixToNodes.get(nodeDatum.name) || []; + const isResolved = nodeData.length > 0; + + + return ( + + {/* Use same circle style for all nodes */} + + + {/* Hex prefix inside circle */} + + {nodeDatum.name} + + + {/* Show all node names below circle for resolved prefixes - clickable */} + {isResolved && nodeData.map((node, index) => { + // Calculate dynamic width based on text length (approximate 6px per character + padding) + const estimatedWidth = Math.max(60, node.name.length * 8 + 20); + return ( + + + {node.name} + + + ); + })} + + ); + }, [initiatingNodeKey, paths, prefixToNodes]); + const GraphView = useCallback(() => { if (!showGraph || pathsCount === 0 || !treeData) return null; @@ -145,36 +268,10 @@ export default function PathVisualization({ pathFunc="step" translate={{ x: graphFullscreen ? 300 : 200, y: graphFullscreen ? 80 : 50 }} separation={{ siblings: 1.2, nonSiblings: 1.5 }} - nodeSize={{ x: 60, y: 60 }} + nodeSize={fixedNodeSize} zoomable={true} draggable={true} - renderCustomNodeElement={({ nodeDatum, toggleNode }) => { - const rootName = initiatingNodeKey ? initiatingNodeKey.substring(0, 2) : "??"; - const isRoot = nodeDatum.name === rootName; - // Check if this node represents an origin pubkey (final 2-char hex from pubkey) - const isOriginPubkey = paths.some(({ pubkey }) => { - const pubkeyPrefix = pubkey.substring(0, 2); - return nodeDatum.name === pubkeyPrefix; - }); - - return ( - - - - {nodeDatum.name} - - - ); - }} + renderCustomNodeElement={renderCustomNodeElement} /> ); @@ -194,7 +291,7 @@ export default function PathVisualization({
-
+
{renderTree()}
@@ -217,12 +314,12 @@ export default function PathVisualization({
-
+
{renderTree()}
); - }, [showGraph, pathsCount, treeData, graphFullscreen, handleFullscreenToggle, paths, initiatingNodeKey]); + }, [showGraph, pathsCount, treeData, graphFullscreen, handleFullscreenToggle, renderCustomNodeElement]); if (!showDropdown) { return ( diff --git a/src/components/SearchResults.tsx b/src/components/SearchResults.tsx index 73c19d6..69ae5e2 100644 --- a/src/components/SearchResults.tsx +++ b/src/components/SearchResults.tsx @@ -1,9 +1,8 @@ "use client"; import { MeshcoreSearchResult } from '@/hooks/useMeshcoreSearch'; -import { MapPinIcon, WifiIcon, ChatBubbleLeftRightIcon, ServerIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline'; -import Link from 'next/link'; -import moment from 'moment'; +import { WifiIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline'; +import NodeCard from '@/components/NodeCard'; interface SearchResultsProps { results: MeshcoreSearchResult[]; @@ -85,76 +84,10 @@ export default function SearchResults({ results, isLoading, error, query, total
{results.map((node) => ( - + ))}
); } -function SearchResultItem({ node }: { node: MeshcoreSearchResult }) { - const lastSeen = new Date(node.last_seen); - const hasLocation = node.has_location === 1; - const isRepeater = node.is_repeater === 1; - const isChatNode = node.is_chat_node === 1; - const isRoomServer = node.is_room_server === 1; - - return ( - -
-
-
-

- {node.node_name || 'Unnamed Node'} -

-
- {isRepeater && ( - - - Repeater - - )} - {isChatNode && ( - - - Chat - - )} - {isRoomServer && ( - - - Room Server - - )} -
-
- -
- {hasLocation && node.latitude && node.longitude && ( -
- - - {node.latitude.toFixed(4)}, {node.longitude.toFixed(4)} - -
- )} - -
- Last seen: {moment(lastSeen).fromNow()} - - {node.public_key.substring(0, 8)}... - -
- -
- Topic: {node.topic} • Broker: {node.broker.split('://')[1]} -
-
-
-
- - ); -} diff --git a/src/hooks/useChatMessages.ts b/src/hooks/useChatMessages.ts new file mode 100644 index 0000000..4b9b23e --- /dev/null +++ b/src/hooks/useChatMessages.ts @@ -0,0 +1,151 @@ +"use client"; + +import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useMemo } from 'react'; +import { buildApiUrl } from '../lib/api'; +import { ChatMessage } from '../components/ChatMessageItem'; + +interface ChatMessagesParams { + channelId?: string; + region?: string; + enabled?: boolean; + autoRefreshEnabled?: boolean; +} + +interface ChatMessagesPage { + messages: ChatMessage[]; + hasMore: boolean; + oldestTimestamp?: string; +} + +const PAGE_SIZE = 20; + +export function useChatMessages({ + channelId, + region, + enabled = true, + autoRefreshEnabled = true, +}: ChatMessagesParams) { + const queryClient = useQueryClient(); + + // Build base query key + const baseQueryKey = useMemo(() => + ['chat-messages', channelId, region] as const, + [channelId, region] + ); + + // Main infinite query for loading messages with pagination + const messagesQuery = useInfiniteQuery({ + queryKey: baseQueryKey, + queryFn: async ({ pageParam, signal }): Promise => { + if (!region) { + throw new Error('Region is required'); + } + + let url = `/api/chat?limit=${PAGE_SIZE}®ion=${encodeURIComponent(region)}`; + if (channelId) { + url += `&channel_id=${channelId}`; + } + + if (pageParam) { + url += `&before=${encodeURIComponent(pageParam)}`; + } + + const response = await fetch(buildApiUrl(url), { signal }); + + if (!response.ok) { + throw new Error(`Failed to fetch chat messages: ${response.statusText}`); + } + + const data = await response.json(); + const messages = Array.isArray(data) ? data : []; + + return { + messages, + hasMore: messages.length === PAGE_SIZE, + oldestTimestamp: messages.length > 0 ? messages[messages.length - 1].ingest_timestamp : undefined, + }; + }, + getNextPageParam: (lastPage) => { + return lastPage.hasMore ? lastPage.oldestTimestamp : undefined; + }, + initialPageParam: undefined as string | undefined, + enabled: enabled && !!region, + staleTime: 10 * 1000, // 10 seconds + gcTime: 5 * 60 * 1000, // 5 minutes + retry: 1, + }); + + // Auto-refresh query to get newer messages + const latestTimestamp = messagesQuery.data?.pages[0]?.messages[0]?.ingest_timestamp; + + const autoRefreshQuery = useQuery({ + queryKey: [...baseQueryKey, 'auto-refresh', latestTimestamp], + queryFn: async ({ signal }): Promise => { + if (!region || !latestTimestamp) { + return []; + } + + let url = `/api/chat?limit=${PAGE_SIZE}®ion=${encodeURIComponent(region)}`; + if (channelId) { + url += `&channel_id=${channelId}`; + } + url += `&after=${encodeURIComponent(latestTimestamp)}`; + + const response = await fetch(buildApiUrl(url), { signal }); + + if (!response.ok) { + throw new Error(`Failed to fetch new chat messages: ${response.statusText}`); + } + + const data = await response.json(); + return Array.isArray(data) ? data : []; + }, + enabled: enabled && autoRefreshEnabled && !!region && !!latestTimestamp, + refetchInterval: 5000, // 5 seconds + staleTime: 0, // Always fresh for auto-refresh + retry: 1, + }); + + // When auto-refresh finds new messages, update the main query + useEffect(() => { + if (autoRefreshQuery.data && autoRefreshQuery.data.length > 0) { + queryClient.setQueryData(baseQueryKey, (oldData: any) => { + if (!oldData?.pages?.[0]) return oldData; + + const newMessages = autoRefreshQuery.data; + const firstPage = oldData.pages[0]; + + // Add new messages to the beginning of the first page + const updatedFirstPage = { + ...firstPage, + messages: [...newMessages, ...firstPage.messages] + }; + + return { + ...oldData, + pages: [updatedFirstPage, ...oldData.pages.slice(1)] + }; + }); + } + }, [autoRefreshQuery.data, queryClient, baseQueryKey]); + + // Flatten all messages from all pages + const allMessages = messagesQuery.data?.pages.flatMap(page => page.messages) ?? []; + + // Check if there are more pages to load + const hasNextPage = messagesQuery.hasNextPage; + + return { + messages: allMessages, + loading: messagesQuery.isLoading, + error: messagesQuery.error || autoRefreshQuery.error, + hasMore: hasNextPage, + loadMore: messagesQuery.fetchNextPage, + isLoadingMore: messagesQuery.isFetchingNextPage, + refresh: () => { + queryClient.invalidateQueries({ queryKey: baseQueryKey }); + }, + isRefreshing: messagesQuery.isRefetching, + }; +} diff --git a/src/hooks/useIntersectionObserver.ts b/src/hooks/useIntersectionObserver.ts new file mode 100644 index 0000000..d00e25a --- /dev/null +++ b/src/hooks/useIntersectionObserver.ts @@ -0,0 +1,59 @@ +"use client"; + +import { useEffect, useRef, useCallback } from 'react'; + +interface UseIntersectionObserverOptions { + threshold?: number; + rootMargin?: string; + enabled?: boolean; +} + +export function useIntersectionObserver( + callback: () => void, + options: UseIntersectionObserverOptions = {} +) { + const { + threshold = 0.1, + rootMargin = '100px', + enabled = true + } = options; + + const targetRef = useRef(null); + const observerRef = useRef(null); + + const handleIntersection = useCallback( + (entries: IntersectionObserverEntry[]) => { + const entry = entries[0]; + if (entry.isIntersecting && enabled) { + callback(); + } + }, + [callback, enabled] + ); + + useEffect(() => { + const target = targetRef.current; + if (!target || !enabled) return; + + // Clean up existing observer + if (observerRef.current) { + observerRef.current.disconnect(); + } + + // Create new observer + observerRef.current = new IntersectionObserver(handleIntersection, { + threshold, + rootMargin, + }); + + observerRef.current.observe(target); + + return () => { + if (observerRef.current) { + observerRef.current.disconnect(); + } + }; + }, [handleIntersection, threshold, rootMargin, enabled]); + + return targetRef; +} diff --git a/src/hooks/useMeshcoreSearch.ts b/src/hooks/useMeshcoreSearch.ts index 70ae391..3427c1b 100644 --- a/src/hooks/useMeshcoreSearch.ts +++ b/src/hooks/useMeshcoreSearch.ts @@ -1,5 +1,7 @@ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueries } from '@tanstack/react-query'; +import { create, windowScheduler, indexedResolver } from '@yornaath/batshit'; import { buildApiUrl } from '../lib/api'; +import { useMemo } from 'react'; export interface MeshcoreSearchResult { public_key: string; @@ -20,17 +22,72 @@ export interface MeshcoreSearchResult { export interface MeshcoreSearchResponse { results: MeshcoreSearchResult[]; total: number; - query: string; - region: string | null; - lastSeen: string | null; - limit: number; } +// Search query parameters +export interface SearchQuery { + query?: string; + region?: string; + lastSeen?: number | null; + limit?: number; + exact?: boolean; + is_repeater?: boolean; +} + +// Create batcher using batshit with simple index-based resolver +const searchBatcher = create({ + fetcher: async (queries: SearchQuery[]) => { + const normalizedQueries = queries.map(q => ({ + query: q.query?.trim() || "", + region: q.region || undefined, + lastSeen: q.lastSeen !== null && q.lastSeen !== undefined ? q.lastSeen : undefined, + limit: q.limit || 50, + exact: q.exact || false, + is_repeater: q.is_repeater + })); + + // Create AbortController for this batch + const abortController = new AbortController(); + + // Store the abort controller so individual queries can cancel the batch + (searchBatcher as any)._currentAbortController = abortController; + + const response = await fetch(buildApiUrl('/api/meshcore/search'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ queries: normalizedQueries }), + signal: abortController.signal, + }); + + if (!response.ok) { + throw new Error(`Failed to execute batch search: ${response.statusText}`); + } + + const batchResponse = await response.json(); + + // Return results with batch context for resolver + return { + results: batchResponse.results || [], + queries: queries + }; + }, + + resolver: (batchData: {results: MeshcoreSearchResult[][], queries: SearchQuery[]}, query: SearchQuery) => { + const index = batchData.queries.findIndex(q => JSON.stringify(q) === JSON.stringify(query)); + return batchData.results[index] || []; + }, + + scheduler: windowScheduler(100) +}); + +// Hook parameters interface UseMeshcoreSearchParams { query: string; region?: string; lastSeen?: number | null; limit?: number; + exact?: boolean; + is_repeater?: boolean; enabled?: boolean; } @@ -39,41 +96,122 @@ export function useMeshcoreSearch({ region, lastSeen, limit = 50, + exact = false, + is_repeater, enabled = true }: UseMeshcoreSearchParams) { - return useQuery({ - queryKey: ['meshcore-search', query, region, lastSeen, limit], - queryFn: async ({ signal }): Promise => { - const params = new URLSearchParams(); - - if (query.trim()) { - params.append('q', query.trim()); - } - if (region) { - params.append('region', region); - } - if (lastSeen !== null && lastSeen !== undefined) { - params.append('lastSeen', lastSeen.toString()); - } - if (limit !== 50) { - params.append('limit', limit.toString()); - } + // Stabilize the search query object to prevent unnecessary re-renders + const trimmedQuery = query.trim(); + const searchQuery: SearchQuery = useMemo(() => ({ + query: trimmedQuery, + region, + lastSeen, + limit, + exact, + is_repeater + }), [trimmedQuery, region, lastSeen, limit, exact, is_repeater]); - const url = `/api/meshcore/search${params.toString() ? `?${params.toString()}` : ''}`; + return useQuery({ + queryKey: ['meshcore-search', searchQuery.query, region, lastSeen, limit, exact, is_repeater], + queryFn: async ({ signal }): Promise => { + // Set up cancellation handler + const handleAbort = () => { + const abortController = (searchBatcher as any)._currentAbortController; + if (abortController) { + abortController.abort(); + } + }; - const response = await fetch(buildApiUrl(url), { - signal, // Use the AbortSignal from TanStack Query - }); + signal?.addEventListener('abort', handleAbort); - if (!response.ok) { - throw new Error(`Failed to search meshcore nodes: ${response.statusText}`); + try { + const queryResults = await searchBatcher.fetch(searchQuery) as MeshcoreSearchResult[] || []; + return { + results: queryResults, + total: queryResults.length + }; + } finally { + signal?.removeEventListener('abort', handleAbort); } - - return response.json(); }, enabled: enabled && query.trim().length > 0, - staleTime: 30 * 1000, // 30 seconds - shorter for search results - gcTime: 5 * 60 * 1000, // 5 minutes + staleTime: 1000, // Reduce stale time to be more responsive to typing + gcTime: 30 * 1000, // Reduce garbage collection time retry: 1, + refetchOnWindowFocus: false, // Prevent duplicate requests on focus + }); +} + +// Hook for multiple searches using useQueries +interface UseMeshcoreSearchesParams { + searches: Array<{ + query: string; + region?: string; + lastSeen?: number | null; + limit?: number; + exact?: boolean; + is_repeater?: boolean; + enabled?: boolean; + }>; +} + +export function useMeshcoreSearches({ searches }: UseMeshcoreSearchesParams) { + // Create query configurations for useQueries + const queryConfigs = useMemo(() => + searches.map((searchParams, index) => { + const { + query, + region, + lastSeen, + limit = 50, + exact = false, + is_repeater, + enabled = true + } = searchParams; + + const trimmedQuery = query.trim(); + const searchQuery: SearchQuery = { + query: trimmedQuery, + region, + lastSeen, + limit, + exact, + is_repeater + }; + + return { + queryKey: ['meshcore-search-batch', trimmedQuery, region, lastSeen, limit, exact, is_repeater], + queryFn: async ({ signal }: { signal?: AbortSignal }): Promise => { + // Set up cancellation handler + const handleAbort = () => { + const abortController = (searchBatcher as any)._currentAbortController; + if (abortController) { + abortController.abort(); + } + }; + + signal?.addEventListener('abort', handleAbort); + + try { + const queryResults = await searchBatcher.fetch(searchQuery) as MeshcoreSearchResult[] || []; + return { + results: queryResults, + total: queryResults.length + }; + } finally { + signal?.removeEventListener('abort', handleAbort); + } + }, + enabled: enabled && trimmedQuery.length > 0, + staleTime: 1000, + gcTime: 30 * 1000, + retry: 1, + refetchOnWindowFocus: false, + }; + }) + , [searches]); + + return useQueries({ + queries: queryConfigs }); } diff --git a/src/hooks/useNodeData.ts b/src/hooks/useNodeData.ts new file mode 100644 index 0000000..4fdfd74 --- /dev/null +++ b/src/hooks/useNodeData.ts @@ -0,0 +1,121 @@ +import { useQuery } from '@tanstack/react-query'; +import { buildApiUrl } from '../lib/api'; + +export interface NodeInfo { + public_key: string; + node_name: string; + latitude: number | null; + longitude: number | null; + has_location: number; + is_repeater: number; + is_chat_node: number; + is_room_server: number; + has_name: number; + first_seen: string; + last_seen: string; +} + +export interface Advert { + group_id: number; + origin_path_pubkey_tuples: Array<[string, string, string]>; // Array of [origin, path, origin_pubkey] tuples + advert_count: number; + earliest_timestamp: string; + latest_timestamp: string; + latitude: number | null; + longitude: number | null; + is_repeater: number; + is_chat_node: number; + is_room_server: number; + has_location: number; +} + +export interface LocationHistory { + mesh_timestamp: string; + latitude: number; + longitude: number; +} + +export interface MqttTopic { + topic: string; + broker: string; + last_packet_time: string; + is_recent: boolean; +} + +export interface MqttInfo { + is_uplinked: boolean; + has_packets: boolean; + topics: MqttTopic[]; +} + +export interface NodeData { + node: NodeInfo; + recentAdverts: Advert[]; + locationHistory: LocationHistory[]; + mqtt: MqttInfo; +} + +export interface NodeError { + error: string; + code: string; + publicKey?: string; +} + +interface UseNodeDataParams { + publicKey: string | null; + limit?: number; + enabled?: boolean; +} + +export function useNodeData({ publicKey, limit = 50, enabled = true }: UseNodeDataParams) { + return useQuery({ + queryKey: ['node-data', publicKey, limit], + queryFn: async (): Promise => { + if (!publicKey) { + throw { error: "Public key is required", code: "MISSING_PUBLIC_KEY" } as NodeError; + } + + const params = new URLSearchParams(); + if (limit !== 50) { + params.append('limit', limit.toString()); + } + + const url = `/api/meshcore/node/${publicKey}${params.toString() ? `?${params.toString()}` : ''}`; + + const response = await fetch(buildApiUrl(url)); + + // Handle specific error responses + if (!response.ok) { + let errorData: NodeError; + try { + errorData = await response.json(); + } catch { + errorData = { + error: `HTTP ${response.status}: ${response.statusText}`, + code: 'UNKNOWN_ERROR' + }; + } + + // Add status information to error for better handling + throw { + ...errorData, + status: response.status + } as NodeError & { status: number }; + } + + return response.json(); + }, + enabled: enabled && !!publicKey, + staleTime: 15 * 60 * 1000, // 15 minutes + gcTime: 15 * 60 * 1000, // 15 minutes + retry: (failureCount, error) => { + // Don't retry for client errors (4xx) + const status = (error as NodeError & { status?: number })?.status; + if (status && status >= 400 && status < 500) { + return false; + } + // Retry up to 1 time for server errors + return failureCount < 1; + }, + }); +} diff --git a/src/hooks/useSearchQuery.ts b/src/hooks/useSearchQuery.ts index d46334e..ee71d25 100644 --- a/src/hooks/useSearchQuery.ts +++ b/src/hooks/useSearchQuery.ts @@ -106,15 +106,17 @@ export function useQueryParams>(defaultValues: T = export interface SearchQuery { q: string; limit?: number; + exact?: boolean; } export function useSearchQuery() { - const { query, setParam } = useQueryParams({ q: '', limit: 50 }); + const { query, setParam } = useQueryParams({ q: '', limit: 50, exact: false }); return { query, setQuery: (q: string) => setParam('q', q), setLimit: (limit: number) => setParam('limit', limit), + setExact: (exact: boolean) => setParam('exact', exact), updateQuery: (updates: Partial) => { Object.entries(updates).forEach(([key, value]) => { setParam(key as keyof SearchQuery, value as any); diff --git a/src/lib/clickhouse/actions.ts b/src/lib/clickhouse/actions.ts index fa669fb..fadbf89 100644 --- a/src/lib/clickhouse/actions.ts +++ b/src/lib/clickhouse/actions.ts @@ -366,80 +366,147 @@ export async function getMeshcoreNodeNeighbors(publicKey: string, lastSeen: stri } } -export async function searchMeshcoreNodes({ - query: searchQuery, - region, - lastSeen, - limit = 50 -}: { - query?: string; - region?: string; +interface SearchQuery { + query?: string; + region?: string; lastSeen?: string | null; - limit?: number; -} = {}) { + limit?: number; + exact?: boolean; + is_repeater?: boolean; +} + +export async function searchMeshcoreNodes(searchParams: SearchQuery | SearchQuery[] = {}) { try { - let where = []; - const params: Record = { limit }; + // Normalize input to array format + const queries = Array.isArray(searchParams) ? searchParams : [searchParams]; - // Add search conditions - if (searchQuery && searchQuery.trim()) { - const trimmedQuery = searchQuery.trim(); + // If no queries or empty array, return empty results + if (queries.length === 0) { + return []; + } + + // Build individual query parts + const queryParts: string[] = []; + const allParams: Record = {}; + + queries.forEach((searchQuery, index) => { + const { + query: searchString, + region, + lastSeen, + limit = 50, + exact = false, + is_repeater + } = searchQuery; - // Check if it looks like a public key (hex string) - if (/^[0-9A-Fa-f]+$/.test(trimmedQuery)) { - // Search by public key prefix - where.push('public_key LIKE {publicKeyPattern:String}'); - params.publicKeyPattern = `${trimmedQuery.toUpperCase()}%`; - } else { - // Search by node name (case insensitive, anywhere in the name) - where.push('lower(node_name) LIKE {namePattern:String}'); - params.namePattern = `%${trimmedQuery.toLowerCase()}%`; + const where: string[] = []; + const queryParams: Record = {}; + + // Add search conditions + if (searchString && searchString.trim()) { + const trimmedQuery = searchString.trim(); + + // Check if it looks like a public key (hex string) + if (/^[0-9A-Fa-f]+$/.test(trimmedQuery)) { + if (exact) { + // Exact public key match + where.push(`public_key = {publicKeyExact_${index}:String}`); + queryParams[`publicKeyExact_${index}`] = trimmedQuery.toUpperCase(); + } else { + // Search by public key prefix + where.push(`public_key LIKE {publicKeyPattern_${index}:String}`); + queryParams[`publicKeyPattern_${index}`] = `${trimmedQuery.toUpperCase()}%`; + } + } else { + if (exact) { + // Exact node name match (case insensitive) + where.push(`lower(node_name) = {nameExact_${index}:String}`); + queryParams[`nameExact_${index}`] = trimmedQuery.toLowerCase(); + } else { + // Search by node name (case insensitive, anywhere in the name) + where.push(`lower(node_name) LIKE {namePattern_${index}:String}`); + queryParams[`namePattern_${index}`] = `%${trimmedQuery.toLowerCase()}%`; + } + } } - } + + // Add lastSeen filter if provided + if (lastSeen !== null && lastSeen !== undefined && lastSeen !== "") { + where.push(`last_seen >= now() - INTERVAL {lastSeen_${index}:UInt32} SECOND`); + queryParams[`lastSeen_${index}`] = Number(lastSeen); + } + + // Add region filtering if specified + const regionFilter = generateRegionWhereClause(region); + if (regionFilter.whereClause) { + where.push(regionFilter.whereClause); + } + + // Add is_repeater filter if specified + if (is_repeater !== undefined) { + where.push(`is_repeater = {isRepeater_${index}:UInt8}`); + queryParams[`isRepeater_${index}`] = is_repeater ? 1 : 0; + } + + const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''; + + const queryPart = ` + SELECT + public_key, + node_name, + latitude, + longitude, + has_location, + is_repeater, + is_chat_node, + is_room_server, + has_name, + first_heard, + last_seen, + broker, + topic, + ${index} as query_index + FROM ( + SELECT + public_key, + argMax(node_name, ingest_timestamp) as node_name, + argMax(latitude, ingest_timestamp) as latitude, + argMax(longitude, ingest_timestamp) as longitude, + argMax(has_location, ingest_timestamp) as has_location, + argMax(is_repeater, ingest_timestamp) as is_repeater, + argMax(is_chat_node, ingest_timestamp) as is_chat_node, + argMax(is_room_server, ingest_timestamp) as is_room_server, + argMax(has_name, ingest_timestamp) as has_name, + min(ingest_timestamp) as first_heard, + max(ingest_timestamp) as last_seen, + argMax(broker, ingest_timestamp) as broker, + argMax(topic, ingest_timestamp) as topic + FROM meshcore_adverts + GROUP BY public_key + ) + ${whereClause} + ORDER BY last_seen DESC + LIMIT {limit_${index}:UInt32} + `; + + queryParts.push(queryPart); + queryParams[`limit_${index}`] = limit; + + // Add query params to the global params object + Object.assign(allParams, queryParams); + }); - // Add lastSeen filter if provided - if (lastSeen !== null && lastSeen !== undefined && lastSeen !== "") { - where.push('last_seen >= now() - INTERVAL {lastSeen:UInt32} SECOND'); - params.lastSeen = Number(lastSeen); - } - - // Add region filtering if specified - const regionFilter = generateRegionWhereClause(region); - if (regionFilter.whereClause) { - where.push(regionFilter.whereClause); - } - - const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''; - - const query = ` - SELECT - public_key, - node_name, - latitude, - longitude, - has_location, - is_repeater, - is_chat_node, - is_room_server, - has_name, - first_heard, - last_seen, - broker, - topic - FROM meshcore_adverts_latest - ${whereClause} - ORDER BY last_seen DESC - LIMIT {limit:UInt32} - `; + // Combine all queries with UNION ALL + const finalQuery = queryParts.join(' UNION ALL '); const resultSet = await clickhouse.query({ - query, - query_params: params, + query: finalQuery, + query_params: allParams, format: 'JSONEachRow' }); const rows = await resultSet.json(); - return rows as Array<{ + type SearchResult = { public_key: string; node_name: string; latitude: number | null; @@ -453,7 +520,30 @@ export async function searchMeshcoreNodes({ last_seen: string; broker: string; topic: string; - }>; + query_index?: number; + }; + + // If single query, return results without query_index + if (!Array.isArray(searchParams)) { + return (rows as SearchResult[]).map(row => { + const { query_index, ...result } = row; + return result; + }); + } + + // For batch queries, group results by query_index + const groupedResults = (rows as SearchResult[]).reduce((acc, row) => { + const index = row.query_index || 0; + if (!acc[index]) { + acc[index] = []; + } + const { query_index, ...result } = row; + acc[index].push(result); + return acc; + }, {} as Record); + + // Return results in the same order as input queries + return queries.map((_, index) => groupedResults[index] || []); } catch (error) { console.error('ClickHouse error in searchMeshcoreNodes:', error); throw error;