messages, refresh button

This commit is contained in:
ajvpot
2025-07-05 00:00:00 +00:00
parent 1b386a6b80
commit bdab51fd22
8 changed files with 342 additions and 121 deletions

View File

@@ -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 });

145
src/app/messages/page.tsx Normal file
View File

@@ -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<any[]>([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [lastBefore, setLastBefore] = useState<string | undefined>(undefined);
const [autoRefreshing, setAutoRefreshing] = useState(false);
const autoRefreshTimeout = useRef<NodeJS.Timeout | null>(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 (
<div className="max-w-2xl mx-auto py-8 px-4" style={{ position: 'relative' }}>
{/* Header row with title and refresh button */}
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold">All Messages</h1>
<RefreshButton
onClick={() => fetchMessages(undefined, true)}
loading={loading}
autoRefreshing={autoRefreshing}
title={autoRefreshing ? "Auto-refreshing..." : "Refresh messages"}
ariaLabel="Refresh messages"
small
/>
</div>
{/* Add keyframes for spin animation */}
<style>{`
@keyframes spin {
100% { transform: rotate(360deg); }
}
`}</style>
<div className="flex flex-col gap-0 border rounded-lg bg-white dark:bg-neutral-900 shadow divide-y divide-gray-200 dark:divide-neutral-800">
{messages.length === 0 && !loading && (
<div className="text-gray-400 text-center py-8">No chat messages found.</div>
)}
{messages.map((msg, i) => (
<ChatMessageItem key={msg.ingest_timestamp + msg.origin + i} msg={msg} showErrorRow showChannelId={true} />
))}
{loading && (
<div className="text-center py-4 text-gray-400">Loading...</div>
)}
{!hasMore && messages.length > 0 && (
<div className="text-center py-4 text-gray-400">No more messages.</div>
)}
</div>
</div>
);
}

View File

@@ -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<any | null>(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 (
<div className="border-b border-gray-200 dark:border-neutral-800 pb-2 mb-2">
<div className="text-xs text-gray-400 flex items-center gap-2">
{formatLocalTime(new Date(parsed.timestamp * 1000).toISOString())}
<span className="text-xs text-gray-500">type: {parsed.msgType}</span>
</div>
<div className="break-all whitespace-pre-wrap">
<span className="font-bold text-blue-800 dark:text-blue-300">{parsed.sender}</span>
{parsed.sender && ": "}
<span>{parsed.text}</span>
</div>
<div className="text-xs text-gray-300">Relayed by: {msg.origin}</div>
</div>
);
}
return (
<div className="border-b border-gray-200 dark:border-neutral-800 pb-2 mb-2">
<div className="text-xs text-gray-400 flex items-center gap-2">
{formatLocalTime(msg.ingest_timestamp)}
</div>
<div className="font-mono break-all whitespace-pre-wrap">
{formatHex(msg.encrypted_message)}
</div>
<div className="text-xs text-gray-300">Relayed by: {msg.origin}</div>
</div>
);
}
export default function ChatBox() {
const { config } = useConfig();
const meshcoreKeys = [

View File

@@ -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<any | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="border-b border-gray-200 dark:border-neutral-800 pb-2 mb-2">
<div className="text-xs text-gray-400 flex items-center gap-2">
{formatLocalTime(new Date(parsed.timestamp * 1000).toISOString())}
<span className="text-xs text-gray-500">type: {parsed.msgType}</span>
{showChannelId && (
<span className="text-xs text-purple-600 dark:text-purple-300 ml-2">channel: {msg.channel_hash}</span>
)}
</div>
<div className="break-all whitespace-pre-wrap">
<span className="font-bold text-blue-800 dark:text-blue-300">{parsed.sender}</span>
{parsed.sender && ": "}
<span>{parsed.text}</span>
</div>
<div className="text-xs text-gray-300">Relayed by: {msg.origin}</div>
</div>
);
}
if (error) {
if (showErrorRow) {
return (
<div className="border-b border-red-200 dark:border-red-800 pb-2 mb-2 bg-red-50 dark:bg-red-900/30">
<div className="text-xs text-red-600 dark:text-red-300 flex items-center gap-2">
Error: {error}
{showChannelId && (
<span className="text-xs text-purple-600 dark:text-purple-300 ml-2">channel: {msg.channel_hash}</span>
)}
</div>
<div className="text-xs text-gray-300">Relayed by: {msg.origin}</div>
</div>
);
}else{
return <></>;
}
}
return (
<div className="border-b border-gray-200 dark:border-neutral-800 pb-2 mb-2">
<div className="text-xs text-gray-400 flex items-center gap-2">
{formatLocalTime(msg.ingest_timestamp)}
{showChannelId && (
<span className="text-xs text-purple-600 dark:text-purple-300 ml-2">channel: {msg.channel_hash}</span>
)}
</div>
<div className="w-full h-5 bg-gray-200 dark:bg-neutral-800 rounded animate-pulse my-2" />
<div className="text-xs text-gray-300">Relayed by: {msg.origin}</div>
</div>
);
}

View File

@@ -14,17 +14,16 @@ export default function Header({ configButtonRef }: HeaderProps) {
<header className="w-full flex items-center justify-between px-6 py-3 bg-white dark:bg-neutral-900 shadow z-20">
<nav className="flex gap-6 items-center">
<Link href="/" className="font-bold text-lg">MeshExplorer</Link>
<Link href="/about">About</Link>
<Link href="/docs">Docs</Link>
<Link href="/messages">Messages</Link>
</nav>
<button
ref={configButtonRef || contextButtonRef}
onClick={openConfig}
className="flex items-center gap-2 px-3 py-2 rounded hover:bg-neutral-100 dark:hover:bg-neutral-800"
aria-label="Open configuration menu"
aria-label="Open settings menu"
>
<Cog6ToothIcon className="h-6 w-6" />
<span className="hidden sm:inline">Config</span>
<span className="hidden sm:inline">Settings</span>
</button>
</header>
);

View File

@@ -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() {
<div style={{ width: "100%", height: "100%", position: "relative" }}>
{/* Only Refresh Button Row */}
<div style={{ position: "absolute", top: 16, right: 16, zIndex: 1000, display: 'flex', alignItems: 'center' }}>
<button
<RefreshButton
onClick={() => bounds && fetchNodes(bounds)}
disabled={loading || !bounds}
style={{
background: "#fff",
color: "#2563eb",
border: "none",
borderRadius: "50%",
width: 40,
height: 40,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: "0 2px 8px rgba(0,0,0,0.10)",
opacity: loading ? 0.7 : 1,
cursor: loading ? "not-allowed" : "pointer",
transition: "background 0.2s, opacity 0.2s",
fontSize: 22,
padding: 0,
}}
aria-label="Refresh map nodes"
loading={loading || !bounds}
title="Refresh map nodes"
>
{/* Refresh Icon (SVG) */}
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={loading ? { animation: 'spin 1s linear infinite' } : {}}
>
<polyline points="23 4 23 10 17 10"/>
<path d="M1 20v-6h6"/>
<path d="M3.51 9a9 9 0 0 1 14.13-3.36L23 10M1 14l4.36 4.36A9 9 0 0 0 20.49 15"/>
</svg>
</button>
ariaLabel="Refresh map nodes"
/>
</div>
{/* Add keyframes for spin animation */}
<style>{`

View File

@@ -0,0 +1,72 @@
import React from "react";
interface RefreshButtonProps {
onClick: () => void;
loading: boolean;
autoRefreshing?: boolean;
title?: string;
ariaLabel?: string;
small?: boolean;
disabled?: boolean;
}
export default function RefreshButton({
onClick,
loading,
autoRefreshing = false,
title = "Refresh",
ariaLabel = "Refresh",
small = false,
disabled = false,
}: RefreshButtonProps) {
const size = small ? 32 : 40;
const iconSize = small ? 18 : 22;
return (
<>
<button
onClick={onClick}
disabled={loading || disabled}
style={{
background: "#fff",
color: "#2563eb",
border: "none",
borderRadius: "50%",
width: size,
height: size,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: "0 2px 8px rgba(0,0,0,0.10)",
opacity: loading ? 0.7 : 1,
cursor: loading ? "not-allowed" : "pointer",
transition: "background 0.2s, opacity 0.2s",
fontSize: iconSize,
padding: 0,
}}
aria-label={ariaLabel}
title={title}
>
<svg
width={iconSize}
height={iconSize}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={(loading || autoRefreshing) ? { animation: 'spin 1s linear infinite' } : {}}
>
<polyline points="23 4 23 10 17 10"/>
<path d="M1 20v-6h6"/>
<path d="M3.51 9a9 9 0 0 1 14.13-3.36L23 10M1 14l4.36 4.36A9 9 0 0 0 20.49 15"/>
</svg>
</button>
<style>{`
@keyframes spin {
100% { transform: rotate(360deg); }
}
`}</style>
</>
);
}

View File

@@ -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<string, any> = { 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;