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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
18
src/apps/lyric/migrations/0016_user_voice_muted_at.py
Normal file
18
src/apps/lyric/migrations/0016_user_voice_muted_at.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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="")
|
||||
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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) {}
|
||||
}());
|
||||
|
||||
78
src/apps/voice/tests/integrated/test_views.py
Normal file
78
src/apps/voice/tests/integrated/test_views.py
Normal 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
9
src/apps/voice/urls.py
Normal 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
43
src/apps/voice/views.py
Normal 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),
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,10 @@
|
||||
{# (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 #}
|
||||
{# 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-ban burger-fan-icon--off"></i>
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user