From 4d48589cef83e35a31b9a7d7ae0836119459e463 Mon Sep 17 00:00:00 2001 From: ajvpot <553597+ajvpot@users.noreply.github.com> Date: Tue, 19 Aug 2025 06:19:14 +0200 Subject: [PATCH] chat regions --- src/app/api/chat/route.ts | 3 +- src/components/ChatBox.tsx | 86 ++++++++++++++++++++++++------- src/components/ConfigContext.tsx | 19 +++++++ src/components/RegionSelector.tsx | 59 +++++++++++++++++++++ src/lib/clickhouse/actions.ts | 15 +++++- src/lib/regions.ts | 40 ++++++++++++++ 6 files changed, 200 insertions(+), 22 deletions(-) create mode 100644 src/components/RegionSelector.tsx create mode 100644 src/lib/regions.ts diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 0f692fd..983879e 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -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 }); diff --git a/src/components/ChatBox.tsx b/src/components/ChatBox.tsx index ff9853c..d6ee23b 100644 --- a/src/components/ChatBox.tsx +++ b/src/components/ChatBox.tsx @@ -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 = () => ( - + ); + // If no region is selected, show the region selector + if (!config?.selectedRegion) { + return ( +
+
+ MeshCore Chat + {!startExpanded && ( + + )} +
+ + {!minimized && ( +
+ { + setMessages([]); + setHasMore(true); + setLastBefore(undefined); + fetchMessages(undefined, true); + }} + className="w-full" + /> +
+ )} +
+ ); + } + return (
- MeshCore Chat +
+ MeshCore Chat + + {getRegionConfig(config.selectedRegion!)?.friendlyName || config.selectedRegion} + +
{(!minimized) && (
+
+
Chat Region
+ +

+ Select a region to filter chat messages by broker and topic +

+
); } diff --git a/src/components/RegionSelector.tsx b/src/components/RegionSelector.tsx new file mode 100644 index 0000000..29efb30 --- /dev/null +++ b/src/components/RegionSelector.tsx @@ -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 ( +
+
+

+ Select a Chat Region +

+

+ Choose a region to filter chat messages +

+
+ +
+ {regions.map(({ name, friendlyName }) => ( + + ))} +
+ +
+

+ You can change this selection later in the Settings menu +

+
+
+ ); +} + diff --git a/src/lib/clickhouse/actions.ts b/src/lib/clickhouse/actions.ts index e0dab4c..c160cf0 100644 --- a/src/lib/clickhouse/actions.ts +++ b/src/lib/clickhouse/actions.ts @@ -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 = { 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' }); diff --git a/src/lib/regions.ts b/src/lib/regions.ts new file mode 100644 index 0000000..c353f7c --- /dev/null +++ b/src/lib/regions.ts @@ -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 })); +} +