import { useCallback, useEffect, useRef } from "react"; import { tickerSelectionFromSearchResult } from "../../../../tickers/selection"; import type { AppState } from "../../../../state/app/context"; import type { AppTickerRepositoryPort } from "../../../../core/app-service-ports"; import type { PluginRegistry } from "../../../../plugins/registry"; import type { InstrumentSearchResult } from "../../../../types/instrument"; import type { PinTickerOptions } from "../../../../types/plugin"; import { publicTickerKey } from "../../../../utils/exchanges"; import { rankTickerSearchItems, upsertTickerFromSearchResult, type TickerSearchCandidate, type TickerSearchInstrumentClass, } from "../../../../tickers/search"; import type { ResultItem } from "../../list/model"; import { buildTickerSearchCacheKey, createQuickLookTickerCandidates, formatInstrumentBadge, } from "./results"; interface UseCommandBarTickerSearchActionsOptions { closeAll: (options?: { revertThemePreview?: boolean }) => void; dispatch: (action: any) => void; focusTicker: (symbol: string, options?: PinTickerOptions) => void; pluginRegistry: Pick; tickerRepository: AppTickerRepositoryPort; tickers: AppState["tickers"]; } export function useCommandBarTickerSearchActions({ closeAll, dispatch, focusTicker, pluginRegistry, tickerRepository, tickers, }: UseCommandBarTickerSearchActionsOptions) { const tickerSearchCacheRef = useRef>(new Map()); const resolveSearchTicker = useCallback(async (result: InstrumentSearchResult, tickerSymbol?: string) => { const { ticker, created } = await upsertTickerFromSearchResult(tickerRepository, result, { tickerSymbol }); dispatch({ type: "UPDATE_TICKER", ticker }); if (created) { pluginRegistry.events.emit("ticker:added", { symbol: ticker.metadata.ticker, ticker }); } return ticker; }, [dispatch, pluginRegistry.events, tickerRepository]); const openTickerResearch = useCallback((result: InstrumentSearchResult, options?: { forceNewPane?: boolean }) => { (async () => { const ticker = await resolveSearchTicker(result); focusTicker(ticker.metadata.ticker, { ...options, ...tickerSelectionFromSearchResult(result) }); closeAll({ revertThemePreview: false }); })(); }, [closeAll, focusTicker, resolveSearchTicker]); const mapTickerSearchCandidateToResultItem = useCallback((candidate: TickerSearchCandidate): ResultItem => { const detail = formatTickerSearchDetail(candidate); // The class lives in the badge, so the trailing text carries only the // venue rather than saying "Equity" twice on one row. An unclassified // instrument has no class to show, so the badge column lifts its venue // instead (a short exchange code qualifies on its own) and the trailing // text goes quiet rather than repeating it. const badge = formatInstrumentBadge(candidate); const right = candidate.exchangeLabel || candidate.primaryExchangeLabel || candidate.right || undefined; if (candidate.kind === "ticker" && candidate.ticker) { return { id: candidate.id, contractKey: candidate.contractKey, ...tickerSelectionFromSearchResult(candidate.result), label: candidate.label, instrumentType: candidate.instrumentType, detail, badge, right, category: candidate.category, kind: "ticker", resolveTicker: async () => candidate.ticker!, secondaryAction: () => { focusTicker(candidate.ticker!.metadata.ticker, { forceNewPane: true, ...tickerSelectionFromSearchResult(candidate.result) }); closeAll({ revertThemePreview: false }); }, action: () => { focusTicker(candidate.ticker!.metadata.ticker, { ...tickerSelectionFromSearchResult(candidate.result) }); closeAll({ revertThemePreview: false }); }, }; } return { id: candidate.id, contractKey: candidate.contractKey, ...tickerSelectionFromSearchResult(candidate.result), label: candidate.label, instrumentType: candidate.instrumentType, detail, badge, right, category: candidate.category, kind: "search", resolveTicker: () => resolveSearchTicker(candidate.result!, publicTickerKey( candidate.symbol, candidate.result!.exchange === "SMART" ? candidate.result!.primaryExchange : candidate.result!.exchange || candidate.result!.primaryExchange, )), secondaryAction: () => openTickerResearch(candidate.result!, { forceNewPane: true }), action: () => openTickerResearch(candidate.result!), }; }, [closeAll, focusTicker, openTickerResearch, resolveSearchTicker]); const buildTickerSearchResultItems = useCallback((candidates: TickerSearchCandidate[], query: string): ResultItem[] => ( candidates.length > 0 ? candidates.map((candidate) => mapTickerSearchCandidateToResultItem(candidate)) : [{ id: "no-results", label: `No matches for "${query}"`, detail: "Try a symbol, company name, exchange, or asset type", category: "Search", kind: "info", action: () => {}, }] ), [mapTickerSearchCandidateToResultItem]); const readTickerSearchCache = useCallback(( query: string, brokerId?: string | null, brokerInstanceId?: string | null, ): TickerSearchCandidate[] | null => { const key = buildTickerSearchCacheKey(query, brokerId, brokerInstanceId); return tickerSearchCacheRef.current.get(key) ?? null; }, []); const writeTickerSearchCache = useCallback(( query: string, candidates: TickerSearchCandidate[], brokerId?: string | null, brokerInstanceId?: string | null, ) => { const key = buildTickerSearchCacheKey(query, brokerId, brokerInstanceId); tickerSearchCacheRef.current.set(key, candidates); while (tickerSearchCacheRef.current.size > 24) { const oldestKey = tickerSearchCacheRef.current.keys().next().value; if (!oldestKey) break; tickerSearchCacheRef.current.delete(oldestKey); } }, []); useEffect(() => { tickerSearchCacheRef.current.clear(); }, [tickers]); const localTickerSearchResultItems = useCallback((query?: string, options?: { category?: string; limit?: number; }): ResultItem[] => { const items = query ? rankTickerSearchItems(createQuickLookTickerCandidates(tickers.values()), query) : createQuickLookTickerCandidates(tickers.values()); return items .slice(0, options?.limit) .map((candidate) => ({ ...mapTickerSearchCandidateToResultItem(candidate), category: options?.category ?? candidate.category, })); }, [mapTickerSearchCandidateToResultItem, tickers]); return { buildTickerSearchResultItems, localTickerSearchResultItems, mapTickerSearchCandidateToResultItem, readTickerSearchCache, writeTickerSearchCache, }; } function formatTickerSearchDetail(candidate: TickerSearchCandidate): string { const metadata = [ formatInstrumentClass(candidate.instrumentClass), candidate.exchangeLabel, candidate.primaryExchangeLabel && candidate.primaryExchangeLabel !== candidate.exchangeLabel ? candidate.primaryExchangeLabel : null, ].filter((value): value is string => !!value); return metadata.length > 0 ? `${candidate.detail} | ${metadata.join(" | ")}` : candidate.detail; } function formatInstrumentClass(instrumentClass: TickerSearchInstrumentClass): string { switch (instrumentClass) { case "equity": return "Equity"; case "fund": return "Fund"; case "derivative": return "Derivative"; case "other": return "Other"; } }