From 668105aeeb5c5f256d3217b43500d9097f3aa42b Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Sat, 30 May 2026 01:41:30 -0400 Subject: [PATCH] =?UTF-8?q?my-sea=20voice:=20persist=20mute=20across=20in-?= =?UTF-8?q?sea=20nav/refresh=20+=203-min=20muted=20auto-disconnect;=20fix?= =?UTF-8?q?=20first-connect=20glow/mute=20race=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MUTE PERSISTENCE (user-spec 2026-05-30) — a voice mute used to vanish on any in-sea navigation/refresh (the mesh tears down + auto-rejoins unmuted). Now the mute is stamped server-side + re-applied on rejoin, with a 3-min muted → auto-disconnect window: - `User.voice_muted_at` (timestamp, not a bare bool, so the 3-min window anchors here) + migration. Per-user, not per-seat: the owner has no seat row, and a user is in ≤1 voice room at a time, so this uniformly covers owner + visitor. - POST `/voice/mute` {muted} sets/clears it (new voice app views.py + urls.py, mounted at `voice/` in core/urls). my_sea + my_sea_visit pass the timestamp to `#id_voice_btn` as `data-voice-muted-at`. - voice-mesh.js gains `setMuted(m)` (set vs. toggleMute's flip), honoured by join's post-getUserMedia `_applyMute`. burger-btn.js: a mute toggle POSTs the state + arms a client timer; the auto-rejoin re-applies the persisted mute + re-arms the timer from the stored timestamp (so the 3-min spans navigations, not resets); an elapsed window on rejoin auto-disconnects instead of rejoining; a fresh manual join clears any stale mute. On timeout: leave voice + clear. FIRST-CONNECT GLOW/MUTE RACE (user-reported) — `setOnStateChange` pushes the current state immediately on subscribe, and voice-glow.js often subscribes MID-JOIN (getUserMedia pending → inCall=false). Its `setVoiceState` only ever DELETED `voice.dataset.inCall` (never re-set it) — wiping the join-vs-mute flag burger-btn.js had just set, so the next click re-joined instead of muting (which also dropped the peer + killed the equalizer). Two fixes: - voice-glow keeps `dataset.inCall` SYMMETRIC (set on true, delete on false), so the mid-join false is restored once the stream resolves → mute works on first connect. - voice-glow subscribes reliably on AUTO-REJOIN too (no click to trigger its poll): voice-mesh.js dispatches `voiceroom:ready` on singleton creation + voice-glow listens, so the glow is mesh-driven (peer-count equalizer) after a refresh, not just the in-call-class fallback. Coverage: - ITs: VoiceMuteViewTest (login/405/invalid-json guards, stamp on true, clear on false, re-mute restamps, missing-key=false). voice+lyric 164 green. - Jasmine: BurgerSpec mute persistence (muteRemainingMs window, rejoin re-mute, expired-window auto-disconnect, toggle-persists + 3-min fires, manual-join clears); VoiceGlowSpec dataset.inCall sync (sets on in-call, clears on not, restores after a mid-join false→true). All green. - Live multi-party voice (mic/2-device) left to manual verification. Code architected by Disco DeDisco Co-Authored-By: Claude Opus 4.8 (1M context) --- src/apps/epic/static/apps/epic/burger-btn.js | 85 ++++++++++++++- src/apps/gameboard/views.py | 4 + .../migrations/0016_user_voice_muted_at.py | 18 ++++ src/apps/lyric/models.py | 9 ++ .../voice/static/apps/voice/voice-glow.js | 21 +++- .../voice/static/apps/voice/voice-mesh.js | 15 +++ src/apps/voice/tests/integrated/test_views.py | 78 ++++++++++++++ src/apps/voice/urls.py | 9 ++ src/apps/voice/views.py | 43 ++++++++ src/core/urls.py | 1 + src/static/tests/BurgerSpec.js | 102 ++++++++++++++++++ src/static/tests/VoiceGlowSpec.js | 30 ++++++ src/static_src/tests/BurgerSpec.js | 102 ++++++++++++++++++ src/static_src/tests/VoiceGlowSpec.js | 30 ++++++ .../apps/gameboard/_partials/_burger.html | 5 +- 15 files changed, 545 insertions(+), 7 deletions(-) create mode 100644 src/apps/lyric/migrations/0016_user_voice_muted_at.py create mode 100644 src/apps/voice/tests/integrated/test_views.py create mode 100644 src/apps/voice/urls.py create mode 100644 src/apps/voice/views.py diff --git a/src/apps/epic/static/apps/epic/burger-btn.js b/src/apps/epic/static/apps/epic/burger-btn.js index cd46c01..763dc56 100644 --- a/src/apps/epic/static/apps/epic/burger-btn.js +++ b/src/apps/epic/static/apps/epic/burger-btn.js @@ -140,6 +140,49 @@ // BYE + the NVM-disconnect guard call this on an explicit leave. window.mySeaVoiceForget = _forgetVoiceRoom; + // ── Mute persistence + 3-min auto-disconnect (user-spec 2026-05-30) ───── + // The mute is stored server-side (User.voice_muted_at) so it SURVIVES in-sea + // nav/refresh — the voice auto-rejoin re-applies it. A mute held for + // MUTE_MAX_MS auto-disconnects the user from voice (+ clears the field). The + // window anchors to the persisted timestamp, so it spans navigations instead + // of resetting on each page. + var MUTE_MAX_MS = 180000; // 3 minutes + var _muteTimer = null; + + function _voiceCsrf() { + var m = document.cookie.match(/(?:^|; )csrftoken=([^;]+)/); + return m ? decodeURIComponent(m[1]) : ''; + } + // Persist (or clear) the caller's mute server-side. Best-effort — voice + // still works locally if the POST fails; only cross-nav persistence is lost. + function _persistMute(muted) { + try { + fetch('/voice/mute', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', 'X-CSRFToken': _voiceCsrf() }, + body: JSON.stringify({ muted: !!muted }), + }).catch(function () {}); + } catch (e) {} + } + // ms remaining before the 3-min auto-disconnect, given the mute's start (ms) + // + now (ms). <= 0 means already elapsed. Pure — exposed for unit testing. + function muteRemainingMs(mutedAtMs, nowMs) { + return MUTE_MAX_MS - (nowMs - mutedAtMs); + } + window._muteRemainingMs = muteRemainingMs; + + function _clearMuteTimer() { + if (_muteTimer) { clearTimeout(_muteTimer); _muteTimer = null; } + } + // Arm the auto-disconnect for the mute that began at `mutedAtMs` (ms). Fires + // `onFire` after the remaining window (next tick if already elapsed). + function _armMuteTimer(mutedAtMs, onFire) { + _clearMuteTimer(); + var remaining = muteRemainingMs(mutedAtMs, Date.now()); + _muteTimer = setTimeout(onFire, remaining > 0 ? remaining : 0); + } + // Surface a join failure to the user instead of failing silently — most // often the secure-context block (INSECURE_CONTEXT) when the dev server is // reached over plain HTTP from a phone. Prefers the Brief banner; falls @@ -165,6 +208,11 @@ var vbtn = document.getElementById('id_voice_btn'); if (!vbtn) return; var roomId = vbtn.getAttribute('data-room-id'); + // Persisted mute timestamp (server-rendered) — present iff the user was + // muted when this page loaded. Drives the auto-rejoin's mute + window. + var mutedAtAttr = vbtn.getAttribute('data-voice-muted-at'); + var persistedMutedAtMs = mutedAtAttr ? Date.parse(mutedAtAttr) : NaN; + var hasPersistedMute = !isNaN(persistedMutedAtMs); // VoiceRoom is lazy-loaded on first use (mesh injected on demand). function withVoiceRoom(cb) { @@ -175,6 +223,17 @@ document.head.appendChild(s); } + // 3-min muted timeout fired: leave voice, forget the room (no rejoin), + // clear the muted UI + the server field. + function _muteAutoDisconnect() { + _clearMuteTimer(); + if (window.VoiceRoom && window.VoiceRoom.leave) window.VoiceRoom.leave(); + _forgetVoiceRoom(); + _persistMute(false); + vbtn.classList.remove('muted', 'in-call'); + delete vbtn.dataset.inCall; + } + function startCall() { if (!window.VoiceRoom || vbtn.dataset.inCall) return; vbtn.dataset.inCall = '1'; @@ -192,20 +251,42 @@ withVoiceRoom(function () { if (!window.VoiceRoom) return; if (!vbtn.dataset.inCall) { + // Fresh MANUAL join → start unmuted; clear any stale persisted + // mute (e.g. muted, then closed the tab, last session). + _clearMuteTimer(); + _persistMute(false); startCall(); } else { var muted = window.VoiceRoom.toggleMute(); vbtn.classList.toggle('muted', muted); + _persistMute(muted); + if (muted) _armMuteTimer(Date.now(), _muteAutoDisconnect); + else _clearMuteTimer(); } }); }); // Auto-rejoin: were we in THIS room before the navigation, and is voice // still available on this page? Silently re-join so the call survives an - // in-my_sea reload (GATE VIEW / NVM / draw nav). + // in-sea reload (GATE VIEW / NVM / draw nav) — CARRYING the mute state. if (roomId && vbtn.classList.contains('active') && _rememberedVoiceRoom() === roomId) { - withVoiceRoom(startCall); + if (hasPersistedMute && muteRemainingMs(persistedMutedAtMs, Date.now()) <= 0) { + // The 3-min mute window already elapsed (muted across a long + // idle / nav) → honour the auto-disconnect: don't rejoin, clear. + _forgetVoiceRoom(); + _persistMute(false); + vbtn.classList.remove('muted'); + } else { + withVoiceRoom(function () { + if (hasPersistedMute && window.VoiceRoom.setMuted) { + window.VoiceRoom.setMuted(true); // honoured post-getUserMedia + vbtn.classList.add('muted'); + _armMuteTimer(persistedMutedAtMs, _muteAutoDisconnect); + } + startCall(); + }); + } } } window.bindVoiceBtn = bindVoiceBtn; diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index 3ce46b0..b140c7f 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -362,6 +362,8 @@ def my_sea(request): "user_has_sig": user_has_sig, "voice_active": voice_active, "voice_room_id": f"mysea-{request.user.id}", + "voice_muted_at": (request.user.voice_muted_at.isoformat() + if request.user.voice_muted_at else ""), "no_equipped_deck": no_equipped_deck, "show_backup_intro_banner": ( user_has_sig and no_equipped_deck and active_draw is None @@ -1057,6 +1059,8 @@ def my_sea_visit(request, owner_id): "owner_draw_id": owner_draw.id if owner_draw is not None else "", "voice_active": invite.voice_active, "voice_room_id": f"mysea-{owner.id}", + "voice_muted_at": (request.user.voice_muted_at.isoformat() + if request.user.voice_muted_at else ""), "significator": sig_card, "significator_reversed": sig_reversed, "my_sea_slots": owner_slots, diff --git a/src/apps/lyric/migrations/0016_user_voice_muted_at.py b/src/apps/lyric/migrations/0016_user_voice_muted_at.py new file mode 100644 index 0000000..c3d988d --- /dev/null +++ b/src/apps/lyric/migrations/0016_user_voice_muted_at.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0 on 2026-05-30 05:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('lyric', '0015_seed_mailman'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='voice_muted_at', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/src/apps/lyric/models.py b/src/apps/lyric/models.py index 17292c7..6002cfb 100644 --- a/src/apps/lyric/models.py +++ b/src/apps/lyric/models.py @@ -194,6 +194,15 @@ class User(AbstractBaseUser): # so each fresh spend re-opens the Brief surface for that cycle. free_draw_brief_dismissed_at = models.DateTimeField(null=True, blank=True) paid_draw_brief_dismissed_at = models.DateTimeField(null=True, blank=True) + # My-Sea voice mute persistence (user-spec 2026-05-30). The user's CURRENT + # voice mute state, stored as a TIMESTAMP (not a bare bool) so the 3-min + # "muted too long → auto-disconnect" window anchors here. Null = not muted. + # Stamped when the user mutes in the mesh; the voice auto-rejoin reads it so + # the mute carries across in-sea navigation/refresh; cleared on unmute, on a + # fresh manual join, + when the 3-min window elapses (client leaves voice). + # Per-user (not per-seat): the owner has no seat row, and a user is in at + # most one voice room at a time, so this uniformly covers owner + visitor. + voice_muted_at = models.DateTimeField(null=True, blank=True) ap_public_key = models.TextField(blank=True, default="") ap_private_key = models.TextField(blank=True, default="") diff --git a/src/apps/voice/static/apps/voice/voice-glow.js b/src/apps/voice/static/apps/voice/voice-glow.js index a3d9e8b..d204cf9 100644 --- a/src/apps/voice/static/apps/voice/voice-glow.js +++ b/src/apps/voice/static/apps/voice/voice-glow.js @@ -117,8 +117,16 @@ // Keep the voice btn's own flags in sync with the mesh truth so a // rejoin starts clean (dataset.inCall gates join-vs-mute in // burger-btn.js) and the render fallback can't get stuck "live". + // SYMMETRIC (set on true, not only delete on false): `setOnStateChange` + // pushes the CURRENT state immediately on subscribe, and voice-glow + // often subscribes mid-join (getUserMedia pending → inCall=false), + // which used to DELETE the `dataset.inCall` burger-btn.js had just + // set — so the next click re-joined instead of muting (+ dropped the + // peer, killing the equalizer). Re-setting it on the inCall=true + // notify restores it once the stream resolves (bug fix 2026-05-30). voice.classList.toggle('in-call', inCall); - if (!inCall) { delete voice.dataset.inCall; } + if (inCall) { voice.dataset.inCall = '1'; } + else { delete voice.dataset.inCall; } render(); } @@ -130,9 +138,13 @@ observers.push(mo); }); - // Subscribe to the mesh. VoiceRoom may load lazily on the first voice - // click (burger-btn.js injects voice-mesh.js), so retry briefly after - // a click until the singleton appears. + // Subscribe to the mesh. VoiceRoom loads lazily — on the first voice + // CLICK (manual join) OR on an AUTO-REJOIN (no click, burger-btn.js calls + // withVoiceRoom directly). Catch BOTH: the `voiceroom:ready` event + // voice-mesh.js fires when its singleton is created (covers auto-rejoin) + // + a post-click poll (covers the manual path even if the event is + // missed). Previously only the click-poll existed, so after a refresh the + // glow was never mesh-driven — no peer-count equalizer (2026-05-30). function subscribe() { if (window.VoiceRoom && window.VoiceRoom.setOnStateChange) { window.VoiceRoom.setOnStateChange(setVoiceState); @@ -141,6 +153,7 @@ return false; } if (!subscribe()) { + document.addEventListener('voiceroom:ready', subscribe); voice.addEventListener('click', function () { var tries = 0; var iv = setInterval(function () { diff --git a/src/apps/voice/static/apps/voice/voice-mesh.js b/src/apps/voice/static/apps/voice/voice-mesh.js index 0d4d325..4e3c0a0 100644 --- a/src/apps/voice/static/apps/voice/voice-mesh.js +++ b/src/apps/voice/static/apps/voice/voice-mesh.js @@ -283,6 +283,17 @@ return this.muted; }; + // Set the mute flag to an explicit value (vs. toggleMute's flip). Used to + // re-apply a PERSISTED mute when the mesh auto-rejoins after in-sea nav (the + // server-stored `User.voice_muted_at` says "still muted"). Safe before join: + // `this.muted` is honoured by `join`'s post-getUserMedia `_applyMute`. + VoiceRoom.prototype.setMuted = function (m) { + this.muted = !!m; + this._applyMute(); // no-op until localStream exists; join re-applies + this._notify(); + return this.muted; + }; + VoiceRoom.prototype._teardown = function () { Object.keys(this.peers).forEach(this._dropPeer.bind(this)); if (this.localStream) { @@ -305,4 +316,8 @@ // Singleton for the page — burger-btn.js drives join/toggle/leave. window.VoiceRoom = window.VoiceRoom || new VoiceRoom(); + // Announce readiness so late subscribers (voice-glow.js) bind reliably even + // when this script is lazy-loaded by an AUTO-REJOIN — there's no voice-btn + // click then to trigger their fallback poll (2026-05-30). + try { document.dispatchEvent(new Event('voiceroom:ready')); } catch (e) {} }()); diff --git a/src/apps/voice/tests/integrated/test_views.py b/src/apps/voice/tests/integrated/test_views.py new file mode 100644 index 0000000..7655fb4 --- /dev/null +++ b/src/apps/voice/tests/integrated/test_views.py @@ -0,0 +1,78 @@ +"""ITs for the voice mute-persistence endpoint (POST /voice/mute). + +Mute is stamped on `User.voice_muted_at` so a my-sea voice mute survives in-sea +navigation/refresh + anchors the 3-min auto-disconnect window (user-spec +2026-05-30). The endpoint just sets/clears the timestamp; the 3-min enforcement ++ rejoin-mute live client-side (burger-btn.js). +""" + +import json + +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from apps.lyric.models import User + + +class VoiceMuteViewTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="mute@test.io", username="muter") + self.client.force_login(self.user) + self.url = reverse("voice_mute") + + def _post(self, muted): + return self.client.post( + self.url, data=json.dumps({"muted": muted}), + content_type="application/json") + + def test_requires_login(self): + self.client.logout() + self.assertEqual(self._post(True).status_code, 302) + + def test_get_returns_405(self): + self.assertEqual(self.client.get(self.url).status_code, 405) + + def test_invalid_json_returns_400(self): + resp = self.client.post(self.url, data="not json", + content_type="application/json") + self.assertEqual(resp.status_code, 400) + + def test_mute_true_stamps_timestamp(self): + before = timezone.now() + resp = self._post(True) + self.assertEqual(resp.status_code, 200) + self.user.refresh_from_db() + self.assertIsNotNone(self.user.voice_muted_at) + self.assertGreaterEqual(self.user.voice_muted_at, before) + # Response echoes the ISO timestamp for the client's 3-min window. + self.assertEqual( + resp.json()["muted_at"], self.user.voice_muted_at.isoformat()) + + def test_mute_false_clears_timestamp(self): + self.user.voice_muted_at = timezone.now() + self.user.save(update_fields=["voice_muted_at"]) + resp = self._post(False) + self.assertEqual(resp.status_code, 200) + self.user.refresh_from_db() + self.assertIsNone(self.user.voice_muted_at) + self.assertIsNone(resp.json()["muted_at"]) + + def test_re_mute_restamps_now(self): + # Re-muting after an earlier mute restarts the 3-min window (a fresh + # `now`), the intended behaviour for an unmute→re-mute. + old = timezone.now() - timezone.timedelta(minutes=2) + self.user.voice_muted_at = old + self.user.save(update_fields=["voice_muted_at"]) + self._post(True) + self.user.refresh_from_db() + self.assertGreater(self.user.voice_muted_at, old) + + def test_missing_muted_key_treated_as_false(self): + self.user.voice_muted_at = timezone.now() + self.user.save(update_fields=["voice_muted_at"]) + resp = self.client.post(self.url, data=json.dumps({}), + content_type="application/json") + self.assertEqual(resp.status_code, 200) + self.user.refresh_from_db() + self.assertIsNone(self.user.voice_muted_at) diff --git a/src/apps/voice/urls.py b/src/apps/voice/urls.py new file mode 100644 index 0000000..1bf9d1b --- /dev/null +++ b/src/apps/voice/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from . import views + +# Mounted under `voice/` in core/urls.py → POST /voice/mute. Action endpoint +# (mutates state) → no trailing slash, per the project URL convention. +urlpatterns = [ + path("mute", views.voice_mute, name="voice_mute"), +] diff --git a/src/apps/voice/views.py b/src/apps/voice/views.py new file mode 100644 index 0000000..88986a6 --- /dev/null +++ b/src/apps/voice/views.py @@ -0,0 +1,43 @@ +"""HTTP views for the voice app. + +The WebRTC mesh itself is signalled over WebSocket (see consumers.py) + holds +no server state. The only HTTP surface is mute-state persistence: a my-sea voice +mute must survive in-sea navigation/refresh (which tear down + re-open the mesh), +so the mute is stamped on `User.voice_muted_at` and the client re-applies it on +the voice auto-rejoin. The timestamp also anchors the 3-min "muted too long → +auto-disconnect" window the client enforces. +""" + +import json + +from django.contrib.auth.decorators import login_required +from django.http import JsonResponse +from django.utils import timezone +from django.views.decorators.http import require_POST + + +@login_required(login_url="/") +@require_POST +def voice_mute(request): + """Persist the caller's voice mute state on `User.voice_muted_at`. + + Body: JSON `{"muted": }`. + - muted=True → stamp `now` (anchors the 3-min auto-disconnect window). + - muted=False → clear (on unmute, a fresh manual join, or the 3-min + timeout's leave). + + Idempotent — re-muting just re-stamps `now` (restarting the 3-min window), + which is the intended behaviour for an unmute→re-mute. Returns + `{ok, muted_at}` (ISO 8601 or null).""" + try: + payload = json.loads(request.body.decode("utf-8") or "{}") + except json.JSONDecodeError: + return JsonResponse({"error": "invalid_json"}, status=400) + muted = bool(payload.get("muted")) + request.user.voice_muted_at = timezone.now() if muted else None + request.user.save(update_fields=["voice_muted_at"]) + return JsonResponse({ + "ok": True, + "muted_at": (request.user.voice_muted_at.isoformat() + if request.user.voice_muted_at else None), + }) diff --git a/src/core/urls.py b/src/core/urls.py index 1a60572..4d2045c 100644 --- a/src/core/urls.py +++ b/src/core/urls.py @@ -15,6 +15,7 @@ urlpatterns = [ path('gameboard/', include('apps.gameboard.urls')), path('gameboard/', include('apps.epic.urls')), path('billboard/', include('apps.billboard.urls')), + path('voice/', include('apps.voice.urls')), path('ap/', include('apps.ap.urls')), path('.well-known/webfinger', ap_views.webfinger, name='webfinger'), # Stripe webhook lives at a stable root-level URL (no `dashboard/` prefix diff --git a/src/static/tests/BurgerSpec.js b/src/static/tests/BurgerSpec.js index a8b9cc1..008da75 100644 --- a/src/static/tests/BurgerSpec.js +++ b/src/static/tests/BurgerSpec.js @@ -303,3 +303,105 @@ describe("voice auto-rejoin", () => { expect(sessionStorage.getItem("mysea-voice-room")).toBeNull(); }); }); + +// Voice mute persistence + 3-min auto-disconnect (2026-05-30) — the mute is +// stored server-side (User.voice_muted_at, POSTed to /voice/mute) so it survives +// in-sea nav/refresh: the auto-rejoin re-applies it, and a mute held 3 min +// auto-disconnects. Clock + Date are faked so timer behaviour is deterministic. +describe("voice mute persistence", () => { + let vbtn, origVR, origFetch, joinSpy, setMutedSpy, leaveSpy, fetchSpy; + const BASE = new Date(2026, 0, 1, 12, 0, 0); + + beforeEach(() => { + jasmine.clock().install(); + jasmine.clock().mockDate(BASE); + try { sessionStorage.removeItem("mysea-voice-room"); } catch (e) {} + vbtn = document.createElement("button"); + vbtn.id = "id_voice_btn"; + vbtn.classList.add("active"); + vbtn.setAttribute("data-room-id", "mysea-abc"); + document.body.appendChild(vbtn); + origVR = window.VoiceRoom; + joinSpy = jasmine.createSpy("join").and.returnValue({ catch: () => {} }); + setMutedSpy = jasmine.createSpy("setMuted"); + leaveSpy = jasmine.createSpy("leave"); + window.VoiceRoom = { + join: joinSpy, setMuted: setMutedSpy, leave: leaveSpy, + toggleMute: () => true, + }; + origFetch = window.fetch; + fetchSpy = jasmine.createSpy("fetch").and.returnValue({ catch: () => {} }); + window.fetch = fetchSpy; + }); + + afterEach(() => { + window.VoiceRoom = origVR; + window.fetch = origFetch; + vbtn.remove(); + try { sessionStorage.removeItem("mysea-voice-room"); } catch (e) {} + jasmine.clock().uninstall(); + }); + + describe("muteRemainingMs()", () => { + it("returns the full 3-min window at t=0", () => { + expect(window._muteRemainingMs(1000, 1000)).toBe(180000); + }); + it("counts down as time elapses", () => { + expect(window._muteRemainingMs(1000, 61000)).toBe(120000); + }); + it("goes non-positive once 3 min elapse", () => { + expect(window._muteRemainingMs(1000, 201000)).toBeLessThanOrEqual(0); + }); + }); + + it("re-applies the persisted mute on auto-rejoin within the window", () => { + sessionStorage.setItem("mysea-voice-room", "mysea-abc"); + vbtn.setAttribute("data-voice-muted-at", + new Date(BASE.getTime() - 60000).toISOString()); + bindVoiceBtn(); + expect(setMutedSpy).toHaveBeenCalledWith(true); + expect(vbtn.classList.contains("muted")).toBe(true); + expect(joinSpy).toHaveBeenCalledWith("mysea-abc"); + }); + + it("does NOT re-mute on rejoin when no mute was persisted", () => { + sessionStorage.setItem("mysea-voice-room", "mysea-abc"); + bindVoiceBtn(); + expect(setMutedSpy).not.toHaveBeenCalled(); + expect(vbtn.classList.contains("muted")).toBe(false); + expect(joinSpy).toHaveBeenCalledWith("mysea-abc"); + }); + + it("auto-disconnects (no rejoin + clears) when the persisted mute already exceeded 3 min", () => { + sessionStorage.setItem("mysea-voice-room", "mysea-abc"); + vbtn.setAttribute("data-voice-muted-at", + new Date(BASE.getTime() - 200000).toISOString()); + bindVoiceBtn(); + expect(joinSpy).not.toHaveBeenCalled(); + expect(vbtn.classList.contains("muted")).toBe(false); + expect(fetchSpy).toHaveBeenCalled(); + const opts = fetchSpy.calls.mostRecent().args[1]; + expect(JSON.parse(opts.body).muted).toBe(false); + }); + + it("persists muted=true on a toggle, then auto-disconnects after 3 min", () => { + bindVoiceBtn(); + vbtn.click(); // manual join → in-call (clears any stale) + fetchSpy.calls.reset(); + vbtn.click(); // in-call → toggle mute + expect(vbtn.classList.contains("muted")).toBe(true); + expect(fetchSpy.calls.mostRecent().args[0]).toBe("/voice/mute"); + expect(JSON.parse(fetchSpy.calls.mostRecent().args[1].body).muted).toBe(true); + jasmine.clock().tick(180001); + expect(leaveSpy).toHaveBeenCalled(); + expect(vbtn.classList.contains("muted")).toBe(false); + expect(vbtn.dataset.inCall).toBeUndefined(); + }); + + it("clears stale mute (POST muted=false) on a fresh manual join", () => { + bindVoiceBtn(); + vbtn.click(); // fresh manual join + expect(fetchSpy).toHaveBeenCalled(); + expect(JSON.parse(fetchSpy.calls.mostRecent().args[1].body).muted).toBe(false); + }); +}); diff --git a/src/static/tests/VoiceGlowSpec.js b/src/static/tests/VoiceGlowSpec.js index d6e62b3..336642d 100644 --- a/src/static/tests/VoiceGlowSpec.js +++ b/src/static/tests/VoiceGlowSpec.js @@ -172,4 +172,34 @@ describe("VoiceGlow", () => { expect(eqCount).toBe(1); }); }); + + // Regression (2026-05-30): voice-glow keeps `voice.dataset.inCall` — the + // flag burger-btn.js reads to tell join-from-mute apart — in sync with the + // mesh truth SYMMETRICALLY. `setOnStateChange` pushes the current state on + // subscribe, so voice-glow often subscribes mid-join (inCall=false). The old + // rule only deleted on false, so that transient wiped the flag burger-btn had + // set + never restored it → the next click re-joined instead of muting. + describe("dataset.inCall sync (join-vs-mute flag)", () => { + it("SETS voice.dataset.inCall when the mesh reports in-call", () => { + vg = bindVoiceGlow(); + vg.setVoiceState({ inCall: true, peerCount: 0 }); + expect(voice.dataset.inCall).toBe("1"); + }); + + it("CLEARS voice.dataset.inCall when the mesh reports not-in-call", () => { + vg = bindVoiceGlow(); + voice.dataset.inCall = "1"; + vg.setVoiceState({ inCall: false, peerCount: 0 }); + expect(voice.dataset.inCall).toBeUndefined(); + }); + + it("RESTORES the flag once a mid-join false is followed by true", () => { + vg = bindVoiceGlow(); + voice.dataset.inCall = "1"; // burger-btn set it + vg.setVoiceState({ inCall: false, peerCount: 0 }); // subscribe mid-join + expect(voice.dataset.inCall).toBeUndefined(); + vg.setVoiceState({ inCall: true, peerCount: 0 }); // stream resolved + expect(voice.dataset.inCall).toBe("1"); // mute now works + }); + }); }); diff --git a/src/static_src/tests/BurgerSpec.js b/src/static_src/tests/BurgerSpec.js index a8b9cc1..008da75 100644 --- a/src/static_src/tests/BurgerSpec.js +++ b/src/static_src/tests/BurgerSpec.js @@ -303,3 +303,105 @@ describe("voice auto-rejoin", () => { expect(sessionStorage.getItem("mysea-voice-room")).toBeNull(); }); }); + +// Voice mute persistence + 3-min auto-disconnect (2026-05-30) — the mute is +// stored server-side (User.voice_muted_at, POSTed to /voice/mute) so it survives +// in-sea nav/refresh: the auto-rejoin re-applies it, and a mute held 3 min +// auto-disconnects. Clock + Date are faked so timer behaviour is deterministic. +describe("voice mute persistence", () => { + let vbtn, origVR, origFetch, joinSpy, setMutedSpy, leaveSpy, fetchSpy; + const BASE = new Date(2026, 0, 1, 12, 0, 0); + + beforeEach(() => { + jasmine.clock().install(); + jasmine.clock().mockDate(BASE); + try { sessionStorage.removeItem("mysea-voice-room"); } catch (e) {} + vbtn = document.createElement("button"); + vbtn.id = "id_voice_btn"; + vbtn.classList.add("active"); + vbtn.setAttribute("data-room-id", "mysea-abc"); + document.body.appendChild(vbtn); + origVR = window.VoiceRoom; + joinSpy = jasmine.createSpy("join").and.returnValue({ catch: () => {} }); + setMutedSpy = jasmine.createSpy("setMuted"); + leaveSpy = jasmine.createSpy("leave"); + window.VoiceRoom = { + join: joinSpy, setMuted: setMutedSpy, leave: leaveSpy, + toggleMute: () => true, + }; + origFetch = window.fetch; + fetchSpy = jasmine.createSpy("fetch").and.returnValue({ catch: () => {} }); + window.fetch = fetchSpy; + }); + + afterEach(() => { + window.VoiceRoom = origVR; + window.fetch = origFetch; + vbtn.remove(); + try { sessionStorage.removeItem("mysea-voice-room"); } catch (e) {} + jasmine.clock().uninstall(); + }); + + describe("muteRemainingMs()", () => { + it("returns the full 3-min window at t=0", () => { + expect(window._muteRemainingMs(1000, 1000)).toBe(180000); + }); + it("counts down as time elapses", () => { + expect(window._muteRemainingMs(1000, 61000)).toBe(120000); + }); + it("goes non-positive once 3 min elapse", () => { + expect(window._muteRemainingMs(1000, 201000)).toBeLessThanOrEqual(0); + }); + }); + + it("re-applies the persisted mute on auto-rejoin within the window", () => { + sessionStorage.setItem("mysea-voice-room", "mysea-abc"); + vbtn.setAttribute("data-voice-muted-at", + new Date(BASE.getTime() - 60000).toISOString()); + bindVoiceBtn(); + expect(setMutedSpy).toHaveBeenCalledWith(true); + expect(vbtn.classList.contains("muted")).toBe(true); + expect(joinSpy).toHaveBeenCalledWith("mysea-abc"); + }); + + it("does NOT re-mute on rejoin when no mute was persisted", () => { + sessionStorage.setItem("mysea-voice-room", "mysea-abc"); + bindVoiceBtn(); + expect(setMutedSpy).not.toHaveBeenCalled(); + expect(vbtn.classList.contains("muted")).toBe(false); + expect(joinSpy).toHaveBeenCalledWith("mysea-abc"); + }); + + it("auto-disconnects (no rejoin + clears) when the persisted mute already exceeded 3 min", () => { + sessionStorage.setItem("mysea-voice-room", "mysea-abc"); + vbtn.setAttribute("data-voice-muted-at", + new Date(BASE.getTime() - 200000).toISOString()); + bindVoiceBtn(); + expect(joinSpy).not.toHaveBeenCalled(); + expect(vbtn.classList.contains("muted")).toBe(false); + expect(fetchSpy).toHaveBeenCalled(); + const opts = fetchSpy.calls.mostRecent().args[1]; + expect(JSON.parse(opts.body).muted).toBe(false); + }); + + it("persists muted=true on a toggle, then auto-disconnects after 3 min", () => { + bindVoiceBtn(); + vbtn.click(); // manual join → in-call (clears any stale) + fetchSpy.calls.reset(); + vbtn.click(); // in-call → toggle mute + expect(vbtn.classList.contains("muted")).toBe(true); + expect(fetchSpy.calls.mostRecent().args[0]).toBe("/voice/mute"); + expect(JSON.parse(fetchSpy.calls.mostRecent().args[1].body).muted).toBe(true); + jasmine.clock().tick(180001); + expect(leaveSpy).toHaveBeenCalled(); + expect(vbtn.classList.contains("muted")).toBe(false); + expect(vbtn.dataset.inCall).toBeUndefined(); + }); + + it("clears stale mute (POST muted=false) on a fresh manual join", () => { + bindVoiceBtn(); + vbtn.click(); // fresh manual join + expect(fetchSpy).toHaveBeenCalled(); + expect(JSON.parse(fetchSpy.calls.mostRecent().args[1].body).muted).toBe(false); + }); +}); diff --git a/src/static_src/tests/VoiceGlowSpec.js b/src/static_src/tests/VoiceGlowSpec.js index d6e62b3..336642d 100644 --- a/src/static_src/tests/VoiceGlowSpec.js +++ b/src/static_src/tests/VoiceGlowSpec.js @@ -172,4 +172,34 @@ describe("VoiceGlow", () => { expect(eqCount).toBe(1); }); }); + + // Regression (2026-05-30): voice-glow keeps `voice.dataset.inCall` — the + // flag burger-btn.js reads to tell join-from-mute apart — in sync with the + // mesh truth SYMMETRICALLY. `setOnStateChange` pushes the current state on + // subscribe, so voice-glow often subscribes mid-join (inCall=false). The old + // rule only deleted on false, so that transient wiped the flag burger-btn had + // set + never restored it → the next click re-joined instead of muting. + describe("dataset.inCall sync (join-vs-mute flag)", () => { + it("SETS voice.dataset.inCall when the mesh reports in-call", () => { + vg = bindVoiceGlow(); + vg.setVoiceState({ inCall: true, peerCount: 0 }); + expect(voice.dataset.inCall).toBe("1"); + }); + + it("CLEARS voice.dataset.inCall when the mesh reports not-in-call", () => { + vg = bindVoiceGlow(); + voice.dataset.inCall = "1"; + vg.setVoiceState({ inCall: false, peerCount: 0 }); + expect(voice.dataset.inCall).toBeUndefined(); + }); + + it("RESTORES the flag once a mid-join false is followed by true", () => { + vg = bindVoiceGlow(); + voice.dataset.inCall = "1"; // burger-btn set it + vg.setVoiceState({ inCall: false, peerCount: 0 }); // subscribe mid-join + expect(voice.dataset.inCall).toBeUndefined(); + vg.setVoiceState({ inCall: true, peerCount: 0 }); // stream resolved + expect(voice.dataset.inCall).toBe("1"); // mute now works + }); + }); }); diff --git a/src/templates/apps/gameboard/_partials/_burger.html b/src/templates/apps/gameboard/_partials/_burger.html index c95030c..19d7973 100644 --- a/src/templates/apps/gameboard/_partials/_burger.html +++ b/src/templates/apps/gameboard/_partials/_burger.html @@ -16,7 +16,10 @@ {# (owner w. a present invitee, or the present invitee). data-room-id #} {# keys the WebRTC mesh group: mysea-. burger-btn.js binds the #} {# active-click → lazy-load voice-mesh.js → join / toggle-mute. #} -