matrix
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 22s

This commit is contained in:
mats committed 2026-09-22 10:21:28 +02:00
1 parent e687d41574
commit 5ce3b46bab
3 files changed
+372 -12

No files matched your search

+286 -8
View File
@@ -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)