diff --git a/public/app.js b/public/app.js index 42d1d0e..547faea 100644 --- a/public/app.js +++ b/public/app.js @@ -1,6 +1,6 @@ /* gibbz voice POC — WebRTC mesh voice + screen share + spatial audio. - POC shortcuts: no auth, no reconnection, no text chat, single audio context. */ -'use strict'; + POC shortcuts: no auth (voice), no E2E crypto (matrix text), single audio context. */ +import * as matrixcs from 'https://cdn.jsdelivr.net/npm/matrix-js-sdk@42.4.0/lib/browser-index.min.js'; // --------------------------------------------------------------------------- // state @@ -28,6 +28,7 @@ const S = { sharing: false, connecting: false, pingTimer: null, + reconnect: { room: null, attempt: 0, timer: null, countdownTimer: null }, }; window.__dbg = S; // POC debug handle @@ -37,6 +38,8 @@ const els = { spatial: $('#spatial'), status: $('#voice-status'), statusDot: $('#status-dot'), statusRow: $('#status-row'), nick: $('#nick'), meAvatar: $('#me-avatar'), mic: $('#btn-mic'), deafen: $('#btn-deafen'), + reconnectBanner: $('#reconnect-banner'), reconnectText: $('#reconnect-text'), + mxPanel: $('#mx-panel'), mxMessages: $('#mx-messages'), mxInput: $('#mx-input'), }; // --------------------------------------------------------------------------- @@ -153,6 +156,77 @@ function setStatus(text, state = '') { } } +// --------------------------------------------------------------------------- +// reconnection (server restarts / redeploys, dropped signaling, etc.) +// --------------------------------------------------------------------------- +const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 15000]; // caps at 15s + +function showReconnectBanner(text) { + els.reconnectText.textContent = text; + els.reconnectBanner.classList.remove('hidden'); +} +function hideReconnectBanner() { + els.reconnectBanner.classList.add('hidden'); +} + +function beginReconnect(room) { + cancelReconnect(); + S.reconnect.room = room; + attemptReconnect(); +} + +function cancelReconnect() { + const r = S.reconnect; + clearTimeout(r.timer); + clearInterval(r.countdownTimer); + r.timer = r.countdownTimer = null; + r.room = null; + r.attempt = 0; + hideReconnectBanner(); +} + +function attemptReconnect() { + const r = S.reconnect; + if (!r.room) return; + const delay = RECONNECT_DELAYS[Math.min(r.attempt, RECONNECT_DELAYS.length - 1)]; + r.attempt++; + let remaining = delay; + const label = () => `Connection lost — reconnecting in ${Math.ceil(remaining / 1000)}s (attempt ${r.attempt})`; + showReconnectBanner(label()); + clearInterval(r.countdownTimer); + r.countdownTimer = setInterval(() => { + remaining = Math.max(0, remaining - 1000); + if (remaining > 0) showReconnectBanner(label()); + }, 1000); + + clearTimeout(r.timer); + r.timer = setTimeout(() => doReconnectAttempt(), delay); +} + +async function doReconnectAttempt() { + const r = S.reconnect; + if (!r.room) return; + clearInterval(r.countdownTimer); + const room = r.room; + showReconnectBanner('Reconnecting…'); + try { + await joinVoice(room); + r.room = null; + r.attempt = 0; + hideReconnectBanner(); + toast('Reconnected', 'ok'); + } catch { + attemptReconnect(); + } +} + +$('#reconnect-retry').onclick = () => { + clearTimeout(S.reconnect.timer); + clearInterval(S.reconnect.countdownTimer); + doReconnectAttempt(); +}; +$('#reconnect-dismiss').onclick = () => cancelReconnect(); + // --------------------------------------------------------------------------- // signaling // --------------------------------------------------------------------------- @@ -171,8 +245,9 @@ function openSignaling(room) { ws.onclose = () => { if (S.pingTimer) { clearInterval(S.pingTimer); S.pingTimer = null; } if (S.ws === ws && S.room) { - toast('Signaling connection lost', 'error'); + const room = S.room; leaveVoice('disconnected'); + beginReconnect(room); } }; ws.onopen = () => { @@ -856,6 +931,212 @@ els.spatial.addEventListener('pointerup', () => { requestAnimationFrame(drawSpatial); // rAF loop stopped while dragging }); +// --------------------------------------------------------------------------- +// Matrix text chat (experimental) — native OAuth2/OIDC auth against the +// homeserver's own authorization server (MSC2965/MSC3861), so there's no +// separate Matrix password: login redirects to matrix.k6n.net itself. +// No E2E crypto — rooms are plaintext, matching this app's POC posture. +// --------------------------------------------------------------------------- +const MX_BASE_URL = 'https://matrix.k6n.net'; +const MX_SERVER_NAME = 'matrix.k6n.net'; +const LS_MX_CLIENT_ID = 'voicepoc.mxClientId'; +const LS_MX_SESSION = 'voicepoc.mxSession'; +const SS_MX_PENDING = 'voicepoc.mxOidcPending'; + +const Mx = { client: null, roomId: null }; + +function mxRedirectUri() { + return location.origin + location.pathname; +} + +async function mxRegisterClientId(authMetadata) { + const cached = localStorage.getItem(LS_MX_CLIENT_ID); + if (cached) return cached; + const clientId = await matrixcs.OAuth2.registerClient(authMetadata, { + client_name: 'gibbz voice', + client_uri: mxRedirectUri(), + redirect_uris: [mxRedirectUri()], + application_type: 'web', + }); + localStorage.setItem(LS_MX_CLIENT_ID, clientId); + return clientId; +} + +async function mxStartLogin() { + try { + const bare = matrixcs.createClient({ baseUrl: MX_BASE_URL }); + const authMetadata = await bare.getAuthMetadata(); + const clientId = await mxRegisterClientId(authMetadata); + const oauth = new matrixcs.OAuth2(authMetadata, { clientId, redirectUri: mxRedirectUri() }); + const state = crypto.randomUUID(); + sessionStorage.setItem(SS_MX_PENDING, JSON.stringify({ + state, + authMetadata, + clientId, + deviceId: oauth.context.deviceId, + codeVerifier: oauth.context.codeVerifier, + redirectUri: oauth.context.redirectUri, + })); + const url = await oauth.generateAuthorizationCodeGrantUrl(state, 'query'); + window.location.href = url; + } catch (err) { + console.error('matrix login failed to start', err); + toast(`Matrix login failed: ${err.message || err}`, 'error'); + } +} + +async function mxCompleteLogin(code, state) { + const raw = sessionStorage.getItem(SS_MX_PENDING); + sessionStorage.removeItem(SS_MX_PENDING); + if (!raw) { toast('Matrix login callback received but no pending login found', 'error'); return; } + const pending = JSON.parse(raw); + if (pending.state !== state) { toast('Matrix login state mismatch — try again', 'error'); return; } + try { + const oauth = new matrixcs.OAuth2(pending.authMetadata, { + clientId: pending.clientId, + deviceId: pending.deviceId, + codeVerifier: pending.codeVerifier, + redirectUri: pending.redirectUri, + }); + const tokens = await oauth.completeAuthorizationCodeGrant(code); + await mxAdoptTokens(tokens); + toast('Matrix connected', 'ok'); + mxShowPanel(); + } catch (err) { + console.error('matrix login completion failed', err); + toast(`Matrix login failed: ${err.message || err}`, 'error'); + } +} + +async function mxAdoptTokens(tokens) { + const probe = matrixcs.createClient({ baseUrl: MX_BASE_URL, accessToken: tokens.access_token }); + const who = await probe.whoami(); + const session = { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || null, + userId: who.user_id, + deviceId: who.device_id, + }; + localStorage.setItem(LS_MX_SESSION, JSON.stringify(session)); + await mxBoot(session); +} + +async function mxBoot(session) { + Mx.client = matrixcs.createClient({ + baseUrl: MX_BASE_URL, + accessToken: session.accessToken, + refreshToken: session.refreshToken || undefined, + userId: session.userId, + deviceId: session.deviceId, + }); + Mx.client.on(matrixcs.ClientEvent.Sync, (state) => { + if (state === 'PREPARED') mxOnReady(); + }); + Mx.client.on(matrixcs.RoomEvent.Timeline, (event, room) => { + if (room && room.roomId === Mx.roomId && event.getType() === 'm.room.message') mxRenderTimeline(); + }); + await Mx.client.startClient({ initialSyncLimit: 30 }); +} + +async function mxOnReady() { + try { + Mx.roomId = await mxJoinOrCreateRoom('gibbz-general', 'gibbz general'); + mxRenderTimeline(); + } catch (err) { + console.error('matrix room join/create failed', err); + toast(`Could not open #general text channel: ${err.message || err}`, 'error'); + } +} + +async function mxJoinOrCreateRoom(aliasLocalPart, name) { + const alias = `#${aliasLocalPart}:${MX_SERVER_NAME}`; + try { + const res = await Mx.client.joinRoom(alias); + return res.roomId; + } catch (err) { + if (err.errcode !== 'M_NOT_FOUND') throw err; + const created = await Mx.client.createRoom({ + room_alias_name: aliasLocalPart, + name, + visibility: 'public', + preset: 'public_chat', + }); + return created.room_id; + } +} + +function mxRenderTimeline() { + if (!Mx.client || !Mx.roomId || !els.mxMessages) return; + const room = Mx.client.getRoom(Mx.roomId); + if (!room) return; + const events = room.getLiveTimeline().getEvents().filter((e) => e.getType() === 'm.room.message'); + els.mxMessages.innerHTML = ''; + for (const ev of events) { + const row = document.createElement('div'); + row.className = 'mx-msg'; + const sender = document.createElement('span'); + sender.className = 'mx-sender'; + sender.textContent = (ev.sender && ev.sender.name) || ev.getSender(); + const body = document.createElement('span'); + body.className = 'mx-body'; + body.textContent = ev.getContent().body || ''; + row.append(sender, body); + els.mxMessages.appendChild(row); + } + els.mxMessages.scrollTop = els.mxMessages.scrollHeight; +} + +function mxShowPanel() { + if (els.mxPanel) els.mxPanel.classList.remove('hidden'); +} + +function mxSend(text) { + if (!Mx.client || !Mx.roomId || !text.trim()) return; + Mx.client.sendTextMessage(Mx.roomId, text.trim()).catch((err) => { + console.error('matrix send failed', err); + toast(`Message failed to send: ${err.message || err}`, 'error'); + }); +} + +async function mxInit() { + const params = new URLSearchParams(location.search); + if (params.has('code') && params.has('state')) { + const code = params.get('code'), state = params.get('state'); + history.replaceState(null, '', location.pathname); + await mxCompleteLogin(code, state); + return; + } + const raw = localStorage.getItem(LS_MX_SESSION); + if (raw) { + try { + await mxBoot(JSON.parse(raw)); + } catch (err) { + console.warn('resuming matrix session failed', err); + localStorage.removeItem(LS_MX_SESSION); + } + } +} +mxInit(); + +document.querySelectorAll('.channel.text').forEach((el) => { + el.onclick = () => { + if (Mx.roomId) { mxShowPanel(); return; } + if (Mx.client) { toast('Connecting to Matrix…'); return; } + mxStartLogin(); + }; +}); +$('#mx-close')?.addEventListener('click', () => els.mxPanel.classList.add('hidden')); +$('#mx-send')?.addEventListener('click', () => { + mxSend(els.mxInput.value); + els.mxInput.value = ''; +}); +els.mxInput?.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter' && !ev.shiftKey) { + ev.preventDefault(); + $('#mx-send').click(); + } +}); + // --------------------------------------------------------------------------- // controls & wiring // --------------------------------------------------------------------------- @@ -911,6 +1192,7 @@ setAvatar(els.meAvatar, S.name, 'me'); document.querySelectorAll('.channel.voice').forEach((el) => { el.onclick = () => { if (S.room === el.dataset.room) return; + cancelReconnect(); document.querySelectorAll('.channel.voice').forEach((c) => c.classList.remove('active')); el.classList.add('active'); joinVoice(el.dataset.room).catch((e) => { @@ -920,10 +1202,6 @@ document.querySelectorAll('.channel.voice').forEach((el) => { }); }; }); -document.querySelectorAll('.channel.text').forEach((el) => { - el.onclick = () => toast('POC: text chat is not implemented'); -}); - // spawn me somewhere spread out, not dead-center on top of others (() => { let h = 0; @@ -939,7 +1217,7 @@ window.addEventListener('keydown', (ev) => { if (ev.key === 'd') toggleDeafen(); if (ev.key === 's') $('#btn-share').click(); }); -window.addEventListener('beforeunload', () => { try { leaveVoice(); } catch {} }); +window.addEventListener('beforeunload', () => { try { cancelReconnect(); leaveVoice(); } catch {} }); // --------------------------------------------------------------------------- // settings modal (custom STUN/TURN) diff --git a/public/index.html b/public/index.html index fcee514..6f3febe 100644 --- a/public/index.html +++ b/public/index.html @@ -28,8 +28,8 @@