chat regions

This commit is contained in:
ajvpot
2025-08-19 06:19:14 +02:00
parent 02e1e5c908
commit 4d48589cef
6 changed files with 200 additions and 22 deletions
+2 -1
View File
@@ -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
View File
@@ -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}&region=${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
+19
View File
@@ -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>
);
}
+59
View File
@@ -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>
);
}
+14 -1
View File
@@ -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' });
+40
View File
@@ -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 }));
}