Files
2026-04-23 20:48:21 +02:00

1312 lines
50 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// ─── Blocked ports ────────────────────────────────────────────────────────────
const BLOCKED_PORTS = new Set([
1,7,9,11,13,15,17,19,20,21,22,23,25,37,42,43,53,69,77,79,87,95,
101,102,103,104,107,109,110,111,113,115,117,119,123,135,137,139,
143,161,179,389,427,465,512,513,514,515,526,530,531,532,540,548,
554,556,563,587,601,636,989,990,993,995,1719,1720,1723,2049,3659,
4045,5060,5061,6000,6566,6665,6666,6667,6668,6669,6697,10080
]);
// ─── State ────────────────────────────────────────────────────────────────────
const state = {
ws: null,
connected: false,
buffers: new Map(), // id (number) → buffer object
activeBufferId: null,
settings: loadSettings(),
prefixAlignMax: 16, // mirrors weechat.look.prefix_align_max
scroll: { pinned: true, newCount: 0 },
smartFilter: new Map(), // bufferId → boolean
};
// ─── Upload ───────────────────────────────────────────────────────────────────
async function uploadFile(file) {
const s = state.settings;
const backend = s.uploadBackend || 'none';
if (backend === 'none') {
showUploadError('No upload backend configured. Open Settings to set one up.');
return;
}
setUploadState('uploading');
try {
let url;
if (backend === 'filehost') {
url = await uploadToFilehost(file, s.filehostUrl);
} else if (backend === 'imgur') {
url = await uploadToImgur(file, s.imgurClientId);
}
setUploadState('ok');
setTimeout(() => setUploadState('idle'), 2000);
// Insert URL into the input box at cursor position
const input = el('chat-input');
const pos = input.selectionStart || input.value.length;
const sep = input.value.length > 0 && !input.value.endsWith(' ') ? ' ' : '';
input.value = input.value.slice(0, pos) + sep + url + input.value.slice(pos);
input.focus();
input.selectionStart = input.selectionEnd = pos + sep.length + url.length;
} catch (err) {
setUploadState('err');
setTimeout(() => setUploadState('idle'), 3000);
showUploadError(`Upload failed: ${err.message}`);
}
}
async function uploadToFilehost(file, baseUrl) {
if (!baseUrl) throw new Error('Filehost URL not configured in Settings.');
const url = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/';
const form = new FormData();
form.append('file', file, file.name);
const res = await fetch(url, { method: 'POST', body: form });
if (!res.ok) throw new Error(`Server returned ${res.status}`);
const text = (await res.text()).trim();
// single_php_filehost returns just the URL as plain text
if (!text.startsWith('http')) throw new Error(`Unexpected response: ${text}`);
return text;
}
async function uploadToImgur(file, clientId) {
if (!clientId) throw new Error('Imgur Client ID not configured in Settings.');
const form = new FormData();
form.append('image', file);
const res = await fetch('https://api.imgur.com/3/image', {
method: 'POST',
headers: { Authorization: `Client-ID ${clientId}` },
body: form,
});
const json = await res.json();
if (!json.success) throw new Error(json.data?.error || 'Imgur upload failed');
return json.data.link;
}
function setUploadState(state_) {
const btn = el('upload-btn');
btn.classList.remove('uploading','upload-ok','upload-err');
if (state_ === 'uploading') { btn.classList.add('uploading'); btn.textContent = '⏳'; }
else if (state_ === 'ok') { btn.classList.add('upload-ok'); btn.textContent = '✓'; }
else if (state_ === 'err') { btn.classList.add('upload-err'); btn.textContent = '✗'; }
else { btn.textContent = '📎'; }
}
function showUploadError(msg) {
// Reuse conn-error style — show a transient error above the inputbar
const box = el('messages');
const row = document.createElement('div');
row.className = 'msg-row msg-system';
row.style.color = 'var(--status-disc)';
row.innerHTML =
`<span class="msg-time">${fmtTime(new Date().toISOString())}</span>` +
`<span class="msg-prefix">upload</span>` +
`<span class="msg-sep"></span>` +
`<span class="msg-text">${escHtml(msg)}</span>`;
box.appendChild(row);
if (state.scroll.pinned) box.scrollTop = box.scrollHeight;
}
// ─── Settings panel ───────────────────────────────────────────────────────────
function openSettings() {
const s = state.settings;
el('s-upload-backend').value = s.uploadBackend || 'none';
el('s-filehost-url').value = s.filehostUrl || '';
el('s-imgur-key').value = s.imgurClientId || '';
el('s-prefix-max').value = s.prefixAlignMax || state.prefixAlignMax;
updateSettingsBackendVis();
el('settings-overlay').style.display = '';
}
function closeSettings() {
el('settings-overlay').style.display = 'none';
}
function saveSettingsPanel() {
state.settings.uploadBackend = el('s-upload-backend').value;
state.settings.filehostUrl = el('s-filehost-url').value.trim();
state.settings.imgurClientId = el('s-imgur-key').value.trim();
const pm = parseInt(el('s-prefix-max').value, 10);
if (pm >= 4 && pm <= 64) {
state.settings.prefixAlignMax = pm;
state.prefixAlignMax = pm;
applyPrefixWidth();
}
// persist
saveSettings();
closeSettings();
}
function updateSettingsBackendVis() {
const v = el('s-upload-backend').value;
el('s-filehost-opts').style.display = v === 'filehost' ? '' : 'none';
el('s-imgur-opts').style.display = v === 'imgur' ? '' : 'none';
}
// ─── Drag & drop ──────────────────────────────────────────────────────────────
function initDragDrop() {
const messages = el('messages');
let dragCounter = 0; // counter to handle child element enter/leave events
// Use the whole window as the drop zone so users can drag anywhere
window.addEventListener('dragenter', e => {
if (!e.dataTransfer.types.includes('Files')) return;
dragCounter++;
el('drag-overlay').style.display = 'flex';
});
window.addEventListener('dragleave', e => {
dragCounter--;
if (dragCounter <= 0) {
dragCounter = 0;
el('drag-overlay').style.display = 'none';
}
});
window.addEventListener('dragover', e => {
if (!e.dataTransfer.types.includes('Files')) return;
e.preventDefault(); // required to allow drop
});
window.addEventListener('drop', e => {
e.preventDefault();
dragCounter = 0;
el('drag-overlay').style.display = 'none';
if (!state.connected) return;
const files = [...e.dataTransfer.files];
if (files.length) uploadFile(files[0]); // upload first file; TODO: queue multiple
});
// Paste image from clipboard (bonus — Ctrl+V with image in clipboard)
el('chat-input').addEventListener('paste', e => {
const items = [...(e.clipboardData?.items || [])];
const imageItem = items.find(i => i.kind === 'file' && i.type.startsWith('image/'));
if (!imageItem) return; // not an image paste, let normal text paste through
e.preventDefault();
const file = imageItem.getAsFile();
if (file) uploadFile(file);
});
}
// ─── Settings ─────────────────────────────────────────────────────────────────
function loadSettings() {
try { return JSON.parse(localStorage.getItem('cathode_settings') || '{}'); }
catch { return {}; }
}
function saveSettings() {
localStorage.setItem('cathode_settings', JSON.stringify(state.settings));
}
// ─── DOM helper ───────────────────────────────────────────────────────────────
const el = id => document.getElementById(id);
// ─── ANSI → HTML ──────────────────────────────────────────────────────────────
const ANSI16 = [
'#1a1a1a','#cc3333','#33cc33','#cccc33',
'#3333cc','#cc33cc','#33cccc','#cccccc',
'#555555','#ff5555','#55ff55','#ffff55',
'#5555ff','#ff55ff','#55ffff','#ffffff',
];
function ansi256(n) {
if (n < 16) return ANSI16[n];
if (n >= 232) { const v = 8 + (n - 232) * 10; return `rgb(${v},${v},${v})`; }
const i = n - 16;
return `rgb(${Math.floor(i/36)*51},${Math.floor((i%36)/6)*51},${(i%6)*51})`;
}
function luminance(css) {
let r, g, b;
const m = css.match(/^rgb\((\d+),(\d+),(\d+)\)$/);
if (m) { r = +m[1]; g = +m[2]; b = +m[3]; }
else if (css.startsWith('#')) {
const h = css.slice(1);
if (h.length === 3) {
r = parseInt(h[0]+h[0],16); g = parseInt(h[1]+h[1],16); b = parseInt(h[2]+h[2],16);
} else {
r = parseInt(h.slice(0,2),16); g = parseInt(h.slice(2,4),16); b = parseInt(h.slice(4,6),16);
}
} else return 0.5;
const lin = c => { c /= 255; return c <= 0.04045 ? c/12.92 : Math.pow((c+0.055)/1.055,2.4); };
return 0.2126*lin(r) + 0.7152*lin(g) + 0.0722*lin(b);
}
// In light theme, force near-white foreground colours to black so they remain legible.
function safeFg(css) {
const theme = document.documentElement.getAttribute('data-theme') || 'dark';
if (theme === 'light' && luminance(css) > 0.70) return '#111111';
return css;
}
function ansiToHtml(text) {
if (!text) return '';
let s = text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
let out = '', spans = 0;
const re = /\[([0-9;]*)m/g;
let last = 0, m;
while ((m = re.exec(s)) !== null) {
out += s.slice(last, m.index);
last = m.index + m[0].length;
const codes = m[1].split(';').map(Number);
let i = 0;
while (i < codes.length) {
if (codes[i] === 0) {
// Reset — close all open spans
if (spans > 0) { out += '</span>'.repeat(spans); spans = 0; }
i++; continue;
}
// Collect all styles for one new span (stop at next reset or end)
// Partial-reset codes (bold-off, italic-off, default-fg, default-bg, etc.)
// treat the same as a full reset: close open spans and move on.
const PARTIAL_RESET = new Set([21,22,23,24,25,27,28,29,39,49,51,52,53,54,55]);
if (PARTIAL_RESET.has(codes[i])) {
if (spans > 0) { out += '</span>'.repeat(spans); spans = 0; }
i++; continue;
}
const st = [];
while (i < codes.length && codes[i] !== 0 && !PARTIAL_RESET.has(codes[i])) {
const c = codes[i];
if (c === 1) { st.push('font-weight:bold');
} else if (c === 3) { st.push('font-style:italic');
} else if (c === 4) { st.push('text-decoration:underline');
} else if (c >= 30 && c <= 37) { st.push(`color:${safeFg(ANSI16[c-30])}`);
} else if (c === 38 && codes[i+1] === 5) { st.push(`color:${safeFg(ansi256(codes[i+2]))}`); i+=2;
} else if (c === 38 && codes[i+1] === 2) { st.push(`color:${safeFg(`rgb(${codes[i+2]},${codes[i+3]},${codes[i+4]})`)}`); i+=4;
} else if (c >= 40 && c <= 47) { st.push(`background:${ANSI16[c-40]}`);
} else if (c === 48 && codes[i+1] === 5) { st.push(`background:${ansi256(codes[i+2])}`); i+=2;
} else if (c === 48 && codes[i+1] === 2) { st.push(`background:rgb(${codes[i+2]},${codes[i+3]},${codes[i+4]})`); i+=4;
} else if (c >= 90 && c <= 97) { st.push(`color:${safeFg(ANSI16[c-90+8])}`);
} else if (c >= 100 && c <= 107) { st.push(`background:${ANSI16[c-100+8]}`);
}
// skip unrecognised codes silently
i++;
}
if (st.length) { out += `<span style="${st.join(';')}">`; spans++; }
}
}
out += s.slice(last);
if (spans > 0) out += '</span>'.repeat(spans);
// Linkify URLs; match through &amp; so query strings aren't cut short
return out.replace(/https?:\/\/(?:[^\s<>"\']|&amp;)+/g, (url) => {
const href = url.replace(/&amp;/g, '&');
const isImg = /\.(png|jpe?g|gif|webp|svg|bmp)(\?.*)?$/i.test(href);
const isVid = /\.(mp4|webm|ogv|mov)(\?.*)?$/i.test(href);
const btn = (isImg || isVid)
? ` <button class="media-toggle" data-url="${href}" data-type="${isImg?'img':'vid'}">${isImg?'Show Image':'Show Video'}</button>`
: '';
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${url}</a>${btn}`;
});
}
// ─── WebSocket / connection ───────────────────────────────────────────────────
function buildAuth(pw) {
return btoa('plain:' + pw).replace(/\+/g,'-').replace(/\//g,'_').replace(/=/g,'');
}
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.`);
const hostFmt = (host.includes(':') && !host.startsWith('[')) ? `[${host}]` : host;
const url = `${tls ? 'wss' : 'ws'}://${hostFmt}:${port}/api`;
hideConnError();
setConnecting(true);
// Defer by one frame so the browser paints the button state before opening the socket
setTimeout(() => {
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;
Object.assign(state.settings, { host, port, 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 on HTTPS with TLS unchecked, the error is almost certainly mixed content
if (location.protocol === 'https:' && !tls) {
showConnError('Secure connection error — cannot connect to an unencrypted relay (ws://) from an HTTPS page. Enable TLS on your relay or use a reverse proxy / Zero Trust tunnel.');
} else {
showConnError('WebSocket error. Check host, port, TLS, and relay config.');
}
};
ws.onclose = () => {
clearTimeout(timer);
if (state.connected) onDisconnected();
};
}, 0); // end deferred frame
}
function wsSend(obj) {
if (state.ws && state.ws.readyState === WebSocket.OPEN)
state.ws.send(JSON.stringify(obj));
}
function disconnect() {
wsSend({ request: 'DELETE /api/sync' });
onDisconnected();
}
// ─── Message dispatch ─────────────────────────────────────────────────────────
function dispatch(msg) {
if (!msg) return;
// Push event
if (msg.code === 0 && msg.event_name) { handleEvent(msg); return; }
// Init response
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;
}
}
// ─── Connection lifecycle ─────────────────────────────────────────────────────
function onConnected() {
setStatus('connected', 'CONNECTED');
initNotifications();
// Stay in CONNECTING… state until handleInit() has data ready
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();
// Apply saved prefix_align_max from settings if present
if (state.settings.prefixAlignMax) state.prefixAlignMax = state.settings.prefixAlignMax;
for (const buf of (msg.body || [])) {
const nicks = {};
collectNicks(buf.nicklist_root, nicks);
state.buffers.set(buf.id, { ...buf, lines: buf.lines||[], nicks, unread:0, highlight:0 });
if (!state.smartFilter.has(buf.id)) state.smartFilter.set(buf.id, true);
}
// Data is ready — now clear the connecting state and reveal the chat UI
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);
}
function onDisconnected() {
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';
showScreen('connect');
state.buffers.clear();
state.activeBufferId = null;
state.scroll.pinned = true;
state.scroll.newCount = 0;
bufNodes.clear();
el('buffer-list').innerHTML = '';
el('messages').innerHTML = '';
el('nicklist').innerHTML = '';
hideNewMsgBanner();
}
// ─── Buffer events ────────────────────────────────────────────────────────────
function onBufOpened(buf) {
if (!buf) return;
state.buffers.set(buf.id, { ...buf, lines:buf.lines||[], nicks:{}, unread:0, highlight:0 });
if (!state.smartFilter.has(buf.id)) state.smartFilter.set(buf.id, true);
rebuildBufList();
if (state.activeBufferId == null) activateBuffer(buf.id);
}
function onBufUpdated(buf) {
if (!buf) return;
const b = state.buffers.get(buf.id);
if (!b) return;
Object.assign(b, buf);
paintNode(buf.id);
if (state.activeBufferId === buf.id) renderChatHeader();
}
function onBufCleared(id) {
const b = state.buffers.get(id);
if (b) { b.lines = []; if (state.activeBufferId === id) el('messages').innerHTML = ''; }
}
function onBufClosed(id) {
state.buffers.delete(id);
removeNode(id);
if (state.activeBufferId === id) {
const first = state.buffers.keys().next().value;
if (first != null) activateBuffer(first);
else { state.activeBufferId = null; el('messages').innerHTML = ''; }
}
}
function onLineAdded(id, line) {
if (!line) return;
const b = state.buffers.get(id);
if (!b) return;
b.lines.push(line);
if (state.activeBufferId === id) {
appendLine(line);
} else {
b.unread++;
if (line.highlight) b.highlight++;
paintNode(id);
}
// Notifications and title badge
maybeNotify(b, line);
updateTitle();
}
// ─── Notifications ────────────────────────────────────────────────────────────
async function initNotifications() {
if (!('Notification' in window)) return;
if (Notification.permission === 'default') {
await Notification.requestPermission();
}
}
function maybeNotify(buf, line) {
if (!line) return;
if (!('Notification' in window) || Notification.permission !== 'granted') return;
// Detect highlight via line.highlight (bool), notify_level >= 2, or IRC tags
const tags = Array.isArray(line.tags) ? line.tags : [];
const isHL = line.highlight
|| (line.notify_level >= 3)
|| tags.includes('notify_highlight');
const isPrivate = (line.notify_level === 2)
|| tags.includes('notify_private');
if (!isHL && !isPrivate) return;
// Suppress only when tab is visible AND user is actively on THIS buffer
const focused = document.visibilityState === 'visible'
&& state.activeBufferId === buf.id;
if (focused) return;
const bufName = buf.short_name || buf.name || '?';
const stripAnsi = s => (s || '').replace(/\x1b\[[0-9;]*m/g, '').trim();
const prefix = stripAnsi(line.prefix);
const body = stripAnsi(line.message);
const title = isPrivate ? `PM from ${prefix}` : `${prefix} in ${bufName}`;
const n = new Notification(title, {
body,
icon: 'apple-touch-icon.png',
tag: `cathode-${buf.id}`, // collapses repeated pings from same buffer
});
n.onclick = () => {
window.focus();
activateBuffer(buf.id);
n.close();
};
}
// ─── Title / favicon badge ────────────────────────────────────────────────────
function updateTitle() {
let total = 0;
for (const buf of state.buffers.values()) total += buf.highlight;
document.title = total > 0 ? `(${total}) Cathode` : 'Cathode';
}
// ─── Nick events ──────────────────────────────────────────────────────────────
function collectNicks(group, out) {
if (!group) return;
for (const n of (group.nicks || [])) out[n.id] = n;
for (const g of (group.groups || [])) collectNicks(g, out);
}
function onNickAdded(id, nick) {
const b = state.buffers.get(id);
if (!b || !nick) return;
b.nicks[nick.id] = nick;
if (state.activeBufferId === id) renderNicklist(b);
}
function onNickRemoved(id, nick) {
const b = state.buffers.get(id);
if (!b || !nick) return;
delete b.nicks[nick.id];
if (state.activeBufferId === id) renderNicklist(b);
}
function onGroupChanged(id) {
const b = state.buffers.get(id);
if (b && state.activeBufferId === id) renderNicklist(b);
}
// ─── Buffer list — keyed DOM, never wiped ─────────────────────────────────────
// bufNodes maps "b:<id>" and "g:<groupkey>" to their DOM nodes.
// Nodes are created once and reused; click listeners survive all updates.
const bufNodes = new Map();
function bKey(id) { return 'b:' + id; }
function gKey(k) { return 'g:' + k; }
function bufMeta(buf) {
const lv = buf.local_variables || {};
const plugin = lv.plugin || '';
const server = lv.server || '';
const type = lv.type || '';
if (!plugin || plugin === 'core')
return { group:'\x00core', groupLabel:'weechat', isServer:false, indent:false };
if (plugin === 'irc') {
if (type === 'server' || !server)
return { group: server||buf.name, groupLabel: server||buf.name, isServer:true, indent:false };
return { group: server, groupLabel: server, isServer:false, indent:true };
}
const gk = server ? `${plugin}.${server}` : plugin;
return { group:gk, groupLabel: server ? `${plugin}/${server}` : plugin,
isServer:!server, indent:!!server };
}
function buildWanted() {
const sorted = [...state.buffers.values()].sort((a,b) => a.number - b.number);
const groups = new Map();
for (const buf of sorted) {
const m = bufMeta(buf);
if (!groups.has(m.group)) groups.set(m.group, { label:m.groupLabel, srv:null, ch:[] });
const g = groups.get(m.group);
if (m.isServer) g.srv = buf; else g.ch.push(buf);
}
const items = [];
for (const [gk, g] of groups) {
if (g.srv) items.push({ key:bKey(g.srv.id), type:'server', buf:g.srv });
else items.push({ key:gKey(gk), type:'header', label:g.label });
for (const buf of g.ch)
items.push({ key:bKey(buf.id), type:'channel', buf });
}
return items;
}
// Full rebuild — called once on init and whenever structure changes (open/close).
function rebuildBufList() {
const container = el('buffer-list');
// Detach all existing nodes
for (const [,node] of bufNodes) node.remove();
bufNodes.clear();
for (const item of buildWanted()) {
const node = makeNode(item);
bufNodes.set(item.key, node);
container.appendChild(node);
}
}
// Repaint a single buffer node (active state, badge, classes) — no DOM move.
function paintNode(id) {
const node = bufNodes.get(bKey(id));
if (!node) return;
const buf = state.buffers.get(id);
if (!buf) return;
const isServer = node.dataset.isServer === '1';
const indent = node.dataset.indent === '1';
const classes = ['buffer-item'];
if (isServer) classes.push('buf-server');
if (indent) classes.push('buf-indented');
if (buf.id === state.activeBufferId) classes.push('active');
if (buf.highlight > 0) classes.push('highlight');
else if (buf.unread > 0) classes.push('unread');
node.className = classes.join(' ');
const name = buf.short_name || buf.name || '?';
const badge = buf.highlight > 0
? `<span class="badge hl-badge">${buf.highlight}</span>`
: buf.unread > 0 ? `<span class="badge">${buf.unread}</span>` : '';
node.innerHTML =
`<span class="buf-num">${buf.number}</span>` +
`<span class="buf-name">${escHtml(name)}</span>${badge}` +
`<button class="buf-close" data-id="${buf.id}" title="Close buffer">×</button>`;
}
// Remove a buffer's node; rebuild if group structure changed.
function removeNode(id) {
const node = bufNodes.get(bKey(id));
if (node) { node.remove(); bufNodes.delete(bKey(id)); }
// May need to remove orphaned group header or promote remaining server entry
rebuildBufList();
}
function makeNode(item) {
if (item.type === 'header') {
const node = document.createElement('div');
node.className = 'buf-group-header';
node.dataset.key = item.key;
node.textContent = item.label;
return node;
}
const isServer = item.type === 'server';
const indent = item.type === 'channel';
const node = document.createElement('div');
node.dataset.key = item.key;
node.dataset.id = String(item.buf.id);
node.dataset.isServer = isServer ? '1' : '0';
node.dataset.indent = indent ? '1' : '0';
// Listener reads id from dataset — survives innerHTML updates on the node.
node.addEventListener('click', () => activateBuffer(Number(node.dataset.id)));
// Initial paint
const classes = ['buffer-item'];
if (isServer) classes.push('buf-server');
if (indent) classes.push('buf-indented');
node.className = classes.join(' ');
const buf = item.buf;
const name = buf.short_name || buf.name || '?';
node.innerHTML =
`<span class="buf-num">${buf.number}</span>` +
`<span class="buf-name">${escHtml(name)}</span>`;
return node;
}
// ─── Activate buffer ──────────────────────────────────────────────────────────
function activateBuffer(id) {
const prev = state.activeBufferId;
const buf = state.buffers.get(id);
if (!buf) return;
state.activeBufferId = id;
state.scroll.pinned = true;
state.scroll.newCount = 0;
buf.unread = 0;
buf.highlight = 0;
if (prev != null && prev !== id) paintNode(prev);
paintNode(id);
renderChatHeader();
renderMessages(buf);
renderNicklist(buf);
hideNewMsgBanner();
updateTitle();
el('chat-input').focus();
}
// ─── Chat rendering ───────────────────────────────────────────────────────────
function renderChatHeader() {
const buf = state.buffers.get(state.activeBufferId);
if (!buf) return;
el('chat-title').textContent = buf.short_name || buf.name || '';
el('chat-topic').innerHTML = buf.title ? ansiToHtml(buf.title) : '';
// Smart filter toggle — only show for IRC channel/private buffers
const lv = buf.local_variables || {};
const isIrc = lv.plugin === 'irc' && lv.type !== 'server';
const sfBtn = el('smartfilter-btn');
if (isIrc) {
const on = state.smartFilter.get(buf.id) !== false;
sfBtn.textContent = on ? 'FILTER: ON' : 'FILTER: OFF';
sfBtn.classList.toggle('sf-off', !on);
sfBtn.style.display = '';
} else {
sfBtn.style.display = 'none';
}
}
function toggleSmartFilter() {
const id = state.activeBufferId;
if (id == null) return;
const cur = state.smartFilter.get(id) !== false;
state.smartFilter.set(id, !cur);
renderChatHeader();
// Re-render messages with new filter state
const buf = state.buffers.get(id);
if (buf) renderMessages(buf);
}
function renderMessages(buf) {
const box = el('messages');
box.innerHTML = '';
for (const line of buf.lines) appendLine(line, false);
box.scrollTop = box.scrollHeight;
// Re-attach scroll listener (innerHTML wipe doesn't remove it, but be safe)
box.onscroll = onMessagesScroll;
}
function appendLine(line, scroll = true) {
if (!line.displayed) return;
// Smart filter: hide join/part/quit/nick noise tagged by WeeChat
if (line.tags && line.tags.includes('irc_smart_filter')) {
const on = state.smartFilter.get(state.activeBufferId) !== false;
if (on) return;
}
const box = el('messages');
// WeeChat may put newlines in a single message — split into sub-lines.
const msgRaw = line.message || '';
const subLines = msgRaw.split('\n');
const time = line.date ? fmtTime(line.date) : '';
const prefix = line.prefix ? ansiToHtml(truncPrefix(line.prefix)) : '';
const hlClass = line.highlight ? ' msg-highlight' : '';
subLines.forEach((sub, i) => {
const row = document.createElement('div');
row.className = 'msg-row' + hlClass;
if (i === 0) {
row.innerHTML =
`<span class="msg-time">${time}</span>` +
`<span class="msg-prefix">${prefix}</span>` +
`<span class="msg-sep"></span>` +
`<span class="msg-text">${ansiToHtml(sub)}</span>`;
} else {
// Continuation line: blank time, blank prefix, same separator
row.innerHTML =
`<span class="msg-time"></span>` +
`<span class="msg-prefix"></span>` +
`<span class="msg-sep"></span>` +
`<span class="msg-text">${ansiToHtml(sub)}</span>`;
}
box.appendChild(row);
});
if (scroll) {
if (state.scroll.pinned) {
box.scrollTop = box.scrollHeight;
} else {
state.scroll.newCount++;
showNewMsgBanner(state.scroll.newCount);
}
}
}
function sysMsg(id, text) {
if (id != null && id !== state.activeBufferId) return;
const box = el('messages');
const row = document.createElement('div');
row.className = 'msg-row msg-system';
row.innerHTML =
`<span class="msg-time">${fmtTime(new Date().toISOString())}</span>` +
`<span class="msg-prefix">--</span>` +
`<span class="msg-sep"></span>` +
`<span class="msg-text">${escHtml(text)}</span>`;
box.appendChild(row);
if (state.scroll.pinned) box.scrollTop = box.scrollHeight;
}
// ─── Scroll lock + new-messages banner ───────────────────────────────────────
function onMessagesScroll() {
const box = el('messages');
// "Pinned" = within 2px of the bottom (tolerance for subpixel rounding)
const atBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 2;
if (atBottom && !state.scroll.pinned) {
state.scroll.pinned = true;
state.scroll.newCount = 0;
hideNewMsgBanner();
} else if (!atBottom && state.scroll.pinned) {
state.scroll.pinned = false;
}
}
function showNewMsgBanner(count) {
let banner = document.getElementById('new-msg-banner');
if (!banner) {
banner = document.createElement('div');
banner.id = 'new-msg-banner';
banner.className = 'new-msg-banner';
banner.addEventListener('click', () => {
const box = el('messages');
box.scrollTop = box.scrollHeight;
state.scroll.pinned = true;
state.scroll.newCount = 0;
hideNewMsgBanner();
});
el('main').appendChild(banner);
}
banner.textContent = `${count} new message${count === 1 ? '' : 's'}`;
}
function hideNewMsgBanner() {
const b = document.getElementById('new-msg-banner');
if (b) b.remove();
}
// ─── Prefix truncation (weechat.look.prefix_align_max) ────────────────────────
// Strips ANSI, measures visible length, re-truncates the raw string.
function truncPrefix(raw) {
if (!raw) return raw;
const visible = raw.replace(/\[[0-9;]*m/g, '');
const max = state.prefixAlignMax;
if (visible.length <= max) return raw;
// Keep the leading ANSI resets/colour codes then truncate visible chars
// Simple approach: strip ANSI, truncate, re-wrap with original opening escape
const firstEsc = raw.match(/^(\[[0-9;]*m)*/);
const prefix = firstEsc ? firstEsc[0] : '';
const plain = visible.slice(0, max - 1) + '…';
return prefix + plain + '';
}
function applyPrefixWidth() {
// Set a CSS variable so the prefix column matches prefix_align_max chars
// IBM Plex Mono is monospaced at ~8px/char at 13px font size
const charWidth = 8.2;
const px = Math.round(state.prefixAlignMax * charWidth) + 16; // +16 for padding
document.documentElement.style.setProperty('--prefix-col-width', px + 'px');
}
// ─── Nicklist ─────────────────────────────────────────────────────────────────
function renderNicklist(buf) {
const box = el('nicklist');
box.innerHTML = '';
const nicks = Object.values(buf.nicks || {}).sort((a,b) => {
const w = p => p==='~'?0 : p==='&'?1 : p==='@'?2 : p==='%'?3 : p==='+'?4 : 5;
const d = w(a.prefix) - w(b.prefix);
return d !== 0 ? d : a.name.localeCompare(b.name, undefined, {sensitivity:'base'});
});
for (const nick of nicks) {
const row = document.createElement('div');
row.className = 'nick-item';
// Prefix character — use prefix_color ANSI if provided, else dim default
const pfxChar = (nick.prefix && nick.prefix.trim()) ? escHtml(nick.prefix) : ' ';
const pfxHtml = nick.prefix_color
? `<span class="nick-pfx" style="color:${nickColorToCss(nick.prefix_color)}">${pfxChar}</span>`
: `<span class="nick-pfx">${pfxChar}</span>`;
// Nick name — use color ANSI if provided (irc.look.color_nicks_in_nicklist)
const nameHtml = nick.color
? `<span class="nick-name" style="color:${safeFg(nickColorToCss(nick.color))}">${escHtml(nick.name)}</span>`
: `<span class="nick-name">${escHtml(nick.name)}</span>`;
row.innerHTML = pfxHtml + nameHtml;
row.addEventListener('click', () => openNickMenu(nick, buf));
box.appendChild(row);
}
}
// Convert a nick color value from the API to a CSS color.
// The API returns either a plain ANSI escape string or a color name.
function nickColorToCss(colorVal) {
if (!colorVal) return '';
// If it looks like an ANSI escape sequence, extract the colour
if (colorVal.includes('\x1b')) {
// Parse the first colour from the escape — re-use ansiToHtml on a dummy char
const html = ansiToHtml(colorVal + 'X\x1b[0m');
const m = html.match(/style="([^"]+)"/);
if (m) {
const colorMatch = m[1].match(/(?:^|;)color:([^;]+)/);
if (colorMatch) return colorMatch[1];
}
return '';
}
// Plain color name (e.g. "lightgreen", "bar_fg") — map known WeeChat names
return weechatColorName(colorVal);
}
// Map WeeChat color names to CSS. bar_fg / default → inherit (use theme fg).
const WEECHAT_COLOR_NAMES = {
'default':'inherit','bar_fg':'inherit','black':'#1a1a1a','darkgray':'#555555',
'red':'#cc3333','lightred':'#ff5555','green':'#33cc33','lightgreen':'#55ff55',
'brown':'#cccc33','yellow':'#ffff55','blue':'#3333cc','lightblue':'#5555ff',
'magenta':'#cc33cc','lightmagenta':'#ff55ff','cyan':'#33cccc','lightcyan':'#55ffff',
'gray':'#cccccc','white':'#ffffff',
};
function weechatColorName(name) {
return WEECHAT_COLOR_NAMES[name.toLowerCase()] || 'inherit';
}
// ─── Nick context menu ────────────────────────────────────────────────────────
function openNickMenu(nick, buf) {
closeNickMenu();
const overlay = document.createElement('div');
overlay.id = 'nick-overlay';
overlay.className = 'nick-overlay';
overlay.addEventListener('click', e => { if (e.target === overlay) closeNickMenu(); });
const menu = document.createElement('div');
menu.className = 'nick-menu';
const hdr = document.createElement('div');
hdr.className = 'nick-menu-hdr';
hdr.textContent = (nick.prefix && nick.prefix.trim() ? nick.prefix : '') + nick.name;
menu.appendChild(hdr);
const myPrefix = ownPrefix(buf);
const isOp = ['@','~','&'].includes(myPrefix);
const actions = [
{ label: '💬 Query', cmd: `/query ${nick.name}` },
{ label: '🔍 Whois', cmd: `/whois ${nick.name}` },
{ label: '🔍 Whois (full)', cmd: `/whois ${nick.name} ${nick.name}` },
{ label: '📌 Ignore', cmd: `/ignore ${nick.name}` },
{ label: '🔇 Kick', cmd: `/kick ${nick.name}`, op: true },
{ label: '🚫 Ban', cmd: `/ban ${nick.name}`, op: true },
];
for (const a of actions) {
if (a.op && !isOp) continue;
const btn = document.createElement('button');
btn.className = 'nick-menu-btn';
btn.textContent = a.label;
btn.addEventListener('click', () => {
wsSend({ request:'POST /api/input', body:{ buffer_name:buf.name, command:a.cmd } });
closeNickMenu();
});
menu.appendChild(btn);
}
overlay.appendChild(menu);
document.body.appendChild(overlay);
overlay._esc = e => { if (e.key === 'Escape') closeNickMenu(); };
document.addEventListener('keydown', overlay._esc);
}
function closeNickMenu() {
const ov = document.getElementById('nick-overlay');
if (!ov) return;
document.removeEventListener('keydown', ov._esc);
ov.remove();
}
function ownPrefix(buf) {
const nick = (buf.local_variables || {}).nick || '';
const entry = Object.values(buf.nicks || {}).find(n => n.name === nick);
return entry ? (entry.prefix || '') : '';
}
// ─── Input ────────────────────────────────────────────────────────────────────
const hist = { lines:[], pos:-1, draft:'' };
const tab = { matches:[], pos:-1, stem:'' };
function sendInput() {
const buf = state.buffers.get(state.activeBufferId);
const text = el('chat-input').value.trim();
if (!buf || !text) return;
hist.lines.unshift(text); hist.pos = -1;
tab.matches = []; tab.pos = -1;
wsSend({ request:'POST /api/input', body:{ buffer_name:buf.name, command:text } });
el('chat-input').value = '';
}
function onInputKey(e) {
if (e.key === 'Tab') {
e.preventDefault();
doTabComplete();
return;
}
if (e.key !== 'Shift') { tab.matches = []; tab.pos = -1; }
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); sendInput();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (hist.pos === -1) hist.draft = el('chat-input').value;
hist.pos = Math.min(hist.pos+1, hist.lines.length-1);
if (hist.lines[hist.pos] !== undefined) el('chat-input').value = hist.lines[hist.pos];
} else if (e.key === 'ArrowDown') {
e.preventDefault();
hist.pos = Math.max(hist.pos-1, -1);
el('chat-input').value = hist.pos === -1 ? hist.draft : hist.lines[hist.pos];
}
}
function doTabComplete() {
const input = el('chat-input');
const val = input.value;
const caret = input.selectionStart;
const before = val.slice(0, caret);
const tokenMatch = before.match(/(\S+)$/);
const token = tokenMatch ? tokenMatch[1] : '';
if (!token) return;
const lower = token.toLowerCase();
if (tab.matches.length === 0 || tab.stem !== lower) {
const buf = state.buffers.get(state.activeBufferId);
if (!buf) return;
let candidates = [];
// Channel completion: token starts with # OR previous word is /join
const prevWord = before.slice(0, before.length - token.length).trim().split(/\s+/).pop() || '';
const wantChannel = token.startsWith('#') || prevWord.toLowerCase() === '/join';
if (wantChannel) {
// Collect all known channel buffer short names
candidates = [...state.buffers.values()]
.filter(b => {
const lv = b.local_variables || {};
return lv.type === 'channel' || (b.short_name||'').startsWith('#');
})
.map(b => b.short_name || b.name);
} else {
// Nick completion
candidates = Object.values(buf.nicks || {}).map(n => n.name);
}
tab.matches = candidates.filter(c => c.toLowerCase().startsWith(lower));
tab.matches.sort((a,b) => a.localeCompare(b, undefined, {sensitivity:'base'}));
tab.pos = -1;
tab.stem = lower;
if (tab.matches.length === 0) return;
}
tab.pos = (tab.pos + 1) % tab.matches.length;
const match = tab.matches[tab.pos];
// Nick at start of line gets ": " suffix; channels and mid-line nicks get space
const atStart = before.trimStart() === token;
const isNick = !match.startsWith('#');
const suffix = (atStart && isNick) ? ': ' : ' ';
const completed = before.slice(0, before.length - token.length) + match + suffix;
input.value = completed + val.slice(caret);
input.selectionStart = input.selectionEnd = completed.length;
}
// ─── UI helpers ───────────────────────────────────────────────────────────────
function showScreen(name) {
el('connect-screen').style.display = name === 'connect' ? '' : 'none';
el('chat-screen').style.display = name === 'chat' ? '' : 'none';
}
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 fmtTime(iso) {
try { return new Date(iso).toLocaleTimeString([],
{hour:'2-digit',minute:'2-digit',second:'2-digit',hour12:false}); }
catch { return ''; }
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ─── Theme ────────────────────────────────────────────────────────────────────
function initTheme() { setTheme(localStorage.getItem('cathode_theme') || 'dark'); }
function setTheme(t) {
document.documentElement.setAttribute('data-theme', t);
localStorage.setItem('cathode_theme', t);
el('theme-toggle').textContent = t === 'dark' ? '◐ LIGHT' : '◑ DARK';
}
function toggleTheme() {
const cur = document.documentElement.getAttribute('data-theme') || 'dark';
setTheme(cur === 'dark' ? 'light' : 'dark');
// Re-render active buffer so safeFg() picks up the new theme
const buf = state.buffers.get(state.activeBufferId);
if (buf) renderMessages(buf);
}
// ─── Port warning / cert ──────────────────────────────────────────────────────
function checkPort() {
const port = parseInt(el('port').value, 10);
const show = BLOCKED_PORTS.has(port);
el('port-warning').textContent = show
? `⚠ Port ${port} is blocked by browsers. Use a different port (e.g. 9000).` : '';
el('port-warning').style.display = show ? 'block' : 'none';
}
function openCertPage() {
const host = el('host').value.trim();
const port = parseInt(el('port').value, 10);
if (!host || !port) return alert('Enter host and port first.');
window.open(`https://${host}:${port}/api/version`, '_blank');
}
// ─── Bootstrap ───────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
initTheme();
// Operator config — read from config.js via window.CATHODE_CONFIG.
// Config values take precedence over anything in localStorage.
const cfg = window.CATHODE_CONFIG || {};
if (cfg.uploadBackend) {
state.settings.uploadBackend = cfg.uploadBackend;
state.settings.filehostUrl = cfg.filehostUrl || '';
state.settings.imgurClientId = cfg.imgurClientId || '';
// Hide upload section in settings so users can't override it
const sec = document.getElementById('settings-upload-section');
if (sec) sec.style.display = 'none';
}
if (cfg.prefixAlignMax) {
state.prefixAlignMax = cfg.prefixAlignMax;
state.settings.prefixAlignMax = cfg.prefixAlignMax;
}
const s = state.settings;
if (s.host) el('host').value = s.host;
if (s.port) el('port').value = s.port;
if (s.tls !== undefined) el('tls').checked = s.tls;
showScreen('connect');
setStatus('disconnected','DISCONNECTED');
el('disconnect-btn').style.display = 'none';
// HTTPS context — ws:// is blocked; show a brief informational note
if (location.protocol === 'https:') {
el('http-notice').style.display = '';
el('tls-locked-note').style.display = '';
}
// Media preview — delegated click on #messages
// Media preview — document-level delegation
document.addEventListener('click', e => {
const btn = e.target.closest('.media-toggle');
if (!btn) return;
const url = btn.dataset.url.replace(/&amp;/g,'&');
const type = btn.dataset.type;
const existing = btn.nextElementSibling;
if (existing && existing.classList.contains('media-preview')) {
existing.remove();
btn.textContent = type === 'img' ? 'Show Image' : 'Show Video';
return;
}
const wrap = document.createElement('span');
wrap.className = 'media-preview';
if (type === 'img') {
const img = document.createElement('img');
img.src = url; img.className = 'preview-img'; img.alt = 'image';
img.title = 'Click to open full size';
img.addEventListener('click', () => window.open(url, '_blank'));
wrap.appendChild(img);
} else {
const vid = document.createElement('video');
vid.src = url; vid.controls = true; vid.className = 'preview-vid';
wrap.appendChild(vid);
}
btn.after(wrap); btn.textContent = type === 'img' ? 'Hide Image' : 'Hide Video';
});
el('smartfilter-btn').addEventListener('click', toggleSmartFilter);
// Buffer close buttons — delegated
el('buffer-list').addEventListener('click', e => {
const btn = e.target.closest('.buf-close');
if (!btn) return;
e.stopPropagation();
const id = Number(btn.dataset.id);
const buf = state.buffers.get(id);
if (!buf) return;
wsSend({ request:'POST /api/input', body:{ buffer_name: buf.name, command: '/close' } });
});
// Join button in sidebar footer
el('sidebar-join-btn').addEventListener('click', () => {
const ch = prompt('Channel to join (e.g. #weechat):');
if (!ch) return;
// Find a server buffer to send /join through, else use active buffer
const buf = state.buffers.get(state.activeBufferId);
if (!buf) return;
wsSend({ request:'POST /api/input', body:{ buffer_name: buf.name, command: '/join ' + ch } });
});
el('connect-btn') .addEventListener('click', connect);
el('disconnect-btn').addEventListener('click', disconnect);
el('theme-toggle') .addEventListener('click', toggleTheme);
el('cert-btn') .addEventListener('click', openCertPage);
el('send-btn') .addEventListener('click', sendInput);
el('chat-input') .addEventListener('keydown', onInputKey);
el('port') .addEventListener('input', checkPort);
// Upload
el('upload-btn') .addEventListener('click', () => el('upload-file').click());
el('upload-file').addEventListener('change', e => {
const file = e.target.files[0];
if (file) { uploadFile(file); e.target.value = ''; }
});
initDragDrop();
// Settings
el('settings-btn') .addEventListener('click', openSettings);
el('settings-close').addEventListener('click', closeSettings);
el('settings-save') .addEventListener('click', saveSettingsPanel);
el('s-upload-backend').addEventListener('change', updateSettingsBackendVis);
el('settings-overlay').addEventListener('click', e => {
if (e.target === el('settings-overlay')) closeSettings();
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeSettings();
});
// Load saved prefix_align_max
if (s.prefixAlignMax) state.prefixAlignMax = s.prefixAlignMax;
['host','port','password'].forEach(id =>
el(id).addEventListener('keydown', e => { if (e.key === 'Enter') connect(); })
);
});