import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { apiClient, type CloudWorldVenueMapPayload, type CloudWorldVenuePayload } from "../../../api-client"; import { DataTableView, EmptyState, getPaneSidebarWidth, getPaneSidebarWidthRange, PaneSidebar, PaneStatusBody, QueryBar, readStoredPaneSidebarWidth, shouldShowPaneSidebar, usePaneFooter, type DataTableCell, type DataTableColumn, type DataTableKeyEvent } from "../../../components"; import { useShortcut } from "../../../react/input"; import { usePluginPaneState } from "../../../public/react"; import { colors } from "../../../theme/colors"; import type { PaneProps } from "../../../types/plugin"; import { Box, Text, TextAttributes, useUiCapabilities, type InputRenderable } from "../../../ui"; import { isPlainKey } from "../../../utils/keyboard"; import { WorldVenueMap } from "./map"; import { filterWorldVenues, formatVenueCountdown, formatVenueLocalTime, venueRemainingSeconds, } from "./model"; export const WORLD_VENUE_MAP_PANE_ID = "world-venue-map"; type VenueColumnId = "status" | "mic" | "name" | "time"; type VenueColumn = DataTableColumn & { id: VenueColumnId }; function venueColumns(width: number): VenueColumn[] { const timeWidth = width >= 34 ? 6 : 0; const fixed = 1 + 6 + timeWidth; return [ { id: "status", label: "", width: 1, align: "left" }, { id: "mic", label: "MIC", width: 6, align: "left" }, { id: "name", label: "VENUE", width: Math.max(10, width - fixed - 6), flexGrow: 1, align: "left" }, ...(timeWidth ? [{ id: "time" as const, label: "LOCAL", width: timeWidth, align: "left" as const }] : []), ]; } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } function SelectedVenueHeader({ venue, checkedAt, now, width, }: { venue: CloudWorldVenuePayload | null; checkedAt: number; now: number; width: number; }) { if (!venue) return ; const remaining = formatVenueCountdown(venueRemainingSeconds(venue, checkedAt, now)); const state = venue.isOpen ? "OPEN" : "CLOSED"; const transition = remaining ? `${venue.isOpen ? "closes" : "opens"} ${remaining}` : ""; return ( {venue.mic} {state} {`${venue.city}, ${venue.country} · ${formatVenueLocalTime(venue.timezone, now)}${transition ? ` · ${transition}` : ""}`} ); } export function WorldVenueMapPane({ focused, width, height }: PaneProps) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [query, setQuery] = useState(""); const [searchFocused, setSearchFocused] = useState(false); const [searchFocusToken, setSearchFocusToken] = useState(0); const [selectedMic, setSelectedMic] = useState(null); const [now, setNow] = useState(Date.now()); const inputRef = useRef(null); const generationRef = useRef(0); const dataRef = useRef(null); dataRef.current = data; const load = useCallback(async () => { const generation = ++generationRef.current; if (!dataRef.current) setLoading(true); setError(null); try { const response = await apiClient.getCloudWorldVenues(); if (generation !== generationRef.current) return; if (!response.data) throw new Error(response.reasonCode ?? "World venue data unavailable"); const next = response.stale ? { ...response.data, stale: true } : response.data; dataRef.current = next; setData(next); setLoading(false); } catch (caught) { if (generation !== generationRef.current) return; setError(errorMessage(caught)); setLoading(false); } }, []); useEffect(() => { void load(); return () => { generationRef.current += 1; }; }, [load]); useEffect(() => { if (!data) return; const delay = Math.max(30_000, Math.min(15 * 60_000, data.refreshAt - Date.now())); const timer = setTimeout(() => void load(), delay); return () => clearTimeout(timer); }, [data, load]); useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 1_000); return () => clearInterval(timer); }, []); const venues = useMemo(() => filterWorldVenues(data?.venues ?? [], query), [data?.venues, query]); useEffect(() => { if (selectedMic && venues.some((venue) => venue.mic === selectedMic)) return; setSelectedMic(venues[0]?.mic ?? null); }, [selectedMic, venues]); const selectedVenue = useMemo( () => venues.find((venue) => venue.mic === selectedMic) ?? null, [selectedMic, venues], ); const focusSearch = useCallback(() => { setSearchFocused(true); setSearchFocusToken((value) => value + 1); }, []); const blurSearch = useCallback(() => setSearchFocused(false), []); const refresh = useCallback(() => void load(), [load]); // The search field only exists once venues have loaded; before that `/` has // nothing to focus and must not leave the pane waiting on a missing field. const searchable = !!data; const searchOpen = searchable && searchFocused; useShortcut((event) => { if (searchOpen || event.targetEditable) return; if (searchable && isPlainKey(event, "/")) { event.preventDefault(); event.stopPropagation(); focusSearch(); } else if (isPlainKey(event, "r")) { event.preventDefault(); event.stopPropagation(); refresh(); } }, { allowEditable: true, enabled: focused }); const handleTableKey = useCallback((event: DataTableKeyEvent) => { if (isPlainKey(event, "/")) { event.preventDefault?.(); event.stopPropagation?.(); focusSearch(); return true; } if (isPlainKey(event, "r")) { event.preventDefault?.(); event.stopPropagation?.(); refresh(); return true; } return false; }, [focusSearch, refresh]); usePaneFooter(WORLD_VENUE_MAP_PANE_ID, () => ({ info: [ ...(loading && !data ? [{ id: "loading", parts: [{ text: "loading", tone: "muted" as const }] }] : []), ...(data && !data.stale ? [{ id: "live", parts: [{ text: "live", tone: "positive" as const }] }] : []), ...(data?.stale ? [{ id: "stale", parts: [{ text: "stale", tone: "warning" as const }] }] : []), ...(error ? [{ id: "error", parts: [{ text: error, tone: "warning" as const }] }] : []), ], hints: [ ...(searchable ? [{ id: "search", key: "/", label: "search", onPress: focusSearch }] : []), ], }), [data, error, focusSearch, loading, searchable]); const { nativePaneChrome } = useUiCapabilities(); const [storedSidebarWidth, setStoredSidebarWidth] = usePluginPaneState("sidebarWidth", null); const [draggedSidebarWidth, setDraggedSidebarWidth] = useState(null); const horizontal = shouldShowPaneSidebar(data?.venues.length ?? 0, width, height); // Venue names need more room than a conversation list, so the width the pane // starts at is its own; a drag replaces it and is kept with the pane. The // terminal's divider cell sits inside the sidebar, so it is added on top to // keep the list 34 cells wide, which is what the LOCAL column needs. const defaultSidebarWidth = Math.max(34, Math.min(46, Math.round(width * 0.34))) + (nativePaneChrome ? 0 : 1); const sidebarWidth = horizontal ? getPaneSidebarWidth( width, !!nativePaneChrome, draggedSidebarWidth ?? readStoredPaneSidebarWidth(storedSidebarWidth) ?? defaultSidebarWidth, ) : width; const sidebarRange = getPaneSidebarWidthRange(width); // The terminal draws the divider as a cell inside the sidebar; the desktop as a hairline. const listWidth = horizontal && !nativePaneChrome ? Math.max(1, sidebarWidth - 1) : sidebarWidth; const mapWidth = horizontal ? Math.max(1, width - sidebarWidth) : width; const mapSectionHeight = horizontal ? height : Math.max(8, Math.floor(height * 0.52)); const tableHeight = horizontal ? height : Math.max(4, height - mapSectionHeight); const mapHeight = Math.max(2, mapSectionHeight - 2); const columns = useMemo(() => venueColumns(listWidth), [listWidth]); const renderCell = useCallback(( venue: CloudWorldVenuePayload, column: VenueColumn, _index: number, rowState: { selected: boolean }, ): DataTableCell => { const selectedColor = rowState.selected ? colors.selectedText : undefined; switch (column.id) { case "status": return { text: venue.isOpen ? "●" : "○", color: selectedColor ?? (venue.isOpen ? colors.positive : colors.textDim), }; case "mic": return { text: venue.mic, color: selectedColor ?? colors.textBright, attributes: TextAttributes.BOLD }; case "name": return { text: venue.title, color: selectedColor ?? colors.textMuted }; case "time": return { text: formatVenueLocalTime(venue.timezone, now), color: selectedColor ?? colors.textDim }; } }, [now]); const sidebarHeader = ( active ? focusSearch() : blurSearch(), focusToken: searchFocusToken, inputRef, debounceMs: 80, onNavigateDown: blurSearch, normalizeValue: (value: string) => value.trim(), }} /> ); const table = ( focused={focused && !searchFocused} selection={{ kind: "id", selectedId: selectedMic, getId: (venue) => venue.mic, onChange: (mic) => setSelectedMic(mic), }} rootWidth={listWidth} rootHeight={tableHeight} rootBefore={sidebarHeader} columns={columns} items={venues} sortColumnId={null} sortDirection="asc" getItemKey={(venue) => venue.mic} renderCell={renderCell} emptyStateTitle={query.trim() ? "No matching venues." : "No venue data."} emptyStateHint={query.trim() ? "Clear search." : undefined} onRootKeyDown={handleTableKey} /> ); if (!data && loading) { return ; } if (!data) { return ; } const map = ( setSelectedMic(venue.mic)} /> ); return horizontal ? ( { setStoredSidebarWidth(next); setDraggedSidebarWidth(null); }, }} > {table} {map} ) : ( {map} {table} ); }