/** * WebIRC Chat - Client-side TypeScript * * @package WebIRC_Chat * @license GPL-2.0-or-later * @since 0.1.0 */ import { readConfig, normalizeHostPort, wsUrlFromConfig } from './config'; import { buildUI, logLine } from './ui'; import { handleCommand } from './commands'; import { processIrcData } from './messageHandler'; import type { Config, UiElements } from './types'; /** * Debug logging utility that respects the debug setting. * Only controls chat screen debug messages, not console logs. * * @since 0.1.0 */ function debugLog(ui: UiElements, message: string, cfg: Config): void { if (cfg.debugLogs) { logLine(ui.log, `[DEBUG] ${message}`, 'notice'); } } (() => { 'use strict'; const root = document.querySelector('.chat-webirc') as HTMLElement | null; if (!root) return; const cfg: Config = readConfig(root); const hostPort = normalizeHostPort(cfg.server); const ui = buildUI(root, hostPort, cfg); let ws: WebSocket | null; let nick = `wp${Math.floor(Math.random() * 10000)}`; let currentChannel = cfg.channel; let pongTimer: number | null = null; let lastAttemptedNick = nick; // Debug initial values debugLog( ui, `Config channel: "${cfg.channel}", currentChannel: "${currentChannel}"`, cfg ); /** * Update nickname display in UI. * * @since 0.1.0 */ function updateNickDisplay(newNick: string): void { ui.nickDisplay.textContent = newNick; } /** * Update channel display in UI. * * @since 0.1.0 */ function updateChannelDisplay(channel: string): void { currentChannel = channel; (ui.root.querySelector('.irc-channel') as HTMLElement).textContent = channel; } /** * Send IRC command over WebSocket. * * @since 0.1.0 */ function send(line: string): void { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(line + '\r\n'); } } /** * Set nickname and update last attempted nick. * * @since 0.1.0 */ function setNick(newNick: string): void { nick = newNick; } /** * Set last attempted nickname. * * @since 0.1.0 */ function setLastAttemptedNick(newNick: string): void { lastAttemptedNick = newNick; } /** * Update nickname for command context. * * @since 0.1.0 */ function updateNick(newNick: string): void { lastAttemptedNick = newNick; } /** * Establish WebSocket connection to IRC server. * * @since 0.1.0 */ function connect(): void { const url = wsUrlFromConfig(cfg, hostPort); const subs = Array.isArray(cfg.subprotocols) && cfg.subprotocols.length ? cfg.subprotocols : undefined; debugLog(ui, `Attempting connection to: ${url}`, cfg); debugLog(ui, `Subprotocols: ${subs ? subs.join(', ') : 'none'}`, cfg); try { ws = new WebSocket(url, subs); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); logLine(ui.log, '• WebSocket init failed: ' + msg, 'err'); debugLog(ui, `built URL: ${url}`, cfg); debugLog(ui, `server spec: ${cfg.server}`, cfg); return; } ws.addEventListener('open', () => { logLine( ui.log, '• ' + ((cfg.i18n && cfg.i18n.connected) || 'connected'), 'sys' ); // Simple registration without capabilities for now send(`NICK ${nick}`); lastAttemptedNick = nick; send(`USER ${nick} 0 * :WebIRC User`); updateNickDisplay(nick); if (pongTimer) { window.clearInterval(pongTimer); } pongTimer = window.setInterval(() => { send('PING :keepalive'); }, 60000); }); ws.addEventListener('message', (ev: MessageEvent) => { const data = ev.data as string | Blob; if (data instanceof Blob) { const reader = new FileReader(); reader.onload = () => { processIrcData(String(reader.result || ''), { ui, cfg, send, nick, currentChannel, updateNickDisplay, updateChannelDisplay, setNick, setLastAttemptedNick, debugLog: (message: string) => debugLog(ui, message, cfg), }); }; reader.readAsText(data); return; } processIrcData(String(data), { ui, cfg, send, nick, currentChannel, updateNickDisplay, updateChannelDisplay, setNick, setLastAttemptedNick, debugLog: (message: string) => debugLog(ui, message, cfg), }); }); ws.addEventListener('close', (e: CloseEvent) => { logLine( ui.log, '• ' + ((cfg.i18n && cfg.i18n.disconnected) || 'disconnected'), 'sys' ); debugLog(ui, `Close code: ${e.code}, reason: ${e.reason}`, cfg); let codeDesc = ''; switch (e.code) { case 1000: codeDesc = 'Normal closure'; break; case 1001: codeDesc = 'Going away'; break; case 1002: codeDesc = 'Protocol error'; break; case 1003: codeDesc = 'Unsupported data'; break; case 1006: codeDesc = 'Abnormal closure (network/server issue)'; break; case 1011: codeDesc = 'Server error'; break; default: codeDesc = 'Unknown'; } debugLog(ui, `Close reason: ${codeDesc}`, cfg); if (pongTimer) { window.clearInterval(pongTimer); pongTimer = null; } }); ws.addEventListener('error', (e: Event) => { logLine( ui.log, '• ' + ((cfg.i18n && cfg.i18n.error) || 'error'), 'err' ); debugLog(ui, `WebSocket error: ${e.type}`, cfg); }); } // Reconnect button handler ui.reconnect.addEventListener('click', () => { try { if (ws) { ws.close(); } } catch {} connect(); }); /** * Update submit button state based on input value. * * @since 0.1.0 */ function updateSubmitButton(): void { ui.submit.disabled = !ui.input.value.trim(); } // Initialize submit button state updateSubmitButton(); // Update submit button when input changes ui.input.addEventListener('input', updateSubmitButton); // Form submission handler (root.querySelector('.irc-form') as HTMLFormElement).addEventListener( 'submit', (e) => { e.preventDefault(); const val = ui.input.value.trim(); if (!val) return; // Handle IRC commands if (val.startsWith('/')) { handleCommand(val, { send, ui, cfg, nick, currentChannel, lastAttemptedNick, updateNick, }); } else { // Regular message debugLog( ui, `Sending message - currentChannel: "${currentChannel}", message: "${val}"`, cfg ); if (!currentChannel || currentChannel.trim() === '') { logLine( ui.log, '• Error: No channel joined. Use /join #channel first.', 'err' ); return; } const privmsgCommand = `PRIVMSG ${currentChannel} :${val}`; debugLog(ui, `Sending IRC command: ${privmsgCommand}`, cfg); send(privmsgCommand); logLine(ui.log, `${nick} ▸ ${val}`, 'self'); } ui.input.value = ''; updateSubmitButton(); } ); connect(); })();