mirror of
https://github.com/ajvpot/meshexplorer.git
synced 2026-05-03 03:52:57 +02:00
a
This commit is contained in:
@@ -1,144 +1,22 @@
|
||||
"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";
|
||||
import ChatBox from "@/components/ChatBox";
|
||||
|
||||
// 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();
|
||||
}
|
||||
// Messages page: displays all chat messages from all channels using the ChatBox component with tabs
|
||||
|
||||
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.origins?.join(',') ?? '')));
|
||||
const newMessages = data.filter((msg: any) => !existingKeys.has(msg.ingest_timestamp + (msg.origins?.join(',') ?? '')));
|
||||
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="max-w-4xl mx-auto py-8 px-4">
|
||||
{/* Header row with title */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-bold">MeshCore 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.origins?.join(',') ?? '') + 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>
|
||||
)}
|
||||
|
||||
{/* ChatBox component with all messages tab enabled and expanded behavior */}
|
||||
<div className="flex justify-center">
|
||||
<ChatBox showAllMessagesTab={true} expanded={true} className="w-full max-w-2xl min-h-[600px]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,24 +19,42 @@ function formatLocalTime(utcString: string): string {
|
||||
return utcDate.toLocaleString();
|
||||
}
|
||||
|
||||
export default function ChatBox() {
|
||||
interface ChatBoxProps {
|
||||
showAllMessagesTab?: boolean;
|
||||
className?: string;
|
||||
expanded?: boolean; // New prop to control expanded behavior
|
||||
}
|
||||
|
||||
interface TabItem {
|
||||
channelName: string;
|
||||
privateKey: string;
|
||||
isAllMessages?: boolean;
|
||||
}
|
||||
|
||||
export default function ChatBox({ showAllMessagesTab = false, className = "", expanded = false }: ChatBoxProps) {
|
||||
const { config } = useConfig();
|
||||
const meshcoreKeys = [
|
||||
const meshcoreKeys: TabItem[] = [
|
||||
{ channelName: "Public", privateKey: "izOH6cXN6mrJ5e26oRXNcg==" },
|
||||
...(config?.meshcoreKeys || [])
|
||||
];
|
||||
|
||||
// Add "All Messages" tab if requested
|
||||
const allTabs: TabItem[] = showAllMessagesTab
|
||||
? [{ channelName: "All Messages", privateKey: "", isAllMessages: true }, ...meshcoreKeys]
|
||||
: meshcoreKeys;
|
||||
|
||||
const [selectedTab, setSelectedTab] = useState(0);
|
||||
const [minimized, setMinimized] = useState(true);
|
||||
const [minimized, setMinimized] = useState(!expanded);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [lastBefore, setLastBefore] = useState<string | undefined>(undefined);
|
||||
|
||||
const selectedKey = meshcoreKeys[selectedTab];
|
||||
const channelId = getChannelIdFromKey(selectedKey.privateKey).toUpperCase();
|
||||
const selectedKey = allTabs[selectedTab];
|
||||
const channelId = selectedKey.isAllMessages ? undefined : getChannelIdFromKey(selectedKey.privateKey).toUpperCase();
|
||||
|
||||
// Only show tabs if more than one channel (public + at least one custom key)
|
||||
const showTabs = meshcoreKeys.length > 1;
|
||||
// Only show tabs if more than one channel (or if we have all messages tab)
|
||||
const showTabs = allTabs.length > 1;
|
||||
|
||||
useEffect(() => {
|
||||
if (!minimized) {
|
||||
@@ -45,13 +63,13 @@ export default function ChatBox() {
|
||||
setLastBefore(undefined);
|
||||
fetchMessages(undefined, true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [minimized, selectedTab]);
|
||||
|
||||
const fetchMessages = async (before?: string, replace = false) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let url = `/api/chat?limit=${PAGE_SIZE}&channel_id=${channelId}`;
|
||||
let url = `/api/chat?limit=${PAGE_SIZE}`;
|
||||
if (channelId) url += `&channel_id=${channelId}`;
|
||||
if (before) url += `&before=${encodeURIComponent(before)}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
@@ -79,29 +97,34 @@ export default function ChatBox() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-80 bg-white dark:bg-neutral-900 rounded-lg shadow-lg flex flex-col ${
|
||||
minimized ? "min-h-[2.5rem] px-4 py-2" : "h-96 px-4 py-4"
|
||||
className={`bg-white dark:bg-neutral-900 rounded-lg shadow-lg flex flex-col ${
|
||||
expanded
|
||||
? className
|
||||
: `w-80 ${minimized ? "min-h-[2.5rem] px-4 py-2" : "h-96 px-4 py-4"} ${className}`
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between" style={{ minHeight: '2rem' }}>
|
||||
<div className={`flex items-center justify-between ${expanded ? "px-4 py-2 border-b border-gray-200 dark:border-neutral-800" : ""}`} style={expanded ? {} : { minHeight: '2rem' }}>
|
||||
<span className="font-semibold text-gray-800 dark:text-gray-100">MeshCore Chat</span>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-neutral-200 dark:hover:bg-neutral-800"
|
||||
onClick={() => setMinimized((m) => !m)}
|
||||
aria-label={minimized ? "Maximize MeshCore Chat" : "Minimize MeshCore Chat"}
|
||||
>
|
||||
{minimized ? (
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
) : (
|
||||
<MinusIcon className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
{!expanded && (
|
||||
<button
|
||||
className="p-1 rounded hover:bg-neutral-200 dark:hover:bg-neutral-800"
|
||||
onClick={() => setMinimized((m) => !m)}
|
||||
aria-label={minimized ? "Maximize MeshCore Chat" : "Minimize MeshCore Chat"}
|
||||
>
|
||||
{minimized ? (
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
) : (
|
||||
<MinusIcon className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!minimized && (
|
||||
|
||||
{(!minimized || expanded) && (
|
||||
<>
|
||||
{showTabs && (
|
||||
<div className="flex gap-1 mb-2 border-b border-gray-200 dark:border-neutral-800">
|
||||
{meshcoreKeys.map((key, idx) => (
|
||||
<div className={`flex gap-1 border-b border-gray-200 dark:border-neutral-800 ${expanded ? "px-4 py-2" : "mb-2"}`}>
|
||||
{allTabs.map((key, idx) => (
|
||||
<button
|
||||
key={key.privateKey + idx}
|
||||
className={`px-2 py-1 text-xs rounded-t font-mono ${
|
||||
@@ -116,17 +139,23 @@ export default function ChatBox() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto text-sm text-gray-700 dark:text-gray-200 flex flex-col-reverse">
|
||||
<div className="flex flex-col-reverse gap-2">
|
||||
|
||||
<div className={`flex-1 overflow-y-auto text-sm text-gray-700 dark:text-gray-200 ${expanded ? "" : "flex flex-col-reverse"}`}>
|
||||
<div className={`${expanded ? "flex flex-col gap-2 p-4" : "flex flex-col-reverse gap-2"}`}>
|
||||
{messages.length === 0 && !loading && (
|
||||
<div className="text-gray-400 text-center mt-8">No chat messages found.</div>
|
||||
<div className={`text-gray-400 text-center ${expanded ? "py-8" : "mt-8"}`}>No chat messages found.</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<ChatMessageItem key={msg.ingest_timestamp + (msg.origins?.join(',') ?? '') + i} msg={msg} />
|
||||
<ChatMessageItem
|
||||
key={msg.ingest_timestamp + (msg.origins?.join(',') ?? '') + i}
|
||||
msg={msg}
|
||||
showErrorRow={selectedKey.isAllMessages}
|
||||
showChannelId={selectedKey.isAllMessages}
|
||||
/>
|
||||
))}
|
||||
{hasMore && (
|
||||
<button
|
||||
className="w-full py-2 bg-gray-100 dark:bg-neutral-800 rounded text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-neutral-700 mt-2"
|
||||
className={`w-full py-2 bg-gray-100 dark:bg-neutral-800 rounded text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-neutral-700 ${expanded ? "" : "mt-2"}`}
|
||||
onClick={handleLoadMore}
|
||||
disabled={loading}
|
||||
>
|
||||
|
||||
@@ -401,10 +401,10 @@ export default function MapView() {
|
||||
<TileLayer
|
||||
url="https://tiles.w0z.is/tiles/{z}/{x}/{y}.png"
|
||||
attribution="Meshcore Coverage © <a href='https://w0z.is/'>w0z.is</a>"
|
||||
minZoom={8}
|
||||
minZoom={1}
|
||||
maxZoom={22}
|
||||
minNativeZoom={11}
|
||||
maxNativeZoom={11}
|
||||
minNativeZoom={8}
|
||||
maxNativeZoom={8}
|
||||
zIndex={1000}
|
||||
opacity={0.7}
|
||||
/>
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function MapWithChat({ nodePositions }: MapWithChatProps) {
|
||||
<div className="flex-1 relative">
|
||||
<MapView />
|
||||
<div className="absolute bottom-6 right-6 z-30">
|
||||
<ChatBox />
|
||||
<ChatBox showAllMessagesTab={false} expanded={false} className="w-80 h-96" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user