This commit is contained in:
ajvpot
2025-07-23 00:00:00 +00:00
parent 493dda637b
commit 57500cfca1
4 changed files with 72 additions and 165 deletions

View File

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

View File

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

View File

@@ -401,10 +401,10 @@ export default function MapView() {
<TileLayer
url="https://tiles.w0z.is/tiles/{z}/{x}/{y}.png"
attribution="Meshcore Coverage &copy; <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}
/>

View File

@@ -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>