import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { apiClient } from "../../../api-client"; import { EmptyState, ExternalLinkText, PaneStatusBody, Prose, SectionHeading, usePaneFooter, type PaneFooterSegment, } from "../../../components"; import { useShortcut } from "../../../react/input"; import { colors, hoverBg } from "../../../theme/colors"; import { Box, ScrollBox, Text, useRendererHost, useUiCapabilities, type ScrollBoxRenderable, } from "../../../ui"; import { truncateToDisplayWidth } from "../../../utils/format"; import { isPlainKey } from "../../../utils/keyboard"; import { useBoundTicker, useTickerRequest } from "../shared/ticker-request"; import { buildFilingEventsFeed, resolveFeedScrollTop, type FilingEventEntry, } from "./feed"; export const FILING_EVENTS_PANE_ID = "filing-events"; const MAX_PROSE_WIDTH = 100; const loadFilingEvents = (symbol: string) => apiClient.getFilingEvents(symbol, 100); /** One filing: when it was filed, what it was about, and what it said. */ function FilingEntry({ entry, nativePaneChrome, onOpen, onSelect, proseWidth, selected, }: { entry: FilingEventEntry; nativePaneChrome: boolean; onOpen: () => void; onSelect: () => void; proseWidth: number; selected: boolean; }) { // Desktop chrome clips the row itself; the terminal has to be told where the // row ends, or a third item label runs off it mid-word. const items = nativePaneChrome ? entry.itemsLabel : truncateToDisplayWidth(entry.itemsLabel, Math.max(4, proseWidth - entry.filedLabel.length - 2)); return ( {items} {entry.headline ? ( ) : null} {entry.points.map((point) => ( ))} {entry.people.map((person) => ( ))} ); } export function FilingEventsPane({ focused, width, }: { focused: boolean; width: number; height: number; }) { const { symbol, exchange } = useBoundTicker(); const ticker = symbol ? symbol.toUpperCase() : null; const nativePaneChrome = useUiCapabilities().nativePaneChrome === true; const rendererHost = useRendererHost(); const { data, loading, error, reload } = useTickerRequest(loadFilingEvents, ticker, exchange); const events = data?.events ?? []; const [selectedId, setSelectedId] = useState(null); const scrollRef = useRef(null); useEffect(() => { const scrollBox = scrollRef.current; if (scrollBox) scrollBox.scrollTop = 0; setSelectedId(null); }, [ticker]); const bodyWidth = Math.max(12, width - 2); const proseWidth = Math.min(bodyWidth, MAX_PROSE_WIDTH); // Desktop chrome wraps the paragraphs itself, across the whole pane; the // terminal wraps at the reading width. Measuring against whichever is in use // keeps the entry offsets on the lines the reader actually sees. const feed = useMemo( () => buildFilingEventsFeed(events, nativePaneChrome ? bodyWidth : proseWidth), [bodyWidth, events, nativePaneChrome, proseWidth], ); const selectedIndex = Math.max( 0, feed.entries.findIndex((entry) => entry.id === selectedId), ); const selected = feed.entries[selectedIndex] ?? null; useEffect(() => { const scrollBox = scrollRef.current; if (!scrollBox?.viewport || !selected) return; scrollBox.scrollTop = resolveFeedScrollTop({ entry: selected, scrollTop: scrollBox.scrollTop, viewportHeight: scrollBox.viewport.height, }); }, [selected]); const openFiling = useCallback(() => { if (selected?.docUrl) void rendererHost.openExternal(selected.docUrl); }, [rendererHost, selected]); /** Selects entry `next`; false when the selection stays, so the key can scroll a tall entry instead. */ const selectEntry = useCallback((next: number): boolean => { const entries = feed.entries; if (entries.length === 0) return false; const index = Math.max(0, Math.min(entries.length - 1, next)); if (index === selectedIndex) return false; setSelectedId(entries[index]?.id ?? null); return true; }, [feed.entries, selectedIndex]); /** The entry about one screen away, so PageUp and PageDown keep the selection on screen. */ const pageTarget = useCallback((direction: 1 | -1): number => { const entries = feed.entries; const current = entries[selectedIndex]; if (!current) return selectedIndex; const page = Math.max(1, (scrollRef.current?.viewport?.height ?? 10) - 1); const target = current.top + direction * page; if (direction > 0) { let index = selectedIndex + 1; while (index + 1 < entries.length && entries[index + 1]!.top <= target) index += 1; return index; } let index = selectedIndex - 1; while (index - 1 >= 0 && entries[index - 1]!.top >= target) index -= 1; return index; }, [feed.entries, selectedIndex]); useShortcut( (event) => { if (isPlainKey(event, "r")) { void reload(); return; } if (isPlainKey(event, "o", "enter", "return")) { event.preventDefault(); openFiling(); return; } if (isPlainKey(event, "home") && selectEntry(0)) { // The top of the feed, so the first section heading shows too. if (scrollRef.current) scrollRef.current.scrollTop = 0; event.preventDefault(); return; } const moved = isPlainKey(event, "j", "down") ? selectEntry(selectedIndex + 1) : isPlainKey(event, "k", "up") ? selectEntry(selectedIndex - 1) : isPlainKey(event, "pagedown") ? selectEntry(pageTarget(1)) : isPlainKey(event, "pageup") ? selectEntry(pageTarget(-1)) : isPlainKey(event, "end") ? selectEntry(feed.entries.length - 1) : false; // A moved selection scrolls itself into view; the pane scroll keys must // not scroll the feed a second time. if (moved) event.preventDefault(); }, { enabled: focused, scope: FILING_EVENTS_PANE_ID }, ); usePaneFooter(FILING_EVENTS_PANE_ID, () => { const info: PaneFooterSegment[] = []; if (loading) { info.push({ id: "loading", parts: [{ text: "loading", tone: "muted" }] }); } if (error && ticker) info.push({ id: "error", parts: [{ text: error, tone: "warning" }] }); const hints = selected ? [{ id: "open", key: "o", label: "pen filing", onPress: openFiling }] : []; return { info, hints }; }, [loading, error, ticker, selected, openFiling]); if (!ticker) return ; if (loading && !data) { return ; } if (error && !data) { return ( ); } if (data && events.length === 0) { return ( ); } return ( {feed.sections.map((section, index) => ( {section.entries.map((entry) => ( { setSelectedId(entry.id); void rendererHost.openExternal(entry.docUrl); }} onSelect={() => setSelectedId(entry.id)} proseWidth={proseWidth} selected={entry.id === selected?.id} /> ))} ))} ); }