my-sea voice: persist mute across in-sea nav/refresh + 3-min muted auto-disconnect; fix first-connect glow/mute race — TDD

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 <discodedisco@outlook.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-30 01:41:30 -04:00
parent de4dcd7979
commit 668105aeeb
15 changed files with 545 additions and 7 deletions

View File

@@ -140,6 +140,49 @@
// BYE + the NVM-disconnect guard call this on an explicit leave. // BYE + the NVM-disconnect guard call this on an explicit leave.
window.mySeaVoiceForget = _forgetVoiceRoom; 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 // Surface a join failure to the user instead of failing silently — most
// often the secure-context block (INSECURE_CONTEXT) when the dev server is // often the secure-context block (INSECURE_CONTEXT) when the dev server is
// reached over plain HTTP from a phone. Prefers the Brief banner; falls // reached over plain HTTP from a phone. Prefers the Brief banner; falls
@@ -165,6 +208,11 @@
var vbtn = document.getElementById('id_voice_btn'); var vbtn = document.getElementById('id_voice_btn');
if (!vbtn) return; if (!vbtn) return;
var roomId = vbtn.getAttribute('data-room-id'); 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). // VoiceRoom is lazy-loaded on first use (mesh injected on demand).
function withVoiceRoom(cb) { function withVoiceRoom(cb) {
@@ -175,6 +223,17 @@
document.head.appendChild(s); 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() { function startCall() {
if (!window.VoiceRoom || vbtn.dataset.inCall) return; if (!window.VoiceRoom || vbtn.dataset.inCall) return;
vbtn.dataset.inCall = '1'; vbtn.dataset.inCall = '1';
@@ -192,20 +251,42 @@
withVoiceRoom(function () { withVoiceRoom(function () {
if (!window.VoiceRoom) return; if (!window.VoiceRoom) return;
if (!vbtn.dataset.inCall) { 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(); startCall();
} else { } else {
var muted = window.VoiceRoom.toggleMute(); var muted = window.VoiceRoom.toggleMute();
vbtn.classList.toggle('muted', muted); 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 // 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 // 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') if (roomId && vbtn.classList.contains('active')
&& _rememberedVoiceRoom() === roomId) { && _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; window.bindVoiceBtn = bindVoiceBtn;

View File

@@ -362,6 +362,8 @@ def my_sea(request):
"user_has_sig": user_has_sig, "user_has_sig": user_has_sig,
"voice_active": voice_active, "voice_active": voice_active,
"voice_room_id": f"mysea-{request.user.id}", "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, "no_equipped_deck": no_equipped_deck,
"show_backup_intro_banner": ( "show_backup_intro_banner": (
user_has_sig and no_equipped_deck and active_draw is None 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 "", "owner_draw_id": owner_draw.id if owner_draw is not None else "",
"voice_active": invite.voice_active, "voice_active": invite.voice_active,
"voice_room_id": f"mysea-{owner.id}", "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": sig_card,
"significator_reversed": sig_reversed, "significator_reversed": sig_reversed,
"my_sea_slots": owner_slots, "my_sea_slots": owner_slots,

View File

@@ -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),
),
]

View File

@@ -194,6 +194,15 @@ class User(AbstractBaseUser):
# so each fresh spend re-opens the Brief surface for that cycle. # so each fresh spend re-opens the Brief surface for that cycle.
free_draw_brief_dismissed_at = models.DateTimeField(null=True, blank=True) free_draw_brief_dismissed_at = models.DateTimeField(null=True, blank=True)
paid_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_public_key = models.TextField(blank=True, default="")
ap_private_key = models.TextField(blank=True, default="") ap_private_key = models.TextField(blank=True, default="")

View File

@@ -117,8 +117,16 @@
// Keep the voice btn's own flags in sync with the mesh truth so a // 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 // rejoin starts clean (dataset.inCall gates join-vs-mute in
// burger-btn.js) and the render fallback can't get stuck "live". // 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); voice.classList.toggle('in-call', inCall);
if (!inCall) { delete voice.dataset.inCall; } if (inCall) { voice.dataset.inCall = '1'; }
else { delete voice.dataset.inCall; }
render(); render();
} }
@@ -130,9 +138,13 @@
observers.push(mo); observers.push(mo);
}); });
// Subscribe to the mesh. VoiceRoom may load lazily on the first voice // Subscribe to the mesh. VoiceRoom loads lazily on the first voice
// click (burger-btn.js injects voice-mesh.js), so retry briefly after // CLICK (manual join) OR on an AUTO-REJOIN (no click, burger-btn.js calls
// a click until the singleton appears. // 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() { function subscribe() {
if (window.VoiceRoom && window.VoiceRoom.setOnStateChange) { if (window.VoiceRoom && window.VoiceRoom.setOnStateChange) {
window.VoiceRoom.setOnStateChange(setVoiceState); window.VoiceRoom.setOnStateChange(setVoiceState);
@@ -141,6 +153,7 @@
return false; return false;
} }
if (!subscribe()) { if (!subscribe()) {
document.addEventListener('voiceroom:ready', subscribe);
voice.addEventListener('click', function () { voice.addEventListener('click', function () {
var tries = 0; var tries = 0;
var iv = setInterval(function () { var iv = setInterval(function () {

View File

@@ -283,6 +283,17 @@
return this.muted; 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 () { VoiceRoom.prototype._teardown = function () {
Object.keys(this.peers).forEach(this._dropPeer.bind(this)); Object.keys(this.peers).forEach(this._dropPeer.bind(this));
if (this.localStream) { if (this.localStream) {
@@ -305,4 +316,8 @@
// Singleton for the page — burger-btn.js drives join/toggle/leave. // Singleton for the page — burger-btn.js drives join/toggle/leave.
window.VoiceRoom = window.VoiceRoom || new VoiceRoom(); 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) {}
}()); }());

View File

@@ -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)

9
src/apps/voice/urls.py Normal file
View File

@@ -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"),
]

43
src/apps/voice/views.py Normal file
View File

@@ -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": <bool>}`.
- 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),
})

View File

@@ -15,6 +15,7 @@ urlpatterns = [
path('gameboard/', include('apps.gameboard.urls')), path('gameboard/', include('apps.gameboard.urls')),
path('gameboard/', include('apps.epic.urls')), path('gameboard/', include('apps.epic.urls')),
path('billboard/', include('apps.billboard.urls')), path('billboard/', include('apps.billboard.urls')),
path('voice/', include('apps.voice.urls')),
path('ap/', include('apps.ap.urls')), path('ap/', include('apps.ap.urls')),
path('.well-known/webfinger', ap_views.webfinger, name='webfinger'), path('.well-known/webfinger', ap_views.webfinger, name='webfinger'),
# Stripe webhook lives at a stable root-level URL (no `dashboard/` prefix # Stripe webhook lives at a stable root-level URL (no `dashboard/` prefix

View File

@@ -303,3 +303,105 @@ describe("voice auto-rejoin", () => {
expect(sessionStorage.getItem("mysea-voice-room")).toBeNull(); 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);
});
});

View File

@@ -172,4 +172,34 @@ describe("VoiceGlow", () => {
expect(eqCount).toBe(1); 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
});
});
}); });

View File

@@ -303,3 +303,105 @@ describe("voice auto-rejoin", () => {
expect(sessionStorage.getItem("mysea-voice-room")).toBeNull(); 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);
});
});

View File

@@ -172,4 +172,34 @@ describe("VoiceGlow", () => {
expect(eqCount).toBe(1); 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
});
});
}); });

View File

@@ -16,7 +16,10 @@
{# (owner w. a present invitee, or the present invitee). data-room-id #} {# (owner w. a present invitee, or the present invitee). data-room-id #}
{# keys the WebRTC mesh group: mysea-<owner_id>. burger-btn.js binds the #} {# keys the WebRTC mesh group: mysea-<owner_id>. burger-btn.js binds the #}
{# active-click → lazy-load voice-mesh.js → join / toggle-mute. #} {# active-click → lazy-load voice-mesh.js → join / toggle-mute. #}
<button id="id_voice_btn" type="button" class="burger-fan-btn{% if voice_active %} active{% endif %}" aria-label="Voice"{% if voice_room_id %} data-room-id="{{ voice_room_id }}"{% endif %}> {# data-voice-muted-at carries the persisted mute timestamp so the voice #}
{# auto-rejoin starts MUTED (mute survives in-sea nav/refresh) + anchors the #}
{# 3-min muted→auto-disconnect window. Empty/absent = not muted. #}
<button id="id_voice_btn" type="button" class="burger-fan-btn{% if voice_active %} active{% endif %}" aria-label="Voice"{% if voice_room_id %} data-room-id="{{ voice_room_id }}"{% endif %}{% if voice_muted_at %} data-voice-muted-at="{{ voice_muted_at }}"{% endif %}>
<i class="fa-solid fa-headset burger-fan-icon--on"></i> <i class="fa-solid fa-headset burger-fan-icon--on"></i>
<i class="fa-solid fa-ban burger-fan-icon--off"></i> <i class="fa-solid fa-ban burger-fan-icon--off"></i>
</button> </button>