zebra-spaces: kick + ban buttons; pagehide sends 'bye'

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.
This commit is contained in:
Russell Ballestrini 2026-06-03 16:56:49 -04:00
parent fb7228b289
commit 740ed2bf7f
No known key found for this signature in database

View file

@ -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');
<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> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-03</span><br>
md5 <span class="stamp-md5">86b88dea62be004921fe4e116bba90eb</span><br>
sha256 <span class="stamp-sha">3686823398c477768b1af4f1dbb86f19cf69923e58d1ad94d8ecb7669e613e00</span><br>
md5 <span class="stamp-md5">8af4cc884304d14625605ebb1b625489</span><br>
sha256 <span class="stamp-sha">199ac36f8e372a5599847c563f699a09be2b415e047aa4444adcec0263f400f0</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>