260 lines
10 KiB
JavaScript
260 lines
10 KiB
JavaScript
import { state, saveSettings, BLOCKED_PORTS } from './state.js';
|
|
import { initNotifications } from './notifications.js';
|
|
import { applyPrefixWidth, sysMsg } from './chat.js';
|
|
import {
|
|
onBufOpened, onBufUpdated, onBufCleared, onBufClosed,
|
|
onLineAdded, onNickAdded, onNickRemoved, onGroupChanged,
|
|
collectNicks, rebuildBufList, activateBuffer,
|
|
} from './buffers.js';
|
|
|
|
const el = id => document.getElementById(id);
|
|
|
|
// ─── Reconnect state ──────────────────────────────────────────────────────────
|
|
const reconnect = {
|
|
enabled: false, // set true after first successful connect; false on user disconnect
|
|
timer: null,
|
|
backoff: 1000, // ms, doubles each attempt up to MAX_BACKOFF
|
|
};
|
|
const MAX_BACKOFF = 30_000;
|
|
const INITIAL_BACKOFF = 1_000;
|
|
|
|
// ─── Float-safe ID parser ─────────────────────────────────────────────────────
|
|
// The relay sometimes sends buffer IDs as JSON floats (e.g. 1709932823649184.0).
|
|
// Parsing those as Number loses precision — parse as string when safe.
|
|
export function parseId(v) {
|
|
if (v == null) return null;
|
|
if (typeof v === 'string') return v; // already a string key
|
|
if (typeof v === 'number') return String(Math.round(v)); // float → integer string
|
|
return String(v);
|
|
}
|
|
|
|
// ─── WebSocket send ───────────────────────────────────────────────────────────
|
|
export function wsSend(obj) {
|
|
if (state.ws && state.ws.readyState === WebSocket.OPEN)
|
|
state.ws.send(JSON.stringify(obj));
|
|
}
|
|
|
|
// ─── Connect ──────────────────────────────────────────────────────────────────
|
|
export function connect() {
|
|
const host = el('host').value.trim();
|
|
const port = parseInt(el('port').value, 10);
|
|
const pass = el('password').value;
|
|
const tls = el('tls').checked;
|
|
|
|
if (!host || !port) return showConnError('Host and port are required.');
|
|
if (BLOCKED_PORTS.has(port)) return showConnError(
|
|
`Port ${port} is blocked by browsers. Use a different port — e.g. 9000.`);
|
|
|
|
reconnect.enabled = false; // reset; re-enabled on first successful connect
|
|
clearReconnectTimer();
|
|
connectTo(host, port, pass, tls);
|
|
}
|
|
|
|
function connectTo(host, port, pass, tls) {
|
|
const hostFmt = (host.includes(':') && !host.startsWith('[')) ? `[${host}]` : host;
|
|
const url = `${tls ? 'wss' : 'ws'}://${hostFmt}:${port}/api`;
|
|
|
|
hideConnError();
|
|
setConnecting(true);
|
|
|
|
let ws;
|
|
try {
|
|
ws = new WebSocket(url, [
|
|
'api.weechat',
|
|
`base64url.bearer.authorization.weechat.${buildAuth(pass)}`
|
|
]);
|
|
} catch (e) {
|
|
setConnecting(false);
|
|
showConnError(`Could not open WebSocket: ${e.message}`);
|
|
return;
|
|
}
|
|
|
|
const timer = setTimeout(() => {
|
|
ws.close();
|
|
setConnecting(false);
|
|
showConnError('Connection timed out. Check host, port, and relay config.');
|
|
}, 8000);
|
|
|
|
ws.onopen = () => {
|
|
clearTimeout(timer);
|
|
state.ws = ws;
|
|
state.connected = true;
|
|
reconnect.enabled = true;
|
|
reconnect.backoff = INITIAL_BACKOFF;
|
|
Object.assign(state.settings, { host, port, pass, tls });
|
|
saveSettings();
|
|
onConnected();
|
|
};
|
|
|
|
ws.onmessage = e => {
|
|
let data;
|
|
try { data = JSON.parse(e.data); } catch { return; }
|
|
if (Array.isArray(data)) data.forEach(dispatch);
|
|
else dispatch(data);
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
clearTimeout(timer);
|
|
setConnecting(false);
|
|
if (location.protocol === 'https:' && !tls) {
|
|
showConnError('Secure connection error — cannot connect to an unencrypted relay (ws://) from an HTTPS page.');
|
|
} else if (!reconnect.enabled) {
|
|
showConnError('WebSocket error. Check host, port, TLS, and relay config.');
|
|
}
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
clearTimeout(timer);
|
|
if (state.connected) {
|
|
onDisconnected(/* userInitiated */ false);
|
|
if (reconnect.enabled) scheduleReconnect(host, port, pass, tls);
|
|
}
|
|
};
|
|
}
|
|
|
|
function scheduleReconnect(host, port, pass, tls) {
|
|
clearReconnectTimer();
|
|
const delay = reconnect.backoff;
|
|
reconnect.backoff = Math.min(reconnect.backoff * 2, MAX_BACKOFF);
|
|
setStatus('connecting', `RECONNECTING in ${Math.round(delay/1000)}s…`);
|
|
reconnect.timer = setTimeout(() => {
|
|
if (!reconnect.enabled) return;
|
|
setStatus('connecting', 'RECONNECTING…');
|
|
connectTo(host, port, pass, tls);
|
|
}, delay);
|
|
}
|
|
|
|
function clearReconnectTimer() {
|
|
if (reconnect.timer) { clearTimeout(reconnect.timer); reconnect.timer = null; }
|
|
}
|
|
|
|
export function disconnect() {
|
|
reconnect.enabled = false;
|
|
clearReconnectTimer();
|
|
wsSend({ request: 'DELETE /api/sync' });
|
|
onDisconnected(/* userInitiated */ true);
|
|
}
|
|
|
|
// ─── Auth ─────────────────────────────────────────────────────────────────────
|
|
function buildAuth(pw) {
|
|
return btoa('plain:' + pw).replace(/\+/g,'-').replace(/\//g,'_').replace(/=/g,'');
|
|
}
|
|
|
|
// ─── Message dispatch ─────────────────────────────────────────────────────────
|
|
function dispatch(msg) {
|
|
if (!msg) return;
|
|
if (msg.code === 0 && msg.event_name) { handleEvent(msg); return; }
|
|
if (msg.request_id === 'init' && msg.body_type === 'buffers') { handleInit(msg); return; }
|
|
}
|
|
|
|
function handleEvent(msg) {
|
|
switch (msg.event_name) {
|
|
case 'buffer_opened': onBufOpened(msg.body); break;
|
|
case 'buffer_closed': onBufClosed(msg.buffer_id); break;
|
|
case 'buffer_renamed':
|
|
case 'buffer_title_changed':
|
|
case 'buffer_localvar_added':
|
|
case 'buffer_localvar_changed':
|
|
case 'buffer_localvar_removed':
|
|
case 'buffer_moved':
|
|
case 'buffer_merged':
|
|
case 'buffer_unmerged':
|
|
case 'buffer_hidden':
|
|
case 'buffer_unhidden': onBufUpdated(msg.body); break;
|
|
case 'buffer_cleared': onBufCleared(msg.buffer_id); break;
|
|
case 'buffer_line_added': onLineAdded(msg.buffer_id, msg.body); break;
|
|
case 'nicklist_nick_added':
|
|
case 'nicklist_nick_changed': onNickAdded(msg.buffer_id, msg.body); break;
|
|
case 'nicklist_nick_removing': onNickRemoved(msg.buffer_id, msg.body); break;
|
|
case 'nicklist_group_added':
|
|
case 'nicklist_group_changed': onGroupChanged(msg.buffer_id); break;
|
|
case 'upgrade': sysMsg(null, '⟳ WeeChat upgrading…'); break;
|
|
case 'upgrade_ended': sysMsg(null, '✓ WeeChat upgrade complete.'); break;
|
|
case 'quit': onDisconnected(); break;
|
|
}
|
|
}
|
|
|
|
// ─── Lifecycle ────────────────────────────────────────────────────────────────
|
|
function onConnected() {
|
|
setStatus('connected', 'CONNECTED');
|
|
initNotifications();
|
|
wsSend([
|
|
{ request: 'GET /api/buffers?lines=-200&nicks=true&colors=ansi', request_id: 'init' },
|
|
{ request: 'POST /api/sync', body: { nicks: true, colors: 'ansi' } }
|
|
]);
|
|
}
|
|
|
|
function handleInit(msg) {
|
|
state.buffers.clear();
|
|
const cfg = window.CATHODE_CONFIG || {};
|
|
if (cfg.prefixAlignMax) {
|
|
state.prefixAlignMax = cfg.prefixAlignMax;
|
|
state.settings.prefixAlignMax = cfg.prefixAlignMax;
|
|
} else if (state.settings.prefixAlignMax) {
|
|
state.prefixAlignMax = state.settings.prefixAlignMax;
|
|
}
|
|
for (const buf of (msg.body || [])) {
|
|
const nicks = {};
|
|
collectNicks(buf.nicklist_root, nicks);
|
|
// Use parseId to handle float IDs from the relay
|
|
const id = parseId(buf.id) ?? buf.id;
|
|
state.buffers.set(id, { ...buf, id, lines: buf.lines||[], nicks, unread:0, highlight:0,
|
|
lastReadId: buf.last_read_line_id ? parseId(buf.last_read_line_id) : null });
|
|
if (!state.smartFilter.has(id)) state.smartFilter.set(id, true);
|
|
}
|
|
setConnecting(false);
|
|
el('disconnect-btn').style.display = '';
|
|
applyPrefixWidth();
|
|
showScreen('chat');
|
|
rebuildBufList();
|
|
state.scroll.pinned = true;
|
|
state.scroll.newCount = 0;
|
|
const first = state.buffers.keys().next().value;
|
|
if (first != null) activateBuffer(first);
|
|
}
|
|
|
|
export function onDisconnected(userInitiated = true) {
|
|
if (!state.connected && !state.ws) return;
|
|
state.connected = false;
|
|
if (state.ws) { try { state.ws.close(); } catch(_){} state.ws = null; }
|
|
setStatus('disconnected', 'DISCONNECTED');
|
|
el('disconnect-btn').style.display = 'none';
|
|
// Only return to connect screen on explicit user disconnect or first-time failure
|
|
// On auto-reconnect we stay on the chat screen showing the status
|
|
if (userInitiated) {
|
|
showScreen('connect');
|
|
state.buffers.clear();
|
|
state.activeBufferId = null;
|
|
state.scroll.pinned = true;
|
|
state.scroll.newCount = 0;
|
|
el('buffer-list').innerHTML = '';
|
|
el('messages').innerHTML = '';
|
|
el('nicklist').innerHTML = '';
|
|
const banner = document.getElementById('new-msg-banner');
|
|
if (banner) banner.remove();
|
|
}
|
|
}
|
|
|
|
// ─── UI helpers (connection-screen) ──────────────────────────────────────────
|
|
function showConnError(msg) {
|
|
el('conn-error').textContent = msg;
|
|
el('conn-error').style.display = 'block';
|
|
}
|
|
function hideConnError() { el('conn-error').style.display = 'none'; }
|
|
|
|
function setConnecting(on) {
|
|
el('connect-btn').disabled = on;
|
|
el('connect-btn').textContent = on ? 'CONNECTING…' : 'CONNECT';
|
|
if (on) setStatus('connecting', 'CONNECTING…');
|
|
}
|
|
|
|
function setStatus(s, text) {
|
|
el('status-dot').className = 'status-dot ' + s;
|
|
el('status-text').textContent = text;
|
|
}
|
|
|
|
function showScreen(name) {
|
|
el('connect-screen').style.display = name === 'connect' ? '' : 'none';
|
|
el('chat-screen').style.display = name === 'chat' ? '' : 'none';
|
|
}
|