1import {useEffect, useRef, useState} from 'react'
2import {
3 MAX_PARTICIPANTS,
4 type ChatItem,
5 type ParticipantInfo
6} from './p2p/network'
7import {VIDEO_QUALITIES, type VideoQuality} from './p2p/settings'
8import {TURN_CONFIGURED, type RelayStatus} from './p2p/turn'
9import {formatTranscript, type TranscriptItem} from './transcribe/store'
10import {useNetwork} from './useNetwork'
12// Screen capture is desktop-only in practice; hide the button where the API
13// doesn't exist (most mobile browsers).
14const canShareScreen =
15 typeof navigator.mediaDevices?.getDisplayMedia === 'function'
17const initialRoomFromHash = (): string => {
18 try {
19 return decodeURIComponent(location.hash.slice(1)).replace(/\s/g, '')
20 } catch {
21 return ''
22 }
23}
25// ---- styles ---------------------------------------------------------------
27const lightBtn: React.CSSProperties = {
28 padding: '0.45rem 1.1rem',
29 borderRadius: 6,
30 border: '1px solid #888',
31 background: '#fff',
32 cursor: 'pointer',
33 fontSize: '1rem'
34}
36const primaryBtn: React.CSSProperties = {
37 ...lightBtn,
38 background: '#1a7f37',
39 borderColor: '#1a7f37',
40 color: '#fff'
41}
43const disabledStyle: React.CSSProperties = {
44 opacity: 0.4,
45 cursor: 'not-allowed'
46}
48const inputStyle: React.CSSProperties = {
49 padding: '0.5rem',
50 fontSize: '1rem',
51 borderRadius: 6,
52 border: '1px solid #bbb',
53 width: '100%',
54 boxSizing: 'border-box'
55}
57// Dark in-room controls.
58const darkBtn: React.CSSProperties = {
59 padding: '0.5rem 0.9rem',
60 borderRadius: 8,
61 border: '1px solid #555',
62 background: '#2a2a2a',
63 color: '#eee',
64 cursor: 'pointer',
65 fontSize: '0.95rem',
66 display: 'inline-flex',
67 alignItems: 'center',
68 gap: '0.4rem'
69}
71// Square icon-only buttons for the control bar. Their meaning is carried by
72// the icon plus a title tooltip and aria-label.
73const iconBtn: React.CSSProperties = {
74 ...darkBtn,
75 padding: '0.65rem',
76 justifyContent: 'center'
77}
79// A mute button while muted — red, the universal "you are muted" signal.
80const mutedIconBtn: React.CSSProperties = {
81 ...iconBtn,
82 background: '#c62828',
83 borderColor: '#c62828',
84 color: '#fff'
85}
87// The screen-share button while sharing.
88const sharingIconBtn: React.CSSProperties = {
89 ...iconBtn,
90 background: '#1a7f37',
91 borderColor: '#1a7f37',
92 color: '#fff'
93}
95const leaveBtn: React.CSSProperties = {
96 ...darkBtn,
97 background: '#c62828',
98 borderColor: '#c62828',
99 color: '#fff'
100}
102// Chip overlaid on a tile (name label, mute badges).
103const tileChip: React.CSSProperties = {
104 background: 'rgba(0, 0, 0, 0.65)',
105 color: '#fff',
106 borderRadius: 6,
107 padding: '0.2rem 0.5rem',
108 fontSize: '0.85rem',
109 display: 'inline-flex',
110 alignItems: 'center',
111 gap: '0.3rem',
112 maxWidth: '90%'
113}
115// ---- icons (stroke-style paths from Feather icons, MIT) --------------------
117const ICONS = {
118 mic: (
119 <>
120 <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
121 <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
122 <path d="M12 19v4" />
123 <path d="M8 23h8" />
124 </>
125 ),
126 micOff: (
127 <>
128 <path d="M1 1l22 22" />
129 <path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6" />
130 <path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23" />
131 <path d="M12 19v4" />
132 <path d="M8 23h8" />
133 </>
134 ),
135 video: (
136 <>
137 <path d="M23 7l-7 5 7 5V7z" />
138 <rect x="1" y="5" width="15" height="14" rx="2" />
139 </>
140 ),
141 videoOff: (
142 <>
143 <path d="M16 16v1a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v3.34l1 1L23 7v10" />
144 <path d="M1 1l22 22" />
145 </>
146 ),
147 monitor: (
148 <>
149 <rect x="2" y="3" width="20" height="14" rx="2" />
150 <path d="M8 21h8" />
151 <path d="M12 17v4" />
152 </>
153 ),
154 link: (
155 <>
156 <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
157 <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
158 </>
159 ),
160 logout: (
161 <>
162 <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
163 <path d="M16 17l5-5-5-5" />
164 <path d="M21 12H9" />
165 </>
166 ),
167 chat: (
168 <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
169 ),
170 x: (
171 <>
172 <path d="M18 6L6 18" />
173 <path d="M6 6l12 12" />
174 </>
175 ),
176 send: (
177 <>
178 <path d="M22 2L11 13" />
179 <path d="M22 2l-7 20-4-9-9-4 20-7z" />
180 </>
181 ),
182 transcript: (
183 <>
184 <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
185 <path d="M14 2v6h6" />
186 <path d="M16 13H8" />
187 <path d="M16 17H8" />
188 <path d="M10 9H8" />
189 </>
190 ),
191 copy: (
192 <>
193 <rect x="9" y="9" width="13" height="13" rx="2" />
194 <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
195 </>
196 ),
197 download: (
198 <>
199 <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
200 <path d="M7 10l5 5 5-5" />
201 <path d="M12 15V3" />
202 </>
203 )
204} as const
206function Icon({
207 name,
208 size = 18,
209 style
210}: {
211 name: keyof typeof ICONS
212 size?: number
213 style?: React.CSSProperties
214}) {
215 return (
216 <svg
217 width={size}
218 height={size}
219 viewBox="0 0 24 24"
220 fill="none"
221 stroke="currentColor"
222 strokeWidth={2}
223 strokeLinecap="round"
224 strokeLinejoin="round"
225 style={{display: 'block', ...style}}
226 aria-hidden
227 >
228 {ICONS[name]}
229 </svg>
230 )
231}
233// ---- video tile -------------------------------------------------------------
235function VideoView({
236 stream,
237 muted,
238 mirror,
239 fit
240}: {
241 stream: MediaStream | null
242 muted: boolean
243 mirror: boolean
244 fit: 'cover' | 'contain'
245}) {
246 const ref = useRef<HTMLVideoElement>(null)
247 useEffect(() => {
248 if (ref.current && ref.current.srcObject !== stream) {
249 ref.current.srcObject = stream
250 }
251 }, [stream])
252 return (
253 <video
254 ref={ref}
255 autoPlay
256 playsInline
257 muted={muted}
258 style={{
259 position: 'absolute',
260 inset: 0,
261 width: '100%',
262 height: '100%',
263 objectFit: fit,
264 transform: mirror ? 'scaleX(-1)' : undefined
265 }}
266 />
267 )
268}
270function Tile({
271 stream,
272 label,
273 isSelf,
274 mirror,
275 audioMuted,
276 videoMuted,
277 connecting,
278 transcribing,
279 fill = false,
280 onClick,
281 tooltip
282}: {
283 stream: MediaStream | null
284 label: string
285 isSelf: boolean
286 mirror: boolean
287 audioMuted: boolean
288 videoMuted: boolean
289 connecting: boolean
290 /** They are sending the room's audio to a transcription service. */
291 transcribing: boolean
292 /** Fill the parent box (spotlight) instead of a fixed 4:3 grid cell. */
293 fill?: boolean
294 onClick?: () => void
295 tooltip?: string
296}) {
297 return (
298 <div
299 onClick={onClick}
300 title={tooltip}
301 style={{
302 position: 'relative',
303 background: '#000',
304 borderRadius: 10,
305 overflow: 'hidden',
306 // The spotlight tile letterboxes (contain) so screen shares and faces
307 // are never cropped; grid/filmstrip cells crop to fill (cover).
308 ...(fill ? {width: '100%', height: '100%'} : {aspectRatio: '4 / 3'}),
309 border: isSelf ? '1px solid #444' : '1px solid #222',
310 cursor: onClick ? 'pointer' : undefined
311 }}
312 >
313 {/* The video element stays mounted even when their camera is off so the
314 audio keeps playing; the placeholder just covers it. */}
315 <VideoView
316 stream={stream}
317 muted={isSelf}
318 mirror={mirror}
319 fit={fill ? 'contain' : 'cover'}
320 />
321 {(videoMuted || connecting) && (
322 <div
323 style={{
324 position: 'absolute',
325 inset: 0,
326 background: '#222',
327 display: 'flex',
328 flexDirection: 'column',
329 alignItems: 'center',
330 justifyContent: 'center',
331 gap: '0.5rem'
332 }}
333 >
334 <div
335 style={{
336 width: 64,
337 height: 64,
338 borderRadius: '50%',
339 background: '#3a3a3a',
340 color: '#ddd',
341 display: 'flex',
342 alignItems: 'center',
343 justifyContent: 'center',
344 fontSize: '1.6rem',
345 fontFamily: 'sans-serif'
346 }}
347 >
348 {(label[0] ?? '?').toUpperCase()}
349 </div>
350 {connecting && (
351 <span style={{color: '#999', fontSize: '0.9rem'}}>Connecting…</span>
352 )}
353 </div>
354 )}
355 {transcribing && (
356 <span
357 title={`${isSelf ? 'You are' : `${label} is`} transcribing this meeting — everyone's audio is being sent to a transcription service.`}
358 style={{
359 ...tileChip,
360 position: 'absolute',
361 left: 8,
362 top: 8,
363 background: 'rgba(160, 20, 20, 0.85)'
364 }}
365 >
366 <span
367 style={{
368 width: 7,
369 height: 7,
370 borderRadius: '50%',
371 background: '#fff',
372 flex: '0 0 auto'
373 }}
374 />
375 transcribing
376 </span>
377 )}
378 <div
379 style={{
380 position: 'absolute',
381 left: 8,
382 bottom: 8,
383 display: 'flex',
384 gap: '0.35rem',
385 alignItems: 'center'
386 }}
387 >
388 <span style={tileChip}>
389 <span
390 style={{
391 overflow: 'hidden',
392 textOverflow: 'ellipsis',
393 whiteSpace: 'nowrap'
394 }}
395 >
396 {label}
397 </span>
398 {audioMuted && <Icon name="micOff" size={13} />}
399 </span>
400 </div>
401 </div>
402 )
403}
405// ---- landing ------------------------------------------------------------------
407function Landing({
408 notice,
409 initialName,
410 initialTurnToken,
411 onDismissNotice,
412 onEnter
413}: {
414 notice: string | null
415 initialName: string
416 initialTurnToken: string
417 onDismissNotice: () => void
418 onEnter: (name: string, room: string, turnToken: string) => void
419}) {
420 const [name, setName] = useState(initialName)
421 const [room, setRoom] = useState(initialRoomFromHash)
422 const [turnToken, setTurnToken] = useState(initialTurnToken)
423 const canEnter = name.trim().length > 0 && room.length > 0
424 const submit = (e: React.FormEvent) => {
425 e.preventDefault()
426 if (canEnter) onEnter(name, room, turnToken)
427 }
428 return (
429 <div
430 style={{
431 fontFamily: 'sans-serif',
432 maxWidth: 460,
433 margin: '3rem auto',
434 padding: '0 1rem'
435 }}
436 >
437 <h1 style={{marginBottom: '0.25rem'}}>CommonRoom</h1>
438 <p style={{color: '#666', marginTop: 0}}>
439 Group video calls with no server. Pick a room, share the link, and
440 talk — everything flows peer-to-peer.
441 </p>
443 {notice && (
444 <div
445 style={{
446 background: '#fff3cd',
447 border: '1px solid #e0c968',
448 borderRadius: 6,
449 padding: '0.5rem 0.75rem',
450 margin: '0.75rem 0',
451 display: 'flex',
452 justifyContent: 'space-between',
453 alignItems: 'center',
454 gap: '0.5rem'
455 }}
456 >
457 <span>{notice}</span>
458 <button style={lightBtn} onClick={onDismissNotice}>
459 OK
460 </button>
461 </div>
462 )}
464 <form onSubmit={submit} style={{marginTop: '1.5rem'}}>
465 <label style={{display: 'block', marginBottom: '1rem'}}>
466 <div style={{marginBottom: '0.3rem'}}>Your name</div>
467 <input
468 autoFocus={!initialName}
469 value={name}
470 onChange={e => setName(e.target.value)}
471 placeholder="any-name-you-like"
472 maxLength={40}
473 style={inputStyle}
474 />
475 </label>
476 <label style={{display: 'block', marginBottom: '1.25rem'}}>
477 <div style={{marginBottom: '0.3rem'}}>Room name</div>
478 <input
479 autoFocus={!!initialName}
480 value={room}
481 onChange={e => setRoom(e.target.value.replace(/\s/g, ''))}
482 placeholder="any-name-you-like (no spaces)"
483 maxLength={100}
484 style={inputStyle}
485 />
486 </label>
487 {TURN_CONFIGURED && (
488 <details
489 open={initialTurnToken.length > 0}
490 style={{marginBottom: '1.25rem'}}
491 >
492 <summary style={{cursor: 'pointer', color: '#555'}}>
493 Relay token (optional)
494 </summary>
495 <input
496 type="password"
497 value={turnToken}
498 onChange={e => setTurnToken(e.target.value)}
499 placeholder="leave blank if you don't have one"
500 maxLength={200}
501 style={{...inputStyle, marginTop: '0.5rem'}}
502 />
503 <p style={{color: '#888', fontSize: '0.85rem', marginBottom: 0}}>
504 Some networks won't let two browsers talk directly, and those
505 need a relay. Only one person in the room needs to enter the
506 token — everyone else picks the relay up from them
507 automatically. The token stays in this browser.
508 </p>
509 </details>
510 )}
511 <button
512 type="submit"
513 style={canEnter ? primaryBtn : {...primaryBtn, ...disabledStyle}}
514 disabled={!canEnter}
515 >
516 Enter room
517 </button>
518 </form>
520 <p style={{color: '#888', fontSize: '0.85rem', marginTop: '1.5rem'}}>
521 Anyone who knows the room name can join — up to {MAX_PARTICIPANTS}{' '}
522 people per room. You enter with your microphone and camera off.
523 </p>
524 </div>
525 )
526}
528// ---- room ----------------------------------------------------------------------
530function CopyLinkButton({compact}: {compact: boolean}) {
531 const [copied, setCopied] = useState(false)
532 const copy = async () => {
533 try {
534 await navigator.clipboard.writeText(location.href)
535 setCopied(true)
536 setTimeout(() => setCopied(false), 1500)
537 } catch {
538 /* clipboard unavailable; the address bar still has the link */
539 }
540 }
541 return (
542 <button
543 style={darkBtn}
544 onClick={() => void copy()}
545 title="Copy the room link to share"
546 >
547 <Icon name="link" size={15} />
548 {copied ? 'Copied!' : compact ? 'Copy link' : 'Copy room link'}
549 </button>
550 )
551}
553// A quiet indicator of whether the room has a relay available, and where it
554// came from. Only shown on builds that have a credential endpoint at all.
555function RelayChip({status}: {status: RelayStatus}) {
556 const [color, label, title] =
557 status === 'self'
558 ? [
559 '#3fb950',
560 'Relay on',
561 'A relay is available from the token you entered, and has been shared with everyone else in the room.'
562 ]
563 : status === 'shared'
564 ? [
565 '#3fb950',
566 'Relay shared',
567 'Another participant shared a relay with the room, so difficult connections can fall back to it.'
568 ]
569 : [
570 '#777',
571 'No relay',
572 "Nobody in the room has entered a relay token. Most connections are fine without one; if someone can't connect, rejoin with a token."
573 ]
574 return (
575 <span
576 title={title}
577 style={{
578 display: 'inline-flex',
579 alignItems: 'center',
580 gap: '0.4rem',
581 fontSize: '0.85rem',
582 color: '#999',
583 cursor: 'default'
584 }}
585 >
586 <span
587 style={{
588 width: 8,
589 height: 8,
590 borderRadius: '50%',
591 background: color,
592 flex: '0 0 auto'
593 }}
594 />
595 {label}
596 </span>
597 )
598}
600// ---- chat -----------------------------------------------------------------
602const useMediaQuery = (query: string): boolean => {
603 const [matches, setMatches] = useState(() => matchMedia(query).matches)
604 useEffect(() => {
605 const mq = matchMedia(query)
606 const onChange = () => setMatches(mq.matches)
607 mq.addEventListener('change', onChange)
608 return () => mq.removeEventListener('change', onChange)
609 }, [query])
610 return matches
611}
613// Make http(s) URLs clickable. Only URLs we matched ourselves become hrefs
614// (never arbitrary schemes), and common trailing punctuation stays as text.
615const URL_RE = /https?:\/\/[^\s]+/g
617function withLinks(text: string): React.ReactNode[] {
618 const out: React.ReactNode[] = []
619 let last = 0
620 let m: RegExpExecArray | null
621 URL_RE.lastIndex = 0
622 while ((m = URL_RE.exec(text))) {
623 let url = m[0]
624 const trail = url.match(/[.,!?;:)\]'"]+$/)
625 if (trail) url = url.slice(0, -trail[0].length)
626 if (url.replace(/^https?:\/\//, '') === '') continue // scheme only
627 if (m.index > last) out.push(text.slice(last, m.index))
628 out.push(
629 <a
630 key={m.index}
631 href={url}
632 target="_blank"
633 rel="noopener noreferrer"
634 style={{color: '#9cc4ff', wordBreak: 'break-all'}}
635 >
636 {url}
637 </a>
638 )
639 last = m.index + url.length
640 URL_RE.lastIndex = last
641 }
642 if (last < text.length) out.push(text.slice(last))
643 return out
644}
646const timeLabel = (t: number): string =>
647 new Date(t).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})
649// Side panels (chat, transcript) share their geometry: docked beside the
650// gallery on wide screens, floating over it on narrow ones. Only one is open
651// at a time, so they never compete for the space.
652const panelStyle = (overlay: boolean): React.CSSProperties => ({
653 ...(overlay
654 ? {
655 position: 'absolute',
656 top: 0,
657 right: 0,
658 bottom: 0,
659 width: 'min(320px, 88vw)',
660 zIndex: 10,
661 boxShadow: '-4px 0 16px rgba(0, 0, 0, 0.5)'
662 }
663 : {width: 300, flex: '0 0 auto'}),
664 background: '#161616',
665 borderLeft: '1px solid #333',
666 display: 'flex',
667 flexDirection: 'column',
668 minHeight: 0
669})
671const panelHeadStyle: React.CSSProperties = {
672 display: 'flex',
673 justifyContent: 'space-between',
674 alignItems: 'center',
675 padding: '0.45rem 0.45rem 0.45rem 0.75rem',
676 borderBottom: '1px solid #333'
677}
679function PanelClose({onClose, what}: {onClose: () => void; what: string}) {
680 return (
681 <button
682 style={{...iconBtn, padding: '0.35rem', border: 'none', background: 'none'}}
683 onClick={onClose}
684 title={`Close ${what}`}
685 aria-label={`Close ${what}`}
686 >
687 <Icon name="x" size={16} />
688 </button>
689 )
690}
692function ChatPanel({
693 items,
694 overlay,
695 onSend,
696 onClose
697}: {
698 items: ChatItem[]
699 overlay: boolean
700 onSend: (text: string) => void
701 onClose: () => void
702}) {
703 const [draft, setDraft] = useState('')
704 const listRef = useRef<HTMLDivElement>(null)
705 // Stick to the bottom unless the user has scrolled up to read.
706 const stickRef = useRef(true)
707 useEffect(() => {
708 const el = listRef.current
709 if (el && stickRef.current) el.scrollTop = el.scrollHeight
710 }, [items.length])
712 const submit = (e: React.FormEvent) => {
713 e.preventDefault()
714 if (!draft.trim()) return
715 onSend(draft)
716 setDraft('')
717 stickRef.current = true
718 }
720 return (
721 <aside style={panelStyle(overlay)}>
722 <div style={panelHeadStyle}>
723 <strong>Chat</strong>
724 <PanelClose onClose={onClose} what="chat" />
725 </div>
726 <div
727 ref={listRef}
728 onScroll={() => {
729 const el = listRef.current
730 if (el) {
731 stickRef.current =
732 el.scrollHeight - el.scrollTop - el.clientHeight < 60
733 }
734 }}
735 style={{flex: 1, minHeight: 0, overflowY: 'auto', padding: '0.6rem 0.75rem'}}
736 >
737 {items.length === 0 && (
738 <p style={{color: '#777', fontSize: '0.85rem'}}>
739 No messages yet. Only people in the room see the chat, and nothing
740 is stored anywhere.
741 </p>
742 )}
743 {items.map(item =>
744 item.kind === 'system' ? (
745 <div
746 key={item.seq}
747 style={{
748 textAlign: 'center',
749 color: '#888',
750 fontStyle: 'italic',
751 fontSize: '0.8rem',
752 margin: '0.45rem 0'
753 }}
754 >
755 {item.text} · {timeLabel(item.time)}
756 </div>
757 ) : (
758 <div key={item.seq} style={{marginBottom: '0.55rem'}}>
759 <div style={{fontSize: '0.75rem', color: '#888'}}>
760 <strong style={{color: '#ccc'}}>{item.name}</strong>{' '}
761 {timeLabel(item.time)}
762 </div>
763 <div
764 style={{
765 whiteSpace: 'pre-wrap',
766 wordBreak: 'break-word',
767 fontSize: '0.92rem'
768 }}
769 >
770 {withLinks(item.text)}
771 </div>
772 </div>
773 )
774 )}
775 </div>
776 <form
777 onSubmit={submit}
778 style={{
779 display: 'flex',
780 gap: '0.4rem',
781 padding: '0.6rem',
782 borderTop: '1px solid #333'
783 }}
784 >
785 <input
786 autoFocus
787 value={draft}
788 onChange={e => setDraft(e.target.value)}
789 placeholder="Message…"
790 maxLength={2000}
791 style={{
792 flex: 1,
793 minWidth: 0,
794 padding: '0.45rem 0.6rem',
795 borderRadius: 8,
796 border: '1px solid #555',
797 background: '#2a2a2a',
798 color: '#eee',
799 fontSize: '0.92rem'
800 }}
801 />
802 <button
803 type="submit"
804 style={draft.trim() ? iconBtn : {...iconBtn, ...disabledStyle}}
805 disabled={!draft.trim()}
806 title="Send"
807 aria-label="Send message"
808 >
809 <Icon name="send" size={16} />
810 </button>
811 </form>
812 </aside>
813 )
814}
816// ---- transcript -------------------------------------------------------------
818const smallBtn: React.CSSProperties = {
819 ...darkBtn,
820 padding: '0.35rem 0.6rem',
821 fontSize: '0.85rem'
822}
824const durationLabel = (seconds: number): string =>
825 seconds < 60
826 ? `${Math.round(seconds)} s`
827 : `${(seconds / 60).toFixed(1)} min`
829function TranscriptPanel({
830 items,
831 seconds,
832 active,
833 hasKey,
834 overlay,
835 onStart,
836 onStop,
837 onClear,
838 onForgetKey,
839 onClose
840}: {
841 items: TranscriptItem[]
842 seconds: number
843 active: boolean
844 hasKey: boolean
845 overlay: boolean
846 onStart: (apiKey: string) => void
847 onStop: () => void
848 onClear: () => void
849 onForgetKey: () => void
850 onClose: () => void
851}) {
852 const [key, setKey] = useState('')
853 const [copied, setCopied] = useState(false)
854 // Clearing throws away something that was deliberately kept, so it asks
855 // first — inline, rather than in a dialog that would cover the transcript
856 // being discussed.
857 const [confirmClear, setConfirmClear] = useState(false)
858 useEffect(() => {
859 if (items.length === 0) setConfirmClear(false)
860 }, [items.length])
861 const listRef = useRef<HTMLDivElement>(null)
862 const stickRef = useRef(true)
863 useEffect(() => {
864 const el = listRef.current
865 if (el && stickRef.current) el.scrollTop = el.scrollHeight
866 }, [items.length])
868 const start = (e: React.FormEvent) => {
869 e.preventDefault()
870 onStart(key)
871 setKey('')
872 }
874 const copy = async () => {
875 try {
876 await navigator.clipboard.writeText(formatTranscript(items))
877 setCopied(true)
878 setTimeout(() => setCopied(false), 1500)
879 } catch {
880 /* clipboard unavailable; the text is still selectable in the panel */
881 }
882 }
884 const save = () => {
885 const url = URL.createObjectURL(
886 new Blob([formatTranscript(items)], {type: 'text/plain'})
887 )
888 const a = document.createElement('a')
889 a.href = url
890 a.download = `transcript-${new Date()
891 .toISOString()
892 .slice(0, 16)
893 .replace(/[:T]/g, '-')}.txt`
894 a.click()
895 setTimeout(() => URL.revokeObjectURL(url), 1000)
896 }
898 return (
899 <aside style={panelStyle(overlay)}>
900 <div style={panelHeadStyle}>
901 <strong>Transcript</strong>
902 <PanelClose onClose={onClose} what="the transcript" />
903 </div>
905 <div
906 style={{
907 padding: '0.6rem 0.75rem',
908 borderBottom: '1px solid #333',
909 fontSize: '0.85rem',
910 color: '#bbb'
911 }}
912 >
913 {active ? (
914 <div
915 style={{display: 'flex', alignItems: 'center', gap: '0.5rem'}}
916 >
917 <span
918 style={{
919 width: 9,
920 height: 9,
921 borderRadius: '50%',
922 background: '#e05252',
923 flex: '0 0 auto'
924 }}
925 />
926 <span style={{flex: 1}}>
927 Listening · {durationLabel(seconds)} sent
928 </span>
929 <button style={smallBtn} onClick={onStop}>
930 Stop
931 </button>
932 </div>
933 ) : hasKey ? (
934 <div style={{display: 'flex', alignItems: 'center', gap: '0.5rem'}}>
935 <span style={{flex: 1}}>
936 Everyone in the room will be told that you are transcribing.
937 </span>
938 <button style={smallBtn} onClick={() => onStart('')}>
939 Start
940 </button>
941 </div>
942 ) : (
943 <form onSubmit={start}>
944 <p style={{margin: '0 0 0.5rem'}}>
945 Transcription runs from this browser through Deepgram, so it
946 needs your own API key. Only speech is sent, not the silence in
947 between, and each person is transcribed separately so the
948 transcript says who said what.
949 </p>
950 <input
951 type="password"
952 value={key}
953 onChange={e => setKey(e.target.value)}
954 placeholder="Deepgram API key"
955 autoComplete="off"
956 style={{
957 width: '100%',
958 boxSizing: 'border-box',
959 padding: '0.45rem 0.6rem',
960 borderRadius: 8,
961 border: '1px solid #555',
962 background: '#2a2a2a',
963 color: '#eee',
964 fontSize: '0.9rem'
965 }}
966 />
967 <button
968 type="submit"
969 style={
970 key.trim()
971 ? {...smallBtn, marginTop: '0.5rem'}
972 : {...smallBtn, marginTop: '0.5rem', ...disabledStyle}
973 }
974 disabled={!key.trim()}
975 >
976 Start transcribing
977 </button>
978 <p style={{margin: '0.5rem 0 0', color: '#888'}}>
979 The key is stored in this browser and is never sent to the other
980 participants. You are the one billed for it.
981 </p>
982 </form>
983 )}
984 </div>
986 <div
987 ref={listRef}
988 onScroll={() => {
989 const el = listRef.current
990 if (el) {
991 stickRef.current =
992 el.scrollHeight - el.scrollTop - el.clientHeight < 60
993 }
994 }}
995 style={{
996 flex: 1,
997 minHeight: 0,
998 overflowY: 'auto',
999 padding: '0.6rem 0.75rem'
1000 }}
1001 >
1002 {items.length === 0 && (
1003 <p style={{color: '#777', fontSize: '0.85rem'}}>
1004 {active
1005 ? 'Nothing transcribed yet. Speech will appear here a moment after someone says it.'
1006 : "No transcript yet. What gets transcribed is kept in this browser under this room's name and will be here next time; nobody else receives it."}
1007 </p>
1008 )}
1009 {items.map(item => (
1010 <div key={item.seq} style={{marginBottom: '0.7rem'}}>
1011 <div style={{fontSize: '0.75rem', color: '#888'}}>
1012 <strong style={{color: '#ccc'}}>{item.name}</strong>{' '}
1013 {timeLabel(item.time)}
1014 </div>
1015 <div
1016 style={{
1017 wordBreak: 'break-word',
1018 fontSize: '0.92rem',
1019 lineHeight: 1.45
1020 }}
1021 >
1022 {item.text}
1023 </div>
1024 </div>
1025 ))}
1026 </div>
1028 {confirmClear ? (
1029 <div
1030 style={{
1031 display: 'flex',
1032 gap: '0.4rem',
1033 alignItems: 'center',
1034 padding: '0.6rem',
1035 borderTop: '1px solid #333',
1036 background: '#2a1a1a'
1037 }}
1038 >
1039 <span style={{flex: 1, fontSize: '0.85rem'}}>
1040 Delete this room's transcript?
1041 </span>
1042 <button
1043 style={{
1044 ...smallBtn,
1045 background: '#c62828',
1046 borderColor: '#c62828',
1047 color: '#fff'
1048 }}
1049 onClick={() => {
1050 onClear()
1051 setConfirmClear(false)
1052 }}
1053 >
1054 Delete
1055 </button>
1056 <button style={smallBtn} onClick={() => setConfirmClear(false)}>
1057 Cancel
1058 </button>
1059 </div>
1060 ) : (
1061 <div
1062 style={{
1063 display: 'flex',
1064 gap: '0.4rem',
1065 alignItems: 'center',
1066 padding: '0.6rem',
1067 borderTop: '1px solid #333'
1068 }}
1069 >
1070 <button
1071 style={items.length ? smallBtn : {...smallBtn, ...disabledStyle}}
1072 disabled={items.length === 0}
1073 onClick={() => void copy()}
1074 title="Copy the transcript"
1075 >
1076 <Icon name="copy" size={14} />
1077 {copied ? 'Copied!' : 'Copy'}
1078 </button>
1079 <button
1080 style={items.length ? smallBtn : {...smallBtn, ...disabledStyle}}
1081 disabled={items.length === 0}
1082 onClick={save}
1083 title="Save the transcript as a text file"
1084 >
1085 <Icon name="download" size={14} />
1086 Save
1087 </button>
1088 <button
1089 style={items.length ? smallBtn : {...smallBtn, ...disabledStyle}}
1090 disabled={items.length === 0}
1091 onClick={() => setConfirmClear(true)}
1092 title="Delete this room's stored transcript"
1093 >
1094 Clear
1095 </button>
1096 <span style={{flex: 1}} />
1097 {hasKey && !active && (
1098 <button
1099 style={{
1100 ...smallBtn,
1101 border: 'none',
1102 background: 'none',
1103 color: '#999'
1104 }}
1105 onClick={onForgetKey}
1106 title="Remove the stored Deepgram key from this browser"
1107 >
1108 Forget key
1109 </button>
1110 )}
1111 </div>
1112 )}
1113 </aside>
1114 )
1115}
1117export default function App() {
1118 const {snapshot, network} = useNetwork()
1119 const {
1120 phase,
1121 roomId,
1122 name,
1123 participants,
1124 audioMuted,
1125 videoMuted,
1126 micAvailable,
1127 camAvailable,
1128 localStream,
1129 screenStream,
1130 settings,
1131 chat,
1132 relay,
1133 transcribing,
1134 transcript,
1135 transcriptSeconds,
1136 hasDeepgramKey,
1137 notice
1138 } = snapshot
1140 // Spotlight: which tile is enlarged ('self', a peer ID, or null = gallery).
1141 const [focusedId, setFocusedId] = useState<string | null>(null)
1142 const focusValid =
1143 focusedId !== null &&
1144 (focusedId === 'self' || participants.some(p => p.peerId === focusedId))
1145 const focus = focusValid ? focusedId : null
1146 useEffect(() => {
1147 // Drop the spotlight when its subject leaves (or we leave the room).
1148 if (focusedId !== null && (phase !== 'room' || !focusValid)) {
1149 setFocusedId(null)
1150 }
1151 }, [phase, focusedId, focusValid])
1152 useEffect(() => {
1153 if (!focus) return
1154 const onKey = (e: KeyboardEvent) => {
1155 if (e.key === 'Escape') setFocusedId(null)
1156 }
1157 window.addEventListener('keydown', onKey)
1158 return () => window.removeEventListener('keydown', onKey)
1159 }, [focus])
1161 // One side panel at a time: docked on wide screens, an overlay on narrow
1162 // ones. The chat's unread badge counts real messages (not join/left lines)
1163 // that arrived while the chat was not the open panel.
1164 const [panel, setPanel] = useState<'chat' | 'transcript' | null>(null)
1165 const chatOpen = panel === 'chat'
1166 const [readCount, setReadCount] = useState(0)
1167 const narrow = useMediaQuery('(max-width: 700px)')
1168 useEffect(() => {
1169 if (phase !== 'room' && panel !== null) setPanel(null)
1170 }, [phase, panel])
1171 useEffect(() => {
1172 // Follows the log while open; also snaps back when the log resets on leave.
1173 if ((chatOpen || readCount > chat.length) && readCount !== chat.length) {
1174 setReadCount(chat.length)
1175 }
1176 }, [chatOpen, readCount, chat.length])
1177 const unread = chatOpen
1178 ? 0
1179 : chat.slice(readCount).filter(m => m.kind === 'chat').length
1181 if (phase === 'landing') {
1182 return (
1183 <Landing
1184 notice={notice}
1185 initialName={network.savedName}
1186 initialTurnToken={network.savedTurnToken}
1187 onDismissNotice={() => network.dismissNotice()}
1188 onEnter={(n, r, t) => void network.enterRoom(n, r, t)}
1189 />
1190 )
1191 }
1193 if (phase === 'joining') {
1194 return (
1195 <div
1196 style={{
1197 fontFamily: 'sans-serif',
1198 maxWidth: 460,
1199 margin: '3rem auto',
1200 padding: '0 1rem'
1201 }}
1202 >
1203 <h1 style={{marginBottom: '0.25rem'}}>CommonRoom</h1>
1204 <p>
1205 Joining <strong>#{roomId}</strong>… your browser may ask for camera
1206 and microphone access (you'll still be muted until you turn them on).
1207 </p>
1208 <button style={lightBtn} onClick={() => network.leave()}>
1209 Cancel
1210 </button>
1211 </div>
1212 )
1213 }
1215 // ---- in-room (dark) ----
1216 const sharing = screenStream !== null
1217 const count = participants.length + 1
1218 const alone = participants.length === 0
1220 const tileTooltip = (focused: boolean) =>
1221 focused ? 'Click to return to the gallery (Esc)' : 'Click to enlarge'
1222 const selfTile = (focused: boolean) => (
1223 <Tile
1224 stream={screenStream ?? localStream}
1225 label={`${name} (you)`}
1226 isSelf
1227 mirror={!sharing}
1228 audioMuted={audioMuted}
1229 videoMuted={videoMuted && !sharing}
1230 connecting={false}
1231 transcribing={transcribing}
1232 fill={focused}
1233 tooltip={tileTooltip(focused)}
1234 onClick={() => setFocusedId(focused ? null : 'self')}
1235 />
1236 )
1237 const peerTile = (p: ParticipantInfo, focused: boolean) => (
1238 <Tile
1239 stream={p.stream}
1240 label={p.name}
1241 isSelf={false}
1242 mirror={false}
1243 audioMuted={p.audioMuted}
1244 videoMuted={p.videoMuted}
1245 connecting={!p.connected}
1246 transcribing={p.transcribing}
1247 fill={focused}
1248 tooltip={tileTooltip(focused)}
1249 onClick={() => setFocusedId(focused ? null : p.peerId)}
1250 />
1251 )
1252 const focusedPeer =
1253 focus && focus !== 'self'
1254 ? participants.find(p => p.peerId === focus)
1255 : undefined
1256 const stripPeers = focus
1257 ? participants.filter(p => p.peerId !== focus)
1258 : []
1260 return (
1261 <div
1262 style={{
1263 position: 'fixed',
1264 inset: 0,
1265 background: '#111',
1266 color: '#eee',
1267 fontFamily: 'sans-serif',
1268 display: 'flex',
1269 flexDirection: 'column'
1270 }}
1271 >
1272 <header
1273 style={{
1274 display: 'flex',
1275 alignItems: 'center',
1276 gap: '0.75rem',
1277 padding: '0.55rem 1rem',
1278 background: '#1c1c1c',
1279 borderBottom: '1px solid #333',
1280 flexWrap: 'wrap'
1281 }}
1282 >
1283 <strong>CommonRoom</strong>
1284 <span
1285 style={{
1286 color: '#bbb',
1287 overflow: 'hidden',
1288 textOverflow: 'ellipsis',
1289 whiteSpace: 'nowrap',
1290 maxWidth: '40%'
1291 }}
1292 >
1293 #{roomId}
1294 </span>
1295 <span style={{color: '#888', fontSize: '0.9rem'}}>
1296 {count} of {MAX_PARTICIPANTS}
1297 </span>
1298 <span style={{flex: 1}} />
1299 <CopyLinkButton compact />
1300 <button
1301 style={leaveBtn}
1302 onClick={() => network.leave()}
1303 title="Leave the room"
1304 >
1305 <Icon name="logout" size={15} />
1306 Leave
1307 </button>
1308 </header>
1310 <div
1311 style={{
1312 flex: 1,
1313 minHeight: 0,
1314 display: 'flex',
1315 position: 'relative'
1316 }}
1317 >
1318 <main
1319 style={{
1320 flex: 1,
1321 minWidth: 0,
1322 overflowY: focus ? 'hidden' : 'auto',
1323 padding: '1rem',
1324 display: 'flex',
1325 flexDirection: 'column'
1326 }}
1327 >
1328 {notice && (
1329 <div
1330 style={{
1331 background: '#3a2f10',
1332 border: '1px solid #8a6d1a',
1333 color: '#f0dfa2',
1334 borderRadius: 8,
1335 padding: '0.5rem 0.75rem',
1336 margin: '0 auto 1rem',
1337 maxWidth: 1100,
1338 display: 'flex',
1339 justifyContent: 'space-between',
1340 alignItems: 'center',
1341 gap: '0.5rem'
1342 }}
1343 >
1344 <span>{notice}</span>
1345 <button style={darkBtn} onClick={() => network.dismissNotice()}>
1346 OK
1347 </button>
1348 </div>
1349 )}
1351 {focus ? (
1352 <>
1353 {/* Spotlight: the chosen tile takes the available space… */}
1354 <div style={{flex: 1, minHeight: 0}}>
1355 {focus === 'self'
1356 ? selfTile(true)
1357 : focusedPeer && peerTile(focusedPeer, true)}
1358 </div>
1359 {/* …and everyone else shrinks to a filmstrip below it. */}
1360 {(focus !== 'self' || stripPeers.length > 0) && (
1361 <div
1362 style={{
1363 overflowX: 'auto',
1364 flex: '0 0 auto',
1365 marginTop: '0.75rem'
1366 }}
1367 >
1368 <div
1369 style={{
1370 display: 'flex',
1371 gap: '0.5rem',
1372 width: 'max-content',
1373 margin: '0 auto'
1374 }}
1375 >
1376 {focus !== 'self' && (
1377 <div style={{width: 150, flex: '0 0 auto'}}>
1378 {selfTile(false)}
1379 </div>
1380 )}
1381 {stripPeers.map(p => (
1382 <div key={p.peerId} style={{width: 150, flex: '0 0 auto'}}>
1383 {peerTile(p, false)}
1384 </div>
1385 ))}
1386 </div>
1387 </div>
1388 )}
1389 </>
1390 ) : (
1391 <>
1392 <div
1393 style={{
1394 display: 'grid',
1395 gap: '0.75rem',
1396 gridTemplateColumns: alone
1397 ? 'minmax(0, 480px)'
1398 : 'repeat(auto-fit, minmax(min(280px, 100%), 1fr))',
1399 justifyContent: 'center',
1400 maxWidth: 1100,
1401 width: '100%',
1402 margin: '0 auto'
1403 }}
1404 >
1405 {selfTile(false)}
1406 {participants.map(p => (
1407 <div key={p.peerId} style={{minWidth: 0}}>
1408 {peerTile(p, false)}
1409 </div>
1410 ))}
1411 </div>
1413 {alone && (
1414 <div
1415 style={{
1416 maxWidth: 480,
1417 margin: '1.25rem auto 0',
1418 background: '#1c1c1c',
1419 border: '1px solid #333',
1420 borderRadius: 10,
1421 padding: '1rem',
1422 textAlign: 'center'
1423 }}
1424 >
1425 <p style={{marginTop: 0}}>
1426 You're the only one here. Share this link so others can join:
1427 </p>
1428 <p
1429 style={{
1430 color: '#9cc4ff',
1431 wordBreak: 'break-all',
1432 fontSize: '0.9rem',
1433 userSelect: 'all'
1434 }}
1435 >
1436 {location.href}
1437 </p>
1438 <CopyLinkButton compact={false} />
1439 </div>
1440 )}
1441 </>
1442 )}
1443 </main>
1444 {panel === 'chat' && (
1445 <ChatPanel
1446 items={chat}
1447 overlay={narrow}
1448 onSend={text => network.sendChat(text)}
1449 onClose={() => setPanel(null)}
1450 />
1451 )}
1452 {panel === 'transcript' && (
1453 <TranscriptPanel
1454 items={transcript}
1455 seconds={transcriptSeconds}
1456 active={transcribing}
1457 hasKey={hasDeepgramKey}
1458 overlay={narrow}
1459 onStart={key => void network.startTranscription(key)}
1460 onStop={() => network.stopTranscription()}
1461 onClear={() => network.clearTranscript()}
1462 onForgetKey={() => network.forgetDeepgramKey()}
1463 onClose={() => setPanel(null)}
1464 />
1465 )}
1466 </div>
1468 <footer
1469 style={{
1470 display: 'flex',
1471 alignItems: 'center',
1472 justifyContent: 'center',
1473 flexWrap: 'wrap',
1474 gap: '0.6rem',
1475 padding: '0.7rem 1rem',
1476 background: '#1c1c1c',
1477 borderTop: '1px solid #333'
1478 }}
1479 >
1480 <button
1481 style={audioMuted ? mutedIconBtn : iconBtn}
1482 title={
1483 audioMuted
1484 ? micAvailable
1485 ? 'Unmute your microphone'
1486 : 'Microphone unavailable — click to try again'
1487 : 'Mute your microphone'
1488 }
1489 aria-label={audioMuted ? 'Unmute your microphone' : 'Mute your microphone'}
1490 onClick={() => network.setAudioMuted(!audioMuted)}
1491 >
1492 <Icon name={audioMuted ? 'micOff' : 'mic'} />
1493 </button>
1494 <button
1495 style={videoMuted ? mutedIconBtn : iconBtn}
1496 title={
1497 videoMuted
1498 ? camAvailable
1499 ? 'Turn your camera on'
1500 : 'Camera unavailable — click to try again'
1501 : 'Turn your camera off'
1502 }
1503 aria-label={videoMuted ? 'Turn your camera on' : 'Turn your camera off'}
1504 onClick={() => network.setVideoMuted(!videoMuted)}
1505 >
1506 <Icon name={videoMuted ? 'videoOff' : 'video'} />
1507 </button>
1508 {canShareScreen && (
1509 <button
1510 style={sharing ? sharingIconBtn : iconBtn}
1511 title={sharing ? 'Stop sharing your screen' : 'Share your screen'}
1512 aria-label={sharing ? 'Stop sharing your screen' : 'Share your screen'}
1513 onClick={() =>
1514 sharing
1515 ? void network.stopScreenShare()
1516 : void network.startScreenShare()
1517 }
1518 >
1519 <Icon name="monitor" />
1520 </button>
1521 )}
1522 <span style={{position: 'relative', display: 'inline-flex'}}>
1523 <button
1524 style={chatOpen ? {...iconBtn, background: '#3a3a3a'} : iconBtn}
1525 title={chatOpen ? 'Close the chat' : 'Open the chat'}
1526 aria-label={chatOpen ? 'Close the chat' : 'Open the chat'}
1527 onClick={() => setPanel(chatOpen ? null : 'chat')}
1528 >
1529 <Icon name="chat" />
1530 </button>
1531 {unread > 0 && (
1532 <span
1533 style={{
1534 position: 'absolute',
1535 top: -5,
1536 right: -5,
1537 minWidth: 18,
1538 height: 18,
1539 borderRadius: 9,
1540 background: '#c62828',
1541 color: '#fff',
1542 fontSize: '0.7rem',
1543 display: 'flex',
1544 alignItems: 'center',
1545 justifyContent: 'center',
1546 padding: '0 4px',
1547 pointerEvents: 'none'
1548 }}
1549 >
1550 {unread > 99 ? '99+' : unread}
1551 </span>
1552 )}
1553 </span>
1554 <button
1555 style={
1556 transcribing
1557 ? {...iconBtn, background: '#a01414', borderColor: '#a01414', color: '#fff'}
1558 : panel === 'transcript'
1559 ? {...iconBtn, background: '#3a3a3a'}
1560 : iconBtn
1561 }
1562 title={
1563 transcribing
1564 ? 'Transcribing — open the transcript'
1565 : 'Transcribe this meeting'
1566 }
1567 aria-label={
1568 transcribing
1569 ? 'Transcribing; open the transcript'
1570 : 'Transcribe this meeting'
1571 }
1572 onClick={() =>
1573 setPanel(panel === 'transcript' ? null : 'transcript')
1574 }
1575 >
1576 <Icon name="transcript" />
1577 </button>
1578 <label
1579 title="Video quality for the whole room — anyone can change it, and it applies to everyone"
1580 style={{
1581 display: 'flex',
1582 alignItems: 'center',
1583 gap: '0.4rem',
1584 fontSize: '0.9rem',
1585 color: '#ccc'
1586 }}
1587 >
1588 Quality
1589 <select
1590 value={settings.videoQuality}
1591 onChange={e => network.setVideoQuality(e.target.value as VideoQuality)}
1592 style={{
1593 padding: '0.4rem',
1594 borderRadius: 8,
1595 border: '1px solid #555',
1596 background: '#2a2a2a',
1597 color: '#eee',
1598 fontSize: '0.9rem'
1599 }}
1600 >
1601 {VIDEO_QUALITIES.map(q => (
1602 <option key={q} value={q}>
1603 {q[0].toUpperCase() + q.slice(1)}
1604 </option>
1605 ))}
1606 </select>
1607 </label>
1608 {TURN_CONFIGURED && <RelayChip status={relay} />}
1609 </footer>
1610 </div>
1611 )
1612}