import { useMemo, type RefObject } from "react"; import type { CloudEarningsTranscriptPayload, CloudTranscriptKeyFigurePayload, CloudTranscriptTurnPayload, } from "../../../api-client"; import { PaneStatusBody, Prose, QueryBar, SectionHeading, type QueryBarSearch } from "../../../components"; import { useShortcut } from "../../../react/input"; import { colors } from "../../../theme/colors"; import { Box, ScrollBox, Text, TextAttributes, useUiCapabilities, type ScrollBoxRenderable, } from "../../../ui"; import { isPlainKey } from "../../../utils/keyboard"; import { formatCallDate, formatDuration, formatSentiment, formatTimestamp, } from "./format"; import { buildTranscriptSegments, filterTranscriptTurns } from "./model"; import { splitParagraphs, splitSentences } from "./prose"; export type ReaderTab = "summary" | "transcript" | "qa"; /** `hint` is the key that jumps to the section, shown on its chip. */ export const READER_TABS: Array<{ label: string; value: ReaderTab; hint: string }> = [ { label: "Summary", value: "summary", hint: "s" }, { label: "Transcript", value: "transcript", hint: "t" }, { label: "Q&A", value: "qa", hint: "q" }, ]; /** * Prose is capped at a comfortable measure. On a wide pane a full-width line * is over two hundred characters, which the eye loses on the way back. */ const MAX_PROSE_WIDTH = 100; const NATIVE_STRETCH_STYLE = { minWidth: 0 }; function speakerColor(turn: CloudTranscriptTurnPayload): string { if (turn.speaker === "Operator") return colors.textDim; if (turn.role === "Analyst") return colors.warning; return colors.textBright; } /** Role and firm after the name; "Operator" is not repeated as its own role. */ function turnDetail(turn: CloudTranscriptTurnPayload): string { const role = turn.role && turn.role !== turn.speaker ? turn.role : null; return [role, turn.company].filter(Boolean).join(", "); } /** A summary section as one point per sentence. */ function Section({ title, body, width, }: { title: string; body: string; width: number; }) { if (!body.trim()) return null; return ( {splitSentences(body).map((sentence, index) => ( ))} ); } /** * The numbers management gave, one per line with the value first so the * column of figures is what the eye lands on. */ function KeyFigures({ figures, width, }: { figures: CloudTranscriptKeyFigurePayload[]; width: number; }) { if (figures.length === 0) return null; const valueWidth = Math.min( 18, Math.max(...figures.map((figure) => figure.value.length)), ); return ( {figures.map((figure) => { const value = figure.value.length > valueWidth ? figure.value : figure.value.padEnd(valueWidth); const rest = [figure.label, figure.note].filter(Boolean).join(", "); return ( ); })} ); } function TurnView({ turn, width, }: { turn: CloudTranscriptTurnPayload; width: number; }) { const detail = turnDetail(turn); // The server cuts paragraphs where the speaker paused on the recording. // A transcript from before that is one block, so it is cut here by length. const paragraphs = turn.paragraphs && turn.paragraphs.length > 0 ? turn.paragraphs : splitParagraphs(turn.text); return ( {/* A transcript the company published as a document has no timings. */} {turn.startSeconds !== null && ( {formatTimestamp(turn.startSeconds)} )} {turn.speaker} {detail ? {detail} : null} {paragraphs.map((paragraph, index) => ( ))} ); } export function TranscriptView({ transcript, loading, error, tab, onTabChange, tabsFocused, query, search, width, scrollRef, }: { transcript: CloudEarningsTranscriptPayload | null; loading: boolean; error: string | null; tab: ReaderTab; onTabChange: (tab: ReaderTab) => void; /** Whether left/right should move between tabs. */ tabsFocused: boolean; /** Free-text filter applied to the turns, for finding a topic in a long call. */ query?: string; /** The find field, drawn in the reader's bar beside the section switch. */ search?: QueryBarSearch; width: number; /** Lets the owning pane drive keyboard scrolling. */ scrollRef?: RefObject; }) { const { nativePaneChrome } = useUiCapabilities(); const isNative = nativePaneChrome === true; const turns = useMemo( () => filterTranscriptTurns(transcript?.turns ?? [], { section: tab === "qa" ? "qa" : "transcript", search: query, }), [transcript, tab, query], ); const fullTextSegments = useMemo(() => ( transcript && tab === "transcript" && !transcript.turns?.length ? buildTranscriptSegments(transcript, "transcript", { search: query }) : [] ), [transcript, tab, query]); const hasQa = (transcript?.turns ?? []).some((turn) => turn.isQa); const readerTabs = READER_TABS.map((entry) => ({ label: entry.label, value: entry.value, hint: entry.hint, disabled: entry.value === "qa" && !hasQa, })); // Scoped: inside a ticker research tab the tab strip also answers h/l and // registered first. A scoped handler runs ahead of unscoped ones in its // phase, so an open call keeps h/l for its sections. useShortcut((event) => { if (!tabsFocused || !transcript) return; const direction = isPlainKey(event, "h", "left") ? -1 : isPlainKey(event, "l", "right") ? 1 : 0; if (!direction) return; event.preventDefault?.(); event.stopPropagation?.(); const enabled = readerTabs.filter((entry) => !entry.disabled); const index = enabled.findIndex((entry) => entry.value === tab); const next = index < 0 ? enabled[direction > 0 ? 0 : enabled.length - 1] : enabled[Math.max(0, Math.min(enabled.length - 1, index + direction))]; if (next && next.value !== tab) onTabChange(next.value); }, { enabled: tabsFocused && !!transcript, scope: "earnings-calls:reader" }); if (loading && !transcript) { return ( ); } if (error && !transcript) { return ( ); } if (!transcript) return null; // The stack title already names ticker and period, so lead with metadata. const meta = [ formatCallDate(transcript.callAt), formatDuration(transcript.durationSeconds), transcript.sentiment !== null ? `sentiment ${formatSentiment(transcript.sentiment)}` : null, ] .filter(Boolean) .join(" · "); // One column of padding each side inside the scroll box. const bodyWidth = Math.max(12, width - 2); const proseWidth = Math.min(bodyWidth, MAX_PROSE_WIDTH); const contentWidth = isNative ? "100%" : bodyWidth; const contentStyle = isNative ? NATIVE_STRETCH_STYLE : undefined; return ( onTabChange(value as ReaderTab), }]} /> {tab === "summary" ? ( <>
{transcript.participants.length > 0 && ( {transcript.participants.map((participant) => ( ))} )} ) : ( <> {turns.map((turn, index) => ( ))} {fullTextSegments.map((segment) => ( ))} {turns.length === 0 && fullTextSegments.length === 0 && ( )} )} ); }