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