mirror of
https://github.com/ajvpot/meshexplorer.git
synced 2026-08-07 00:52:45 +02:00
chat regions
This commit is contained in:
@@ -9,7 +9,8 @@ export async function GET(req: Request) {
|
||||
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, after, channelId } as { limit?: number, before?: string, after?: string, channelId?: string });
|
||||
const region = searchParams.get("region") || undefined;
|
||||
const messages = await getLatestChatMessages({ limit, before, after, channelId, region } as { limit?: number, before?: string, after?: string, channelId?: string, region?: string });
|
||||
return NextResponse.json(messages);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Failed to fetch chat messages" }, { status: 500 });
|
||||
|
||||
+66
-20
@@ -7,6 +7,8 @@ import { getChannelIdFromKey } from "../lib/meshcore";
|
||||
import ChatMessageItem, { ChatMessage } from "./ChatMessageItem";
|
||||
import RefreshButton from "./RefreshButton";
|
||||
import { buildApiUrl } from "../lib/api";
|
||||
import RegionSelector from "./RegionSelector";
|
||||
import { getRegionConfig } from "../lib/regions";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
@@ -59,9 +61,11 @@ export default function ChatBox({ showAllMessagesTab = false, className = "", st
|
||||
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}`;
|
||||
let url = `/api/chat?limit=${PAGE_SIZE}®ion=${encodeURIComponent(config.selectedRegion!)}`;
|
||||
if (channelId) url += `&channel_id=${channelId}`;
|
||||
|
||||
if (after) {
|
||||
@@ -94,18 +98,11 @@ export default function ChatBox({ showAllMessagesTab = false, className = "", st
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Only set hasMore to false if we don't have a lastBefore value (can't load more)
|
||||
if (!lastBefore) {
|
||||
setHasMore(false);
|
||||
}
|
||||
if (after) {
|
||||
// Silently fail for auto-refresh
|
||||
console.error('Auto-refresh failed:', error);
|
||||
}
|
||||
console.error('Load failed:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [channelId]);
|
||||
}, [channelId, config.selectedRegion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!minimized) {
|
||||
@@ -114,7 +111,7 @@ export default function ChatBox({ showAllMessagesTab = false, className = "", st
|
||||
setLastBefore(undefined);
|
||||
fetchMessages(undefined, true);
|
||||
}
|
||||
}, [minimized, selectedTab]);
|
||||
}, [minimized, selectedTab, config?.selectedRegion, fetchMessages]);
|
||||
|
||||
// Auto-refresh effect
|
||||
useEffect(() => {
|
||||
@@ -128,7 +125,7 @@ export default function ChatBox({ showAllMessagesTab = false, className = "", st
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [minimized, channelId, messages]);
|
||||
}, [minimized, channelId, messages, fetchMessages]);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (lastBefore) {
|
||||
@@ -144,15 +141,59 @@ export default function ChatBox({ showAllMessagesTab = false, className = "", st
|
||||
};
|
||||
|
||||
const LoadMoreButton = () => (
|
||||
<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 ${startExpanded ? "" : "mt-2"}`}
|
||||
onClick={handleLoadMore}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Loading..." : "Load more"}
|
||||
</button>
|
||||
<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 ${startExpanded ? "" : "mt-2"}`}
|
||||
onClick={handleLoadMore}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Loading..." : "Load more"}
|
||||
</button>
|
||||
);
|
||||
|
||||
// If no region is selected, show the region selector
|
||||
if (!config?.selectedRegion) {
|
||||
return (
|
||||
<div className={`bg-white dark:bg-neutral-900 rounded-lg shadow-lg flex flex-col ${
|
||||
startExpanded
|
||||
? className
|
||||
: minimized
|
||||
? "w-80 h-10 px-3 py-1"
|
||||
: "w-80 h-96 px-4 py-4"
|
||||
}`}>
|
||||
<div className={`flex items-center justify-between ${startExpanded ? "px-4 py-2 border-b border-gray-200 dark:border-neutral-800" : ""}`} style={startExpanded ? {} : { minHeight: minimized ? '2rem' : '2rem' }}>
|
||||
<span className="font-semibold text-gray-800 dark:text-gray-100">MeshCore Chat</span>
|
||||
{!startExpanded && (
|
||||
<button
|
||||
className="p-1 rounded text-gray-800 dark:text-gray-100 hover:bg-neutral-100 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 && (
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<RegionSelector
|
||||
onRegionSelected={() => {
|
||||
setMessages([]);
|
||||
setHasMore(true);
|
||||
setLastBefore(undefined);
|
||||
fetchMessages(undefined, true);
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-white dark:bg-neutral-900 rounded-lg shadow-lg flex flex-col ${
|
||||
@@ -164,7 +205,12 @@ export default function ChatBox({ showAllMessagesTab = false, className = "", st
|
||||
}`}
|
||||
>
|
||||
<div className={`flex items-center justify-between ${startExpanded ? "px-4 py-2 border-b border-gray-200 dark:border-neutral-800" : ""}`} style={startExpanded ? {} : { minHeight: minimized ? '2rem' : '2rem' }}>
|
||||
<span className="font-semibold text-gray-800 dark:text-gray-100">MeshCore Chat</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-gray-800 dark:text-gray-100">MeshCore Chat</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{getRegionConfig(config.selectedRegion!)?.friendlyName || config.selectedRegion}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{(!minimized) && (
|
||||
<RefreshButton
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import React, { createContext, useContext, useState, useEffect, useRef, useLayoutEffect, ReactNode } from "react";
|
||||
import { getChannelIdFromKey } from "../lib/meshcore";
|
||||
import { getRegionFriendlyNames } from "../lib/regions";
|
||||
import Modal from "./Modal";
|
||||
|
||||
// Config shape
|
||||
@@ -17,6 +18,7 @@ export type Config = {
|
||||
showNodeNames?: boolean; // add show node names toggle
|
||||
meshcoreKeys?: MeshcoreKey[]; // meshcore private keys
|
||||
showMeshcoreCoverageOverlay?: boolean; // meshcore overlay toggle
|
||||
selectedRegion?: string; // selected region for chat messages
|
||||
};
|
||||
|
||||
const TILE_LAYERS = [
|
||||
@@ -33,6 +35,7 @@ const DEFAULT_CONFIG: Config = {
|
||||
showNodeNames: true, // default to show node names
|
||||
meshcoreKeys: [], // default empty
|
||||
showMeshcoreCoverageOverlay: false, // meshcore overlay default
|
||||
selectedRegion: undefined, // no region selected by default
|
||||
};
|
||||
|
||||
const LAST_SEEN_OPTIONS = [
|
||||
@@ -241,6 +244,22 @@ function ConfigPopover({ config, setConfig, onClose, anchorRef, onOpenKeyModal }
|
||||
Manage Meshcore Private Keys
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<div className="font-medium mb-2">Chat Region</div>
|
||||
<select
|
||||
className="w-full p-2 border rounded"
|
||||
value={config.selectedRegion || ''}
|
||||
onChange={e => setConfig({ ...config, selectedRegion: e.target.value || undefined })}
|
||||
>
|
||||
<option value="">Select a region...</option>
|
||||
{getRegionFriendlyNames().map(({ name, friendlyName }) => (
|
||||
<option key={name} value={name}>{friendlyName}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Select a region to filter chat messages by broker and topic
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
import { useConfig } from "./ConfigContext";
|
||||
import { getRegionFriendlyNames } from "../lib/regions";
|
||||
|
||||
interface RegionSelectorProps {
|
||||
onRegionSelected?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function RegionSelector({ onRegionSelected, className = "" }: RegionSelectorProps) {
|
||||
const { config, setConfig } = useConfig();
|
||||
const regions = getRegionFriendlyNames();
|
||||
|
||||
const handleRegionSelect = (regionName: string) => {
|
||||
setConfig({ ...config, selectedRegion: regionName });
|
||||
if (onRegionSelected) {
|
||||
onRegionSelected();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bg-white dark:bg-neutral-900 rounded-lg shadow-lg p-6 ${className}`}>
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 dark:text-gray-100 mb-2">
|
||||
Select a Chat Region
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
Choose a region to filter chat messages
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{regions.map(({ name, friendlyName }) => (
|
||||
<button
|
||||
key={name}
|
||||
onClick={() => handleRegionSelect(name)}
|
||||
className="w-full p-4 text-left border border-gray-200 dark:border-neutral-700 rounded-lg hover:bg-gray-50 dark:hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
<div className="font-medium text-gray-800 dark:text-gray-100">
|
||||
{friendlyName}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{name === 'seattle' && 'Broker: mqtt.davekeogh.com, Base topics: meshcore, meshcore/salish'}
|
||||
{name === 'portland' && 'Broker: mqtt.davekeogh.com, Base topic: meshcore/pdx'}
|
||||
{name === 'boston' && 'Broker: mqtt.davekeogh.com, Base topic: meshcore/bos'}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
You can change this selection later in the Settings menu
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,10 +50,11 @@ export async function getNodePositions({ minLat, maxLat, minLng, maxLng, nodeTyp
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLatestChatMessages({ limit = 20, before, after, channelId }: { limit?: number, before?: string, after?: string, channelId?: string } = {}) {
|
||||
export async function getLatestChatMessages({ limit = 20, before, after, channelId, region }: { limit?: number, before?: string, after?: string, channelId?: string, region?: string } = {}) {
|
||||
try {
|
||||
let where = [];
|
||||
const params: Record<string, any> = { limit };
|
||||
|
||||
if (before) {
|
||||
where.push('ingest_timestamp < {before:DateTime64}');
|
||||
params.before = before;
|
||||
@@ -66,6 +67,18 @@ export async function getLatestChatMessages({ limit = 20, before, after, channel
|
||||
where.push('channel_hash = {channelId:String}');
|
||||
params.channelId = channelId;
|
||||
}
|
||||
|
||||
// Add region filtering if specified
|
||||
if (region) {
|
||||
if (region === 'seattle') {
|
||||
where.push("arrayExists(x -> x.1 = 'tcp://mqtt.davekeogh.com:1883' AND (x.2 = 'meshcore' OR x.2 = 'meshcore/salish'), topic_broker_array)");
|
||||
} else if (region === 'portland') {
|
||||
where.push("arrayExists(x -> x.1 = 'tcp://mqtt.davekeogh.com:1883' AND x.2 = 'meshcore/pdx', topic_broker_array)");
|
||||
} else if (region === 'boston') {
|
||||
where.push("arrayExists(x -> x.1 = 'tcp://mqtt.davekeogh.com:1883' AND x.2 = 'meshcore/bos', topic_broker_array)");
|
||||
}
|
||||
}
|
||||
|
||||
const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
|
||||
const query = `SELECT ingest_timestamp, mesh_timestamp, channel_hash, mac, hex(encrypted_message) AS encrypted_message, message_count, origin_path_array FROM meshcore_public_channel_messages ${whereClause} ORDER BY ingest_timestamp DESC LIMIT {limit:UInt32}`;
|
||||
const resultSet = await clickhouse.query({ query, query_params: params, format: 'JSONEachRow' });
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
export interface RegionConfig {
|
||||
name: string;
|
||||
friendlyName: string;
|
||||
broker: string;
|
||||
topics: string[];
|
||||
}
|
||||
|
||||
export const REGIONS: RegionConfig[] = [
|
||||
{
|
||||
name: "seattle",
|
||||
friendlyName: "Seattle (PugetMesh, SalishMesh)",
|
||||
broker: "tcp://mqtt.davekeogh.com:1883",
|
||||
topics: ["meshcore", "meshcore/salish"]
|
||||
},
|
||||
{
|
||||
name: "portland",
|
||||
friendlyName: "Portland",
|
||||
broker: "tcp://mqtt.davekeogh.com:1883",
|
||||
topics: ["meshcore/pdx"]
|
||||
},
|
||||
{
|
||||
name: "boston",
|
||||
friendlyName: "Boston",
|
||||
broker: "tcp://mqtt.davekeogh.com:1883",
|
||||
topics: ["meshcore/bos"]
|
||||
}
|
||||
];
|
||||
|
||||
export function getRegionConfig(regionName: string): RegionConfig | undefined {
|
||||
return REGIONS.find(region => region.name === regionName);
|
||||
}
|
||||
|
||||
export function getRegionNames(): string[] {
|
||||
return REGIONS.map(region => region.name);
|
||||
}
|
||||
|
||||
export function getRegionFriendlyNames(): { name: string; friendlyName: string }[] {
|
||||
return REGIONS.map(region => ({ name: region.name, friendlyName: region.friendlyName }));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user