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}` } })); } }