Add spotlight view, room chat, and clearer media-failure messages
- Click a tile to enlarge it (others shrink to a filmstrip); click again
or press Esc to return to the gallery. Spotlight letterboxes instead of
cropping so screen shares stay readable.
- Ephemeral room chat over the control channels: join/left lines (hello
now carries a self-reported joinedAt), clickable http(s) links opening
in new tabs, unread badge, side panel on desktop / overlay on mobile.
- getUserMedia failures now report the actual cause (permission vs no
device vs device-busy, error name included) at join time and on retry;
a camera held by another app no longer masquerades as a permissions
problem.
4 changed files+652−83
CLAUDE.mdmodified+22−2View file
@@ -14,7 +14,10 @@ src/p2p/
1414 peer.ts WebRTC wrapper: media + control channel — ported (replaceTrack generalized to audio|video)
1515 settings.ts shared ROOM settings, quality presets — default quality is 'medium', not 'auto'
1616 network.ts the heart: rooms, presence, mesh, media, settings sync
17-src/App.tsx landing form (light) + in-room view (dark), video grid, control bar
17+src/App.tsx landing form (light) + in-room view (dark), video grid with
18+ click-to-spotlight (gallery ↔ one big tile + filmstrip; Esc or
19+ click again to return), control bar, chat panel (side panel on
20+ wide screens, overlay ≤700px, unread badge)
1821 ```
1922
2023 ## Key design decisions
@@ -33,7 +36,12 @@ src/App.tsx landing form (light) + in-room view (dark), video grid, control b
3336 AudioContext-destination track / black canvas-capture track), so
3437 offer/answer stays symmetric and the one-offer, no-renegotiation design
3538 holds. Unmuting without a real device retries getUserMedia and upgrades the
36- placeholder via `replaceTrack` on every connection.
39+ placeholder via `replaceTrack` on every connection. getUserMedia failures
40+ surface a cause-specific notice (`mediaErrorMessage`: permission vs
41+ not-found vs device-busy, error name included) both at join and on retry —
42+ on Linux, a camera held by another browser fails with NotReadableError,
43+ which is NOT a permissions problem. The combined audio+video request fails
44+ as a whole in that case, so `acquireMedia` retries each kind separately.
3745 - **Soft cap of 8** (`MAX_PARTICIPANTS`). A peer already holding 7 connections
3846 answers an unknown peer's announcement/offer with `{t:'room-full'}` on the
3947 newcomer's topic instead of connecting; a newcomer with zero connections
@@ -54,6 +62,18 @@ src/App.tsx landing form (light) + in-room view (dark), video grid, control b
5462 `replaceTrack` per peer; a peer that joins mid-share gets the screen track
5563 from `outgoingStream()`. Same-kind replacement avoids renegotiation — never
5664 addTrack mid-connection.
65+- **Chat is ephemeral and never relayed.** `{t:'chat', text}` broadcasts on
66+ the control channels; every message arrives directly from its author over a
67+ channel established via signed signaling, so authorship needs no extra
68+ crypto. There is deliberately NO history replay for late joiners — replay
69+ would mean peers relaying others' messages, which a malicious peer could
70+ fabricate; adding history requires signing each message. Log capped at 500,
71+ messages at 2000 chars. Join/left lines are derived locally: hello carries a
72+ self-reported `joinedAt`, and a peer whose join predates ours gets no
73+ "joined" line on first sight (they were already here) — but `chatSeen`
74+ ensures a blip-reconnect logs "joined" to match its "left". Links: only
75+ http(s) URLs matched by `withLinks` become anchors (target=_blank,
76+ rel=noopener noreferrer); never linkify other schemes.
5777 - **Cleanup is join-generation-guarded.** `joinSeq` is bumped on every
5878 join/leave; async work (getUserMedia, topic hashing, display capture)
5979 re-checks it after each await. `leave()` unsubscribes topics, stops all
README.mdmodified+6−1View file
@@ -8,10 +8,15 @@ Enter your name and a room name — any string you like (no spaces) — and you'
88 in. Share the room URL with anyone; everyone who joins the same room is
99 connected to everyone else over a full WebRTC mesh, up to 8 people. You enter
1010 with your microphone and camera **off** and turn them on when you're ready.
11+Click any tile to enlarge it (the rest shrink to a filmstrip); click it again
12+or press Esc to return to the gallery.
1113
1214 The room has shared settings that anyone can change and that apply to
1315 everyone — currently the video quality (low / medium / high / auto, medium by
14-default). You can also share your screen in place of your camera.
16+default). You can also share your screen in place of your camera, and there's
17+a room chat (with join/left notices and clickable links) that is as ephemeral
18+as the call itself: you only see what's said while you're in the room, and
19+nothing is stored anywhere.
1520
1621 ## How it works
1722
src/App.tsxmodified+474−68View file
@@ -1,5 +1,9 @@
11 import {useEffect, useRef, useState} from 'react'
2-import {MAX_PARTICIPANTS, type ParticipantInfo} from './p2p/network'
2+import {
3+ MAX_PARTICIPANTS,
4+ type ChatItem,
5+ type ParticipantInfo
6+} from './p2p/network'
37 import {VIDEO_QUALITIES, type VideoQuality} from './p2p/settings'
48 import {useNetwork} from './useNetwork'
59
@@ -157,6 +161,21 @@ const ICONS = {
157161 <path d="M16 17l5-5-5-5" />
158162 <path d="M21 12H9" />
159163 </>
164+ ),
165+ chat: (
166+ <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" />
167+ ),
168+ x: (
169+ <>
170+ <path d="M18 6L6 18" />
171+ <path d="M6 6l12 12" />
172+ </>
173+ ),
174+ send: (
175+ <>
176+ <path d="M22 2L11 13" />
177+ <path d="M22 2l-7 20-4-9-9-4 20-7z" />
178+ </>
160179 )
161180 } as const
162181
@@ -192,11 +211,13 @@ function Icon({
192211 function VideoView({
193212 stream,
194213 muted,
195- mirror
214+ mirror,
215+ fit
196216 }: {
197217 stream: MediaStream | null
198218 muted: boolean
199219 mirror: boolean
220+ fit: 'cover' | 'contain'
200221 }) {
201222 const ref = useRef<HTMLVideoElement>(null)
202223 useEffect(() => {
@@ -215,7 +236,7 @@ function VideoView({
215236 inset: 0,
216237 width: '100%',
217238 height: '100%',
218- objectFit: 'cover',
239+ objectFit: fit,
219240 transform: mirror ? 'scaleX(-1)' : undefined
220241 }}
221242 />
@@ -229,7 +250,10 @@ function Tile({
229250 mirror,
230251 audioMuted,
231252 videoMuted,
232- connecting
253+ connecting,
254+ fill = false,
255+ onClick,
256+ tooltip
233257 }: {
234258 stream: MediaStream | null
235259 label: string
@@ -238,21 +262,35 @@ function Tile({
238262 audioMuted: boolean
239263 videoMuted: boolean
240264 connecting: boolean
265+ /** Fill the parent box (spotlight) instead of a fixed 4:3 grid cell. */
266+ fill?: boolean
267+ onClick?: () => void
268+ tooltip?: string
241269 }) {
242270 return (
243271 <div
272+ onClick={onClick}
273+ title={tooltip}
244274 style={{
245275 position: 'relative',
246276 background: '#000',
247277 borderRadius: 10,
248278 overflow: 'hidden',
249- aspectRatio: '4 / 3',
250- border: isSelf ? '1px solid #444' : '1px solid #222'
279+ // The spotlight tile letterboxes (contain) so screen shares and faces
280+ // are never cropped; grid/filmstrip cells crop to fill (cover).
281+ ...(fill ? {width: '100%', height: '100%'} : {aspectRatio: '4 / 3'}),
282+ border: isSelf ? '1px solid #444' : '1px solid #222',
283+ cursor: onClick ? 'pointer' : undefined
251284 }}
252285 >
253286 {/* The video element stays mounted even when their camera is off so the
254287 audio keeps playing; the placeholder just covers it. */}
255- <VideoView stream={stream} muted={isSelf} mirror={mirror} />
288+ <VideoView
289+ stream={stream}
290+ muted={isSelf}
291+ mirror={mirror}
292+ fit={fill ? 'contain' : 'cover'}
293+ />
256294 {(videoMuted || connecting) && (
257295 <div
258296 style={{
@@ -435,17 +473,210 @@ function CopyLinkButton({compact}: {compact: boolean}) {
435473 )
436474 }
437475
438-function ParticipantTile({p}: {p: ParticipantInfo}) {
476+// ---- chat -----------------------------------------------------------------
477+
478+const useMediaQuery = (query: string): boolean => {
479+ const [matches, setMatches] = useState(() => matchMedia(query).matches)
480+ useEffect(() => {
481+ const mq = matchMedia(query)
482+ const onChange = () => setMatches(mq.matches)
483+ mq.addEventListener('change', onChange)
484+ return () => mq.removeEventListener('change', onChange)
485+ }, [query])
486+ return matches
487+}
488+
489+// Make http(s) URLs clickable. Only URLs we matched ourselves become hrefs
490+// (never arbitrary schemes), and common trailing punctuation stays as text.
491+const URL_RE = /https?:\/\/[^\s]+/g
492+
493+function withLinks(text: string): React.ReactNode[] {
494+ const out: React.ReactNode[] = []
495+ let last = 0
496+ let m: RegExpExecArray | null
497+ URL_RE.lastIndex = 0
498+ while ((m = URL_RE.exec(text))) {
499+ let url = m[0]
500+ const trail = url.match(/[.,!?;:)\]'"]+$/)
501+ if (trail) url = url.slice(0, -trail[0].length)
502+ if (url.replace(/^https?:\/\//, '') === '') continue // scheme only
503+ if (m.index > last) out.push(text.slice(last, m.index))
504+ out.push(
505+ <a
506+ key={m.index}
507+ href={url}
508+ target="_blank"
509+ rel="noopener noreferrer"
510+ style={{color: '#9cc4ff', wordBreak: 'break-all'}}
511+ >
512+ {url}
513+ </a>
514+ )
515+ last = m.index + url.length
516+ URL_RE.lastIndex = last
517+ }
518+ if (last < text.length) out.push(text.slice(last))
519+ return out
520+}
521+
522+const timeLabel = (t: number): string =>
523+ new Date(t).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})
524+
525+function ChatPanel({
526+ items,
527+ overlay,
528+ onSend,
529+ onClose
530+}: {
531+ items: ChatItem[]
532+ overlay: boolean
533+ onSend: (text: string) => void
534+ onClose: () => void
535+}) {
536+ const [draft, setDraft] = useState('')
537+ const listRef = useRef<HTMLDivElement>(null)
538+ // Stick to the bottom unless the user has scrolled up to read.
539+ const stickRef = useRef(true)
540+ useEffect(() => {
541+ const el = listRef.current
542+ if (el && stickRef.current) el.scrollTop = el.scrollHeight
543+ }, [items.length])
544+
545+ const submit = (e: React.FormEvent) => {
546+ e.preventDefault()
547+ if (!draft.trim()) return
548+ onSend(draft)
549+ setDraft('')
550+ stickRef.current = true
551+ }
552+
439553 return (
440- <Tile
441- stream={p.stream}
442- label={p.name}
443- isSelf={false}
444- mirror={false}
445- audioMuted={p.audioMuted}
446- videoMuted={p.videoMuted}
447- connecting={!p.connected}
448- />
554+ <aside
555+ style={{
556+ ...(overlay
557+ ? {
558+ position: 'absolute',
559+ top: 0,
560+ right: 0,
561+ bottom: 0,
562+ width: 'min(320px, 88vw)',
563+ zIndex: 10,
564+ boxShadow: '-4px 0 16px rgba(0, 0, 0, 0.5)'
565+ }
566+ : {width: 300, flex: '0 0 auto'}),
567+ background: '#161616',
568+ borderLeft: '1px solid #333',
569+ display: 'flex',
570+ flexDirection: 'column',
571+ minHeight: 0
572+ }}
573+ >
574+ <div
575+ style={{
576+ display: 'flex',
577+ justifyContent: 'space-between',
578+ alignItems: 'center',
579+ padding: '0.45rem 0.45rem 0.45rem 0.75rem',
580+ borderBottom: '1px solid #333'
581+ }}
582+ >
583+ <strong>Chat</strong>
584+ <button
585+ style={{...iconBtn, padding: '0.35rem', border: 'none', background: 'none'}}
586+ onClick={onClose}
587+ title="Close chat"
588+ aria-label="Close chat"
589+ >
590+ <Icon name="x" size={16} />
591+ </button>
592+ </div>
593+ <div
594+ ref={listRef}
595+ onScroll={() => {
596+ const el = listRef.current
597+ if (el) {
598+ stickRef.current =
599+ el.scrollHeight - el.scrollTop - el.clientHeight < 60
600+ }
601+ }}
602+ style={{flex: 1, minHeight: 0, overflowY: 'auto', padding: '0.6rem 0.75rem'}}
603+ >
604+ {items.length === 0 && (
605+ <p style={{color: '#777', fontSize: '0.85rem'}}>
606+ No messages yet. Only people in the room see the chat, and nothing
607+ is stored anywhere.
608+ </p>
609+ )}
610+ {items.map(item =>
611+ item.kind === 'system' ? (
612+ <div
613+ key={item.seq}
614+ style={{
615+ textAlign: 'center',
616+ color: '#888',
617+ fontStyle: 'italic',
618+ fontSize: '0.8rem',
619+ margin: '0.45rem 0'
620+ }}
621+ >
622+ {item.text} · {timeLabel(item.time)}
623+ </div>
624+ ) : (
625+ <div key={item.seq} style={{marginBottom: '0.55rem'}}>
626+ <div style={{fontSize: '0.75rem', color: '#888'}}>
627+ <strong style={{color: '#ccc'}}>{item.name}</strong>{' '}
628+ {timeLabel(item.time)}
629+ </div>
630+ <div
631+ style={{
632+ whiteSpace: 'pre-wrap',
633+ wordBreak: 'break-word',
634+ fontSize: '0.92rem'
635+ }}
636+ >
637+ {withLinks(item.text)}
638+ </div>
639+ </div>
640+ )
641+ )}
642+ </div>
643+ <form
644+ onSubmit={submit}
645+ style={{
646+ display: 'flex',
647+ gap: '0.4rem',
648+ padding: '0.6rem',
649+ borderTop: '1px solid #333'
650+ }}
651+ >
652+ <input
653+ autoFocus
654+ value={draft}
655+ onChange={e => setDraft(e.target.value)}
656+ placeholder="Message…"
657+ maxLength={2000}
658+ style={{
659+ flex: 1,
660+ minWidth: 0,
661+ padding: '0.45rem 0.6rem',
662+ borderRadius: 8,
663+ border: '1px solid #555',
664+ background: '#2a2a2a',
665+ color: '#eee',
666+ fontSize: '0.92rem'
667+ }}
668+ />
669+ <button
670+ type="submit"
671+ style={draft.trim() ? iconBtn : {...iconBtn, ...disabledStyle}}
672+ disabled={!draft.trim()}
673+ title="Send"
674+ aria-label="Send message"
675+ >
676+ <Icon name="send" size={16} />
677+ </button>
678+ </form>
679+ </aside>
449680 )
450681 }
451682
@@ -463,9 +694,50 @@ export default function App() {
463694 localStream,
464695 screenStream,
465696 settings,
697+ chat,
466698 notice
467699 } = snapshot
468700
701+ // Spotlight: which tile is enlarged ('self', a peer ID, or null = gallery).
702+ const [focusedId, setFocusedId] = useState<string | null>(null)
703+ const focusValid =
704+ focusedId !== null &&
705+ (focusedId === 'self' || participants.some(p => p.peerId === focusedId))
706+ const focus = focusValid ? focusedId : null
707+ useEffect(() => {
708+ // Drop the spotlight when its subject leaves (or we leave the room).
709+ if (focusedId !== null && (phase !== 'room' || !focusValid)) {
710+ setFocusedId(null)
711+ }
712+ }, [phase, focusedId, focusValid])
713+ useEffect(() => {
714+ if (!focus) return
715+ const onKey = (e: KeyboardEvent) => {
716+ if (e.key === 'Escape') setFocusedId(null)
717+ }
718+ window.addEventListener('keydown', onKey)
719+ return () => window.removeEventListener('keydown', onKey)
720+ }, [focus])
721+
722+ // Chat panel: side panel on wide screens, overlay on narrow ones. The
723+ // unread badge counts real messages (not join/left lines) that arrived
724+ // while the panel was closed.
725+ const [chatOpen, setChatOpen] = useState(false)
726+ const [readCount, setReadCount] = useState(0)
727+ const narrow = useMediaQuery('(max-width: 700px)')
728+ useEffect(() => {
729+ if (phase !== 'room' && chatOpen) setChatOpen(false)
730+ }, [phase, chatOpen])
731+ useEffect(() => {
732+ // Follows the log while open; also snaps back when the log resets on leave.
733+ if ((chatOpen || readCount > chat.length) && readCount !== chat.length) {
734+ setReadCount(chat.length)
735+ }
736+ }, [chatOpen, readCount, chat.length])
737+ const unread = chatOpen
738+ ? 0
739+ : chat.slice(readCount).filter(m => m.kind === 'chat').length
740+
469741 if (phase === 'landing') {
470742 return (
471743 <Landing
@@ -504,6 +776,44 @@ export default function App() {
504776 const count = participants.length + 1
505777 const alone = participants.length === 0
506778
779+ const tileTooltip = (focused: boolean) =>
780+ focused ? 'Click to return to the gallery (Esc)' : 'Click to enlarge'
781+ const selfTile = (focused: boolean) => (
782+ <Tile
783+ stream={screenStream ?? localStream}
784+ label={`${name} (you)`}
785+ isSelf
786+ mirror={!sharing}
787+ audioMuted={audioMuted}
788+ videoMuted={videoMuted && !sharing}
789+ connecting={false}
790+ fill={focused}
791+ tooltip={tileTooltip(focused)}
792+ onClick={() => setFocusedId(focused ? null : 'self')}
793+ />
794+ )
795+ const peerTile = (p: ParticipantInfo, focused: boolean) => (
796+ <Tile
797+ stream={p.stream}
798+ label={p.name}
799+ isSelf={false}
800+ mirror={false}
801+ audioMuted={p.audioMuted}
802+ videoMuted={p.videoMuted}
803+ connecting={!p.connected}
804+ fill={focused}
805+ tooltip={tileTooltip(focused)}
806+ onClick={() => setFocusedId(focused ? null : p.peerId)}
807+ />
808+ )
809+ const focusedPeer =
810+ focus && focus !== 'self'
811+ ? participants.find(p => p.peerId === focus)
812+ : undefined
813+ const stripPeers = focus
814+ ? participants.filter(p => p.peerId !== focus)
815+ : []
816+
507817 return (
508818 <div
509819 style={{
@@ -554,7 +864,24 @@ export default function App() {
554864 </button>
555865 </header>
556866
557- <main style={{flex: 1, overflowY: 'auto', padding: '1rem'}}>
867+ <div
868+ style={{
869+ flex: 1,
870+ minHeight: 0,
871+ display: 'flex',
872+ position: 'relative'
873+ }}
874+ >
875+ <main
876+ style={{
877+ flex: 1,
878+ minWidth: 0,
879+ overflowY: focus ? 'hidden' : 'auto',
880+ padding: '1rem',
881+ display: 'flex',
882+ flexDirection: 'column'
883+ }}
884+ >
558885 {notice && (
559886 <div
560887 style={{
@@ -578,61 +905,108 @@ export default function App() {
578905 </div>
579906 )}
580907
581- <div
582- style={{
583- display: 'grid',
584- gap: '0.75rem',
585- gridTemplateColumns: alone
586- ? 'minmax(0, 480px)'
587- : 'repeat(auto-fit, minmax(min(280px, 100%), 1fr))',
588- justifyContent: 'center',
589- maxWidth: 1100,
590- margin: '0 auto'
591- }}
592- >
593- <Tile
594- stream={screenStream ?? localStream}
595- label={`${name} (you)`}
596- isSelf
597- mirror={!sharing}
598- audioMuted={audioMuted}
599- videoMuted={videoMuted && !sharing}
600- connecting={false}
601- />
602- {participants.map(p => (
603- <ParticipantTile key={p.peerId} p={p} />
604- ))}
605- </div>
606-
607- {alone && (
608- <div
609- style={{
610- maxWidth: 480,
611- margin: '1.25rem auto 0',
612- background: '#1c1c1c',
613- border: '1px solid #333',
614- borderRadius: 10,
615- padding: '1rem',
616- textAlign: 'center'
617- }}
618- >
619- <p style={{marginTop: 0}}>
620- You're the only one here. Share this link so others can join:
621- </p>
622- <p
908+ {focus ? (
909+ <>
910+ {/* Spotlight: the chosen tile takes the available space… */}
911+ <div style={{flex: 1, minHeight: 0}}>
912+ {focus === 'self'
913+ ? selfTile(true)
914+ : focusedPeer && peerTile(focusedPeer, true)}
915+ </div>
916+ {/* …and everyone else shrinks to a filmstrip below it. */}
917+ {(focus !== 'self' || stripPeers.length > 0) && (
918+ <div
919+ style={{
920+ overflowX: 'auto',
921+ flex: '0 0 auto',
922+ marginTop: '0.75rem'
923+ }}
924+ >
925+ <div
926+ style={{
927+ display: 'flex',
928+ gap: '0.5rem',
929+ width: 'max-content',
930+ margin: '0 auto'
931+ }}
932+ >
933+ {focus !== 'self' && (
934+ <div style={{width: 150, flex: '0 0 auto'}}>
935+ {selfTile(false)}
936+ </div>
937+ )}
938+ {stripPeers.map(p => (
939+ <div key={p.peerId} style={{width: 150, flex: '0 0 auto'}}>
940+ {peerTile(p, false)}
941+ </div>
942+ ))}
943+ </div>
944+ </div>
945+ )}
946+ </>
947+ ) : (
948+ <>
949+ <div
623950 style={{
624- color: '#9cc4ff',
625- wordBreak: 'break-all',
626- fontSize: '0.9rem',
627- userSelect: 'all'
951+ display: 'grid',
952+ gap: '0.75rem',
953+ gridTemplateColumns: alone
954+ ? 'minmax(0, 480px)'
955+ : 'repeat(auto-fit, minmax(min(280px, 100%), 1fr))',
956+ justifyContent: 'center',
957+ maxWidth: 1100,
958+ width: '100%',
959+ margin: '0 auto'
628960 }}
629961 >
630- {location.href}
631- </p>
632- <CopyLinkButton compact={false} />
633- </div>
962+ {selfTile(false)}
963+ {participants.map(p => (
964+ <div key={p.peerId} style={{minWidth: 0}}>
965+ {peerTile(p, false)}
966+ </div>
967+ ))}
968+ </div>
969+
970+ {alone && (
971+ <div
972+ style={{
973+ maxWidth: 480,
974+ margin: '1.25rem auto 0',
975+ background: '#1c1c1c',
976+ border: '1px solid #333',
977+ borderRadius: 10,
978+ padding: '1rem',
979+ textAlign: 'center'
980+ }}
981+ >
982+ <p style={{marginTop: 0}}>
983+ You're the only one here. Share this link so others can join:
984+ </p>
985+ <p
986+ style={{
987+ color: '#9cc4ff',
988+ wordBreak: 'break-all',
989+ fontSize: '0.9rem',
990+ userSelect: 'all'
991+ }}
992+ >
993+ {location.href}
994+ </p>
995+ <CopyLinkButton compact={false} />
996+ </div>
997+ )}
998+ </>
634999 )}
6351000 </main>
1001+ {chatOpen && (
1002+ <ChatPanel
1003+ items={chat}
1004+ overlay={narrow}
1005+ onSend={text => network.sendChat(text)}
1006+ onClose={() => setChatOpen(false)}
1007+ />
1008+ )}
1009+ </div>
6361010
6371011 <footer
6381012 style={{
@@ -688,6 +1062,38 @@ export default function App() {
6881062 <Icon name="monitor" />
6891063 </button>
6901064 )}
1065+ <span style={{position: 'relative', display: 'inline-flex'}}>
1066+ <button
1067+ style={chatOpen ? {...iconBtn, background: '#3a3a3a'} : iconBtn}
1068+ title={chatOpen ? 'Close the chat' : 'Open the chat'}
1069+ aria-label={chatOpen ? 'Close the chat' : 'Open the chat'}
1070+ onClick={() => setChatOpen(!chatOpen)}
1071+ >
1072+ <Icon name="chat" />
1073+ </button>
1074+ {unread > 0 && (
1075+ <span
1076+ style={{
1077+ position: 'absolute',
1078+ top: -5,
1079+ right: -5,
1080+ minWidth: 18,
1081+ height: 18,
1082+ borderRadius: 9,
1083+ background: '#c62828',
1084+ color: '#fff',
1085+ fontSize: '0.7rem',
1086+ display: 'flex',
1087+ alignItems: 'center',
1088+ justifyContent: 'center',
1089+ padding: '0 4px',
1090+ pointerEvents: 'none'
1091+ }}
1092+ >
1093+ {unread > 99 ? '99+' : unread}
1094+ </span>
1095+ )}
1096+ </span>
6911097 <label
6921098 title="Video quality for the whole room — anyone can change it, and it applies to everyone"
6931099 style={{
src/p2p/network.tsmodified+150−12View file
@@ -54,10 +54,15 @@ type ControlMsg =
5454 name: string
5555 audioMuted: boolean
5656 videoMuted: boolean
57+ /** Self-reported epoch ms of when they entered the room, so receivers
58+ * can tell "was already here when I arrived" from "joined after me"
59+ * for the chat's join lines. */
60+ joinedAt: number
5761 settings: SettingEntry[]
5862 }
5963 | ({t: 'set'} & SettingEntry)
6064 | {t: 'mute'; audio: boolean; video: boolean}
65+ | {t: 'chat'; text: string}
6166 | {t: 'bye'}
6267
6368 const ANNOUNCE_INTERVAL_MS = 5000
@@ -70,6 +75,9 @@ const CONNECT_RETRY_MS = 15000
7075
7176 const NAME_KEY = 'commonroom:name'
7277
78+const CHAT_MAX_LENGTH = 2000
79+const CHAT_LOG_CAP = 500
80+
7381 export type Phase = 'landing' | 'joining' | 'room'
7482
7583 interface Conn {
@@ -86,6 +94,18 @@ interface Conn {
8694 videoMuted: boolean
8795 }
8896
97+export interface ChatItem {
98+ /** Monotonic per-session sequence number; stable React key. */
99+ seq: number
100+ kind: 'chat' | 'system'
101+ /** The author's peer ID; null for system lines. */
102+ peerId: string | null
103+ name: string
104+ text: string
105+ /** Local arrival time (epoch ms). */
106+ time: number
107+}
108+
89109 export interface ParticipantInfo {
90110 peerId: string
91111 name: string
@@ -109,6 +129,7 @@ export interface Snapshot {
109129 localStream: MediaStream | null
110130 screenStream: MediaStream | null
111131 settings: RoomSettings
132+ chat: ChatItem[]
112133 notice: string | null
113134 }
114135
@@ -140,6 +161,19 @@ export class Network {
140161 Record<keyof RoomSettings, {rev: number; by: string}>
141162 > = {}
142163
164+ // Chat is ephemeral: you only see what's said while you're in the room.
165+ // Messages arrive directly from their author over the authenticated
166+ // channel, so there's no relaying and nothing to forge.
167+ private chat: ChatItem[] = []
168+ private chatSeq = 0
169+ /** When WE entered the room (epoch ms), reported in our hello. */
170+ private joinedAtMs = 0
171+ /** peerId -> name for peers currently counted present in the chat. */
172+ private chatPresent = new Map<string, string>()
173+ /** Peers we've ever logged a join/left line for (so a reconnect after a
174+ * network blip gets a "joined" line to match its "left" line). */
175+ private chatSeen = new Set<string>()
176+
143177 private notice: string | null = null
144178
145179 private snapshot!: Snapshot
@@ -187,6 +221,15 @@ export class Network {
187221 this.localStream = media.stream
188222 this.micAvailable = media.mic
189223 this.camAvailable = media.cam
224+ // Tell the user right away when a device didn't come up (and why), so
225+ // they aren't surprised at unmute time.
226+ if (!media.mic && !media.cam) {
227+ this.notice = `${mediaErrorMessage('camera or microphone', media.camError)} You've still joined — the mic/camera buttons will retry.`
228+ } else if (!media.cam) {
229+ this.notice = `${mediaErrorMessage('camera', media.camError)} The camera button will retry.`
230+ } else if (!media.mic) {
231+ this.notice = `${mediaErrorMessage('microphone', media.micError)} The mic button will retry.`
232+ }
190233 // Everyone enters muted.
191234 this.audioMuted = true
192235 this.videoMuted = true
@@ -231,6 +274,8 @@ export class Network {
231274 )
232275
233276 this.phase = 'room'
277+ this.joinedAtMs = Date.now()
278+ this.pushSystem('You joined')
234279 void this.announce()
235280 this.announceTimer = window.setInterval(
236281 () => void this.announce(),
@@ -280,6 +325,9 @@ export class Network {
280325 this.videoMuted = true
281326 this.settings = {...DEFAULT_SETTINGS}
282327 this.settingsMeta = {}
328+ this.chat = []
329+ this.chatPresent.clear()
330+ this.chatSeen.clear()
283331 this.root = ''
284332 this.roomId = null
285333 this.phase = 'landing'
@@ -297,34 +345,40 @@ export class Network {
297345 stream: MediaStream
298346 mic: boolean
299347 cam: boolean
348+ micError: unknown
349+ camError: unknown
300350 }> {
301351 try {
302352 const s = await navigator.mediaDevices.getUserMedia({
303353 audio: true,
304354 video: true
305355 })
306- return {stream: s, mic: true, cam: true}
356+ return {stream: s, mic: true, cam: true, micError: null, camError: null}
307357 } catch {
308- /* fall through to per-kind attempts */
358+ // The combined request fails as a whole if EITHER device is unusable
359+ // (in Firefox, e.g., a camera held by another app fails it even though
360+ // the mic is fine) — retry each kind on its own to keep what works.
309361 }
310362 let audio: MediaStreamTrack | null = null
311363 let video: MediaStreamTrack | null = null
364+ let micError: unknown = null
365+ let camError: unknown = null
312366 try {
313367 const s = await navigator.mediaDevices.getUserMedia({audio: true})
314368 audio = s.getAudioTracks()[0] ?? null
315- } catch {
316- /* no mic */
369+ } catch (err) {
370+ micError = err
317371 }
318372 try {
319373 const s = await navigator.mediaDevices.getUserMedia({video: true})
320374 video = s.getVideoTracks()[0] ?? null
321- } catch {
322- /* no camera */
375+ } catch (err) {
376+ camError = err
323377 }
324378 const stream = new MediaStream()
325379 stream.addTrack(audio ?? this.silentAudioTrack())
326380 stream.addTrack(video ?? blackVideoTrack())
327- return {stream, mic: audio !== null, cam: video !== null}
381+ return {stream, mic: audio !== null, cam: video !== null, micError, camError}
328382 }
329383
330384 private silentAudioTrack(): MediaStreamTrack {
@@ -426,6 +480,11 @@ export class Network {
426480 close: () => {
427481 if (this.conns.get(peerId) === conn) {
428482 this.conns.delete(peerId)
483+ const chatName = this.chatPresent.get(peerId)
484+ if (chatName !== undefined) {
485+ this.chatPresent.delete(peerId)
486+ this.pushSystem(`${chatName} left`)
487+ }
429488 this.rebuildSnapshot()
430489 }
431490 }
@@ -488,6 +547,7 @@ export class Network {
488547 name: this.name ?? '',
489548 audioMuted: this.audioMuted,
490549 videoMuted: this.effectiveVideoMuted(),
550+ joinedAt: this.joinedAtMs,
491551 settings
492552 } satisfies ControlMsg)
493553 )
@@ -509,6 +569,21 @@ export class Network {
509569 if (Array.isArray(msg.settings)) {
510570 for (const entry of msg.settings) this.applyRemoteSetting(entry)
511571 }
572+ if (!this.chatPresent.has(peerId)) {
573+ const name =
574+ this.presence.get(peerId)?.name ??
575+ conn.name ??
576+ peerId.slice(0, 8)
577+ // No join line for people who were already here when we arrived
578+ // (their self-reported join predates ours) — unless we've logged a
579+ // "left" for them before, in which case this is a return.
580+ const joinedAt = typeof msg.joinedAt === 'number' ? msg.joinedAt : 0
581+ const preexisting =
582+ joinedAt <= this.joinedAtMs && !this.chatSeen.has(peerId)
583+ this.chatPresent.set(peerId, name)
584+ this.chatSeen.add(peerId)
585+ if (!preexisting) this.pushSystem(`${name} joined`)
586+ }
512587 this.rebuildSnapshot()
513588 return
514589 }
@@ -525,6 +600,20 @@ export class Network {
525600 this.rebuildSnapshot()
526601 return
527602 }
603+ case 'chat': {
604+ if (typeof msg.text !== 'string') return
605+ const text = msg.text.slice(0, CHAT_MAX_LENGTH)
606+ if (!text.trim()) return
607+ this.pushChatItem({
608+ kind: 'chat',
609+ peerId,
610+ name:
611+ this.presence.get(peerId)?.name ?? conn.name ?? peerId.slice(0, 8),
612+ text
613+ })
614+ this.rebuildSnapshot()
615+ return
616+ }
528617 case 'bye': {
529618 this.presence.delete(peerId)
530619 conn.peer.destroy() // its close handler removes it and rebuilds
@@ -533,6 +622,34 @@ export class Network {
533622 }
534623 }
535624
625+ // ---- chat ------------------------------------------------------------------
626+
627+ private pushChatItem(item: Omit<ChatItem, 'seq' | 'time'>) {
628+ this.chat.push({...item, seq: this.chatSeq++, time: Date.now()})
629+ if (this.chat.length > CHAT_LOG_CAP) {
630+ this.chat.splice(0, this.chat.length - CHAT_LOG_CAP)
631+ }
632+ }
633+
634+ private pushSystem(text: string) {
635+ this.pushChatItem({kind: 'system', peerId: null, name: '', text})
636+ }
637+
638+ /** Send a chat message to everyone in the room (and our own log). */
639+ sendChat(text: string) {
640+ if (this.phase !== 'room') return
641+ const trimmed = text.trim().slice(0, CHAT_MAX_LENGTH)
642+ if (!trimmed) return
643+ this.broadcastControl({t: 'chat', text: trimmed})
644+ this.pushChatItem({
645+ kind: 'chat',
646+ peerId: selfId,
647+ name: this.name ?? '',
648+ text: trimmed
649+ })
650+ this.rebuildSnapshot()
651+ }
652+
536653 // ---- shared room settings ------------------------------------------------
537654 //
538655 // ONE settings object for the whole room, editable by anyone. Sync is
@@ -651,9 +768,8 @@ export class Network {
651768 let stream: MediaStream
652769 try {
653770 stream = await navigator.mediaDevices.getUserMedia({audio: true})
654- } catch {
655- this.notice =
656- 'Could not access your microphone — check browser permissions.'
771+ } catch (err) {
772+ this.notice = mediaErrorMessage('microphone', err)
657773 this.rebuildSnapshot()
658774 return
659775 }
@@ -683,8 +799,8 @@ export class Network {
683799 let stream: MediaStream
684800 try {
685801 stream = await navigator.mediaDevices.getUserMedia({video: true})
686- } catch {
687- this.notice = 'Could not access your camera — check browser permissions.'
802+ } catch (err) {
803+ this.notice = mediaErrorMessage('camera', err)
688804 this.rebuildSnapshot()
689805 return
690806 }
@@ -829,12 +945,34 @@ export class Network {
829945 localStream: this.localStream,
830946 screenStream: this.screenStream,
831947 settings: this.settings,
948+ chat: this.chat,
832949 notice: this.notice
833950 }
834951 for (const l of this.listeners) l()
835952 }
836953 }
837954
955+/** A human-readable reason for a getUserMedia failure. The error name is
956+ * included so the real cause is visible — "permission denied" and "another
957+ * app is holding the camera" need entirely different fixes. */
958+const mediaErrorMessage = (what: string, err: unknown): string => {
959+ const rawName = (err as {name?: unknown} | null)?.name
960+ const name = typeof rawName === 'string' ? rawName : ''
961+ switch (name) {
962+ case 'NotAllowedError':
963+ case 'SecurityError':
964+ return `Access to your ${what} was blocked — check this site's permissions in your browser.`
965+ case 'NotFoundError':
966+ case 'OverconstrainedError':
967+ return `No ${what} was found on this device.`
968+ case 'NotReadableError':
969+ case 'AbortError':
970+ return `Your ${what} could not be started — it may be in use by another app or browser (${name}).`
971+ default:
972+ return `Could not access your ${what}${name ? ` (${name})` : ''}.`
973+ }
974+}
975+
838976 /** A tiny black video track, used as a placeholder when there is no camera. */
839977 const blackVideoTrack = (): MediaStreamTrack => {
840978 const canvas = document.createElement('canvas')