import { state, saveSettings } from './state.js';
import { parseId } from './connection.js';
import { ansiToHtml, nickColorToCss, safeFg } from './ansi.js';
import { renderMessages, renderChatHeader, hideNewMsgBanner, appendLine } from './chat.js';
import { maybeNotify, updateTitle } from './notifications.js';
const el = id => document.getElementById(id);
const esc = s => String(s).replace(/&/g,'&').replace(//g,'>');
// ─── Buffer events ────────────────────────────────────────────────────────────
export function onBufOpened(buf) {
if (!buf) return;
const id = parseId(buf.id);
buf = { ...buf, id };
state.buffers.set(id, { ...buf, lines: buf.lines||[], nicks:{}, unread:0, highlight:0 });
if (!state.smartFilter.has(id)) state.smartFilter.set(id, true);
rebuildBufList();
if (state.activeBufferId == null) activateBuffer(id);
}
export function onBufUpdated(buf) {
if (!buf) return;
const id = parseId(buf.id);
const b = state.buffers.get(id);
if (!b) return;
Object.assign(b, { ...buf, id });
paintNode(id);
if (state.activeBufferId === id) renderChatHeader();
}
export function onBufCleared(rawId) {
const id = parseId(rawId);
const b = state.buffers.get(id);
if (b) { b.lines = []; if (state.activeBufferId === id) el('messages').innerHTML = ''; }
}
export function onBufClosed(rawId) {
const id = parseId(rawId);
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 = ''; }
}
}
export function onLineAdded(rawId, line) {
if (!line) return;
const id = parseId(rawId);
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);
}
maybeNotify(b, line, activateBuffer);
updateTitle();
}
// ─── Nick events ──────────────────────────────────────────────────────────────
export 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);
}
export function onNickAdded(rawId, nick) {
const id = parseId(rawId);
const b = state.buffers.get(id);
if (!b || !nick) return;
b.nicks[nick.id] = nick;
if (state.activeBufferId === id) renderNicklist(b);
}
export function onNickRemoved(rawId, nick) {
const id = parseId(rawId);
const b = state.buffers.get(id);
if (!b || !nick) return;
delete b.nicks[nick.id];
if (state.activeBufferId === id) renderNicklist(b);
}
export function onGroupChanged(rawId) {
const id = parseId(rawId);
const b = state.buffers.get(id);
if (b && state.activeBufferId === id) renderNicklist(b);
}
// ─── Nicklist ─────────────────────────────────────────────────────────────────
export 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';
const pfxChar = (nick.prefix && nick.prefix.trim()) ? esc(nick.prefix) : ' ';
const pfxHtml = nick.prefix_color
? `${pfxChar}`
: `${pfxChar}`;
const nameHtml = nick.color
? `${esc(nick.name)}`
: `${esc(nick.name)}`;
row.innerHTML = pfxHtml + nameHtml;
row.addEventListener('click', () => openNickMenu(nick, buf));
box.appendChild(row);
}
}
// ─── 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 myPfx = ownPrefix(buf);
const isOp = ['@','~','&'].includes(myPfx);
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', () => {
wsSendRef(a.cmd, buf.name);
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 || '') : '';
}
// wsSend reference — set by main.js after connection module loads
let wsSendRef = () => {};
export function setWsSend(fn) {
wsSendRef = (cmd, bufName) => fn({ request: 'POST /api/input', body: { buffer_name: bufName, command: cmd } });
}
// ─── Buffer list DOM ──────────────────────────────────────────────────────────
const bufNodes = new Map();
const bKey = id => 'b:' + id;
const gKey = key => 'g:' + key;
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;
}
export function rebuildBufList() {
const container = el('buffer-list');
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);
}
}
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 (String(buf.id) === String(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
? `${buf.highlight}`
: buf.unread > 0 ? `${buf.unread}` : '';
node.innerHTML =
`${buf.number}` +
`${esc(name)}${badge}` +
``;
}
function removeNode(id) {
const node = bufNodes.get(bKey(id));
if (node) { node.remove(); bufNodes.delete(bKey(id)); }
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';
node.addEventListener('click', () => activateBuffer(node.dataset.id));
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 =
`${buf.number}` +
`${esc(name)}` +
``;
return node;
}
// ─── Activate buffer ──────────────────────────────────────────────────────────
export 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();
// Sync read position back to WeeChat by switching to the buffer there too.
// This marks lines as read in WeeChat's state, updating last_read_line_id.
// We send it as a direct API input command rather than going through the input box.
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
state.ws.send(JSON.stringify({
request: 'POST /api/input',
body: { buffer_name: 'core.weechat', command: `/buffer ${buf.name}` }
}));
}
}