zebra-report: deploy FSM-driven call-state UI
This commit is contained in:
parent
385ccf5723
commit
bbffd61d89
1 changed files with 117 additions and 51 deletions
|
|
@ -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');
|
|||
|
||||
<footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
|
||||
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> · built <span class="stamp-date">2026-06-04</span><br>
|
||||
md5 <span class="stamp-md5">b8b69ddba03d2bdcee2e24c83a119b8c</span><br>
|
||||
sha256 <span class="stamp-sha">e79516aedb1b8a5a087cce2a23135f1d72d83ce22ccf517b9905635af26b471a</span><br>
|
||||
md5 <span class="stamp-md5">da9f26a202e920b3ee45134f748d4132</span><br>
|
||||
sha256 <span class="stamp-sha">8b14d4543a3123ba78e2edb87ca1e55373c7b336506c791ee2f8151adc91d2c2</span><br>
|
||||
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
|
||||
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
|
||||
</footer>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue