File size: 6,113 Bytes
921d377 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 | /**
* CallEventRow β enterprise-grade inline render of a phone call in
* the chat thread. Replaces the heavy PostCallCard card on desktop.
*
* Industry pattern (Slack Huddle / MS Teams "Meeting ended" /
* Google Chat video event): a call is a SYSTEM EVENT in the
* timeline, not a content block. We render it as:
*
* State 1 β Default (collapsed):
*
* ββββββββββ π Call Β· 26s Β· 22:54 ββββββββββ
*
* Thin centered divider, 12 px muted text, no box, no colored
* button. The whole label is clickable to expand.
*
* State 2 β Hover (desktop):
*
* ββ π Call Β· 26s Β· 22:54 ββ Transcript Β· β» Resume ββ
*
* Action links fade in on hover (opacity 0 β 100). Text-weight,
* not pill buttons. Dot-separated, reads as one phrase.
*
* State 3 β Expanded (on click):
*
* ββ π Call Β· 26s Β· 22:54 ββ Collapse Β· β» Resume ββ
* You are you very sexy right
* Secretary Sexy That's a rather direct questionβ¦
*
* Transcript prints inline unframed, indented, with a muted
* speaker label column. No background, no border, no rounded
* corners β just indented text that reads as part of the flow.
*
* Keeps PhoneCallDivider as the Voice-mode renderer (quieter, no
* actions); CallEventRow is the chat-mode companion that adds the
* reveal-on-demand affordances. Both components share the visual
* language so the product reads consistently across surfaces.
*/
import React, { useState } from 'react';
import { Phone, RotateCcw } from 'lucide-react';
function formatDuration(s) {
const clamped = Math.max(0, Math.floor(s));
if (clamped < 60)
return `${clamped}s`;
const m = Math.floor(clamped / 60);
const rest = clamped % 60;
return rest === 0 ? `${m}m` : `${m}m ${rest}s`;
}
function formatTime(ms) {
try {
return new Date(ms).toLocaleTimeString([], {
hour: 'numeric',
minute: '2-digit',
});
}
catch {
return '';
}
}
/** Strip markdown image syntax from a transcript line so the raw
* URL doesn't render as text. Mirrors the sanitizer applied to
* TTS in App.tsx (stripMarkdownForSpeech). */
function stripTranscriptMarkup(text) {
return text
.replace(/!\[[^\]]*\]\([^)]+\)/g, '') // images
.replace(/\s+/g, ' ')
.trim();
}
const CallEventRow = ({ durationSec, endedAt, personaName, transcript, onResume, defaultExpanded = false, className, }) => {
const [expanded, setExpanded] = useState(defaultExpanded);
const hasTranscript = !!(transcript && transcript.length > 0);
const canResume = !!onResume;
const hasActions = hasTranscript || canResume;
const timeLabel = endedAt ? formatTime(endedAt) : '';
const durLabel = formatDuration(durationSec);
const summary = timeLabel ? `Call Β· ${durLabel} Β· ${timeLabel}` : `Call Β· ${durLabel}`;
const labelBtnLabel = expanded ? 'Collapse call row' : 'Expand call row';
const rowClickable = hasTranscript;
return (<div role="region" aria-label={summary} className={['group my-2', className ?? ''].join(' ')}>
{/* Divider rule + centered label + hover action cluster */}
<div className="flex items-center gap-3 text-white/40 text-[12px] tracking-wide select-none">
<div className="flex-1 h-px bg-white/10"/>
<button type="button" onClick={() => { if (rowClickable)
setExpanded((v) => !v); }} disabled={!rowClickable} aria-label={labelBtnLabel} className={[
'flex items-center gap-1.5 whitespace-nowrap transition-colors',
rowClickable
? 'cursor-pointer hover:text-white/70'
: 'cursor-default',
'bg-transparent border-0 p-0 m-0 font-inherit',
].join(' ')}>
<Phone size={11} aria-hidden="true" className="opacity-70"/>
<span>{summary}</span>
</button>
{hasActions && (<div className={[
'flex items-center gap-2 text-[11px] text-white/45 whitespace-nowrap',
// Hidden at rest, reveals on hover of the region.
// Always visible when the row is expanded so the
// Collapse link is reachable without re-hovering.
expanded ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
'transition-opacity duration-150',
].join(' ')}>
{hasTranscript && (<button type="button" onClick={(e) => { e.stopPropagation(); setExpanded((v) => !v); }} className="bg-transparent border-0 p-0 m-0 cursor-pointer hover:text-white/85 transition-colors">
{expanded ? 'Collapse' : 'Transcript'}
</button>)}
{hasTranscript && canResume && (<span className="text-white/20" aria-hidden="true">Β·</span>)}
{canResume && (<button type="button" onClick={(e) => { e.stopPropagation(); onResume(); }} className="inline-flex items-center gap-1 bg-transparent border-0 p-0 m-0 cursor-pointer hover:text-white/85 transition-colors" aria-label="Resume call">
<RotateCcw size={10} aria-hidden="true" className="opacity-80"/>
<span>Resume</span>
</button>)}
</div>)}
<div className="flex-1 h-px bg-white/10"/>
</div>
{/* Expanded transcript β inline, unframed */}
{expanded && hasTranscript && (<div className="mt-3 mb-1 space-y-1.5">
{transcript.map((line, i) => {
const label = line.who === 'user' ? 'You' : (personaName || 'Assistant');
return (<div key={i} className="flex gap-4 text-[13.5px] leading-relaxed">
<div className="w-28 flex-shrink-0 text-white/35 text-[11px] uppercase tracking-wider pt-0.5">
{label}
</div>
<div className="flex-1 text-white/75">
{stripTranscriptMarkup(line.text)}
</div>
</div>);
})}
</div>)}
</div>);
};
export default CallEventRow;
|