import { useCallback, useEffect, useMemo, useState } from "react"; import { Box, TextAttributes } from "../../../ui"; import { DataTableView, PaneStatusBody, Tabs, usePaneFooter, usePaneHeaderTabs, usePaneNoticeFooter, type DataTableCell, type DataTableKeyEvent, } from "../../../components"; import { PriceSparkline } from "../../../components/price-sparkline/view"; import type { CryptoAssetKind, CryptoMarketAsset } from "../../../api-client/crypto-markets"; import { ApiRequestError } from "../../../api-client/errors"; import { useAsyncResource } from "../../../react/async-resource"; import { usePaneVisible } from "../../../state/app/activity"; import { useLiveQuoteEntries } from "../../../state/hooks/quote-streaming"; import { colors, priceColor } from "../../../theme/colors"; import { TICKER_RESEARCH_PANE_ID } from "../../../types/config"; import type { QuoteSubscriptionTarget } from "../../../types/data-provider"; import type { Quote } from "../../../types/financials"; import type { QueryEntry } from "../../../market-data/result-types"; import type { PaneProps } from "../../../types/plugin"; import { publicTickerKey } from "../../../utils/exchanges"; import { formatCompact } from "../../../utils/format"; import { isPlainKey } from "../../../utils/keyboard"; import { usePluginPaneState, usePluginTickerActions } from "../../runtime"; import { useLiveStreamingSetting } from "../shared/live-streaming"; import { useResearchCloudSession } from "../shared/research-cloud-session"; import { cachedCryptoMarkets, loadCryptoMarkets } from "./client"; import { buildCryptoColumns, buildCryptoRows, CRYPTO_TABS, DEFAULT_CRYPTO_SORT, formatCryptoPercent, nextCryptoSort, sortCryptoRows, type CryptoColumn, type CryptoRow, type CryptoSortPreference, } from "./model"; /** * The board is the price for every row without a live stream: coins the live * feed does not carry, and every coin on a delayed plan, whose streamed quotes * trail it by 15 minutes. It refreshes on this clock, not the app's data one. */ export const CRYPTO_BOARD_REFRESH_MS = 15_000; /** Once every streamed row is live, the board only carries the rest of the tab. */ const CRYPTO_BOARD_STREAMING_REFRESH_MS = 60_000; /** Rows streamed beyond the visible window so a short scroll lands on live prices. */ const STREAM_OVERSCAN = 8; /** Before the table reports its window, stream what a full-height pane shows. */ const INITIAL_STREAM_ROWS = 40; const NO_QUOTES = new Map>(); const clearDenied = (error: unknown) => error instanceof ApiRequestError && [401, 403].includes(error.status ?? 0); const cryptoTickerKey = (row: CryptoRow) => publicTickerKey(row.asset.symbol, "CCC"); function quoteTargets( assets: readonly CryptoMarketAsset[], selectedId: string | null, ): QuoteSubscriptionTarget[] { return assets.map((asset) => ({ symbol: asset.symbol, exchange: "CCC", surface: "screener", visible: true, selected: asset.symbol === selectedId, weight: asset.symbol === selectedId ? 100 : 70, })); } function renderCryptoCell(row: CryptoRow, column: CryptoColumn, selected: boolean): DataTableCell { const selectedColor = selected ? colors.selectedText : undefined; const signed = (value: number | null) => ({ text: formatCryptoPercent(value), color: selectedColor ?? (value == null ? colors.textDim : priceColor(value)), }); switch (column.id) { case "rank": return { text: String(row.rank), color: selectedColor ?? colors.textDim }; case "code": return { text: row.code, color: selectedColor ?? colors.textBright, attributes: TextAttributes.BOLD }; case "name": return { text: row.name, color: selectedColor }; case "price": return { text: row.priceText, color: selectedColor }; case "changePercent": return signed(row.changePercent); case "return7d": return signed(row.return7d); case "return30d": return signed(row.return30d); case "return1y": return signed(row.return1y); case "trend": return { text: "", content: , }; case "volume24h": return { text: row.volume24h == null ? "—" : formatCompact(row.volume24h, { fixedDecimals: true }), color: selectedColor ?? colors.textDim }; case "marketCap": return { text: row.marketCap == null ? "—" : formatCompact(row.marketCap, { fixedDecimals: true }), color: selectedColor ?? colors.textDim }; } } export function CryptoBoardPane({ width, height, focused }: PaneProps) { const session = useResearchCloudSession(); const loader = useCallback((force: boolean) => loadCryptoMarkets(force), [session.requestKey]); const resource = useAsyncResource(loader, { initialData: cachedCryptoMarkets, clearOnError: clearDenied }); const data = resource.data?.payload; const { pinTicker } = usePluginTickerActions(); const liveStreaming = useLiveStreamingSetting(); const [activeTab, setActiveTab] = usePluginPaneState("activeTab", "coin"); const [selectedId, setSelectedId] = usePluginPaneState("selected", null); const [sort, setSort] = useState(DEFAULT_CRYPTO_SORT); const [visibleRange, setVisibleRange] = useState({ start: 0, end: INITIAL_STREAM_ROWS }); const paneVisible = usePaneVisible(); const reloadBoard = resource.load; const tabAssets = useMemo( () => data?.assets.filter((asset) => asset.kind === activeTab) ?? [], [activeTab, data], ); // The stream follows the rows on screen. Ordering by the board snapshot keeps // live ticks from reshuffling the subscription; the overscan covers the drift. const streamedAssets = useMemo(() => { const ordered = sortCryptoRows(buildCryptoRows(tabAssets, activeTab, NO_QUOTES), sort).map((row) => row.asset); const window = ordered.slice( Math.max(0, visibleRange.start - STREAM_OVERSCAN), visibleRange.end + STREAM_OVERSCAN, ); const selected = ordered.find((asset) => asset.symbol === selectedId); return selected && !window.includes(selected) ? [...window, selected] : window; }, [activeTab, selectedId, sort, tabAssets, visibleRange]); const targets = useMemo(() => quoteTargets(streamedAssets, selectedId), [selectedId, streamedAssets]); const { entries, freshnessNow } = useLiveQuoteEntries(targets, { freshnessScopeKey: `crypto-board:${activeTab}`, liveStreaming, }); const rows = useMemo( () => sortCryptoRows(buildCryptoRows(tabAssets, activeTab, entries, freshnessNow), sort), [activeTab, entries, freshnessNow, sort, tabAssets], ); const streamedIds = useMemo(() => new Set(streamedAssets.map((asset) => asset.symbol)), [streamedAssets]); const allStreamedLive = liveStreaming && streamedIds.size > 0 && rows.every((row) => !streamedIds.has(row.id) || row.live); const boardRefreshMs = allStreamedLive ? CRYPTO_BOARD_STREAMING_REFRESH_MS : CRYPTO_BOARD_REFRESH_MS; useEffect(() => { if (!paneVisible) return; const timer = setInterval(() => void reloadBoard(false), boardRefreshMs); return () => clearInterval(timer); }, [boardRefreshMs, paneVisible, reloadBoard]); const renderCell = useCallback( (row: CryptoRow, column: CryptoColumn, _index: number, rowState: { selected: boolean }) => ( renderCryptoCell(row, column, rowState.selected) ), [], ); useEffect(() => { if (!rows.length) return; if (!selectedId || !rows.some((row) => row.id === selectedId)) setSelectedId(rows[0]!.id); }, [rows, selectedId, setSelectedId]); const latestUpdate = rows.reduce( (latest, row) => (row.updatedAt != null && (latest == null || row.updatedAt > latest) ? row.updatedAt : latest), null, ); const columns = useMemo(() => buildCryptoColumns(width), [width]); const tabItems = CRYPTO_TABS.map((tab) => ({ label: tab.label, value: tab.value, })); const selectTab = (value: string) => { setActiveTab(value as CryptoAssetKind); setSelectedId(null); setVisibleRange({ start: 0, end: INITIAL_STREAM_ROWS }); }; const tabsInHeader = usePaneHeaderTabs({ tabs: tabItems, activeValue: activeTab, onSelect: selectTab, focused }); usePaneNoticeFooter({ registrationId: "crypto-board:notices", focused, notices: [...(data?.warnings ?? []), ...(resource.data?.refreshError ? [resource.data.refreshError] : [])], }); usePaneFooter("crypto-board", () => ({ info: [ // Background refreshes run every 15s; only the first load is worth a label. ...(resource.loading && !data ? [{ id: "loading", parts: [{ text: "loading", tone: "muted" as const }] }] : []), ...(data && resource.error ? [{ id: "refresh", parts: [{ text: "refresh failed", tone: "warning" as const }] }] : []), ...(latestUpdate != null ? [{ id: "updated", parts: [{ text: new Date(latestUpdate).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }), tone: "muted" as const, }], }] : []), ...(resource.data?.stale ? [{ id: "cached", parts: [{ text: "cached", tone: "warning" as const }] }] : []), ], }), [data, latestUpdate, resource.data?.stale, resource.error, resource.loading]); const handleKeyDown = useCallback((event: DataTableKeyEvent) => { if (!isPlainKey(event, "r")) return false; event.preventDefault?.(); event.stopPropagation?.(); void resource.reload(); return true; }, [resource.reload]); return ( {!tabsInHeader && ( )} focused={focused} selection={{ kind: "id", selectedId, getId: (row) => row.id, onChange: (id) => setSelectedId(id), }} onRootKeyDown={handleKeyDown} resetScrollKey={activeTab} columns={columns} items={rows} sortable sortColumnId={sort.columnId} sortDirection={sort.direction} onHeaderClick={(columnId) => setSort((current) => nextCryptoSort(current, columnId))} onSortChange={(columnId, direction) => setSort((current) => ({ ...current, columnId: columnId as CryptoSortPreference["columnId"], direction }))} isColumnSortable={(column) => column.id !== "trend"} getItemKey={(row) => row.id} onActivate={(row) => pinTicker(cryptoTickerKey(row), { floating: true, paneType: TICKER_RESEARCH_PANE_ID, instrument: null, })} visibleRangeKey={`${activeTab}:${sort.columnId}:${sort.direction}`} onVisibleRangeChange={setVisibleRange} renderCell={renderCell} emptyStateTitle="No crypto assets returned." /> ); }