diff --git a/test/zebra-fsm.test.js b/test/zebra-fsm.test.js index 6a57906..c0fecc3 100644 --- a/test/zebra-fsm.test.js +++ b/test/zebra-fsm.test.js @@ -559,7 +559,7 @@ test('joined + LEAVE → leaving → idle on DONE', () => { eq(m.state, 'idle'); }); -test('idle entry clears uuid + role + bootedBy', () => { +test('idle entry clears uuid + role + bootedBy + bootedAction', () => { const m = createFSM(callSpec); m.send('ENTER', { code: 'r', handle: 'h' }); m.send('WELCOME', { uuid: 'u', role: 'host' }); @@ -567,6 +567,56 @@ test('idle entry clears uuid + role + bootedBy', () => { eq(m.context.uuid, ''); eq(m.context.role, ''); eq(m.context.bootedBy, null); + eq(m.context.bootedAction, null); +}); + +console.log('callSpec — bootedAction propagates (kicked-vs-banned-vs-blocked UI):'); + +test('BOOTED with action=kick sets bootedAction=kick', () => { + const m = createFSM(callSpec); + m.send('ENTER', { code: 'r', handle: 'h' }); + m.send('WELCOME', { uuid: 'u', role: 'listener' }); + m.send('BOOTED', { by: 'modA', action: 'kick' }); + eq(m.state, 'booted'); + eq(m.context.bootedAction, 'kick'); + eq(m.context.bootedBy, 'modA'); +}); + +test('BOOTED with action=ban sets bootedAction=ban', () => { + const m = createFSM(callSpec); + m.send('ENTER', { code: 'r', handle: 'h' }); + m.send('WELCOME', { uuid: 'u', role: 'listener' }); + m.send('BOOTED', { by: 'modA', action: 'ban' }); + eq(m.state, 'booted'); + eq(m.context.bootedAction, 'ban'); +}); + +test('BOOTED with action=blocked sets bootedAction=blocked (signal-server denied)', () => { + const m = createFSM(callSpec); + m.send('ENTER', { code: 'r', handle: 'h' }); + m.send('WELCOME', { uuid: 'u', role: 'listener' }); + m.send('BOOTED', { by: 'signal', action: 'blocked' }); + eq(m.state, 'booted'); + eq(m.context.bootedAction, 'blocked'); +}); + +test('BOOTED with no action defaults bootedAction to kick (back-compat)', () => { + const m = createFSM(callSpec); + m.send('ENTER', { code: 'r', handle: 'h' }); + m.send('WELCOME', { uuid: 'u', role: 'listener' }); + m.send('BOOTED', { by: 'modA' }); + eq(m.state, 'booted'); + eq(m.context.bootedAction, 'kick'); +}); + +test('BOOTED → ACK → idle clears bootedAction', () => { + const m = createFSM(callSpec); + m.send('ENTER', { code: 'r', handle: 'h' }); + m.send('WELCOME', { uuid: 'u', role: 'listener' }); + m.send('BOOTED', { by: 'modA', action: 'ban' }); + m.send('ACK'); + eq(m.state, 'idle'); + eq(m.context.bootedAction, null); }); console.log('callSpec — role transitions stay in joined:'); diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index 00adfa5..9559e00 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -1185,10 +1185,13 @@ const remoteTileSpec = { * topology without inventing a new state per role permutation. */ const callSpec = { initial: 'idle', - context: { code: '', handle: '', uuid: '', role: '', bootedBy: null, lastError: null }, + /* bootedAction — 'kick' | 'ban' | 'blocked' | null. The UI observer + * derives the kicked-vs-banned-vs-blocked status text + whether + * btn-enter should be re-enabled (kick: yes, ban/blocked: no). */ + context: { code: '', handle: '', uuid: '', role: '', bootedBy: null, bootedAction: null, lastError: null }, states: { idle: { - entry: (ctx) => { ctx.uuid = ''; ctx.role = ''; ctx.bootedBy = null; }, + entry: (ctx) => { ctx.uuid = ''; ctx.role = ''; ctx.bootedBy = null; ctx.bootedAction = null; }, on: { ENTER: { target: 'connecting', @@ -1203,7 +1206,7 @@ const callSpec = { action: (ctx, ev) => { if (ev.payload){ ctx.uuid = ev.payload.uuid || ''; ctx.role = ev.payload.role || ''; } }, }, FAILED: { target: 'idle', action: (ctx, ev) => { ctx.lastError = ev.payload && ev.payload.error; } }, - BOOTED: { target: 'booted', action: (ctx, ev) => { ctx.bootedBy = ev.payload && ev.payload.by; } }, + BOOTED: { target: 'booted', action: (ctx, ev) => { ctx.bootedBy = ev.payload && ev.payload.by; ctx.bootedAction = (ev.payload && ev.payload.action) || 'kick'; } }, LEAVE: 'idle', }, }, @@ -1217,7 +1220,7 @@ const callSpec = { }, WS_DROPPED: 'reconnecting', LEAVE: 'leaving', - BOOTED: { target: 'booted', action: (ctx, ev) => { ctx.bootedBy = ev.payload && ev.payload.by; } }, + BOOTED: { target: 'booted', action: (ctx, ev) => { ctx.bootedBy = ev.payload && ev.payload.by; ctx.bootedAction = (ev.payload && ev.payload.action) || 'kick'; } }, }, }, reconnecting: { @@ -1232,7 +1235,7 @@ const callSpec = { }, FAILED: { target: 'idle', action: (ctx, ev) => { ctx.lastError = ev.payload && ev.payload.error; } }, LEAVE: 'leaving', - BOOTED: { target: 'booted', action: (ctx, ev) => { ctx.bootedBy = ev.payload && ev.payload.by; } }, + BOOTED: { target: 'booted', action: (ctx, ev) => { ctx.bootedBy = ev.payload && ev.payload.by; ctx.bootedAction = (ev.payload && ev.payload.action) || 'kick'; } }, }, }, leaving: { @@ -3566,13 +3569,90 @@ function canSpeak(role){ return role==='host' || role==='cohost' || role==='spea const roomMachines = wireZebraMachines(); if (typeof window !== 'undefined'){ window.roomMachines = roomMachines; } /* visible trace of every CallFSM transition so QA can correlate UI - * symptoms with state changes. Will tighten the log noise once the - * runtime takes over from the imperative handlers. */ + * symptoms with state changes. */ roomMachines.call.observe(({ state, prev, ev }) => { if (prev === null || state === prev) return; logLine('', 'call: ' + prev + ' → ' + state + (ev && ev.type ? ' [' + ev.type + ']' : '')); }); +/* Call-FSM-driven UI. Single source of truth for the entry/connected/ + * kicked/banned UI states (dot color, button visibility, status text, + * entry-row visibility). Previously these were scattered classList + + * setStatus writes across `welcome`, `peer-booted` (self branch), + * btn-leave click, and handleBlocked — easy to drift, hard to test. + * + * Fox 2026-06-04: "all systems need state machines." This observer + * makes the existing callSpec the authoritative UI driver; the + * imperative paths now just dispatch events into the FSM and trust + * the observer to update the chrome. + * + * Side-effect-only — never reads anything except its ctx. Idempotent + * because it only fires on REAL transitions (prev !== state). */ +function applyCallStateUI(state, prev, ctx){ + const dot = $('dot-call'), status = $('call-status'); + const btnEnter = $('btn-enter'), btnLeave = $('btn-leave'), btnMute = $('btn-mute'); + const rowEntry = $('row-entry'), secRoom = $('sec-room'); + switch (state){ + case 'idle': { + /* fresh page, or post-leave */ + if (dot) dot.className = 'dot warn'; + if (rowEntry) rowEntry.classList.remove('hidden'); + if (secRoom) secRoom.classList.add('hidden'); + if (btnLeave){ btnLeave.classList.add('hidden'); btnLeave.disabled = true; } + if (btnMute){ btnMute.classList.add('hidden'); btnMute.disabled = true; } + if (btnEnter) btnEnter.disabled = false; + if (status && prev === 'leaving') setStatus('left', null); + break; + } + case 'connecting': { + if (dot) dot.className = 'dot warn'; + if (btnEnter) btnEnter.disabled = true; + if (status) setStatus('connecting…'); + break; + } + case 'joined': { + if (dot) dot.className = 'dot ok'; + if (rowEntry) rowEntry.classList.add('hidden'); + if (secRoom) secRoom.classList.remove('hidden'); + if (btnLeave){ btnLeave.classList.remove('hidden'); btnLeave.disabled = false; } + if (btnMute) btnMute.classList.remove('hidden'); + if (status && ctx.role) setStatus('connected as '+ctx.role, 'ok'); + break; + } + case 'reconnecting': { + if (dot) dot.className = 'dot warn'; + if (status) setStatus('rendezvous dropped — reconnecting…'); + break; + } + case 'leaving': { + if (dot) dot.className = 'dot warn'; + if (btnLeave){ btnLeave.classList.add('hidden'); btnLeave.disabled = true; } + if (btnMute){ btnMute.classList.add('hidden'); btnMute.disabled = true; } + break; + } + case 'booted': { + /* kicked or banned (or signal-blocked); ctx.bootedAction may be + * 'kick' | 'ban' | 'blocked'. Each gets a distinct status. */ + if (dot) dot.className = 'dot warn'; + if (secRoom) secRoom.classList.add('hidden'); + if (rowEntry) rowEntry.classList.remove('hidden'); + if (btnLeave){ btnLeave.classList.add('hidden'); btnLeave.disabled = true; } + if (btnMute){ btnMute.classList.add('hidden'); btnMute.disabled = true; } + if (btnEnter) btnEnter.disabled = (ctx.bootedAction === 'ban' || ctx.bootedAction === 'blocked'); + const verb = ctx.bootedAction === 'ban' ? 'banned from this space' + : ctx.bootedAction === 'blocked' ? 'blocked from this space' + : 'kicked from this space'; + const cls = ctx.bootedAction === 'ban' || ctx.bootedAction === 'blocked' ? 'err' : 'warn'; + if (status) setStatus(verb, cls); + break; + } + } +} +roomMachines.call.observe(({ state, prev, ctx }) => { + if (prev === null || state === prev) return; + try { applyCallStateUI(state, prev, ctx); } catch(e){ logLine('err','call UI observer: '+e.message); } +}); + let ws = null, wantConnected = false, sigKey = null, sigReconnect = null; function send(obj){ if (ws && ws.readyState===1) ws.send(JSON.stringify(obj)); } @@ -3615,8 +3695,8 @@ async function joinSpace(){ roomID = await deriveSignalRoom(code); sigKey = await deriveSignalKey(code); wantConnected = true; - $('btn-enter').disabled = true; - setStatus('connecting…'); + /* btn-enter disable + 'connecting…' status are applied by the + * call FSM observer once we land in the 'connecting' state. */ roomMachines.call.send('ENTER', { code, handle: myHandle }); openSignal(); } @@ -3845,17 +3925,12 @@ async function handleSignal(raw){ broadcastSpotlight(); reorderTiles(); logLine('', 'joined as '+myRole+' — uuid '+myUUID); - setStatus('connected as '+myRole, 'ok'); - $('dot-call').className='dot ok'; - $('btn-leave').disabled = false; - $('btn-leave').classList.remove('hidden'); - $('btn-mute').classList.remove('hidden'); - $('sec-room').classList.remove('hidden'); - /* Hide the rendezvous code + enter button once joined — fox - * 2026-06-04: "prevents people without the password or link - * from being able to see it on the screen" (over-the-shoulder - * privacy). The row reappears on leave / kick / WS-tear. */ - $('row-entry').classList.add('hidden'); + /* dot, leave/mute button visibility, sec-room reveal, entry-row + * hide and status text are all driven by applyCallStateUI() + * from the 'joined' state. Welcome already dispatched WELCOME + * to the call FSM above (line ~3914), so the observer has + * already run by this point. Keeping this comment here as a + * pointer for the next migration. */ /* remember which space this tab is in so a hard refresh auto-rejoins. * sessionStorage is per-tab so tab A in space X + tab B in space Y * stay independent and clear cleanly on tab close. */ @@ -4163,23 +4238,12 @@ async function handleSignal(raw){ sfuUnpublishGame().catch(()=>{}); sfuUnsubscribe().catch(()=>{}); dropMic(); - /* UI: kicked-self should NOT look like a connected listener - * still in the room. Flip dot from green/connected to a - * hollow/warn state, drop the leave button (already left), - * disable the mute button, and reset the status line. Fox - * 2026-06-04: 'kicked listener gets green left state and - * still has a leave button even though they are out of the - * room — should be gone'. */ - $('dot-call').className = 'dot warn'; - setStatus(m.action === 'ban' ? 'banned from this space' : 'kicked from this space', 'warn'); - $('btn-leave').classList.add('hidden'); - $('btn-leave').disabled = true; - $('btn-mute').classList.add('hidden'); - $('btn-mute').disabled = true; - $('btn-enter').disabled = false; - /* Bring the entry row back so the kicked user can re-enter - * a different (or same) rendezvous code manually. */ - $('row-entry').classList.remove('hidden'); + /* UI (dot, leave/mute buttons, entry-row, status text) is + * now driven by the call-state observer applyCallStateUI() + * from the 'booted' state. We just need to dispatch BOOTED + * with the action so the observer can render the right + * status text (kicked/banned). */ + roomMachines.call.send('BOOTED', { by: m.by, action: m.action || 'kick' }); } /* Authoritative tile teardown by pubkey — booted users can't be * publishing anything anymore by definition. The SFU side eviction @@ -5246,13 +5310,14 @@ function handleBlocked(source){ if (blocked) return; blocked = true; wantConnected = false; - roomMachines.call.send('BOOTED', { by: source }); + /* call.observe drives the dot/buttons/entry-row/status from + * 'blocked' bootedAction — see applyCallStateUI. */ + roomMachines.call.send('BOOTED', { by: source, action: 'blocked' }); /* booted = clear auto-rejoin so a refresh doesn't immediately retry */ try { sessionStorage.removeItem(ACTIVE_CALL_KEY); } catch(_){} try { sessionStorage.removeItem(ACTIVE_CAM_KEY); } catch(_){} showNotice('You are blocked from this space.', 'warn'); logLine('err','blocked ('+source+') — stopping reconnects'); - setStatus('blocked from this space','err'); if (sigReconnect){ clearTimeout(sigReconnect); sigReconnect = null; } if (ws){ try { ws.close(); } catch(_){} ws = null; } for (const u of [...peers.keys()]) tearPeer(u); @@ -5619,24 +5684,25 @@ $('btn-leave').addEventListener('click', async () => { /* tear all video tiles regardless of source — fresh slate next time */ for (const k of [...screenVideos.keys()]) removeScreenTile(k); for (const k of [...cameraVideos.keys()]) removeCameraTile(k); - $('sec-room').classList.add('hidden'); + /* sec-* visibility for invite/listener-actions/share/screen-share/ + * spotlight isn't yet under the call FSM observer (room-internal + * UI, not the top-line entry/connected/booted chrome). Keep these + * imperative for now; the next FSM migration can hoist them. */ $('sec-invite').classList.add('hidden'); $('sec-listener-actions').classList.add('hidden'); $('sec-share').classList.add('hidden'); $('sec-screen-share').classList.add('hidden'); $('sec-spotlight').classList.add('hidden'); - /* bring the rendezvous-code + enter row back when leaving */ - $('row-entry').classList.remove('hidden'); spotlight = null; + /* call FSM: LEAVE → leaving → DONE → idle. applyCallStateUI sets + * dot warn, hides leave/mute buttons, shows entry-row, sets status + * 'left', re-enables btn-enter. */ + roomMachines.call.send('LEAVE'); roomMachines.call.send('DONE'); hideNotice(); - $('dot-call').className='dot warn'; - setStatus('left', null); - $('btn-enter').disabled = false; - $('btn-leave').disabled = true; - $('btn-leave').classList.add('hidden'); - $('btn-mute').disabled = true; - $('btn-mute').classList.add('hidden'); + /* reset mute button label even though the FSM observer hides it — + * next join restores visibility and the residue 'unmute'/'invert' + * styling would otherwise leak. */ $('btn-mute').textContent = 'mute'; $('btn-mute').className = 'hidden'; /* Preserve the user's last mute choice across leave+rejoin — * fox: 'when I leave and rejoin the mic state is unmuted'. Don't @@ -5653,8 +5719,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');