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 && (
)}
>
)}
);
}