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.
|
/* gibbz voice POC — WebRTC mesh voice + screen share + spatial audio.
|
||||||
POC shortcuts: no auth, no reconnection, no text chat, single audio context. */
|
POC shortcuts: no auth (voice), no E2E crypto (matrix text), single audio context. */
|
||||||
'use strict';
|
import * as matrixcs from 'https://cdn.jsdelivr.net/npm/matrix-js-sdk@42.4.0/lib/browser-index.min.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// state
|
// state
|
||||||
@@ -28,6 +28,7 @@ const S = {
|
|||||||
sharing: false,
|
sharing: false,
|
||||||
connecting: false,
|
connecting: false,
|
||||||
pingTimer: null,
|
pingTimer: null,
|
||||||
|
reconnect: { room: null, attempt: 0, timer: null, countdownTimer: null },
|
||||||
};
|
};
|
||||||
window.__dbg = S; // POC debug handle
|
window.__dbg = S; // POC debug handle
|
||||||
|
|
||||||
@@ -37,6 +38,8 @@ const els = {
|
|||||||
spatial: $('#spatial'), status: $('#voice-status'), statusDot: $('#status-dot'),
|
spatial: $('#spatial'), status: $('#voice-status'), statusDot: $('#status-dot'),
|
||||||
statusRow: $('#status-row'), nick: $('#nick'),
|
statusRow: $('#status-row'), nick: $('#nick'),
|
||||||
meAvatar: $('#me-avatar'), mic: $('#btn-mic'), deafen: $('#btn-deafen'),
|
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
|
// signaling
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -171,8 +245,9 @@ function openSignaling(room) {
|
|||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
if (S.pingTimer) { clearInterval(S.pingTimer); S.pingTimer = null; }
|
if (S.pingTimer) { clearInterval(S.pingTimer); S.pingTimer = null; }
|
||||||
if (S.ws === ws && S.room) {
|
if (S.ws === ws && S.room) {
|
||||||
toast('Signaling connection lost', 'error');
|
const room = S.room;
|
||||||
leaveVoice('disconnected');
|
leaveVoice('disconnected');
|
||||||
|
beginReconnect(room);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
@@ -856,6 +931,212 @@ els.spatial.addEventListener('pointerup', () => {
|
|||||||
requestAnimationFrame(drawSpatial); // rAF loop stopped while dragging
|
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
|
// controls & wiring
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -911,6 +1192,7 @@ setAvatar(els.meAvatar, S.name, 'me');
|
|||||||
document.querySelectorAll('.channel.voice').forEach((el) => {
|
document.querySelectorAll('.channel.voice').forEach((el) => {
|
||||||
el.onclick = () => {
|
el.onclick = () => {
|
||||||
if (S.room === el.dataset.room) return;
|
if (S.room === el.dataset.room) return;
|
||||||
|
cancelReconnect();
|
||||||
document.querySelectorAll('.channel.voice').forEach((c) => c.classList.remove('active'));
|
document.querySelectorAll('.channel.voice').forEach((c) => c.classList.remove('active'));
|
||||||
el.classList.add('active');
|
el.classList.add('active');
|
||||||
joinVoice(el.dataset.room).catch((e) => {
|
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
|
// spawn me somewhere spread out, not dead-center on top of others
|
||||||
(() => {
|
(() => {
|
||||||
let h = 0;
|
let h = 0;
|
||||||
@@ -939,7 +1217,7 @@ window.addEventListener('keydown', (ev) => {
|
|||||||
if (ev.key === 'd') toggleDeafen();
|
if (ev.key === 'd') toggleDeafen();
|
||||||
if (ev.key === 's') $('#btn-share').click();
|
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)
|
// settings modal (custom STUN/TURN)
|
||||||
|
|||||||
+24
-3
@@ -28,8 +28,8 @@
|
|||||||
<aside id="sidebar">
|
<aside id="sidebar">
|
||||||
<header id="server-name">gibbz voice <span class="poc-tag">POC</span></header>
|
<header id="server-name">gibbz voice <span class="poc-tag">POC</span></header>
|
||||||
<div class="channel-group-label">Text channels</div>
|
<div class="channel-group-label">Text channels</div>
|
||||||
<div class="channel text" data-noop>#
|
<div class="channel text" data-mx-room="gibbz-general">#
|
||||||
general <span class="hint">not implemented</span></div>
|
general <span class="hint">matrix</span></div>
|
||||||
<div class="channel-group-label">Voice channels</div>
|
<div class="channel-group-label">Voice channels</div>
|
||||||
<div class="channel voice" data-room="general">🔊 General <span class="count" data-room-count="general"></span></div>
|
<div class="channel voice" data-room="general">🔊 General <span class="count" data-room-count="general"></span></div>
|
||||||
<div class="channel voice" data-room="gaming">🔊 Gaming <span class="count" data-room-count="gaming"></span></div>
|
<div class="channel voice" data-room="gaming">🔊 Gaming <span class="count" data-room-count="gaming"></span></div>
|
||||||
@@ -97,8 +97,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">×</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>
|
<div id="toasts"></div>
|
||||||
|
|
||||||
<script src="app.js?v=10"></script>
|
<script type="module" src="app.js?v=11"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+62
-1
@@ -59,7 +59,7 @@ body {
|
|||||||
cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.channel:hover { background: var(--bg-3); color: var(--text-0); }
|
.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.text .hint { font-size: 10px; margin-left: 4px; }
|
||||||
.channel.voice.active { background: rgba(88,101,242,.25); color: var(--text-0); }
|
.channel.voice.active { background: rgba(88,101,242,.25); color: var(--text-0); }
|
||||||
.channel .count { float: right; color: var(--text-2); font-size: 12px; }
|
.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 { background: transparent; color: var(--text-1); }
|
||||||
.modal-actions button.ghost:hover { text-decoration: underline; }
|
.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 */
|
||||||
#toasts {
|
#toasts {
|
||||||
position: fixed; bottom: 16px; right: 16px; display: flex; flex-direction: column;
|
position: fixed; bottom: 16px; right: 16px; display: flex; flex-direction: column;
|
||||||
|
|||||||
Reference in new issue
Block a user