import React from 'react'; import styled, { keyframes, useTheme } from 'styled-components'; import { Attachment } from '../types/index'; import { radii } from './tokens'; const ClipIcon = (props: React.SVGProps) => ( ); const MicIcon = (props: React.SVGProps) => ( ); const SquareIcon = (props: React.SVGProps) => ( ); const DocumentIcon = (props: React.SVGProps) => ( ); const AlertIcon = (props: React.SVGProps) => ( ); const CrossIcon = (props: React.SVGProps) => ( ); const spin = keyframes` to { transform: rotate(360deg); } `; /** * Determinate while bytes move, indeterminate while the server parses: that * phase has no percentage, and a bar frozen at 100% reads as a hang. */ const ProgressRing = styled.svg<{ $indeterminate?: boolean }>` width: 14px; height: 14px; transform: rotate(-90deg); animation: ${(props) => (props.$indeterminate ? spin : 'none')} 900ms linear infinite; transform-origin: center; @media (prefers-reduced-motion: reduce) { animation: none; } `; const CIRCUMFERENCE = 2 * Math.PI * 6; const AttachmentProgress = ({ attachment }: { attachment: Attachment }) => { const indeterminate = attachment.status === 'processing'; const fraction = indeterminate ? 0.25 : Math.min(Math.max(attachment.progress, 0), 100) / 100; return ( ); }; const ChipList = styled.div` display: flex; flex-wrap: wrap; gap: 6px; padding: 2px 2px 0 2px; `; const FailureList = styled.div` display: flex; flex-direction: column; gap: 2px; padding: 4px 2px 0 2px; font-size: 11.5px; line-height: 1.45; color: ${(props) => props.theme.danger!.text}; overflow-wrap: anywhere; `; const Chip = styled.div<{ $failed?: boolean }>` box-sizing: border-box; display: inline-flex; align-items: center; gap: 6px; max-width: min(100%, 200px); padding: 4px 4px 4px 8px; border-radius: ${radii.full}; font-size: 12px; line-height: 1.5; border: 1px solid ${(props) => props.$failed ? props.theme.danger!.border : props.theme.hairline}; background: ${(props) => props.$failed ? props.theme.danger!.soft : props.theme.primary.bg}; color: ${(props) => props.$failed ? props.theme.danger!.text : props.theme.primary.text}; `; const ChipGlyph = styled.span` display: inline-flex; flex-shrink: 0; align-items: center; justify-content: center; color: ${(props) => props.theme.accent!.base}; `; const ChipLabel = styled.span` min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; `; const ChipRemove = styled.button` display: inline-flex; flex-shrink: 0; align-items: center; justify-content: center; width: 18px; height: 18px; padding: 0; border: none; border-radius: ${radii.full}; background: transparent; color: inherit; opacity: 0.65; cursor: pointer; transition: opacity 0.15s ease; &:hover { opacity: 1; } &:focus-visible { outline: 2px solid ${(props) => props.theme.accent!.base}; outline-offset: 1px; opacity: 1; } `; /** Chip status, for the hover title. */ const statusLabel = (attachment: Attachment): string => { if (attachment.status === 'uploading') return `Uploading ${attachment.progress}%`; if (attachment.status === 'processing') return 'Processing'; if (attachment.status === 'failed') return attachment.error ?? 'Failed'; return 'Ready'; }; export const AttachmentChips = ({ attachments, onRemove, }: { attachments: Attachment[]; onRemove: (id: string) => void; }) => { // A touch user cannot see a tooltip, and a phone picker's unsupported // file lands here. Say why in the open. const failures = attachments.filter( (attachment) => attachment.status === 'failed' && attachment.error, ); return ( <> {attachments.map((attachment) => { const failed = attachment.status === 'failed'; return ( {attachment.fileName} onRemove(attachment.id)} aria-label={`Remove ${attachment.fileName}`} > ); })} {failures.length > 0 && ( {failures.map((attachment) => ( {attachment.fileName}: {attachment.error} ))} )} ); }; export const ControlBar = styled.div` display: flex; align-items: center; gap: 6px; padding: 0 2px; `; export const ControlGroup = styled.div` display: flex; flex: 1; min-width: 0; flex-wrap: wrap; align-items: center; gap: 6px; `; const ControlButton = styled.button<{ $recording?: boolean }>` display: inline-flex; align-items: center; gap: 5px; height: 28px; padding: 0 10px; border-radius: ${radii.full}; border: 1px solid ${(props) => props.$recording ? props.theme.danger!.border : props.theme.hairline}; background: ${(props) => props.$recording ? props.theme.danger!.soft : 'transparent'}; color: ${(props) => props.$recording ? props.theme.danger!.text : props.theme.secondary.text}; font-family: inherit; font-size: 12px; font-weight: 500; line-height: 1; cursor: pointer; transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease; &:hover:not(:disabled) { background: ${(props) => props.$recording ? props.theme.danger!.soft : props.theme.secondary.bg}; color: ${(props) => props.$recording ? props.theme.danger!.text : props.theme.primary.text}; } &:focus-visible { outline: 2px solid ${(props) => props.theme.accent!.base}; outline-offset: 2px; } &:disabled { opacity: 0.5; cursor: default; } `; export const AttachButton = ({ onClick, disabled, }: { onClick: () => void; disabled?: boolean; }) => ( // A button opening a hidden input, not a label wrapping one: a // `display: none` input takes no focus, so the label form is unreachable // from the keyboard. ); export type MicButtonState = 'idle' | 'recording' | 'transcribing'; const MIC_LABELS: Record = { idle: 'Voice', recording: 'Stop', transcribing: 'Transcribing', }; const MIC_TITLES: Record = { idle: 'Voice input', recording: 'Stop recording', transcribing: 'Transcribing audio', }; export const MicButton = ({ state, disabled, onClick, }: { state: MicButtonState; disabled?: boolean; onClick: () => void; }) => ( {state === 'recording' ? ( ); export const ComposerNote = styled.div<{ $tone?: 'danger' }>` padding: 2px 4px 0 4px; font-size: 11.5px; line-height: 1.5; color: ${(props) => props.$tone === 'danger' ? props.theme.danger!.text : props.theme.secondary.text}; `; const SentList = styled.div` display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; `; const SentChip = styled.span` display: inline-flex; align-items: center; gap: 5px; max-width: min(100%, 200px); padding: 3px 10px; border-radius: ${radii.full}; border: 1px solid ${(props) => props.theme.hairline}; background: ${(props) => props.theme.secondary.bg}; color: ${(props) => props.theme.secondary.text}; font-size: 11.5px; line-height: 1.6; `; export const SentAttachments = ({ attachments, }: { attachments: { id: string; fileName: string }[]; }) => ( {attachments.map((attachment) => ( ))} ); // One bar per animation frame, so the row holds about a second of history. const WAVEFORM_BARS = 48; const BAR_GAP_RATIO = 0.4; // Speech peaks well below full scale; the floor keeps a quiet line visible. const LEVEL_GAIN = 2.8; const MIN_BAR_RATIO = 0.06; const WaveformRow = styled.div` display: flex; flex: 1; min-width: 0; align-items: center; gap: 10px; padding: 0 10px; min-height: ${(props) => props.theme.dimensions!.size === 'large' ? '60px' : '40px'}; `; const WaveformCanvas = styled.canvas` flex: 1; min-width: 0; height: 28px; display: block; `; const ListeningLabel = styled.span` flex-shrink: 0; font-size: 11.5px; font-weight: 500; color: ${(props) => props.theme.secondary.text}; `; /** * Live microphone level, standing in for the input while dictation runs. * Canvas inside a rAF loop, because sixty React updates a second would * re-render the whole composer. A null analyser draws the resting line. */ export const VoiceWaveform = ({ analyserRef, label, }: { analyserRef: React.RefObject; label: string; }) => { const canvasRef = React.useRef(null); const theme = useTheme(); const barColor = theme.accent!.base; React.useEffect(() => { const canvas = canvasRef.current; const context = canvas?.getContext('2d'); if (!canvas || !context) return; const levels = new Array(WAVEFORM_BARS).fill(0); // Bare `Uint8Array` widens to ArrayBufferLike, which the analyser // signature rejects. let samples: Uint8Array | null = null; let frame = 0; const draw = () => { frame = window.requestAnimationFrame(draw); const analyser = analyserRef.current; let level = 0; if (analyser) { if (!samples || samples.length !== analyser.fftSize) samples = new Uint8Array(analyser.fftSize); analyser.getByteTimeDomainData(samples); // Time-domain bytes ride on 128; RMS of the deviation is the level. let sumSquares = 0; for (let index = 0; index < samples.length; index += 1) { const deviation = (samples[index] - 128) / 128; sumSquares += deviation * deviation; } level = Math.sqrt(sumSquares / samples.length); } levels.push(Math.min(1, level * LEVEL_GAIN)); levels.shift(); // Re-read each frame: the panel resizes and the row reflows. const ratio = window.devicePixelRatio || 1; const width = canvas.clientWidth; const height = canvas.clientHeight; if (!width || !height) return; if (canvas.width !== Math.round(width * ratio)) { canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio); } context.setTransform(ratio, 0, 0, ratio, 0, 0); context.clearRect(0, 0, width, height); context.fillStyle = barColor; const slot = width / WAVEFORM_BARS; const barWidth = Math.max(1, slot * (1 - BAR_GAP_RATIO)); const radius = barWidth / 2; for (let index = 0; index < WAVEFORM_BARS; index += 1) { const magnitude = Math.max(MIN_BAR_RATIO, levels[index]); const barHeight = magnitude * height; const x = index * slot + (slot - barWidth) / 2; const y = (height - barHeight) / 2; context.globalAlpha = 0.35 + 0.65 * (index / WAVEFORM_BARS); context.beginPath(); context.roundRect(x, y, barWidth, barHeight, radius); context.fill(); } context.globalAlpha = 1; }; frame = window.requestAnimationFrame(draw); return () => window.cancelAnimationFrame(frame); }, [analyserRef, barColor]); return ( ); }; export const DropOverlay = styled.div` position: absolute; inset: 0; z-index: 5; display: flex; align-items: center; justify-content: center; pointer-events: none; padding: 16px; box-sizing: border-box; background: ${(props) => props.theme.primary.bg}; opacity: 0.94; font-size: 13px; font-weight: 500; color: ${(props) => props.theme.secondary.text}; `; export const DropTarget = styled.div` display: flex; align-items: center; gap: 8px; padding: 18px 22px; border-radius: ${radii.md}; border: 1px dashed ${(props) => props.theme.accent!.base}; background: ${(props) => props.theme.accent!.soft}; color: ${(props) => props.theme.primary.text}; `; export { ClipIcon };