import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { CloudEarningsCallPayload, CloudEarningsTranscriptPayload, } from "../../../api-client"; import { recordResearchActivity } from "../../../api-client/research-activity"; import { Button, DataTableStackView, EmptyState, PaneStatusBody, QueryBar, Spinner, usePaneFooter, useTableLoadMore, type DataTableCell, type DataTableKeyEvent, type DataTableRootKeyContext, type PaneFooterSegment } from "../../../components"; import { useShortcut } from "../../../react/input"; import { usePaneSettingValue } from "../../../state/app/context"; import { colors } from "../../../theme/colors"; import { Box, Text, useRendererHost, type InputRenderable, type ScrollBoxRenderable, } from "../../../ui"; import { isPlainKey } from "../../../utils/keyboard"; import { isPlainArrowUp, stopSearchFocusNavigation } from "../../../utils/search-focus-navigation"; import { usePluginPaneState } from "../../runtime"; import { SignInWall } from "../cloud/auth-actions"; import { useCloudPlanAction, useCloudUpgradeAction } from "../shared/cloud-upgrade"; import { usePlanAccess } from "../shared/plan-access"; import { useBoundTicker as useSymbolBinding } from "../shared/ticker-request"; import { callStatusLabel, isPendingTranscript, loadEarningsCalls, loadTranscript, statusOf, } from "./data"; import { callTitle, formatCallDate, formatDuration, formatPeriod, formatSentiment } from "./format"; import { findCallForQuarter } from "./model"; import { READER_TABS, TranscriptView, type ReaderTab } from "./transcript-view"; export const EARNINGS_CALLS_PANE_ID = "earnings-calls"; function isReaderTab(value: string): value is ReaderTab { return READER_TABS.some((tab) => tab.value === value); } interface CallColumn { id: string; label: string; width: number; align: "left" | "right"; } function buildColumns(width: number, showTicker: boolean): CallColumn[] { const tickerWidth = showTicker ? 8 : 0; const dateWidth = 10; const periodWidth = 8; const lengthWidth = 7; // Wide enough for a state such as "in progress" on calls not yet produced. const sentimentWidth = 11; const companyWidth = Math.max( 10, width - tickerWidth - dateWidth - periodWidth - lengthWidth - sentimentWidth - 8, ); const columns: CallColumn[] = []; if (showTicker) columns.push({ id: "ticker", label: "TICKER", width: tickerWidth, align: "left" }); columns.push( { id: "company", label: "COMPANY", width: companyWidth, align: "left" }, { id: "date", label: "DATE", width: dateWidth, align: "left" }, { id: "period", label: "PERIOD", width: periodWidth, align: "left" }, { id: "length", label: "LENGTH", width: lengthWidth, align: "right" }, { id: "sentiment", label: "TONE", width: sentimentWidth, align: "right" }, ); return columns; } function renderCell(call: CloudEarningsCallPayload, column: CallColumn): DataTableCell { switch (column.id) { case "ticker": return { text: call.ticker }; case "company": return { text: call.companyName ?? call.ticker, color: colors.textDim }; case "date": return { text: formatCallDate(call.callAt), color: colors.textDim }; case "period": return { text: formatPeriod(call.fiscalYear, call.fiscalQuarter) }; case "length": return { text: formatDuration(call.durationSeconds), color: colors.textDim }; case "sentiment": { if (!call.hasTranscript) return { text: callStatusLabel(call), color: colors.textDim }; const tone = call.sentiment === null ? colors.textDim : call.sentiment > 0.15 ? colors.positive : call.sentiment < -0.15 ? colors.negative : colors.textDim; return { text: formatSentiment(call.sentiment), color: tone }; } default: return { text: "" }; } } type SortDirection = "asc" | "desc"; interface CallSort { columnId: string; direction: SortDirection; } const DEFAULT_SORT: CallSort = { columnId: "date", direction: "desc" }; /** "AVGO", "BRK-B", "brk.b": something worth asking the server about. */ const TICKER_PATTERN = /^[A-Z]{1,5}(?:[.-][A-Z]{1,2})?$/; /** How often to ask again while a transcript is being produced. */ const PRODUCE_POLL_MS = 15_000; /** How often to ask again while the server is still finding a company's calls. */ const LOOKUP_POLL_MS = 20_000; /** Rows per list request. Scrolling to the end of the shelf asks for the next page. */ const CALL_PAGE_SIZE = 50; interface TickerLookup { ticker: string; /** "none": a real company, but no reachable call. "unknown": not a listed symbol. */ status: "found" | "pending" | "none" | "unknown" | "error"; calls: CloudEarningsCallPayload[]; fetchedAt?: number; stale?: boolean; refreshError?: string; refreshRequest?: number; } /** A page can overlap the rows already held when the shelf reorders mid-scroll. */ function appendNewCalls( current: CloudEarningsCallPayload[], incoming: CloudEarningsCallPayload[], ): CloudEarningsCallPayload[] { const known = new Set(current.map((call) => call.id)); const added = incoming.filter((call) => !known.has(call.id)); return added.length > 0 ? [...current, ...added] : current; } function sortValue(call: CloudEarningsCallPayload, columnId: string): string | number { switch (columnId) { case "ticker": return call.ticker; case "company": return (call.companyName ?? call.ticker).toLowerCase(); case "period": return (call.fiscalYear ?? 0) * 10 + (call.fiscalQuarter ?? 0); case "length": return call.durationSeconds ?? 0; case "sentiment": return call.sentiment ?? Number.NEGATIVE_INFINITY; default: return call.callAt ? Date.parse(call.callAt) : 0; } } /** * Props are the intersection of a pane and a ticker research tab so the same * component can serve both surfaces. */ interface EarningsCallsViewProps { focused: boolean; width: number; height: number; } export function EarningsCallsPane({ focused, width, height }: EarningsCallsViewProps) { const { symbol } = useSymbolBinding(); const access = usePlanAccess(); const openUpgrade = useCloudUpgradeAction(); const openPlan = useCloudPlanAction(); const [calls, setCalls] = useState([]); const [listStatus, setListStatus] = useState<"idle" | "loading" | "loaded" | "error">("idle"); const [listError, setListError] = useState<{ message: string; status?: number } | null>(null); const [stale, setStale] = useState(false); const [listFetchedAt, setListFetchedAt] = useState(0); const listRequestVersion = useRef(0); const [listCompletedRequest, setListCompletedRequest] = useState(0); // The shelf is paged: the server answers a page at a time and reports no // total, so a full page means there is probably another one behind it. const [hasMore, setHasMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false); const [nextOffset, setNextOffset] = useState(0); const tableScrollRef = useRef(null); const [selectedId, setSelectedId] = usePluginPaneState("selectedId", null); const [detailOpen, setDetailOpen] = usePluginPaneState("detailOpen", false); const [transcript, setTranscript] = useState(null); useEffect(() => { if (focused && transcript && detailOpen) recordResearchActivity("pro_feature_used", "transcripts"); }, [focused, transcript, detailOpen]); const [transcriptLoading, setTranscriptLoading] = useState(false); const [transcriptError, setTranscriptError] = useState<{ message: string; status?: number; } | null>(null); const [readerTab, setReaderTab] = usePluginPaneState("readerTab", "summary"); // A layout or `gloomberb shot CALLS NVDA --quarter latest --reader summary` // can land straight on a transcript instead of the shelf. `quarter` takes // the same tokens as the headless option; only transcribed calls qualify, // since opening a call without one asks the server to produce it. const [openQuarter] = usePaneSettingValue("quarter", ""); const [openReader] = usePaneSettingValue("reader", ""); const openedQuarter = useRef(null); const [sort, setSort] = useState(DEFAULT_SORT); const [searchQuery, setSearchQuery] = useState(""); const [searchFocused, setSearchFocused] = useState(false); const [searchFocusToken, setSearchFocusToken] = useState(0); const searchInputRef = useRef(null); const transcriptScrollRef = useRef(null); // An exact symbol query asks for the company's shelf, including older calls. const [lookup, setLookup] = useState(null); const [lookupRefresh, setLookupRefresh] = useState<{ ticker: string; request: number } | null>(null); // Set while a call opened without a transcript is being produced. const [producing, setProducing] = useState(false); // The list answered "pending": the server is still searching for calls. const [listPending, setListPending] = useState(false); const focusSearch = useCallback(() => { setSearchFocused(true); setSearchFocusToken((current) => current + 1); }, []); const blurSearch = useCallback(() => setSearchFocused(false), []); const ticker = symbol ? symbol.toUpperCase() : null; const fetchCalls = useCallback( (force: boolean) => { if (!access.emailVerified || !access.hasProAccess) return; const request = ++listRequestVersion.current; setListStatus((current) => (current === "loaded" ? current : "loading")); setLoadingMore(false); loadEarningsCalls(ticker, { force, limit: CALL_PAGE_SIZE }) .then((result) => { if (request !== listRequestVersion.current) return; setCalls(result.calls); setNextOffset(result.calls.length); setHasMore(result.sourceLimitReached === true); setStale(result.stale); setListFetchedAt(result.fetchedAt); setListPending(result.pending === true && result.calls.length === 0); setListError( result.refreshError ? { message: result.refreshError, status: result.errorStatus } : null, ); setListStatus("loaded"); setListCompletedRequest(request); }) .catch((error: unknown) => { if (request !== listRequestVersion.current) return; setListPending(false); setHasMore(false); setNextOffset(0); setListError({ message: error instanceof Error ? error.message : String(error), status: statusOf(error), }); setListStatus("error"); }); }, [ticker, access.emailVerified, access.hasProAccess], ); // Scrolling to the end of the shelf asks the server for the next page. A // page belongs to the list request that was current when it was asked for: // a refresh or a binding change abandons whatever was in flight. const loadMoreCalls = useCallback(() => { if (!access.emailVerified || !access.hasProAccess) return; const request = listRequestVersion.current; setLoadingMore(true); loadEarningsCalls(ticker, { limit: CALL_PAGE_SIZE, offset: nextOffset }) .then((result) => { if (request !== listRequestVersion.current) return; setCalls((current) => appendNewCalls(current, result.calls)); setNextOffset(nextOffset + result.calls.length); setHasMore(result.sourceLimitReached === true); setLoadingMore(false); }) .catch((error: unknown) => { if (request !== listRequestVersion.current) return; // The rows already loaded stay: only the next page failed. setHasMore(false); setLoadingMore(false); setListError({ message: error instanceof Error ? error.message : String(error), status: statusOf(error), }); }); }, [ticker, nextOffset, access.emailVerified, access.hasProAccess]); const loadMoreFromScroll = useTableLoadMore( tableScrollRef, hasMore && !loadingMore && !detailOpen && listStatus === "loaded", loadMoreCalls, ); // The call restored with the pane survives the first run; only a later // binding change closes the reader and clears the selection. const firstShelfLoadRef = useRef(true); useEffect(() => { const keepRestoredCall = firstShelfLoadRef.current; firstShelfLoadRef.current = false; setCalls([]); setHasMore(false); setLoadingMore(false); setNextOffset(0); setListStatus("idle"); setListPending(false); setStale(false); setListFetchedAt(0); setListError(null); setLookup(null); setLookupRefresh(null); if (!keepRestoredCall) { setSelectedId(null); setDetailOpen(false); } setTranscript(null); setTranscriptLoading(false); setTranscriptError(null); setProducing(false); setSearchQuery(""); if (!keepRestoredCall) setReaderTab("summary"); fetchCalls(false); return () => { listRequestVersion.current++; }; }, [fetchCalls]); // A research tab whose company is still being searched checks back until // the calls appear. useEffect(() => { if (!listPending) return; const timer = setTimeout(() => fetchCalls(true), LOOKUP_POLL_MS); return () => clearTimeout(timer); }, [listPending, fetchCalls, listCompletedRequest]); // The global shelf may contain only one of a company's calls. Resolve exact // symbol queries independently so older quarters remain reachable. const lookupTicker = useMemo(() => { if (ticker || detailOpen) return null; const candidate = searchQuery.trim().toUpperCase(); if (!TICKER_PATTERN.test(candidate)) return null; return candidate; }, [ticker, detailOpen, searchQuery]); // Answered for the current query, or still waiting on the server. const lookupState: TickerLookup["status"] | "loading" | null = !lookupTicker ? null : lookup?.ticker === lookupTicker ? lookup.status : "loading"; useEffect(() => { if (!lookupTicker || !access.emailVerified || !access.hasProAccess) return; const answered = lookup?.ticker === lookupTicker; const refreshRequest = lookupRefresh?.ticker === lookupTicker ? lookupRefresh.request : 0; const manualRefresh = refreshRequest !== (answered ? lookup.refreshRequest ?? 0 : 0); if (answered && lookup.status !== "pending" && !manualRefresh) return; let cancelled = false; // Manual reload refreshes this company as well as the global shelf. const delay = manualRefresh ? 0 : answered ? LOOKUP_POLL_MS : 500; const timer = setTimeout(() => { loadEarningsCalls(lookupTicker, { force: true }) .then((result) => { if (cancelled) return; setLookup({ ticker: lookupTicker, status: result.unknownTicker ? "unknown" : result.calls.length > 0 ? "found" : result.pending ? "pending" : "none", calls: result.calls, fetchedAt: result.fetchedAt, stale: result.stale, refreshError: result.refreshError, refreshRequest, }); }) .catch((error: unknown) => { if (cancelled) return; const retainRows = ![401, 402, 403].includes(statusOf(error) ?? 0); setLookup((current) => ({ ticker: lookupTicker, status: "error", calls: retainRows && current?.ticker === lookupTicker ? current.calls : [], fetchedAt: retainRows && current?.ticker === lookupTicker ? current.fetchedAt : undefined, stale: retainRows && current?.ticker === lookupTicker && current.calls.length > 0, refreshError: error instanceof Error ? error.message : String(error), refreshRequest, })); }); }, delay); return () => { cancelled = true; clearTimeout(timer); }; }, [lookupTicker, lookup, lookupRefresh, access.emailVerified, access.hasProAccess]); const allCalls = useMemo(() => { if (!lookup || lookup.calls.length === 0) return calls; const refreshed = new Map(lookup.calls.map((call) => [call.id, call])); const lookupIsNewer = (lookup.fetchedAt ?? 0) >= listFetchedAt; const known = new Set(calls.map((call) => call.id)); return [ ...calls.map((call) => lookupIsNewer ? refreshed.get(call.id) ?? call : call), ...lookup.calls.filter((call) => !known.has(call.id)), ]; }, [calls, lookup, listFetchedAt]); const rows = useMemo(() => { const query = searchQuery.trim().toLowerCase(); const matched = query ? allCalls.filter((call) => [ call.ticker, call.companyName ?? "", formatPeriod(call.fiscalYear, call.fiscalQuarter), formatCallDate(call.callAt), ] .join(" ") .toLowerCase() .includes(query), ) : allCalls; const sorted = [...matched].sort((a, b) => { const left = sortValue(a, sort.columnId); const right = sortValue(b, sort.columnId); if (left === right) return 0; const order = left < right ? -1 : 1; return sort.direction === "asc" ? order : -order; }); return sorted; }, [allCalls, sort, searchQuery]); useEffect(() => { const quarter = openQuarter.trim(); if (!quarter || openedQuarter.current === quarter || listStatus !== "loaded") return; const target = findCallForQuarter( allCalls.filter((call) => call.hasTranscript), quarter, ); if (!target) return; openedQuarter.current = quarter; setSelectedId(target.id); setReaderTab(isReaderTab(openReader) ? openReader : "summary"); setDetailOpen(true); }, [openQuarter, openReader, listStatus, allCalls]); const selected = useMemo( () => allCalls.find((call) => call.id === selectedId) ?? null, [allCalls, selectedId], ); const selectedCallId = selected?.id ?? null; const selectedHasTranscript = selected?.hasTranscript ?? false; // The reader shows only a call the pane can find. A restored call that is not // on the reloaded shelf (a lookup result, or one that aged off) closes once // the shelf has loaded, rather than leave the keys pointing at a reader that // is not on screen. const readerOpen = detailOpen && !!selected; useEffect(() => { // A `quarter` setting still waiting to open its call wins over closing. const pendingQuarter = openQuarter.trim(); if (pendingQuarter && openedQuarter.current !== pendingQuarter) return; if (detailOpen && !selected && listStatus === "loaded") setDetailOpen(false); }, [detailOpen, listStatus, openQuarter, selected, setDetailOpen]); const selectedTranscript = transcript?.id === selectedCallId ? transcript : null; // Marks a call as transcribed in whichever list holds it, once it is. const markTranscribed = useCallback((callId: string) => { const update = (list: CloudEarningsCallPayload[]) => list.map((call) => call.id === callId ? { ...call, hasTranscript: true, status: "published" } : call, ); setCalls(update); setLookup((current) => (current ? { ...current, calls: update(current.calls) } : current)); }, []); // Warms a published transcript while the cursor rests on its row, so // opening it does not wait. Only calls that already have one: the same // request on a call without a transcript asks the server to produce it, // which is a deliberate act reserved for Enter. const prefetchTranscript = useCallback((call: CloudEarningsCallPayload) => { if (!call.hasTranscript || !access.emailVerified || !access.hasProAccess) return; void loadTranscript(call.id).catch(() => {}); }, [access.emailVerified, access.hasProAccess]); // A published transcript is immutable, so it loads once per call. Opening // a call that has none asks the server to produce it, then checks back // until it arrives. useEffect(() => { if (!detailOpen || !selectedCallId || !access.emailVerified || !access.hasProAccess) return; // A binding change clears selection in an effect; do not start a request // for that previous selection during the same render's effect pass. if (ticker && selected?.ticker.toUpperCase() !== ticker) return; let cancelled = false; let timer: ReturnType | null = null; setTranscriptError(null); const attempt = (force: boolean) => { setTranscriptLoading(true); loadTranscript(selectedCallId, { force }) .then((result) => { if (cancelled) return; if (isPendingTranscript(result)) { setTranscript(null); setProducing(true); timer = setTimeout(() => attempt(true), PRODUCE_POLL_MS); return; } setTranscript(result); setProducing(false); if (!selectedHasTranscript) { markTranscribed(selectedCallId); fetchCalls(true); } }) .catch((error: unknown) => { if (cancelled) return; setTranscript(null); setProducing(false); setTranscriptError({ message: error instanceof Error ? error.message : String(error), status: statusOf(error), }); }) .finally(() => { if (!cancelled) setTranscriptLoading(false); }); }; attempt(!selectedHasTranscript); return () => { cancelled = true; if (timer) clearTimeout(timer); setProducing(false); }; // The call's identity is what matters; list refreshes must not restart this. // eslint-disable-next-line react-hooks/exhaustive-deps }, [detailOpen, selectedCallId, ticker, access.emailVerified, access.hasProAccess]); const scrollTranscriptBy = useCallback((delta: number) => { const scrollBox = transcriptScrollRef.current; if (!scrollBox?.viewport) return; const maxScrollTop = Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height); scrollBox.scrollTop = Math.max(0, Math.min(maxScrollTop, scrollBox.scrollTop + delta)); }, []); // A new call or a narrowed transcript should start at the top. useEffect(() => { const scrollBox = transcriptScrollRef.current; if (scrollBox) scrollBox.scrollTop = 0; }, [selectedId, detailOpen, readerTab, searchQuery]); // A new search opens matching turns; an existing query must not override // the reader's subsequent choice to return to the summary. const changeTranscriptQuery = useCallback((query: string) => { setSearchQuery(query); if (query.trim()) setReaderTab((current) => current === "summary" ? "transcript" : current); }, []); const rendererHost = useRendererHost(); const openSource = useCallback(() => { if (selected?.webcastUrl) void rendererHost.openExternal(selected.webcastUrl); }, [rendererHost, selected]); const signInRequired = !access.signedIn || listError?.status === 401; const verificationRequired = !signInRequired && (!access.emailVerified || listError?.status === 403); // The whole feature is a Pro entitlement, so the list itself can be refused. const proRequired = !signInRequired && !verificationRequired && (!access.hasProAccess || listError?.status === 402); const transcriptProRequired = transcriptError?.status === 402; const columns = useMemo(() => buildColumns(width, !ticker), [width, ticker]); const handleRootKey = useCallback( (event: DataTableKeyEvent, context: DataTableRootKeyContext) => { if (context.selectedIndex <= 0 && isPlainArrowUp(event)) { stopSearchFocusNavigation(event); focusSearch(); return true; } if (isPlainKey(event, "/")) { stopSearchFocusNavigation(event); focusSearch(); return true; } if (isPlainKey(event, "r")) { stopSearchFocusNavigation(event); fetchCalls(true); if (lookupTicker) setLookupRefresh((current) => ({ ticker: lookupTicker, request: (current?.request ?? 0) + 1 })); return true; } return false; }, [fetchCalls, focusSearch, lookupTicker], ); // `q` is the app's quit key, handled ahead of pane handlers. The reader // claims it first while it is open, the same way other panes take keys // that clash with global ones. useShortcut( (event) => { if (!isPlainKey(event, "q")) return; stopSearchFocusNavigation(event); setReaderTab("qa"); }, { enabled: focused && readerOpen && !searchFocused, phase: "before", scope: "earnings-calls:reader", }, ); // Only a loaded transcript has a find field; a wall, an upsell or a first // load has no list or reader for the search keys to reach. const readerSearchable = readerOpen && !!selectedTranscript && !transcriptProRequired; // Mirrors the bodies below: the list renders unless a wall, an upsell, or an // empty shelf's loading, failed or research-tab state takes its place. const listShown = !signInRequired && !verificationRequired && !proRequired && !(calls.length === 0 && (listStatus === "loading" || listStatus === "error" || !!ticker)); const handleDetailKey = useCallback( (event: DataTableKeyEvent) => { if (readerSearchable && isPlainKey(event, "/")) { stopSearchFocusNavigation(event); focusSearch(); return true; } if (isPlainKey(event, "s")) { stopSearchFocusNavigation(event); setReaderTab("summary"); return true; } if (isPlainKey(event, "t")) { stopSearchFocusNavigation(event); setReaderTab("transcript"); return true; } if (isPlainKey(event, "o")) { stopSearchFocusNavigation(event); openSource(); return true; } if (isPlainKey(event, "j", "down")) { stopSearchFocusNavigation(event); scrollTranscriptBy(1); return true; } if (isPlainArrowUp(event) || isPlainKey(event, "k")) { stopSearchFocusNavigation(event); // Like the list: pressing up at the top moves into the search field. if ((transcriptScrollRef.current?.scrollTop ?? 0) <= 0 && isPlainArrowUp(event)) { focusSearch(); return true; } scrollTranscriptBy(-1); return true; } return false; }, [focusSearch, readerSearchable, scrollTranscriptBy, openSource], ); usePaneFooter( EARNINGS_CALLS_PANE_ID, () => { const info: PaneFooterSegment[] = []; if (listStatus === "loading") { info.push({ id: "loading", parts: [{ text: "loading", tone: "muted" }] }); } else if (loadingMore) { info.push({ id: "loading-more", parts: [{ text: "loading more calls", tone: "muted" }] }); } if (producing) { info.push({ id: "producing", parts: [{ text: "producing transcript", tone: "muted" }] }); } else if (transcriptLoading) { info.push({ id: "transcribing", parts: [{ text: "loading transcript", tone: "muted" }] }); } if (listPending) { info.push({ id: "searching", parts: [{ text: "searching for calls", tone: "muted" }] }); } if (lookupTicker && lookupState) { if (lookupState === "loading" || lookupState === "pending") { info.push({ id: "lookup", parts: [{ text: `looking up ${lookupTicker}`, tone: "muted" }] }); } else if (lookupState === "unknown") { info.push({ id: "lookup", parts: [{ text: `${lookupTicker} not found`, tone: "warning" }] }); } else if (lookupState === "none") { info.push({ id: "lookup", parts: [{ text: `no ${lookupTicker} calls yet`, tone: "muted" }] }); } else if (lookupState === "error") { info.push({ id: "lookup", parts: [{ text: `${lookupTicker}: ${lookup?.refreshError ?? "lookup failed"}`, tone: "warning" }] }); } } const visibleLookup = lookup && (lookup.ticker === lookupTicker || (detailOpen && selected?.ticker === lookup.ticker)) ? lookup : null; if (visibleLookup?.stale) { info.push({ id: "lookup-stale", parts: [{ text: `${visibleLookup.ticker} stale cache`, tone: "warning" }] }); } if (visibleLookup?.refreshError && lookupState !== "error") { info.push({ id: "lookup-error", parts: [{ text: `${visibleLookup.ticker}: ${visibleLookup.refreshError}`, tone: "warning" }] }); } if (stale) { info.push({ id: "stale", parts: [{ text: "stale cache", tone: "warning" }] }); } if (listError) { info.push({ id: "error", parts: [{ text: listError.message, tone: "warning" }] }); } if (proRequired || transcriptProRequired) { info.push({ id: "pro", parts: [{ text: "pro required", tone: "warning" }] }); } const hints = !listShown ? [] : readerOpen ? [ ...(selected?.webcastUrl ? [{ id: "open", key: "o", label: "pen source", onPress: openSource }] : []), ...(readerSearchable ? [{ id: "find", key: "/", label: "find", onPress: focusSearch }] : []), ] : [{ id: "search", key: "/", label: "search", onPress: focusSearch }]; return { info, hints }; }, [ listStatus, loadingMore, transcriptLoading, producing, listPending, lookupState, lookupTicker, lookup, stale, listError, proRequired, transcriptProRequired, detailOpen, listShown, readerOpen, readerSearchable, selected, focusSearch, openSource, fetchCalls, ], ); if (signInRequired || verificationRequired) { return ( ); } if (proRequired) { return (