import { Box } from "../../../ui"; import { getCurrentPluginTarget } from "../../current-target"; import { recordResearchActivity } from "../../../api-client/research-activity"; import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; import type { PaneProps, TickerResearchTabDef } from "../../../types/plugin"; import { t, tf } from "../../../i18n"; import { quoteSubscriptionTargetFromTicker } from "../../../market-data/request-types"; import { useAppDispatch, useAppSelector, usePaneCollection, usePaneInstance, usePaneStateValue, usePaneTicker, usePaneAppConfig, } from "../../../state/app/context"; import { useQuoteUpdates } from "../../../state/hooks/quote-streaming"; import { getCollectionName, getCollectionTickerCount } from "../../../state/selectors"; import { getSharedRegistry } from "../../registry"; import { ChoiceDialog, EmptyState, NestedPaneTabs, PaneFooterScope, Tabs, usePaneFooter, usePaneHeaderTabs, usePaneMenuItems } from "../../../components"; import { useOptionalDialog, type PromptContext } from "../../../ui/dialog"; import { useThrottledCommitValue } from "../../../react/use-throttled-commit-value"; import { resolveOptionsTarget } from "../../../utils/options"; import { buildVisibleTickerResearchTabs, getTickerResearchPaneSettings, resolveLockedTabId, } from "./settings"; import { TICKER_RESEARCH_BUILTIN_TABS } from "./research-tabs"; import { useLiveStreamingSetting } from "../shared/live-streaming"; import { useCloudAccessFooter } from "../shared/cloud-upgrade"; import { CLOUD_QUOTE_DELAY_MINUTES } from "../shared/plan-access"; import { parsePublicTickerKey } from "../../../utils/exchanges"; import { tickerHasYahooSuffix } from "../../../sources/yahoo-finance/symbols"; import { tickerQuoteFooterInfo } from "./quote-footer"; import { ResearchTabKeysProvider, useResearchTabKeysHost } from "./research-tab-keys"; const TICKER_RESEARCH_TAB_COMMIT_DELAY_MS = 120; function sameStringSet(left: Set, right: Set): boolean { if (left.size !== right.size) return false; for (const value of left) { if (!right.has(value)) return false; } return true; } function registryTickerResearchTabsSnapshot(registry: ReturnType): string { if (!registry) return ""; return [...registry.tickerResearchTabs.values()] .map((tab) => `${tab.id}:${tab.name}:${tab.order}:${registry.getTickerResearchTabPluginId?.(tab.id) ?? ""}`) .join("\0"); } function useRegistryTickerResearchTabsSnapshot(registry: ReturnType): string { const subscribe = useCallback((onStoreChange: () => void) => { const events = registry?.events; if (!events) return () => {}; const unregisterRegistered = events.on("plugin:registered", onStoreChange); const unregisterUnregistered = events.on("plugin:unregistered", onStoreChange); return () => { unregisterRegistered(); unregisterUnregistered(); }; }, [registry]); const getSnapshot = useCallback(() => registryTickerResearchTabsSnapshot(registry), [registry]); return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } export function TickerResearchPane({ focused, width, height }: PaneProps) { const dispatch = useAppDispatch(); const config = usePaneAppConfig(); const paneInstance = usePaneInstance(); const { ticker, financials, error: instrumentError } = usePaneTicker(); const liveStreaming = useLiveStreamingSetting(); const streamingTarget = quoteSubscriptionTargetFromTicker(ticker, ticker?.metadata.ticker, "provider"); const streamingTargets = useMemo(() => ( streamingTarget ? [{ ...streamingTarget, surface: "detail" as const, visible: true, selected: true, weight: 100, }] : [] ), [ streamingTarget?.symbol, streamingTarget?.exchange, streamingTarget?.route, streamingTarget?.context?.brokerId, streamingTarget?.context?.brokerInstanceId, streamingTarget?.context?.instrument, ]); useQuoteUpdates(streamingTargets, { liveStreaming }); const { collectionId } = usePaneCollection(); const [committedActiveTabId, setCommittedActiveTabId] = usePaneStateValue("activeTabId", "overview"); const { value: activeTabId, setValue: setActiveTabId, } = useThrottledCommitValue( committedActiveTabId, setCommittedActiveTabId, TICKER_RESEARCH_TAB_COMMIT_DELAY_MS, { commitPendingOnUnmount: true }, ); useEffect(() => { if (!focused || !ticker || getCurrentPluginTarget() !== "web") return; const url = new URL(window.location.href); if (ticker.metadata.broker_contracts?.length) { for (const key of ["ticker", "exchange", "tab"]) url.searchParams.delete(key); window.history.replaceState(window.history.state, "", url.href); return; } url.searchParams.set("ticker", ticker.metadata.ticker); // A bare ticker can name different issuers on different venues. Keep the // selected listing on reload, and replace any previous pane's venue. if (ticker.metadata.exchange && !parsePublicTickerKey(ticker.metadata.ticker).exchange && !tickerHasYahooSuffix(ticker.metadata.ticker)) { url.searchParams.set("exchange", ticker.metadata.exchange); } else { url.searchParams.delete("exchange"); } url.searchParams.set("tab", activeTabId); window.history.replaceState(window.history.state, "", url.href); }, [focused, ticker?.metadata.ticker, ticker?.metadata.exchange, activeTabId, ticker?.metadata.broker_contracts]); const [pluginCaptured, setPluginCaptured] = useState(false); useEffect(() => { if (focused && Number.isFinite(financials?.quote?.price) && (financials?.quote?.price ?? 0) > 0) { recordResearchActivity("research_viewed", "research"); } }, [focused, financials?.quote?.price]); const [mountedTabIds, setMountedTabIds] = useState>(() => new Set()); const paneSettings = getTickerResearchPaneSettings(paneInstance?.settings); const hasOptionsChain = !!resolveOptionsTarget(ticker)?.effectiveTicker; const collectionTickerCount = useAppSelector((state) => getCollectionTickerCount(state, collectionId)); const collectionName = useAppSelector((state) => getCollectionName(state, collectionId)); const disabledPlugins = config.disabledPlugins; const registry = getSharedRegistry(); const tickerResearchTabsSnapshot = useRegistryTickerResearchTabsSnapshot(registry); // Hosts that render a pane without booting the plugin registry (the desktop // screenshot renderer) would otherwise leave the body with no tabs at all. const tickerResearchTabs = useMemo(() => ( registry ? [...registry.tickerResearchTabs.values()].filter((tab) => { const ownerId = registry.getTickerResearchTabPluginId?.(tab.id); return !ownerId || !disabledPlugins.includes(ownerId); }) : TICKER_RESEARCH_BUILTIN_TABS ), [disabledPlugins, registry, tickerResearchTabsSnapshot]); const allTabs = buildVisibleTickerResearchTabs(tickerResearchTabs, ticker, financials, { config, hasOptionsChain, }); const resolvedTabId = paneSettings.hideTabs ? resolveLockedTabId(paneSettings, allTabs) : (allTabs.some((tab) => tab.id === activeTabId) ? activeTabId : (allTabs[0]?.id ?? "overview")); // Only quote/chart views use the parent quote status. Other research tabs // own their data status (the options chain may have a different delay/feed). const quoteFooterActive = resolvedTabId === "overview" || resolvedTabId === "chart"; const cloudAccess = useCloudAccessFooter({ delayLabel: tf("{count}m", { count: CLOUD_QUOTE_DELAY_MINUTES }), degraded: financials?.quote?.dataSource !== "live", focused: focused && quoteFooterActive, segmentId: "ticker-research-access", shortcutScope: "ticker-research:upgrade", }); usePaneFooter( "ticker-research-access", () => quoteFooterActive ? { // Chart owns additional footer actions, so keep its parent status compact. info: tickerQuoteFooterInfo(financials?.quote, cloudAccess.segment, resolvedTabId === "overview" ? width : undefined), hints: cloudAccess.hint ? [cloudAccess.hint] : undefined, order: -1, } : null, [cloudAccess.hint, cloudAccess.segment, financials?.quote, quoteFooterActive, resolvedTabId, width], ); const visibleTabIdKey = allTabs.map((tab) => tab.id).join("\0"); // A quote tick re-renders this pane; the tab strip only has to re-render // when a tab appears, disappears, or is renamed. const tabItemsKey = allTabs.map((tab) => `${tab.id}:${tab.name}`).join("\0"); const tabItems = useMemo( () => allTabs.map((tab) => ({ label: t(tab.name), value: tab.id })), [tabItemsKey], ); // A tab's detail that moves with h/l itself (a 13F fund's sections) takes // those keys while it is open; Esc gives them back to the strip. const researchTabKeys = useResearchTabKeysHost(); const stripFocused = focused && !pluginCaptured && !researchTabKeys.claimed; const tabsInHeader = usePaneHeaderTabs(!paneSettings.hideTabs && ticker ? { tabs: tabItems, activeValue: resolvedTabId, onSelect: setActiveTabId, focused: stripFocused, } : null); // h/l step one tab at a time; with twenty tabs the pane menu jumps straight to one. const dialog = useOptionalDialog(); const showTabs = !paneSettings.hideTabs && !!ticker; usePaneMenuItems("ticker-research:go-to-tab", () => { if (!showTabs || !dialog || tabItems.length < 2) return null; return [{ id: "go-to-tab", label: t("Go to Tab…"), onSelect: () => { void dialog.prompt({ closeOnClickOutside: true, content: (context: PromptContext) => ( ({ id: tab.value, label: tab.label }))} /> ), }).then((tabId) => { if (tabId) setActiveTabId(tabId); }).catch(() => {}); }, }]; }, [dialog, resolvedTabId, setActiveTabId, showTabs, tabItems]); const tabBarHeight = paneSettings.hideTabs || tabsInHeader ? 0 : 1; const contentHeight = Math.max(1, height - tabBarHeight); const visibleTabIds = useMemo(() => new Set(allTabs.map((tab) => tab.id)), [visibleTabIdKey]); const renderedTabIds = useMemo(() => { const next = new Set(); for (const tabId of mountedTabIds) { if (visibleTabIds.has(tabId)) next.add(tabId); } if (visibleTabIds.has(resolvedTabId)) { next.add(resolvedTabId); } return next; }, [mountedTabIds, resolvedTabId, visibleTabIds]); const handlePluginCapture = useCallback((capturing: boolean) => { setPluginCaptured(capturing); dispatch({ type: "SET_INPUT_CAPTURED", captured: capturing }); }, [dispatch]); const ignorePluginCapture = useCallback(() => {}, []); useEffect(() => { setPluginCaptured(false); dispatch({ type: "SET_INPUT_CAPTURED", captured: false }); }, [resolvedTabId, dispatch]); useEffect(() => { setMountedTabIds((current) => { const next = new Set(); for (const tabId of current) { if (visibleTabIds.has(tabId)) next.add(tabId); } if (visibleTabIds.has(resolvedTabId)) { next.add(resolvedTabId); } return sameStringSet(current, next) ? current : next; }); }, [resolvedTabId, visibleTabIds]); if (!ticker) { const isEmptyFollowCollection = paneInstance?.binding?.kind === "follow" && !!collectionId && collectionTickerCount === 0; const message = instrumentError ?? (isEmptyFollowCollection ? tf("No tickers in {name}.", { name: collectionName || t("this collection") }) : t("No ticker selected.")); return ( ); } return ( {!paneSettings.hideTabs && !tabsInHeader && ( )} {tickerResearchTabs.map((tab) => { if (!renderedTabIds.has(tab.id) || !visibleTabIds.has(tab.id)) return null; const TickerResearchTab = tab.component; const isActive = resolvedTabId === tab.id; return ( ); })} ); }