my-sea voice: guard before NVM disconnects voice + harden mute (honor pre-stream mute) — TDD
Phase 4 of the my-sea voice batch (user-spec 2026-05-29). ── Voice-disconnect guard (item 6, the achievable slice) ── Every my-sea navigation is a full page reload, which tears the WebRTC mesh down — so voice can't literally persist across a reload without an SPA-style no-reload nav (a separate, larger refactor, deferred). What ships now: the gear-menu NVM warns before dropping the call. - _my_sea_gear.html: the NVM routes through an inline, dependency-free `mySeaGuardedNav(event, url)`. When voice is LIVE (VoiceRoom.localStream) AND the target leaves my_sea (`url` has no `/my-sea`), it pops the shared guard portal — "Leave the Sea? You'll disconnect from voice." — before navigating; confirm proceeds, dismiss stays. NVMs that stay within my_sea, or any nav with no live mic, go straight through. Covers all NVMs (owner my_sea, the gatekeeper, the spectator) since they all include this partial. ── Bug B: desktop mute (mute robustness) ── - voice-mesh.js: extracted `_applyMute()` and call it from join (post- getUserMedia) as well as toggleMute. On desktop the first join pops a mic- permission prompt; a mute toggled while that prompt is open used to be lost because the stream didn't exist yet — re-applying after it resolves makes the mute stick. Teardown resets `muted=false` so a rejoin starts clean. - voice-glow.js: setVoiceState now syncs the voice btn's own flags (.in-call / .muted / dataset.inCall) to the mesh truth, so a rejoin starts clean and the glow's DOM fallback can't get stuck "live". Tests: +5 VoiceMeshSpec mute specs (incl. the pre-stream-mute Bug-B case); +2 NVM-guard FTs (warn when live / pass through when not); 3 gear-NVM ITs updated for the mySeaGuardedNav markup. 286 gameboard ITs + 433 Jasmine specs green. Note: true cross-view voice persistence (no-reload within-my_sea nav) + the desktop-mute live confirmation remain to verify on staging w. Redis up. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1040,7 +1040,8 @@ class MySeaViewTest(TestCase):
|
|||||||
def test_gear_nvm_navs_to_gameboard_on_landing_phase(self):
|
def test_gear_nvm_navs_to_gameboard_on_landing_phase(self):
|
||||||
"""Landing-phase NVM = "back out of my-sea entirely" → /gameboard/."""
|
"""Landing-phase NVM = "back out of my-sea entirely" → /gameboard/."""
|
||||||
response = self.client.get(reverse("my_sea"))
|
response = self.client.get(reverse("my_sea"))
|
||||||
self.assertIn("location.href='/gameboard/'", response.content.decode())
|
self.assertIn(
|
||||||
|
"mySeaGuardedNav(event, '/gameboard/')", response.content.decode())
|
||||||
|
|
||||||
def test_sea_btn_is_inactive_on_landing_phase(self):
|
def test_sea_btn_is_inactive_on_landing_phase(self):
|
||||||
"""Sea sub-btn flips ACTIVE only when the user is in the picker
|
"""Sea sub-btn flips ACTIVE only when the user is in the picker
|
||||||
@@ -1120,7 +1121,7 @@ class MySeaViewTest(TestCase):
|
|||||||
)
|
)
|
||||||
response = self.client.get(reverse("my_sea"))
|
response = self.client.get(reverse("my_sea"))
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"location.href='/gameboard/my-sea/?phase=landing'",
|
"mySeaGuardedNav(event, '/gameboard/my-sea/?phase=landing')",
|
||||||
response.content.decode(),
|
response.content.decode(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2473,8 +2474,10 @@ class MySeaGateViewTest(TestCase):
|
|||||||
2026-05-26 so the gatekeeper exit lands one step back."""
|
2026-05-26 so the gatekeeper exit lands one step back."""
|
||||||
response = self.client.get(reverse("my_sea_gate"))
|
response = self.client.get(reverse("my_sea_gate"))
|
||||||
body = response.content.decode()
|
body = response.content.decode()
|
||||||
self.assertIn("location.href='/gameboard/my-sea/'", body)
|
# NVM routes through mySeaGuardedNav (voice-disconnect guard, 2026-05-29)
|
||||||
self.assertNotIn("location.href='/gameboard/'", body)
|
# but still carries the same target URL.
|
||||||
|
self.assertIn("mySeaGuardedNav(event, '/gameboard/my-sea/')", body)
|
||||||
|
self.assertNotIn("mySeaGuardedNav(event, '/gameboard/')", body)
|
||||||
|
|
||||||
def test_gate_view_renders_burger_btn_and_fan(self):
|
def test_gate_view_renders_burger_btn_and_fan(self):
|
||||||
response = self.client.get(reverse("my_sea_gate"))
|
response = self.client.get(reverse("my_sea_gate"))
|
||||||
|
|||||||
@@ -75,6 +75,12 @@
|
|||||||
st = st || {};
|
st = st || {};
|
||||||
inCall = !!st.inCall;
|
inCall = !!st.inCall;
|
||||||
peerCount = st.peerCount || 0;
|
peerCount = st.peerCount || 0;
|
||||||
|
// 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".
|
||||||
|
voice.classList.toggle('in-call', inCall);
|
||||||
|
voice.classList.toggle('muted', inCall && !!st.muted);
|
||||||
|
if (!inCall) { delete voice.dataset.inCall; }
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -180,6 +180,7 @@
|
|||||||
});
|
});
|
||||||
}).then(function (stream) {
|
}).then(function (stream) {
|
||||||
self.localStream = stream;
|
self.localStream = stream;
|
||||||
|
self._applyMute(); // honor a mute toggled during the permission prompt
|
||||||
var scheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
var scheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
self.ws = new WebSocket(scheme + '://' + window.location.host + '/ws/voice/' + roomId + '/');
|
self.ws = new WebSocket(scheme + '://' + window.location.host + '/ws/voice/' + roomId + '/');
|
||||||
self.ws.onmessage = function (e) { self._onMessage(JSON.parse(e.data)); };
|
self.ws.onmessage = function (e) { self._onMessage(JSON.parse(e.data)); };
|
||||||
@@ -189,13 +190,23 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Push the current `muted` flag onto the live audio tracks. Extracted so
|
||||||
|
// BOTH toggleMute AND join (post-getUserMedia) call it: on desktop the
|
||||||
|
// first join pops a mic-permission prompt, and a mute toggled while that
|
||||||
|
// prompt is open used to be lost because the stream didn't exist yet —
|
||||||
|
// the fresh tracks then came up enabled regardless. Re-applying after the
|
||||||
|
// stream resolves makes the mute stick (Bug B, 2026-05-29).
|
||||||
|
VoiceRoom.prototype._applyMute = function () {
|
||||||
|
if (!this.localStream) return;
|
||||||
|
var enabled = !this.muted;
|
||||||
|
this.localStream.getAudioTracks().forEach(function (t) {
|
||||||
|
t.enabled = enabled;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
VoiceRoom.prototype.toggleMute = function () {
|
VoiceRoom.prototype.toggleMute = function () {
|
||||||
this.muted = !this.muted;
|
this.muted = !this.muted;
|
||||||
if (this.localStream) {
|
this._applyMute();
|
||||||
this.localStream.getAudioTracks().forEach(function (t) {
|
|
||||||
t.enabled = !this.muted;
|
|
||||||
}, this);
|
|
||||||
}
|
|
||||||
this._notify();
|
this._notify();
|
||||||
return this.muted;
|
return this.muted;
|
||||||
};
|
};
|
||||||
@@ -206,7 +217,8 @@
|
|||||||
this.localStream.getTracks().forEach(function (t) { t.stop(); });
|
this.localStream.getTracks().forEach(function (t) { t.stop(); });
|
||||||
this.localStream = null;
|
this.localStream = null;
|
||||||
}
|
}
|
||||||
this._notify(); // call ended — back to not-in-call
|
this.muted = false; // a rejoin starts unmuted
|
||||||
|
this._notify(); // call ended — back to not-in-call
|
||||||
};
|
};
|
||||||
|
|
||||||
VoiceRoom.prototype.leave = function () {
|
VoiceRoom.prototype.leave = function () {
|
||||||
|
|||||||
@@ -2143,3 +2143,56 @@ class MySeaVisitSpectatorCrossTest(FunctionalTest):
|
|||||||
self.assertTrue(self.browser.find_elements(By.ID, "id_sea_stage"))
|
self.assertTrue(self.browser.find_elements(By.ID, "id_sea_stage"))
|
||||||
self.assertFalse(self.browser.find_elements(
|
self.assertFalse(self.browser.find_elements(
|
||||||
By.CSS_SELECTOR, ".my-sea-scroll"))
|
By.CSS_SELECTOR, ".my-sea-scroll"))
|
||||||
|
|
||||||
|
|
||||||
|
class MySeaVisitVoiceGuardTest(FunctionalTest):
|
||||||
|
"""Phase 4 (2026-05-29) — when voice is LIVE, the gear-menu NVM that leaves
|
||||||
|
my_sea warns via the shared guard portal before disconnecting; with no live
|
||||||
|
mic it navigates straight through. (Mesh stubbed — no real WebRTC.)"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
_seed_earthman_sig_pile()
|
||||||
|
_seed_gameboard_applets()
|
||||||
|
self.owner = User.objects.create(email="owner@test.io", username="owner")
|
||||||
|
_assign_sig(self.owner)
|
||||||
|
self.visitor_email = "viz@test.io"
|
||||||
|
self.visitor = User.objects.create(
|
||||||
|
email=self.visitor_email, username="viz")
|
||||||
|
from apps.gameboard.models import SeaInvite
|
||||||
|
SeaInvite.objects.create(
|
||||||
|
owner=self.owner, invitee=self.visitor,
|
||||||
|
invitee_email=self.visitor_email,
|
||||||
|
status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
|
||||||
|
token_deposited_at=timezone.now(),
|
||||||
|
voice_until=timezone.now() + timedelta(hours=24),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _open_nvm(self):
|
||||||
|
gear = self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, ".gear-btn[data-menu-target='id_my_sea_menu']"))
|
||||||
|
self.browser.execute_script("arguments[0].click()", gear)
|
||||||
|
return self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, "#id_my_sea_menu .btn-cancel"))
|
||||||
|
|
||||||
|
def test_nvm_warns_before_disconnecting_when_voice_live(self):
|
||||||
|
self.create_pre_authenticated_session(self.visitor_email)
|
||||||
|
self.browser.get(
|
||||||
|
self.live_server_url + f"/gameboard/my-sea/visit/{self.owner.id}/")
|
||||||
|
self.browser.execute_script("window.VoiceRoom = {localStream: {}};")
|
||||||
|
self._open_nvm().click()
|
||||||
|
portal = self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, "#id_guard_portal.active"))
|
||||||
|
self.assertIn(
|
||||||
|
"disconnect from voice",
|
||||||
|
portal.find_element(By.CSS_SELECTOR, ".guard-message").text.lower())
|
||||||
|
self.assertIn(f"/my-sea/visit/{self.owner.id}/", self.browser.current_url)
|
||||||
|
|
||||||
|
def test_nvm_navigates_directly_when_no_live_voice(self):
|
||||||
|
self.create_pre_authenticated_session(self.visitor_email)
|
||||||
|
self.browser.get(
|
||||||
|
self.live_server_url + f"/gameboard/my-sea/visit/{self.owner.id}/")
|
||||||
|
self.browser.execute_script("window.VoiceRoom = {localStream: null};")
|
||||||
|
self._open_nvm().click()
|
||||||
|
self.wait_for(lambda: self.assertNotIn(
|
||||||
|
"/my-sea/visit/", self.browser.current_url))
|
||||||
|
|||||||
@@ -40,3 +40,56 @@ describe('voice-mesh tuneOpus', function () {
|
|||||||
expect(window.tuneOpus('')).toBe('');
|
expect(window.tuneOpus('')).toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mute path — the one deterministic slice of the mesh we can unit-test. The
|
||||||
|
// _applyMute extraction fixes Bug B (2026-05-29): a mute toggled while the
|
||||||
|
// desktop getUserMedia permission prompt is open must stick once the stream
|
||||||
|
// resolves (join re-applies it post-stream).
|
||||||
|
describe('voice-mesh mute', function () {
|
||||||
|
var vr, trackA, trackB;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
vr = window.VoiceRoom;
|
||||||
|
trackA = { enabled: true };
|
||||||
|
trackB = { enabled: true };
|
||||||
|
vr.localStream = { getAudioTracks: function () { return [trackA, trackB]; } };
|
||||||
|
vr.muted = false;
|
||||||
|
vr._onState = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
vr.localStream = null;
|
||||||
|
vr.muted = false;
|
||||||
|
vr._onState = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggleMute disables the audio tracks + returns muted', function () {
|
||||||
|
expect(vr.toggleMute()).toBe(true);
|
||||||
|
expect(trackA.enabled).toBe(false);
|
||||||
|
expect(trackB.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggleMute twice re-enables the tracks', function () {
|
||||||
|
vr.toggleMute();
|
||||||
|
expect(vr.toggleMute()).toBe(false);
|
||||||
|
expect(trackA.enabled).toBe(true);
|
||||||
|
expect(trackB.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('_applyMute honors a mute set before the stream existed (Bug B)', function () {
|
||||||
|
// muted toggled while the permission prompt was open (no stream yet)…
|
||||||
|
vr.localStream = null;
|
||||||
|
vr.muted = true;
|
||||||
|
// …then the stream arrives and join calls _applyMute.
|
||||||
|
vr.localStream = { getAudioTracks: function () { return [trackA]; } };
|
||||||
|
vr._applyMute();
|
||||||
|
expect(trackA.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notifies subscribers with the muted flag on toggle', function () {
|
||||||
|
var seen = null;
|
||||||
|
vr.setOnStateChange(function (st) { seen = st; });
|
||||||
|
vr.toggleMute();
|
||||||
|
expect(seen.muted).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -40,3 +40,56 @@ describe('voice-mesh tuneOpus', function () {
|
|||||||
expect(window.tuneOpus('')).toBe('');
|
expect(window.tuneOpus('')).toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mute path — the one deterministic slice of the mesh we can unit-test. The
|
||||||
|
// _applyMute extraction fixes Bug B (2026-05-29): a mute toggled while the
|
||||||
|
// desktop getUserMedia permission prompt is open must stick once the stream
|
||||||
|
// resolves (join re-applies it post-stream).
|
||||||
|
describe('voice-mesh mute', function () {
|
||||||
|
var vr, trackA, trackB;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
vr = window.VoiceRoom;
|
||||||
|
trackA = { enabled: true };
|
||||||
|
trackB = { enabled: true };
|
||||||
|
vr.localStream = { getAudioTracks: function () { return [trackA, trackB]; } };
|
||||||
|
vr.muted = false;
|
||||||
|
vr._onState = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
vr.localStream = null;
|
||||||
|
vr.muted = false;
|
||||||
|
vr._onState = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggleMute disables the audio tracks + returns muted', function () {
|
||||||
|
expect(vr.toggleMute()).toBe(true);
|
||||||
|
expect(trackA.enabled).toBe(false);
|
||||||
|
expect(trackB.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggleMute twice re-enables the tracks', function () {
|
||||||
|
vr.toggleMute();
|
||||||
|
expect(vr.toggleMute()).toBe(false);
|
||||||
|
expect(trackA.enabled).toBe(true);
|
||||||
|
expect(trackB.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('_applyMute honors a mute set before the stream existed (Bug B)', function () {
|
||||||
|
// muted toggled while the permission prompt was open (no stream yet)…
|
||||||
|
vr.localStream = null;
|
||||||
|
vr.muted = true;
|
||||||
|
// …then the stream arrives and join calls _applyMute.
|
||||||
|
vr.localStream = { getAudioTracks: function () { return [trackA]; } };
|
||||||
|
vr._applyMute();
|
||||||
|
expect(trackA.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notifies subscribers with the muted flag on toggle', function () {
|
||||||
|
var seen = null;
|
||||||
|
vr.setOnStateChange(function (st) { seen = st; });
|
||||||
|
vr.toggleMute();
|
||||||
|
expect(seen.muted).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,8 +9,33 @@
|
|||||||
{# `<button>` + reads correctly as sans-serif). #}
|
{# `<button>` + reads correctly as sans-serif). #}
|
||||||
{# See [[feedback-btn-vs-anchor-font-family]]. #}
|
{# See [[feedback-btn-vs-anchor-font-family]]. #}
|
||||||
{% if not nvm_url %}{% url 'gameboard' as nvm_url %}{% endif %}
|
{% if not nvm_url %}{% url 'gameboard' as nvm_url %}{% endif %}
|
||||||
|
{# mySeaGuardedNav — defined inline (dependency-free; this partial loads on #}
|
||||||
|
{# pages w/o the voice JS). When voice is LIVE and the NVM target leaves #}
|
||||||
|
{# my_sea entirely (url has no `/my-sea`), warn via the shared guard portal #}
|
||||||
|
{# before navigating, since the page reload tears the mesh down for this #}
|
||||||
|
{# party. NVMs that stay within my_sea, or any nav with no live mic, go #}
|
||||||
|
{# straight through. (Voice-disconnect guard — user-spec 2026-05-29.) #}
|
||||||
|
<script>
|
||||||
|
window.mySeaGuardedNav = window.mySeaGuardedNav || function (e, url) {
|
||||||
|
var live = window.VoiceRoom && window.VoiceRoom.localStream;
|
||||||
|
var leavesMySea = url.indexOf('/my-sea') === -1;
|
||||||
|
if (live && leavesMySea && typeof window.showGuard === 'function') {
|
||||||
|
if (e && e.preventDefault) e.preventDefault();
|
||||||
|
var anchor = (e && (e.currentTarget || e.target)) || document.body;
|
||||||
|
window.showGuard(
|
||||||
|
anchor,
|
||||||
|
'Leave the Sea?<br>You’ll disconnect from voice.',
|
||||||
|
function () { window.location.href = url; }, // confirm
|
||||||
|
null, // dismiss = stay
|
||||||
|
{ yesLabel: 'NVM' }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = url;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
<div id="id_my_sea_menu" style="display:none">
|
<div id="id_my_sea_menu" style="display:none">
|
||||||
<button type="button" class="btn btn-cancel" onclick="location.href='{{ nvm_url }}'">NVM</button>
|
<button type="button" class="btn btn-cancel" onclick="mySeaGuardedNav(event, '{{ nvm_url }}')">NVM</button>
|
||||||
{# Spectator-only BYE (Phase B) — drops presence (status=LEFT, frees seat #}
|
{# Spectator-only BYE (Phase B) — drops presence (status=LEFT, frees seat #}
|
||||||
{# 2C, kills voice). Rendered below NVM only when the caller passes a #}
|
{# 2C, kills voice). Rendered below NVM only when the caller passes a #}
|
||||||
{# `leave_url`; the owner's pages never do, so their menu is unchanged. #}
|
{# `leave_url`; the owner's pages never do, so their menu is unchanged. #}
|
||||||
|
|||||||
Reference in New Issue
Block a user