matrix
This commit is contained in:
1 parent
e687d41574
commit
5ce3b46bab
3 files changed
+372
-12
No files matched your search
+286
-8
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user