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)
+24 -3
View File
@@ -28,8 +28,8 @@
<aside id="sidebar">
<header id="server-name">gibbz voice <span class="poc-tag">POC</span></header>
<div class="channel-group-label">Text channels</div>
<div class="channel text" data-noop>#
general <span class="hint">not implemented</span></div>
<div class="channel text" data-mx-room="gibbz-general">#
general <span class="hint">matrix</span></div>
<div class="channel-group-label">Voice channels</div>
<div class="channel voice" data-room="general">&#128266; General <span class="count" data-room-count="general"></span></div>
<div class="channel voice" data-room="gaming">&#128266; Gaming <span class="count" data-room-count="gaming"></span></div>
@@ -97,8 +97,29 @@
</div>
</div>
<div id="mx-panel" class="hidden">
<div class="mx-header">
<span>#general <span class="poc-tag">matrix · experimental</span></span>
<button id="mx-close" class="ghost" title="Close">&times;</button>
</div>
<div id="mx-messages"></div>
<div class="mx-composer">
<textarea id="mx-input" rows="1" placeholder="Message #general (Enter to send)"></textarea>
<button id="mx-send">Send</button>
</div>
</div>
<div id="reconnect-banner" class="hidden" role="alert">
<div class="reconnect-spinner"></div>
<span id="reconnect-text">Connection lost</span>
<div class="reconnect-actions">
<button id="reconnect-retry">Retry now</button>
<button id="reconnect-dismiss" class="ghost">Dismiss</button>
</div>
</div>
<div id="toasts"></div>
<script src="app.js?v=10"></script>
<script type="module" src="app.js?v=11"></script>
</body>
</html>
+62 -1
View File
@@ -59,7 +59,7 @@ body {
cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.channel:hover { background: var(--bg-3); color: var(--text-0); }
.channel.text { cursor: not-allowed; color: var(--text-2); }
.channel.text { color: var(--text-2); }
.channel.text .hint { font-size: 10px; margin-left: 4px; }
.channel.voice.active { background: rgba(88,101,242,.25); color: var(--text-0); }
.channel .count { float: right; color: var(--text-2); font-size: 12px; }
@@ -284,6 +284,67 @@ body {
.modal-actions button.ghost { background: transparent; color: var(--text-1); }
.modal-actions button.ghost:hover { text-decoration: underline; }
/* matrix text chat panel */
#mx-panel {
position: fixed; top: 0; right: 0; bottom: 0; width: 340px; max-width: 90vw;
background: var(--bg-1); border-left: 1px solid rgba(0,0,0,.35);
display: flex; flex-direction: column; z-index: 40;
box-shadow: -8px 0 24px rgba(0,0,0,.45);
}
#mx-panel.hidden { display: none; }
.mx-header {
padding: 12px 14px; font-weight: 700; font-size: 14px;
display: flex; align-items: center; justify-content: space-between;
border-bottom: 1px solid rgba(0,0,0,.25); flex-shrink: 0;
}
.mx-header button.ghost {
background: transparent; border: none; color: var(--text-1);
font-size: 20px; line-height: 1; cursor: pointer; padding: 0 4px;
}
.mx-header button.ghost:hover { color: var(--text-0); }
#mx-messages { flex: 1; overflow: auto; padding: 10px 14px; display: flex; flex-direction: column; gap: 8px; }
.mx-msg { font-size: 13px; line-height: 1.4; }
.mx-sender { font-weight: 700; color: var(--text-0); margin-right: 6px; }
.mx-body { color: var(--text-1); white-space: pre-wrap; overflow-wrap: anywhere; }
.mx-composer {
display: flex; gap: 8px; padding: 10px 14px; border-top: 1px solid rgba(0,0,0,.25); flex-shrink: 0;
}
.mx-composer textarea {
flex: 1; resize: none; background: var(--bg-0); border: 1px solid var(--bg-4);
border-radius: 6px; color: var(--text-0); padding: 8px 10px; font: inherit; max-height: 100px;
}
.mx-composer textarea:focus { outline: none; border-color: var(--brand); }
.mx-composer button {
background: var(--brand); border: none; color: #fff; border-radius: 6px;
padding: 0 14px; font-weight: 600; cursor: pointer;
}
/* reconnect banner */
#reconnect-banner {
position: fixed; top: 16px; left: 50%; transform: translateX(-50%);
z-index: 70; display: flex; align-items: center; gap: 12px;
background: var(--bg-0); border: 1px solid var(--red);
box-shadow: 0 8px 28px rgba(0,0,0,.55), 0 0 0 1px rgba(242,63,67,.15);
border-radius: 10px; padding: 10px 14px; font-size: 13px;
animation: banner-in .2s ease-out;
}
#reconnect-banner.hidden { display: none; }
#reconnect-banner #reconnect-text { color: var(--text-0); font-weight: 600; white-space: nowrap; }
.reconnect-spinner {
width: 14px; height: 14px; border-radius: 50%; flex-shrink: 0;
border: 2px solid rgba(242,63,67,.25); border-top-color: var(--red);
animation: spin .8s linear infinite;
}
.reconnect-actions { display: flex; gap: 6px; margin-left: 4px; }
.reconnect-actions button {
padding: 5px 10px; border-radius: 6px; border: none; cursor: pointer;
font-weight: 600; font-size: 12px; background: var(--red); color: #fff;
}
.reconnect-actions button.ghost { background: transparent; color: var(--text-1); }
.reconnect-actions button.ghost:hover { color: var(--text-0); text-decoration: underline; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes banner-in { from { transform: translate(-50%, -12px); opacity: 0; } }
/* toasts */
#toasts {
position: fixed; bottom: 16px; right: 16px; display: flex; flex-direction: column;