From bdab51fd226970d387fc2867cd47041da57de0d1 Mon Sep 17 00:00:00 2001 From: ajvpot <553597+ajvpot@users.noreply.github.com> Date: Sat, 5 Jul 2025 00:00:00 +0000 Subject: [PATCH] messages, refresh button --- src/app/api/chat/route.ts | 3 +- src/app/messages/page.tsx | 145 +++++++++++++++++++++++++++++ src/components/ChatBox.tsx | 78 +--------------- src/components/ChatMessageItem.tsx | 109 ++++++++++++++++++++++ src/components/Header.tsx | 7 +- src/components/MapView.tsx | 43 +-------- src/components/RefreshButton.tsx | 72 ++++++++++++++ src/lib/clickhouse/actions.ts | 6 +- 8 files changed, 342 insertions(+), 121 deletions(-) create mode 100644 src/app/messages/page.tsx create mode 100644 src/components/ChatMessageItem.tsx create mode 100644 src/components/RefreshButton.tsx diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index d6bf916..0f692fd 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -7,8 +7,9 @@ export async function GET(req: Request) { const { searchParams } = new URL(req.url); const limit = parseInt(searchParams.get("limit") || "20", 10); const before = searchParams.get("before") || undefined; + const after = searchParams.get("after") || undefined; const channelId = searchParams.get("channel_id") || undefined; - const messages = await getLatestChatMessages({ limit, before, channelId } as { limit?: number, before?: string, channelId?: string }); + const messages = await getLatestChatMessages({ limit, before, after, channelId } as { limit?: number, before?: string, after?: string, channelId?: string }); return NextResponse.json(messages); } catch (error) { return NextResponse.json({ error: "Failed to fetch chat messages" }, { status: 500 }); diff --git a/src/app/messages/page.tsx b/src/app/messages/page.tsx new file mode 100644 index 0000000..30d2533 --- /dev/null +++ b/src/app/messages/page.tsx @@ -0,0 +1,145 @@ +"use client"; +import { useEffect, useState, useMemo, useRef } from "react"; +import { useConfig } from "@/components/ConfigContext"; +import { decryptMeshcoreGroupMessage } from "@/lib/meshcore_decrypt"; +import { getChannelIdFromKey } from "@/lib/meshcore"; +import ChatMessageItem, { ChatMessage } from "@/components/ChatMessageItem"; +import RefreshButton from "@/components/RefreshButton"; + +// Messages page: displays all chat messages from all channels with infinite scrolling. If a message cannot be decrypted, shows a row explaining the reason. + +const PAGE_SIZE = 40; + +function formatLocalTime(utcString: string): string { + const utcDate = new Date(utcString + (utcString.endsWith('Z') ? '' : 'Z')); + return utcDate.toLocaleString(); +} + +export default function MessagesPage() { + const { config } = useConfig(); + const meshcoreKeys = useMemo(() => [ + "izOH6cXN6mrJ5e26oRXNcg==", // Public key always included + ...(config?.meshcoreKeys?.map((k: any) => k.privateKey) || []) + ], [config]); + + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [lastBefore, setLastBefore] = useState(undefined); + const [autoRefreshing, setAutoRefreshing] = useState(false); + const autoRefreshTimeout = useRef(null); + + // Infinite scroll + useEffect(() => { + const onScroll = () => { + if ( + window.innerHeight + window.scrollY >= document.body.offsetHeight - 300 && + !loading && hasMore + ) { + fetchMessages(lastBefore); + } + }; + window.addEventListener("scroll", onScroll); + return () => window.removeEventListener("scroll", onScroll); + // eslint-disable-next-line + }, [loading, hasMore, lastBefore]); + + useEffect(() => { + setMessages([]); + setHasMore(true); + setLastBefore(undefined); + fetchMessages(undefined, true); + // eslint-disable-next-line + }, [meshcoreKeys.join(",")]); + + // Poll for new messages every 10s + useEffect(() => { + const interval = setInterval(async () => { + if (messages.length === 0) return; + const latest = messages[0]?.ingest_timestamp; + if (!latest) return; + try { + setAutoRefreshing(true); + let url = `/api/chat?limit=${PAGE_SIZE}&after=${encodeURIComponent(latest)}`; + const res = await fetch(url); + const data = await res.json(); + if (Array.isArray(data) && data.length > 0) { + // Only prepend messages that are not already present + const existingKeys = new Set(messages.map((m) => m.ingest_timestamp + m.origin)); + const newMessages = data.filter((msg: any) => !existingKeys.has(msg.ingest_timestamp + msg.origin)); + if (newMessages.length > 0) { + setMessages((prev) => [...newMessages, ...prev]); + } + } + } catch {} + // Show auto-refresh spinner for 1s + if (autoRefreshTimeout.current) clearTimeout(autoRefreshTimeout.current); + autoRefreshTimeout.current = setTimeout(() => setAutoRefreshing(false), 1000); + }, 10000); + return () => { + clearInterval(interval); + if (autoRefreshTimeout.current) clearTimeout(autoRefreshTimeout.current); + }; + }, [messages]); + + const fetchMessages = async (before?: string, replace = false) => { + setLoading(true); + try { + let url = `/api/chat?limit=${PAGE_SIZE}`; + if (before) url += `&before=${encodeURIComponent(before)}`; + // No channel_id: fetch all channels + const res = await fetch(url); + const data = await res.json(); + if (Array.isArray(data)) { + setMessages((prev) => replace ? data : [...prev, ...data]); + setHasMore(data.length === PAGE_SIZE); + if (data.length > 0) { + setLastBefore(data[data.length - 1].ingest_timestamp); + } + } else { + setHasMore(false); + } + } catch { + setHasMore(false); + } finally { + setLoading(false); + } + }; + + return ( +
+ {/* Header row with title and refresh button */} +
+

All Messages

+ fetchMessages(undefined, true)} + loading={loading} + autoRefreshing={autoRefreshing} + title={autoRefreshing ? "Auto-refreshing..." : "Refresh messages"} + ariaLabel="Refresh messages" + small + /> +
+ {/* Add keyframes for spin animation */} + +
+ {messages.length === 0 && !loading && ( +
No chat messages found.
+ )} + {messages.map((msg, i) => ( + + ))} + {loading && ( +
Loading...
+ )} + {!hasMore && messages.length > 0 && ( +
No more messages.
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/ChatBox.tsx b/src/components/ChatBox.tsx index ac1f57f..68cc91e 100644 --- a/src/components/ChatBox.tsx +++ b/src/components/ChatBox.tsx @@ -4,18 +4,7 @@ import { MinusIcon, PlusIcon } from "@heroicons/react/24/outline"; import { useConfig } from "./ConfigContext"; import { decryptMeshcoreGroupMessage } from "../lib/meshcore_decrypt"; import { getChannelIdFromKey } from "../lib/meshcore"; - -interface ChatMessage { - ingest_timestamp: string; - origin: string; - mesh_timestamp: string; - packet: string; - path_len: number; - path: string; - channel_hash: string; - mac: string; - encrypted_message: string; -} +import ChatMessageItem, { ChatMessage } from "./ChatMessageItem"; const PAGE_SIZE = 20; @@ -30,71 +19,6 @@ function formatLocalTime(utcString: string): string { return utcDate.toLocaleString(); } -function ChatMessageItem({ msg }: { msg: ChatMessage }) { - const { config } = useConfig(); - const knownKeys = [ - ...(config?.meshcoreKeys?.map((k: any) => k.privateKey) || []), - "izOH6cXN6mrJ5e26oRXNcg==", // Always include public key - ]; - const [parsed, setParsed] = useState(null); - - useEffect(() => { - let cancelled = false; - (async () => { - try { - const result = await decryptMeshcoreGroupMessage({ - encrypted_message: msg.encrypted_message, - mac: msg.mac, - channel_hash: msg.channel_hash, - knownKeys, - parse: true, - }); - if (!cancelled) { - setParsed(result); - if (result === null) { - console.warn("Meshcore message could not be parsed", { msg }); - } - } - } catch (err) { - if (!cancelled) { - setParsed(null); - console.error("Error during Meshcore decryption/parsing", { msg, err }); - } - } - })(); - return () => { cancelled = true; }; - }, [msg.encrypted_message, msg.mac, msg.channel_hash, knownKeys.join(",")]); - - if (parsed) { - return ( -
-
- {formatLocalTime(new Date(parsed.timestamp * 1000).toISOString())} - type: {parsed.msgType} -
-
- {parsed.sender} - {parsed.sender && ": "} - {parsed.text} -
-
Relayed by: {msg.origin}
-
- ); - } - - return ( -
-
- {formatLocalTime(msg.ingest_timestamp)} -
-
- {formatHex(msg.encrypted_message)} -
-
Relayed by: {msg.origin}
-
- ); -} - export default function ChatBox() { const { config } = useConfig(); const meshcoreKeys = [ diff --git a/src/components/ChatMessageItem.tsx b/src/components/ChatMessageItem.tsx new file mode 100644 index 0000000..b97420d --- /dev/null +++ b/src/components/ChatMessageItem.tsx @@ -0,0 +1,109 @@ +"use client"; +import { useState, useEffect } from "react"; +import { useConfig } from "./ConfigContext"; +import { decryptMeshcoreGroupMessage } from "../lib/meshcore_decrypt"; + +export interface ChatMessage { + ingest_timestamp: string; + origin: string; + mesh_timestamp: string; + packet: string; + path_len: number; + path: string; + channel_hash: string; + mac: string; + encrypted_message: string; +} + +function formatHex(hex: string): string { + return hex.replace(/(.{2})/g, "$1 ").trim(); +} + +function formatLocalTime(utcString: string): string { + const utcDate = new Date(utcString + (utcString.endsWith('Z') ? '' : 'Z')); + return utcDate.toLocaleString(); +} + +export default function ChatMessageItem({ msg, showErrorRow, showChannelId }: { msg: ChatMessage, showErrorRow?: boolean, showChannelId?: boolean }) { + const { config } = useConfig(); + const knownKeys = [ + ...(config?.meshcoreKeys?.map((k: any) => k.privateKey) || []), + "izOH6cXN6mrJ5e26oRXNcg==", // Always include public key + ]; + const [parsed, setParsed] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + const result = await decryptMeshcoreGroupMessage({ + encrypted_message: msg.encrypted_message, + mac: msg.mac, + channel_hash: msg.channel_hash, + knownKeys, + parse: true, + }); + if (!cancelled) { + if (result === null) { + setParsed(null); + setError("Could not decrypt message with any known key."); + } else { + setParsed(result); + setError(null); + } + } + })(); + return () => { cancelled = true; }; + }, [msg.encrypted_message, msg.mac, msg.channel_hash, knownKeys.join(",")]); + + if (parsed) { + return ( +
+
+ {formatLocalTime(new Date(parsed.timestamp * 1000).toISOString())} + type: {parsed.msgType} + {showChannelId && ( + channel: {msg.channel_hash} + )} +
+
+ {parsed.sender} + {parsed.sender && ": "} + {parsed.text} +
+
Relayed by: {msg.origin}
+
+ ); + } + + if (error) { + if (showErrorRow) { + return ( +
+
+ Error: {error} + {showChannelId && ( + channel: {msg.channel_hash} + )} +
+
Relayed by: {msg.origin}
+
+ ); +}else{ + return <>; +} + } + + return ( +
+
+ {formatLocalTime(msg.ingest_timestamp)} + {showChannelId && ( + channel: {msg.channel_hash} + )} +
+
+
Relayed by: {msg.origin}
+
+ ); +} \ No newline at end of file diff --git a/src/components/Header.tsx b/src/components/Header.tsx index aeeb662..f4bafd4 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -14,17 +14,16 @@ export default function Header({ configButtonRef }: HeaderProps) {
); diff --git a/src/components/MapView.tsx b/src/components/MapView.tsx index 4eb9903..19e9359 100644 --- a/src/components/MapView.tsx +++ b/src/components/MapView.tsx @@ -9,6 +9,7 @@ import 'leaflet.markercluster/dist/MarkerCluster.css'; import 'leaflet.markercluster/dist/MarkerCluster.Default.css'; import { useConfig } from "./ConfigContext"; import moment from "moment"; +import RefreshButton from "@/components/RefreshButton"; const DEFAULT = { lat: 47.6062, // Seattle @@ -369,46 +370,12 @@ export default function MapView() {
{/* Only Refresh Button Row */}
- + ariaLabel="Refresh map nodes" + />
{/* Add keyframes for spin animation */} + + ); +} \ No newline at end of file diff --git a/src/lib/clickhouse/actions.ts b/src/lib/clickhouse/actions.ts index 6f4644f..7221a9a 100644 --- a/src/lib/clickhouse/actions.ts +++ b/src/lib/clickhouse/actions.ts @@ -45,13 +45,17 @@ export async function getNodePositions({ minLat, maxLat, minLng, maxLng, nodeTyp }>; } -export async function getLatestChatMessages({ limit = 20, before, channelId }: { limit?: number, before?: string, channelId?: string } = {}) { +export async function getLatestChatMessages({ limit = 20, before, after, channelId }: { limit?: number, before?: string, after?: string, channelId?: string } = {}) { let where = []; const params: Record = { limit }; if (before) { where.push('ingest_timestamp < {before:DateTime64}'); params.before = before; } + if (after) { + where.push('ingest_timestamp > {after:DateTime64}'); + params.after = after; + } if (channelId) { where.push('channel_hash = {channelId:String}'); params.channelId = channelId;