From 740ed2bf7f362fb6da8064f2ad62e91c18141227 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Wed, 3 Jun 2026 16:56:49 -0400 Subject: [PATCH] zebra-spaces: kick + ban buttons; pagehide sends 'bye' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two moderation actions, two buttons: - kick : evict the session, allow rejoin - ban : evict + block the pubkey (old 'boot' semantics) Renders 'X was kicked by Y' vs 'X was banned by Y' off the new peer-booted.action field. Self-notice text follows the same split. pagehide / beforeunload now sends a synchronous 'bye' so a closed tab counts as a strong-leave (peer-left + SFU evict immediately) instead of waiting 8s for the hiccup grace. Fox 2026-06-03 — "will closed tab on phone but the audio kept playing until i kicked him out". With the new bye on pagehide his tab-close will trigger the same fast-path the leave button does. beforeunload kept as a fallback for older desktop browsers that fire it before pagehide; pagehide is the cross-mobile primary. --- web/zebra-spaces.html | 75 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 61 insertions(+), 14 deletions(-) diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index dd97784..5c99fda 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -3405,7 +3405,12 @@ async function handleSignal(raw){ const victPub = pubHexFromMsg(m, 'pubkey') || (mm && mm.pubkey ? pubHexFromMsg({pubkey:mm.pubkey},'pubkey') : ''); const byPub = pubHexFromMsg(m, 'by_pubkey'); - logLine('', idTag(m.uuid, victPub)+' was removed by '+(by||byPub?idTag(m.by, byPub):'a mod')); + /* server now annotates the action ('kick' | 'ban'); older + * servers send no action — fall back to the generic "removed". */ + const verb = m.action === 'kick' ? 'was kicked' + : m.action === 'ban' ? 'was banned' + : 'was removed'; + logLine('', idTag(m.uuid, victPub)+' '+verb+' by '+(by||byPub?idTag(m.by, byPub):'a mod')); /* same departure chime as peer-left — they're gone either way. */ if (m.uuid !== myUUID) playToneLeave(); if (m.uuid === myUUID){ @@ -3414,8 +3419,11 @@ async function handleSignal(raw){ * down our SFU + mesh PCs too so we actually stop hearing / * broadcasting — closing the WS alone leaves the WebRTC paths up. */ wantConnected = false; - showNotice('You were removed from this space'+(by?' by '+by.handle:'')+'.', 'warn'); - logLine('err','you were removed from this space'); + const noticeVerb = m.action === 'kick' ? 'kicked from' + : m.action === 'ban' ? 'banned from' + : 'removed from'; + showNotice('You were '+noticeVerb+' this space'+(by?' by '+by.handle:'')+'.', 'warn'); + logLine('err','you were '+(m.action || 'removed')+' from this space'); for (const u of [...peers.keys()]) tearPeer(u); sfuUnpublish().catch(()=>{}); sfuUnpublishScreen().catch(()=>{}); @@ -3729,10 +3737,21 @@ async function modDemote(uuid, to){ const sig = await signBytes(sigAction(roomID, roomEpoch, 'demote', uuid, to)); send({ type:'demote', target: uuid, to, epoch: roomEpoch, sig }); } -async function modBoot(uuid){ - if (!confirm('boot this person from the space?')) return; - const sig = await signBytes(sigAction(roomID, roomEpoch, 'boot', uuid)); - send({ type:'boot', target: uuid, epoch: roomEpoch, sig }); +/* kick: drop the peer from this session — evicts their SFU PCs (audio + * actually stops) but does NOT block their pubkey. They can rejoin + * freely. Use when someone's audio is leaking from a closed-tab / + * wrong-device / hung session that we just want gone right now. */ +async function modKick(uuid){ + if (!confirm('kick this person? (they can rejoin)')) return; + const sig = await signBytes(sigAction(roomID, roomEpoch, 'kick', uuid)); + send({ type:'kick', target: uuid, epoch: roomEpoch, sig }); +} +/* ban: drop AND block their pubkey for the room's hold window — they + * cannot rejoin. Use for actual moderation removals. */ +async function modBan(uuid){ + if (!confirm('ban this person? (they cannot rejoin)')) return; + const sig = await signBytes(sigAction(roomID, roomEpoch, 'ban', uuid)); + send({ type:'ban', target: uuid, epoch: roomEpoch, sig }); } /* ================================================================== @@ -3799,12 +3818,19 @@ function renderRoom(){ b.textContent = '→ speaker'; b.onclick = () => modDemote(m.uuid, 'speaker').catch(e=>logLine('err','demote: '+e.message)); acts.appendChild(b); } - /* boot allowed against anyone except host; cohosts also can't boot cohosts (host only) */ + /* kick + ban allowed against anyone except host; cohosts also + * can't kick/ban cohosts (host only). Two buttons because the + * actions have different blast radius: kick = drop this session + * (rejoinable), ban = block-by-pubkey for the room's hold window. */ if (m.role !== 'host' && !(m.role === 'cohost' && myRole !== 'host')){ - const b = document.createElement('button'); b.className='small'; - b.textContent = 'boot'; - b.onclick = () => modBoot(m.uuid).catch(e => logLine('err','boot: '+e.message)); - acts.appendChild(b); + const kb = document.createElement('button'); kb.className='small'; + kb.textContent = 'kick'; + kb.onclick = () => modKick(m.uuid).catch(e => logLine('err','kick: '+e.message)); + acts.appendChild(kb); + const bb = document.createElement('button'); bb.className='small'; + bb.textContent = 'ban'; + bb.onclick = () => modBan(m.uuid).catch(e => logLine('err','ban: '+e.message)); + acts.appendChild(bb); } } row.appendChild(badge); row.appendChild(handle); row.appendChild(pub); @@ -4120,6 +4146,27 @@ if (navigator.mediaDevices && navigator.mediaDevices.addEventListener){ * sees how many inputs exist. */ refreshMicList(); +/* tab-close strong-leave: when the page is about to unload, send 'bye' + * BEFORE the WS gets torn down by the browser. Without this, closing a + * tab (or backgrounding the phone browser, or swiping the app away) + * leaves the WS to die silently — the signal server treats that as a + * hiccup and waits 8s before broadcasting peer-left + evicting SFU PCs. + * During those 8s the rest of the room still hears the closed tab's + * mic. Fox 2026-06-03 ("will closed tab on phone — audio kept playing + * until i kicked him"). pagehide is the cross-browser reliable signal + * for this; beforeunload doesn't fire on mobile Safari and is unreliable + * on PWAs. We use both: pagehide is primary, beforeunload covers older + * desktop Firefox / Chromium that fire it before pagehide. */ +function sendByeAndClose(){ + try { + if (ws && ws.readyState === WebSocket.OPEN){ + ws.send(JSON.stringify({ type: 'bye' })); + } + } catch(_){} +} +window.addEventListener('pagehide', sendByeAndClose); +window.addEventListener('beforeunload', sendByeAndClose); + /* laptop-lid-close / sleep / suspend recovery: when the tab comes back * to visible, check whether our SFU sub PC is still in a healthy state. * Some browsers (Chromium on Linux specifically) don't fire @@ -4246,8 +4293,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');