(function () { 'use strict'; const EMOJI_LIST = ['๐Ÿ˜€', '๐Ÿ˜ƒ', '๐Ÿ˜„', '๐Ÿ˜', '๐Ÿ˜…', '๐Ÿ˜‚', '๐Ÿคฃ', '๐Ÿ˜Š', '๐Ÿ˜‡', '๐Ÿ™‚', '๐Ÿ˜‰', '๐Ÿ˜Œ', '๐Ÿ˜', '๐Ÿฅฐ', '๐Ÿ˜˜', '๐Ÿ˜‹', '๐Ÿ˜›', '๐Ÿ˜œ', '๐Ÿคช', '๐Ÿ˜', '๐Ÿค—', '๐Ÿคญ', '๐Ÿคซ', '๐Ÿค”', '๐Ÿ˜', '๐Ÿ˜‘', '๐Ÿ˜ถ', '๐Ÿ˜', '๐Ÿ˜’', '๐Ÿ™„', '๐Ÿ˜ฌ', '๐Ÿ˜Œ', '๐Ÿ˜”', '๐Ÿ˜ช', '๐Ÿ˜ด', '๐Ÿ˜ท', '๐Ÿค’', '๐Ÿค•', '๐Ÿคข', '๐Ÿฅต', '๐Ÿฅถ', '๐Ÿฅด', '๐Ÿ˜ต', '๐Ÿคฏ', '๐Ÿค ', '๐Ÿฅณ', '๐Ÿ˜Ž', '๐Ÿค“', '๐Ÿง', '๐Ÿ˜•', '๐Ÿ˜Ÿ', '๐Ÿ˜ฎ', '๐Ÿ˜ฏ', '๐Ÿ˜ฒ', '๐Ÿ˜ณ', '๐Ÿฅบ', '๐Ÿ˜จ', '๐Ÿ˜ฐ', '๐Ÿ˜ฅ', '๐Ÿ˜ข', '๐Ÿ˜ญ', '๐Ÿ˜ฑ', '๐Ÿ˜–', '๐Ÿ˜ฃ', '๐Ÿ˜ž', '๐Ÿ˜“', '๐Ÿ˜ฉ', '๐Ÿ˜ซ', '๐Ÿฅฑ', '๐Ÿ˜ค', '๐Ÿ˜ก', '๐Ÿ˜ ', '๐Ÿคฌ', '๐Ÿ˜ˆ', '๐Ÿ’€', '๐Ÿ’ฉ', '๐Ÿคก', '๐Ÿ‘ป', '๐Ÿ‘ฝ', '๐Ÿค–', '๐Ÿ‘', '๐Ÿ‘Ž', '๐Ÿ‘', '๐Ÿ™Œ', '๐Ÿค', '๐Ÿ™', '๐Ÿ’ช', '๐Ÿค˜', 'โค๏ธ', '๐Ÿงก', '๐Ÿ’›', '๐Ÿ’š', '๐Ÿ’™', '๐Ÿ’œ', '๐Ÿ–ค', '๐Ÿ’”', '๐Ÿ’•', '๐Ÿ’ž', '๐Ÿ’“', '๐Ÿ’—', '๐Ÿ’–', 'โญ', '๐ŸŒŸ', 'โœจ', '๐Ÿ’ซ', '๐Ÿ”ฅ', '๐Ÿ’ฅ', '๐ŸŽ‰', '๐ŸŽŠ', '๐ŸŽˆ', '๐ŸŽ', '๐Ÿ†', '๐Ÿš€', '๐Ÿ’ป', '๐Ÿ“ฑ', '๐Ÿ“ง', '๐Ÿ”—', '๐Ÿ“', '๐Ÿ”’', '๐ŸŒˆ', 'โ˜€๏ธ', '๐ŸŒ™', 'โšก', '๐Ÿ’ก', '๐ŸŽต', '๐ŸŽฎ', '๐ŸŽฒ']; const MAX_FILE_BYTES = Math.floor(1.5 * 1024 * 1024); const MAX_MESSAGES_PER_ROOM = 500; const SYNC_MESSAGES_PER_ROOM = 80; const TYPING_EXPIRE_MS = 3500; const AVATAR_COLORS = ['#2dd4bf', '#38bdf8', '#60a5fa', '#34d399', '#fbbf24', '#fb7185', '#a78bfa', '#f472b6']; const SETTINGS_KEY = 'chatAdvanced_settings'; const NICK_KEY = 'chatAdvanced_nickname'; const SEED_KEY = 'chatAdvanced_seedHex'; const SESSION_KEY = 'chatAdvanced_session'; const TOPIC_KEY = 'chatAdvanced_topic'; const encoder = new TextEncoder(); const decoder = new TextDecoder(); if (typeof marked !== 'undefined') { try { marked.setOptions({ gfm: true, breaks: true }); } catch (_) {} } const state = { swarm: null, swarmId: '', topic: 'bridge-swarm-advanced-v1', connected: false, connections: new Map(), nickname: '', publicKey: '', currentRoom: 'lobby', rooms: new Map([['lobby', { topic: 'General discussion', createdBy: null }]]), users: new Map(), messages: new Map([['lobby', []]]), typingByKey: new Map(), typingTimeout: null, lastTypingSent: 0, autoScroll: true, notifications: false, sounds: true, markdown: true, sharedFiles: new Map(), fileBytes: new Map(), audioCtx: null, sessionSaveTimer: null, resuming: false, }; const elements = {}; const composeControls = []; function $(id) { return document.getElementById(id); } function init() { cacheElements(); setupEventListeners(); loadSettings(); restoreUiFromSession(); renderRoomList(); renderFileList(); setComposeEnabled(false); showJoinModal(); setStatus('Waiting for BridgeSwarmโ€ฆ'); updateConnectionStatus('offline'); BridgeSwarmExamples.waitForBridgeSwarm() .then(function () { var sess = loadSession(); if (sess && sess.active && sess.topic) { setStatus('Restoring sessionโ€ฆ'); return resumeSession(sess); } setStatus('Ready โ€” join a topic to chat.'); }) .catch(function (err) { setStatus(err.message || 'BridgeSwarm not available', true); setJoinError(err.message || 'BridgeSwarm extension / host not ready.'); }); window.addEventListener('bridge-swarm-host-disconnect', onHostDisconnect); window.addEventListener('beforeunload', function () { if (state.connected) saveSession(true); }); } function cacheElements() { const ids = [ 'status', 'connectionStatus', 'nickname', 'publicKey', 'avatar', 'roomList', 'userList', 'userCount', 'messages', 'messagesEmpty', 'messageInput', 'typingIndicator', 'typingUsers', 'currentRoom', 'roomTopic', 'memberCount', 'messageCount', 'peerCount', 'fileList', 'filesEmpty', 'joinModal', 'joinTopic', 'joinNickname', 'btnJoinChat', 'joinError', 'createRoomModal', 'newRoomName', 'newRoomTopic', 'btnCreateRoom', 'btnCancelCreate', 'btnConfirmCreate', 'btnSend', 'btnToggleEmoji', 'btnSendFile', 'btnClearChat', 'btnLeave', 'btnBold', 'btnItalic', 'btnStrike', 'btnCode', 'btnCodeBlock', 'btnLink', 'btnList', 'btnQuote', 'btnEmoji2', 'fileInput', 'chkNotifications', 'chkSounds', 'chkAutoScroll', 'chkMarkdown', 'toastContainer', 'btnToggleLeft', 'btnToggleRight', 'drawerBackdrop', 'leftSidebar', 'rightSidebar', 'appContainer', 'mobileTitle', ]; ids.forEach(function (id) { elements[id] = $(id); }); composeControls.push( elements.messageInput, elements.btnSend, elements.btnToggleEmoji, elements.btnSendFile, elements.btnClearChat, elements.btnCreateRoom, elements.nickname, elements.btnBold, elements.btnItalic, elements.btnStrike, elements.btnCode, elements.btnCodeBlock, elements.btnLink, elements.btnList, elements.btnQuote, elements.btnEmoji2 ); } function setupEventListeners() { elements.btnJoinChat.addEventListener('click', joinChat); elements.joinNickname.addEventListener('keydown', function (e) { if (e.key === 'Enter') joinChat(); }); elements.joinTopic.addEventListener('keydown', function (e) { if (e.key === 'Enter') joinChat(); }); elements.btnCreateRoom.addEventListener('click', function () { elements.createRoomModal.classList.remove('hidden'); elements.newRoomName.focus(); }); elements.btnCancelCreate.addEventListener('click', closeCreateRoomModal); elements.btnConfirmCreate.addEventListener('click', createRoom); elements.btnLeave.addEventListener('click', leaveChat); elements.btnSend.addEventListener('click', sendMessage); elements.messageInput.addEventListener('keydown', function (e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } if (e.ctrlKey || e.metaKey) { if (e.key === 'b') { e.preventDefault(); wrapText('**'); } else if (e.key === 'i') { e.preventDefault(); wrapText('*'); } else if (e.key === 'k') { e.preventDefault(); wrapText('`'); } } }); elements.messageInput.addEventListener('input', autoResize); elements.messageInput.addEventListener('input', handleTyping); elements.btnToggleEmoji.addEventListener('click', function (e) { e.stopPropagation(); toggleEmojiPicker(); }); elements.btnEmoji2.addEventListener('click', function (e) { e.stopPropagation(); toggleEmojiPicker(); }); elements.btnSendFile.addEventListener('click', function () { elements.fileInput.click(); }); elements.fileInput.addEventListener('change', handleFileSelect); elements.btnClearChat.addEventListener('click', clearChat); elements.btnBold.addEventListener('click', function () { wrapText('**'); }); elements.btnItalic.addEventListener('click', function () { wrapText('*'); }); elements.btnStrike.addEventListener('click', function () { wrapText('~~'); }); elements.btnCode.addEventListener('click', function () { wrapText('`'); }); elements.btnCodeBlock.addEventListener('click', function () { wrapText('\n```\n', '\n```\n'); }); elements.btnLink.addEventListener('click', insertLink); elements.btnList.addEventListener('click', function () { wrapText('\n- ', ''); }); elements.btnQuote.addEventListener('click', function () { wrapText('\n> ', ''); }); elements.chkNotifications.addEventListener('change', function (e) { state.notifications = e.target.checked; saveSettings(); }); elements.chkSounds.addEventListener('change', function (e) { state.sounds = e.target.checked; saveSettings(); }); elements.chkAutoScroll.addEventListener('change', function (e) { state.autoScroll = e.target.checked; saveSettings(); }); elements.chkMarkdown.addEventListener('change', function (e) { state.markdown = e.target.checked; saveSettings(); renderCurrentRoomMessages(); }); elements.publicKey.addEventListener('click', copyPublicKey); elements.nickname.addEventListener('change', updateNickname); document.addEventListener('click', function (e) { var picker = document.querySelector('.emoji-picker'); if (!picker) return; if (picker.contains(e.target)) return; if (elements.btnToggleEmoji.contains(e.target) || elements.btnEmoji2.contains(e.target)) return; picker.remove(); }); document.addEventListener('keydown', function (e) { if (e.key !== 'Escape') return; closeCreateRoomModal(); var picker = document.querySelector('.emoji-picker'); if (picker) picker.remove(); closeDrawers(); }); elements.btnToggleLeft.addEventListener('click', function () { toggleDrawer('left'); }); elements.btnToggleRight.addEventListener('click', function () { toggleDrawer('right'); }); elements.drawerBackdrop.addEventListener('click', closeDrawers); } function loadSettings() { try { var settings = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}'); state.notifications = settings.notifications === true; state.sounds = settings.sounds !== false; state.autoScroll = settings.autoScroll !== false; state.markdown = settings.markdown !== false; elements.chkNotifications.checked = state.notifications; elements.chkSounds.checked = state.sounds; elements.chkAutoScroll.checked = state.autoScroll; elements.chkMarkdown.checked = state.markdown; } catch (_) {} try { var nick = localStorage.getItem(NICK_KEY); if (nick) elements.joinNickname.value = nick; } catch (_) {} try { var topic = localStorage.getItem(TOPIC_KEY); if (topic) elements.joinTopic.value = topic; } catch (_) {} } function saveSettings() { try { localStorage.setItem(SETTINGS_KEY, JSON.stringify({ notifications: state.notifications, sounds: state.sounds, autoScroll: state.autoScroll, markdown: state.markdown, })); } catch (_) {} } function getOrCreateSeedHex() { try { var existing = localStorage.getItem(SEED_KEY); if (existing && /^[0-9a-f]{64}$/i.test(existing)) return existing.toLowerCase(); } catch (_) {} var bytes = new Uint8Array(32); if (window.crypto && crypto.getRandomValues) crypto.getRandomValues(bytes); else for (var i = 0; i < 32; i++) bytes[i] = Math.floor(Math.random() * 256); var hex = Array.prototype.map.call(bytes, function (b) { return b.toString(16).padStart(2, '0'); }).join(''); try { localStorage.setItem(SEED_KEY, hex); } catch (_) {} return hex; } function loadSession() { try { var raw = sessionStorage.getItem(SESSION_KEY); if (!raw) return null; return JSON.parse(raw); } catch (_) { return null; } } function clearSession() { try { sessionStorage.removeItem(SESSION_KEY); } catch (_) {} } function scheduleSaveSession() { if (!state.connected) return; if (state.sessionSaveTimer) clearTimeout(state.sessionSaveTimer); state.sessionSaveTimer = setTimeout(function () { state.sessionSaveTimer = null; saveSession(false); }, 250); } function saveSession(immediate) { if (!state.connected && !immediate) return; if (!state.connected) return; var rooms = []; state.rooms.forEach(function (meta, name) { rooms.push({ name: name, topic: meta.topic || '', createdBy: meta.createdBy || null }); }); var messages = {}; state.messages.forEach(function (list, room) { messages[room] = list.slice(-SYNC_MESSAGES_PER_ROOM).map(function (m) { return { id: m.id, author: m.author, authorKey: m.authorKey || '', content: m.content, room: m.room || room, timestamp: m.timestamp || Date.now(), kind: m.kind || 'text', file: m.file ? { id: m.file.id, name: m.file.name, size: m.file.size || 0, mime: m.file.mime || 'application/octet-stream', authorKey: m.file.authorKey || '', author: m.file.author || '', room: m.file.room || room, } : null, }; }); }); var files = []; state.sharedFiles.forEach(function (f) { files.push({ id: f.id, name: f.name, size: f.size || 0, mime: f.mime || 'application/octet-stream', authorKey: f.authorKey || '', author: f.author || '', room: f.room || 'lobby', }); }); var payload = { active: true, swarmId: state.swarmId || (state.swarm && state.swarm.swarmId) || '', topic: state.topic, nickname: state.nickname, publicKey: state.publicKey, currentRoom: state.currentRoom, rooms: rooms, messages: messages, files: files, savedAt: Date.now(), }; try { sessionStorage.setItem(SESSION_KEY, JSON.stringify(payload)); } catch (_) {} } function restoreUiFromSession() { var sess = loadSession(); if (!sess) return; if (sess.nickname) { elements.joinNickname.value = sess.nickname; elements.nickname.value = sess.nickname; } if (sess.topic) elements.joinTopic.value = sess.topic; applyLocalSnapshot(sess); } function applyLocalSnapshot(sess) { if (!sess) return; if (Array.isArray(sess.rooms)) { sess.rooms.forEach(function (r) { if (!r || !r.name) return; state.rooms.set(r.name, { topic: r.topic || '', createdBy: r.createdBy || null, }); if (!state.messages.has(r.name)) state.messages.set(r.name, []); }); } if (sess.messages && typeof sess.messages === 'object') { Object.keys(sess.messages).forEach(function (room) { if (!state.rooms.has(room)) { state.rooms.set(room, { topic: '', createdBy: null }); } var list = Array.isArray(sess.messages[room]) ? sess.messages[room] : []; state.messages.set(room, list.slice()); }); } if (Array.isArray(sess.files)) { sess.files.forEach(function (f) { if (!f || !f.id) return; state.sharedFiles.set(f.id, f); }); } if (sess.currentRoom && state.rooms.has(sess.currentRoom)) { state.currentRoom = sess.currentRoom; } } function isPlaceholderNick(nick) { return !nick || nick === 'Peer' || nick === 'peer'; } function upsertUser(publicKey, nickname, status) { if (!publicKey || publicKey === state.publicKey) return; var existing = state.users.get(publicKey); var nick = nickname; if (isPlaceholderNick(nick)) { nick = (existing && !isPlaceholderNick(existing.nickname)) ? existing.nickname : ''; } if (!nick) nick = (existing && existing.nickname) || 'Anonymous'; state.users.set(publicKey, { nickname: nick, publicKey: publicKey, status: status || 'online', lastSeen: Date.now(), }); } function setJoinError(msg) { if (!msg) { elements.joinError.classList.add('hidden'); elements.joinError.textContent = ''; return; } elements.joinError.textContent = msg; elements.joinError.classList.remove('hidden'); } function showJoinModal() { elements.joinModal.classList.remove('hidden'); elements.joinNickname.focus(); } function closeCreateRoomModal() { elements.createRoomModal.classList.add('hidden'); elements.newRoomName.value = ''; elements.newRoomTopic.value = ''; } function setComposeEnabled(on) { composeControls.forEach(function (el) { if (el) el.disabled = !on; }); elements.btnLeave.disabled = !on; } /* โ€”โ€”โ€” Wire / lifecycle โ€”โ€”โ€” */ async function startSwarm(topic, nickname, opts) { opts = opts || {}; var resume = !!opts.resume; var swarmId = opts.swarmId || state.swarmId || ('swarm_chat_' + Math.random().toString(36).slice(2, 10)); var seedHex = getOrCreateSeedHex(); state.topic = topic; state.nickname = nickname; state.swarmId = swarmId; state.resuming = resume; elements.nickname.value = nickname; try { localStorage.setItem(NICK_KEY, nickname); } catch (_) {} try { localStorage.setItem(TOPIC_KEY, topic); } catch (_) {} state.swarm = new window.BridgeSwarm({ appName: 'chat-advanced', swarmId: swarmId, seedHex: seedHex, }); state.swarm.on('connection', handleConnection); state.swarm.on('error', function (err) { showToast('Swarm error: ' + (err && err.message ? err.message : err), 'error'); }); // Resolve identity BEFORE joining so handshakes never send an empty publicKey var pubKey = await state.swarm.getPublicKey(); state.publicKey = pubKey; elements.publicKey.textContent = 'Your ID: ' + pubKey.slice(0, 16) + 'โ€ฆ'; updateAvatar(nickname, pubKey); state.users.set(pubKey, { nickname: nickname, publicKey: pubKey, status: 'online', lastSeen: Date.now(), }); await state.swarm.join(topic); if (typeof state.swarm.resumeConnections === 'function') { await state.swarm.resumeConnections(); } state.connected = true; elements.joinModal.classList.add('hidden'); setComposeEnabled(true); setStatus('Connected ยท topic "' + topic + '"'); updateConnectionStatus('connected'); if (state.currentRoom && state.rooms.has(state.currentRoom)) { switchRoom(state.currentRoom); } else { renderRoomList(); renderCurrentRoomMessages(); } renderFileList(); updateUserList(); updateCounts(); ensureMessagesEmpty(); broadcastPresence(); if (!resume) { broadcastSystem(nickname + ' joined'); showToast('Connected!', 'success'); } else { showToast('Session restored', 'success'); } saveSession(true); state.resuming = false; } async function joinChat() { setJoinError(''); if (typeof window.BridgeSwarm === 'undefined') { setJoinError('BridgeSwarm is not ready. Load the extension and native host, then retry.'); setStatus('Extension not ready', true); return; } if (state.swarm) return; var topic = elements.joinTopic.value.trim() || 'bridge-swarm-advanced-v1'; var nickname = elements.joinNickname.value.trim() || 'Anonymous'; elements.btnJoinChat.disabled = true; setStatus('Connectingโ€ฆ'); updateConnectionStatus('connecting'); try { await BridgeSwarmExamples.waitForBridgeSwarm(); if (typeof BridgeSwarm.ready === 'function') { await BridgeSwarm.ready(); } await startSwarm(topic, nickname, { resume: false }); } catch (err) { console.error('Failed to join:', err); if (state.swarm) { try { await state.swarm.destroy(); } catch (_) {} } state.swarm = null; state.connected = false; state.resuming = false; setJoinError(err.message || String(err)); setStatus('Failed: ' + (err.message || err), true); updateConnectionStatus('offline'); showJoinModal(); showToast('Failed to connect: ' + (err.message || err), 'error'); } finally { elements.btnJoinChat.disabled = false; } } async function resumeSession(sess) { if (!sess || state.swarm) return; setJoinError(''); elements.btnJoinChat.disabled = true; setStatus('Restoring sessionโ€ฆ'); updateConnectionStatus('connecting'); applyLocalSnapshot(sess); try { if (typeof BridgeSwarm.ready === 'function') { await BridgeSwarm.ready(); } await startSwarm( sess.topic || elements.joinTopic.value.trim() || 'bridge-swarm-advanced-v1', sess.nickname || elements.joinNickname.value.trim() || 'Anonymous', { resume: true, swarmId: sess.swarmId } ); } catch (err) { console.error('Failed to resume session:', err); clearSession(); if (state.swarm) { try { await state.swarm.destroy(); } catch (_) {} } state.swarm = null; state.connected = false; state.resuming = false; setStatus('Ready โ€” join a topic to chat.'); updateConnectionStatus('offline'); showJoinModal(); setJoinError('Could not restore session. Join again.'); } finally { elements.btnJoinChat.disabled = false; } } async function leaveChat() { if (!state.swarm) return; try { broadcastSystem(state.nickname + ' left'); await new Promise(function (r) { setTimeout(r, 80); }); try { await state.swarm.leave(state.topic); } catch (_) {} try { await state.swarm.destroy(); } catch (_) {} } finally { clearSession(); resetSession('Left chat. Join again when ready.'); } } function onHostDisconnect() { if (!state.swarm && !state.connected) return; showToast('Native host disconnected', 'error'); clearSession(); resetSession('Host disconnected. Rejoin when the host is back.'); } function resetSession(statusMsg) { state.swarm = null; state.swarmId = ''; state.connected = false; state.resuming = false; state.connections.clear(); state.users.clear(); state.typingByKey.clear(); state.publicKey = ''; state.fileBytes.clear(); if (state.sessionSaveTimer) { clearTimeout(state.sessionSaveTimer); state.sessionSaveTimer = null; } setComposeEnabled(false); updateConnectionStatus('offline'); setStatus(statusMsg || 'Disconnected', true); updateUserList(); updateCounts(); updateTypingIndicator(); elements.publicKey.textContent = 'Your ID: (join to generate)'; showJoinModal(); } function handleConnection(conn, peerInfo, meta) { var connId = conn.connId || ('conn_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8)); var peerKey = peerInfo && peerInfo.publicKey ? peerInfo.publicKey : ''; var resumed = !!(meta && meta.resumed) || state.resuming; // Wait for handshake for the display name โ€” never park "Peer" in the list if (peerKey && state.users.has(peerKey)) { upsertUser(peerKey, state.users.get(peerKey).nickname, 'online'); updateUserList(); } state.connections.set(connId, { conn: conn, peerInfo: peerInfo, publicKey: peerKey, resumed: resumed, }); updateCounts(); conn.on('data', function (data) { handleMessage(connId, data); }); conn.on('end', function () { handleDisconnect(connId); }); conn.on('error', function (err) { console.error('Connection error:', err); }); if (!state.publicKey || !state.nickname) return; sendToPeer(connId, { type: 'handshake', nickname: state.nickname, publicKey: state.publicKey, }); // Full state sync so late joiners get rooms, history, and file metadata sendToPeer(connId, buildStateSync()); sendToPeer(connId, { type: 'state-request', requesterKey: state.publicKey }); } function handleDisconnect(connId) { var entry = state.connections.get(connId); var peerKey = entry && (entry.publicKey || (entry.peerInfo && entry.peerInfo.publicKey)); var nick = peerKey && state.users.get(peerKey) ? state.users.get(peerKey).nickname : 'Peer'; state.connections.delete(connId); if (peerKey) { state.users.delete(peerKey); state.typingByKey.delete(peerKey); addSystemMessage(nick + ' left', true); } updateUserList(); updateCounts(); updateTypingIndicator(); } /* โ€”โ€”โ€” Protocol โ€”โ€”โ€” */ function writePayload(conn, obj) { return Promise.resolve(conn.write(encoder.encode(JSON.stringify(obj)))).catch(function (err) { console.warn('write failed', err); }); } function sendToPeer(connId, message) { var entry = state.connections.get(connId); if (!entry) return; writePayload(entry.conn, message); } function broadcast(message) { state.connections.forEach(function (entry) { writePayload(entry.conn, message); }); } function broadcastPresence() { var users = []; state.users.forEach(function (u) { users.push({ nickname: u.nickname, publicKey: u.publicKey, status: u.status || 'online', lastSeen: u.lastSeen || Date.now(), }); }); broadcast({ type: 'presence', users: users }); } function buildStateSync() { var rooms = []; state.rooms.forEach(function (meta, name) { rooms.push({ name: name, topic: meta.topic || '', createdBy: meta.createdBy || null, }); }); var messages = []; state.messages.forEach(function (list, room) { var slice = list.slice(-SYNC_MESSAGES_PER_ROOM); slice.forEach(function (m) { var copy = { id: m.id, author: m.author, authorKey: m.authorKey || '', content: m.content, room: m.room || room, timestamp: m.timestamp || Date.now(), kind: m.kind || 'text', }; if (m.file) { copy.file = { id: m.file.id, name: m.file.name, size: m.file.size || 0, mime: m.file.mime || 'application/octet-stream', authorKey: m.file.authorKey || m.authorKey || '', author: m.file.author || m.author || '', room: m.file.room || m.room || room, }; } messages.push(copy); }); }); var files = []; state.sharedFiles.forEach(function (f) { files.push({ id: f.id, name: f.name, size: f.size || 0, mime: f.mime || 'application/octet-stream', authorKey: f.authorKey || '', author: f.author || '', room: f.room || 'lobby', }); }); var users = []; state.users.forEach(function (u) { users.push({ nickname: u.nickname, publicKey: u.publicKey, status: u.status || 'online', lastSeen: u.lastSeen || Date.now(), }); }); return { type: 'state-sync', fromKey: state.publicKey, rooms: rooms, messages: messages, files: files, users: users, }; } function applyStateSync(msg) { if (!msg || msg.fromKey === state.publicKey) return; var changedRooms = false; var changedMsgs = false; var changedFiles = false; if (Array.isArray(msg.rooms)) { msg.rooms.forEach(function (r) { if (!r || !r.name) return; var name = String(r.name).trim(); if (!name) return; if (!state.rooms.has(name)) { state.rooms.set(name, { topic: r.topic || '', createdBy: r.createdBy || null, }); if (!state.messages.has(name)) state.messages.set(name, []); changedRooms = true; } else if (r.topic) { var meta = state.rooms.get(name); if (meta.topic !== r.topic) { meta.topic = r.topic; changedRooms = true; if (state.currentRoom === name) { elements.roomTopic.textContent = r.topic; } } } }); } if (Array.isArray(msg.messages)) { msg.messages.forEach(function (m) { if (!m || !m.id) return; var room = m.room || 'lobby'; if (!state.rooms.has(room)) { state.rooms.set(room, { topic: '', createdBy: null }); changedRooms = true; } if (!state.messages.has(room)) state.messages.set(room, []); var list = state.messages.get(room); if (list.some(function (x) { return x.id === m.id; })) return; var entry = { id: m.id, author: m.author || 'Unknown', authorKey: m.authorKey || '', content: m.content || '', room: room, timestamp: m.timestamp || Date.now(), kind: m.kind || (m.file ? 'file' : 'text'), file: m.file || null, }; list.push(entry); list.sort(function (a, b) { return (a.timestamp || 0) - (b.timestamp || 0); }); while (list.length > MAX_MESSAGES_PER_ROOM) list.shift(); changedMsgs = true; if (m.file && m.file.id && !state.sharedFiles.has(m.file.id)) { state.sharedFiles.set(m.file.id, { id: m.file.id, name: m.file.name || 'file', size: m.file.size || 0, mime: m.file.mime || 'application/octet-stream', authorKey: m.file.authorKey || m.authorKey || '', author: m.file.author || m.author || '', room: room, }); changedFiles = true; } }); } if (Array.isArray(msg.files)) { msg.files.forEach(function (f) { if (!f || !f.id) return; if (!state.sharedFiles.has(f.id)) { state.sharedFiles.set(f.id, { id: f.id, name: f.name || 'file', size: f.size || 0, mime: f.mime || 'application/octet-stream', authorKey: f.authorKey || '', author: f.author || '', room: f.room || 'lobby', }); changedFiles = true; } }); } if (Array.isArray(msg.users)) { handlePresence({ users: msg.users }); } if (changedRooms) renderRoomList(); if (changedFiles) renderFileList(); if (changedMsgs) { renderCurrentRoomMessages(); updateCounts(); } else if (changedRooms) { updateCounts(); } if (changedRooms || changedMsgs || changedFiles) scheduleSaveSession(); } function broadcastTyping(isTyping) { broadcast({ type: 'typing', author: state.nickname, authorKey: state.publicKey, isTyping: !!isTyping, room: state.currentRoom, }); } function broadcastSystem(text) { var msg = { type: 'system', id: 'sys_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6), content: text, room: state.currentRoom, timestamp: Date.now(), }; broadcast(msg); addSystemMessage(text, false, msg); } async function handleMessage(connId, data) { try { var msg = JSON.parse(decoder.decode(data)); var type = msg.type; switch (type) { case 'handshake': handleHandshake(connId, msg); break; case 'state-sync': applyStateSync(msg); break; case 'state-request': sendToPeer(connId, buildStateSync()); break; case 'chat': await handleChatMessage(msg); break; case 'system': addSystemMessage(msg.content || '', false, msg); break; case 'typing': handleTypingIndicator(msg); break; case 'presence': handlePresence(msg); break; case 'room': case 'room join': handleRoomAnnounce(msg); break; case 'file': case 'file share': await handleFileAnnounce(msg); break; case 'file-request': case 'file request': await handleFileRequest(msg); break; case 'file-data': case 'file data': await handleFileData(msg); break; } } catch (e) { console.error('Failed to parse message:', e); } } function handleHandshake(connId, msg) { var entry = state.connections.get(connId); var peerKey = (msg && msg.publicKey) || (entry && entry.publicKey) || ''; if (entry) { entry.publicKey = peerKey || entry.publicKey; if (entry.peerInfo && peerKey) entry.peerInfo.publicKey = peerKey; // Merge transport key with handshake key if they differ (keep nick on both) if (entry.peerInfo && entry.peerInfo.publicKey && peerKey && entry.peerInfo.publicKey !== peerKey) { upsertUser(entry.peerInfo.publicKey, msg.nickname, 'online'); } } if (peerKey && peerKey !== state.publicKey) { var wasNew = !state.users.has(peerKey); upsertUser(peerKey, msg.nickname || 'Anonymous', 'online'); var skipJoinNotice = state.resuming || (entry && entry.resumed); if (wasNew && !skipJoinNotice) { addSystemMessage((msg.nickname || 'Someone') + ' joined', true); } } broadcastPresence(); // Reply with our full snapshot after handshake identity is known sendToPeer(connId, buildStateSync()); updateUserList(); updateCounts(); scheduleSaveSession(); } function handlePresence(msg) { if (!msg || !Array.isArray(msg.users)) return; msg.users.forEach(function (u) { if (!u || !u.publicKey || u.publicKey === state.publicKey) return; upsertUser(u.publicKey, u.nickname || 'Anonymous', u.status || 'online'); }); updateUserList(); updateCounts(); } function handleRoomAnnounce(msg) { var name = (msg.name || msg.room || '').trim(); if (!name) return; var topic = msg.topic || ''; if (!state.rooms.has(name)) { state.rooms.set(name, { topic: topic, createdBy: msg.authorKey || null }); if (!state.messages.has(name)) state.messages.set(name, []); renderRoomList(); showToast('Room #' + name + ' available', 'info'); } else if (topic) { var meta = state.rooms.get(name); meta.topic = topic; if (state.currentRoom === name) elements.roomTopic.textContent = topic; renderRoomList(); } } async function handleChatMessage(msg) { if (!msg || typeof msg.content !== 'string') return; if (msg.authorKey && msg.author) upsertUser(msg.authorKey, msg.author, 'online'); var message = { id: msg.id || ('msg_' + Date.now()), author: msg.author || 'Unknown', authorKey: msg.authorKey || '', content: msg.content, room: msg.room || 'lobby', timestamp: msg.timestamp || Date.now(), kind: 'text', file: msg.file || null, }; pushMessage(message); if (message.room === state.currentRoom) { await renderMessage(message); } if (state.notifications && msg.authorKey !== state.publicKey) { showToast(msg.author + ': ' + msg.content.slice(0, 50), 'info'); } if (state.sounds && msg.authorKey !== state.publicKey) { playNotificationSound(); } updateCounts(); } function addSystemMessage(text, localOnly, existing) { if (!text) return; var message = existing || { id: 'sys_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6), author: 'system', authorKey: '', content: text, room: state.currentRoom, timestamp: Date.now(), kind: 'system', }; message.kind = 'system'; pushMessage(message); if (message.room === state.currentRoom || !existing) { if (message.room === state.currentRoom) renderMessage(message); } updateCounts(); } function pushMessage(message) { if (!state.messages.has(message.room)) state.messages.set(message.room, []); var list = state.messages.get(message.room); if (list.some(function (m) { return m.id === message.id; })) return; list.push(message); while (list.length > MAX_MESSAGES_PER_ROOM) list.shift(); scheduleSaveSession(); } function handleTypingIndicator(msg) { if (!msg || msg.authorKey === state.publicKey) return; if (msg.room && msg.room !== state.currentRoom) return; var key = msg.authorKey || msg.author; if (!key) return; if (msg.authorKey && msg.author) upsertUser(msg.authorKey, msg.author, 'online'); var known = msg.authorKey && state.users.get(msg.authorKey); if (msg.isTyping) { state.typingByKey.set(key, { nickname: (known && known.nickname) || msg.author || 'Someone', expires: Date.now() + TYPING_EXPIRE_MS, }); } else { state.typingByKey.delete(key); } updateTypingIndicator(); } /* โ€”โ€”โ€” Files โ€”โ€”โ€” */ async function handleFileAnnounce(msg) { if (!msg || !msg.file || !msg.file.id) return; var file = { id: msg.file.id, name: msg.file.name || 'file', size: msg.file.size || 0, mime: msg.file.mime || 'application/octet-stream', authorKey: msg.authorKey || '', author: msg.author || 'Someone', room: msg.room || 'lobby', data: null, }; if (file.authorKey && file.author) upsertUser(file.authorKey, file.author, 'online'); // Legacy: if peer still embeds data, accept once if (msg.file.data && typeof msg.file.data === 'string') { state.fileBytes.set(file.id, msg.file.data); file.data = msg.file.data; } state.sharedFiles.set(file.id, file); var chatMsg = { id: msg.id || ('filemsg_' + file.id), author: msg.author || 'Unknown', authorKey: msg.authorKey || '', content: 'Shared a file: ' + file.name, room: msg.room || 'lobby', timestamp: msg.timestamp || Date.now(), kind: 'file', file: file, }; pushMessage(chatMsg); if (chatMsg.room === state.currentRoom) await renderMessage(chatMsg); renderFileList(); if (!state.fileBytes.has(file.id) && msg.authorKey && msg.authorKey !== state.publicKey) { var entry = findConnByPeerKey(msg.authorKey); if (entry) { writePayload(entry.conn, { type: 'file-request', fileId: file.id, requesterKey: state.publicKey, }); } } } async function handleFileRequest(msg) { var fileId = msg.fileId; var requesterKey = msg.requesterKey; if (!fileId || !requesterKey) return; var meta = state.sharedFiles.get(fileId); var data = state.fileBytes.get(fileId); if (!data) return; var entry = findConnByPeerKey(requesterKey); if (!entry) return; writePayload(entry.conn, { type: 'file-data', fileId: fileId, fileName: meta ? meta.name : 'file', fileSize: meta ? meta.size : 0, mime: meta ? meta.mime : 'application/octet-stream', data: data, }); } async function handleFileData(msg) { if (!msg || !msg.fileId || !msg.data) return; state.fileBytes.set(msg.fileId, msg.data); var meta = state.sharedFiles.get(msg.fileId); if (meta) { meta.data = msg.data; meta.size = msg.fileSize || meta.size; meta.name = msg.fileName || meta.name; } else { state.sharedFiles.set(msg.fileId, { id: msg.fileId, name: msg.fileName || 'file', size: msg.fileSize || 0, mime: msg.mime || 'application/octet-stream', data: msg.data, }); } renderFileList(); showToast('File ready: ' + (msg.fileName || 'file'), 'success'); } function findConnByPeerKey(peerKey) { var found = null; state.connections.forEach(function (entry) { var k = entry.publicKey || (entry.peerInfo && entry.peerInfo.publicKey); if (k === peerKey) found = entry; }); return found; } function handleFileSelect(e) { var file = e.target.files && e.target.files[0]; e.target.value = ''; if (!file || !state.connected) return; if (file.size > MAX_FILE_BYTES) { showToast('File too large (max ' + formatFileSize(MAX_FILE_BYTES) + ')', 'error'); return; } showToast('Reading fileโ€ฆ', 'info'); var reader = new FileReader(); reader.onload = function () { var base64 = String(reader.result || '').split(',')[1] || ''; var fileId = 'file_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6); state.fileBytes.set(fileId, base64); var meta = { id: fileId, name: file.name, size: file.size, mime: file.type || 'application/octet-stream', authorKey: state.publicKey, author: state.nickname, room: state.currentRoom, }; state.sharedFiles.set(fileId, meta); renderFileList(); var message = { type: 'file', id: 'msg_' + Date.now(), author: state.nickname, authorKey: state.publicKey, content: 'Shared a file: ' + file.name, room: state.currentRoom, timestamp: Date.now(), file: { id: fileId, name: file.name, size: file.size, mime: meta.mime, }, }; broadcast(message); handleFileAnnounce(message); showToast('File shared', 'success'); }; reader.onerror = function () { showToast('Failed to read file', 'error'); }; reader.readAsDataURL(file); } function downloadFile(file) { var data = state.fileBytes.get(file.id) || file.data; if (!data) { if (file.authorKey && file.authorKey !== state.publicKey) { var entry = findConnByPeerKey(file.authorKey); if (entry) { writePayload(entry.conn, { type: 'file-request', fileId: file.id, requesterKey: state.publicKey, }); showToast('Requesting file from peerโ€ฆ', 'info'); return; } } showToast('File data not available yet', 'error'); return; } try { var binary = atob(data); var bytes = new Uint8Array(binary.length); for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); var blob = new Blob([bytes], { type: file.mime || 'application/octet-stream' }); var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = file.name || 'download'; a.click(); setTimeout(function () { URL.revokeObjectURL(url); }, 2000); } catch (err) { showToast('Download failed: ' + err.message, 'error'); } } /* โ€”โ€”โ€” UI render โ€”โ€”โ€” */ async function sendMessage() { var content = elements.messageInput.value.trim(); if (!content || !state.connected) return; var message = { type: 'chat', id: 'msg_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8), author: state.nickname, authorKey: state.publicKey, content: content, room: state.currentRoom, timestamp: Date.now(), }; broadcast(message); await handleChatMessage(message); elements.messageInput.value = ''; elements.messageInput.style.height = 'auto'; broadcastTyping(false); } async function renderMessage(msg) { ensureMessagesEmpty(true); var div = document.createElement('div'); if (msg.kind === 'system') { div.className = 'message system-message'; div.textContent = msg.content; elements.messages.appendChild(div); scrollToBottom(); return; } div.className = 'message' + (msg.authorKey === state.publicKey ? ' own' : ''); var time = new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); var color = avatarColor(msg.authorKey || msg.author); var bodyHtml; if (msg.kind === 'file' && msg.file) { bodyHtml = '
' + '๐Ÿ“„' + '
' + escapeHtml(msg.file.name) + '
' + '' + formatFileSize(msg.file.size || 0) + '
' + '' + '
'; } else if (state.markdown && typeof marked !== 'undefined') { var raw = marked.parse(msg.content); bodyHtml = sanitizeHtml(raw); } else { bodyHtml = formatMessageSimple(msg.content); } div.innerHTML = '
' + escapeHtml((msg.author || '?')[0].toUpperCase()) + '
' + '
' + '
' + escapeHtml(msg.author) + '' + '' + escapeHtml(time) + '
' + '
' + bodyHtml + '
'; if (typeof hljs !== 'undefined') { div.querySelectorAll('pre code').forEach(function (block) { try { hljs.highlightElement(block); } catch (_) {} }); } var dl = div.querySelector('.btn-download'); if (dl) { dl.addEventListener('click', function () { var f = state.sharedFiles.get(dl.getAttribute('data-file-id')); if (f) downloadFile(f); }); } elements.messages.appendChild(div); scrollToBottom(); } function sanitizeHtml(html) { if (typeof DOMPurify !== 'undefined') { return DOMPurify.sanitize(html, { USE_PROFILES: { html: true }, FORBID_TAGS: ['style', 'script', 'iframe', 'object', 'embed', 'form'], FORBID_ATTR: ['style', 'onerror', 'onclick', 'onload'], ALLOW_DATA_ATTR: false, }); } return formatMessageSimple(String(html).replace(/<[^>]*>/g, '')); } function formatMessageSimple(text) { var escaped = escapeHtml(text); escaped = escaped.replace(/`([^`]+)`/g, '$1'); escaped = escaped.replace(/\*\*([^*]+)\*\*/g, '$1'); escaped = escaped.replace(/\*([^*]+)\*/g, '$1'); escaped = escaped.replace(/~~([^~]+)~~/g, '$1'); escaped = escaped.replace(/\n/g, '
'); return escaped; } function ensureMessagesEmpty(hide) { var empty = elements.messagesEmpty; if (!empty) return; if (hide) { empty.classList.add('hidden'); return; } var list = state.messages.get(state.currentRoom) || []; if (!state.connected && list.length === 0) { empty.textContent = 'Join a topic to start chatting. Open this page in a second tab to talk P2P.'; empty.classList.remove('hidden'); } else if (list.length === 0) { empty.textContent = 'No messages in #' + state.currentRoom + ' yet. Say hello.'; empty.classList.remove('hidden'); } else { empty.classList.add('hidden'); } } async function renderCurrentRoomMessages() { elements.messages.innerHTML = ''; var empty = document.createElement('div'); empty.className = 'empty-state'; empty.id = 'messagesEmpty'; elements.messages.appendChild(empty); elements.messagesEmpty = empty; var messages = state.messages.get(state.currentRoom) || []; if (messages.length === 0) { ensureMessagesEmpty(false); return; } empty.classList.add('hidden'); for (var i = 0; i < messages.length; i++) { await renderMessage(messages[i]); } } function updateUserList() { elements.userList.innerHTML = ''; var peers = []; state.users.forEach(function (u) { if (!u || !u.publicKey || u.publicKey === state.publicKey) return; if (isPlaceholderNick(u.nickname)) return; peers.push(u); }); // Also surface peers that have an open connection + known nick via connection map state.connections.forEach(function (entry) { var key = entry.publicKey || (entry.peerInfo && entry.peerInfo.publicKey); if (!key || key === state.publicKey) return; var u = state.users.get(key); if (u && !isPlaceholderNick(u.nickname) && peers.indexOf(u) === -1) peers.push(u); }); elements.userCount.textContent = '(' + peers.length + ')'; if (peers.length === 0) { var li = document.createElement('li'); li.className = 'empty-hint'; li.textContent = state.connected ? (state.connections.size > 0 ? 'Waiting for peer namesโ€ฆ' : 'No other peers online') : 'Not connected'; elements.userList.appendChild(li); return; } peers.sort(function (a, b) { return (a.nickname || '').localeCompare(b.nickname || ''); }); peers.forEach(function (u) { var li = document.createElement('li'); li.className = 'user-item'; var color = avatarColor(u.publicKey); var nick = u.nickname || 'Anonymous'; li.innerHTML = '' + escapeHtml(nick[0].toUpperCase()) + '' + '' + escapeHtml(nick) + '' + ''; elements.userList.appendChild(li); }); } function renderRoomList() { elements.roomList.innerHTML = ''; var names = Array.from(state.rooms.keys()).sort(function (a, b) { if (a === 'lobby') return -1; if (b === 'lobby') return 1; return a.localeCompare(b); }); names.forEach(function (name) { var li = document.createElement('li'); li.className = 'room' + (name === state.currentRoom ? ' active' : ''); li.setAttribute('role', 'option'); li.setAttribute('aria-selected', name === state.currentRoom ? 'true' : 'false'); li.tabIndex = 0; var count = (state.messages.get(name) || []).length; li.innerHTML = '#' + '' + escapeHtml(name) + '' + '' + count + ''; li.addEventListener('click', function () { switchRoom(name); closeDrawers(); }); li.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); switchRoom(name); closeDrawers(); } }); elements.roomList.appendChild(li); }); } function switchRoom(room) { if (!state.rooms.has(room)) return; state.currentRoom = room; elements.currentRoom.textContent = '#' + room; if (elements.mobileTitle) elements.mobileTitle.textContent = '#' + room; var roomData = state.rooms.get(room); elements.roomTopic.textContent = (roomData && roomData.topic) || ''; state.typingByKey.clear(); updateTypingIndicator(); renderCurrentRoomMessages(); renderRoomList(); updateCounts(); } function createRoom() { if (!state.connected) { showToast('Join a topic first', 'error'); return; } var name = elements.newRoomName.value.trim().replace(/\s+/g, '-').toLowerCase(); var topic = elements.newRoomTopic.value.trim(); if (!name) { showToast('Please enter a room name', 'error'); return; } if (!/^[a-z0-9._-]{1,30}$/.test(name)) { showToast('Use letters, numbers, . _ - only', 'error'); return; } if (!state.rooms.has(name)) { state.rooms.set(name, { topic: topic, createdBy: state.publicKey }); state.messages.set(name, []); broadcast({ type: 'room', name: name, topic: topic, authorKey: state.publicKey, }); // Keep peers fully aligned (rooms + history snapshot) broadcast(buildStateSync()); broadcastSystem(state.nickname + ' created #' + name); } else if (topic) { state.rooms.get(name).topic = topic; broadcast({ type: 'room', name: name, topic: topic, authorKey: state.publicKey, }); broadcast(buildStateSync()); } renderRoomList(); switchRoom(name); closeCreateRoomModal(); scheduleSaveSession(); showToast('Room #' + name + ' ready', 'success'); } function updateCounts() { var msgs = state.messages.get(state.currentRoom) || []; elements.messageCount.textContent = String(msgs.length); elements.peerCount.textContent = String(state.connections.size); // Members โ‰ˆ self + online peers (topic-wide; rooms are channels) var namedPeers = 0; state.users.forEach(function (u) { if (u.publicKey !== state.publicKey && !isPlaceholderNick(u.nickname)) namedPeers += 1; }); elements.memberCount.textContent = String(1 + namedPeers); renderRoomList(); } function updateTypingIndicator() { var now = Date.now(); state.typingByKey.forEach(function (v, k) { if (v.expires < now) state.typingByKey.delete(k); }); if (state.typingByKey.size > 0) { var names = []; state.typingByKey.forEach(function (v) { names.push(v.nickname); }); elements.typingUsers.textContent = names.slice(0, 3).join(', ') + (names.length > 3 ? ' and others' : ''); elements.typingIndicator.classList.add('active'); } else { elements.typingIndicator.classList.remove('active'); } } function renderFileList() { elements.fileList.innerHTML = ''; if (state.sharedFiles.size === 0) { var empty = document.createElement('li'); empty.className = 'empty-hint'; empty.id = 'filesEmpty'; empty.textContent = 'No shared files yet'; elements.fileList.appendChild(empty); elements.filesEmpty = empty; return; } state.sharedFiles.forEach(function (file) { var li = document.createElement('li'); li.className = 'file-item'; var ready = state.fileBytes.has(file.id) || file.data; li.innerHTML = '๐Ÿ“„' + '' + escapeHtml(file.name) + '' + '' + formatFileSize(file.size || 0) + (ready ? '' : ' ยท pending') + ''; li.addEventListener('click', function () { downloadFile(file); }); elements.fileList.appendChild(li); }); } function clearChat() { if (!confirm('Clear all messages in this room?')) return; state.messages.set(state.currentRoom, []); renderCurrentRoomMessages(); updateCounts(); } function copyPublicKey() { if (!state.publicKey) return; navigator.clipboard.writeText(state.publicKey).then(function () { showToast('Public key copied', 'success'); }).catch(function () { showToast('Could not copy', 'error'); }); } function updateNickname() { if (!state.connected) return; state.nickname = elements.nickname.value.trim() || 'Anonymous'; try { localStorage.setItem(NICK_KEY, state.nickname); } catch (_) {} updateAvatar(state.nickname, state.publicKey); if (state.users.has(state.publicKey)) { state.users.get(state.publicKey).nickname = state.nickname; } broadcastPresence(); scheduleSaveSession(); } function hashHue(str) { var h = 0; for (var i = 0; i < str.length; i++) h = ((h << 5) - h + str.charCodeAt(i)) | 0; return Math.abs(h); } function avatarColor(seed) { return AVATAR_COLORS[hashHue(String(seed || 'x')) % AVATAR_COLORS.length]; } function updateAvatar(name, key) { elements.avatar.style.background = avatarColor(key || name); elements.avatar.textContent = name ? name[0].toUpperCase() : '?'; } function handleTyping() { if (!state.connected) return; var now = Date.now(); if (now - state.lastTypingSent > 900) { state.lastTypingSent = now; broadcastTyping(true); } clearTimeout(state.typingTimeout); state.typingTimeout = setTimeout(function () { broadcastTyping(false); }, 2000); } function autoResize() { elements.messageInput.style.height = 'auto'; elements.messageInput.style.height = Math.min(elements.messageInput.scrollHeight, 120) + 'px'; } function toggleEmojiPicker() { var picker = document.querySelector('.emoji-picker'); if (picker) { picker.remove(); return; } picker = document.createElement('div'); picker.className = 'emoji-picker'; picker.setAttribute('role', 'listbox'); EMOJI_LIST.forEach(function (emoji) { var span = document.createElement('button'); span.type = 'button'; span.className = 'emoji'; span.textContent = emoji; span.setAttribute('aria-label', 'Insert ' + emoji); span.addEventListener('click', function () { elements.messageInput.value += emoji; elements.messageInput.focus(); picker.remove(); autoResize(); }); picker.appendChild(span); }); elements.messageInput.parentElement.appendChild(picker); } function wrapText(before, after) { if (after === undefined) after = before; var start = elements.messageInput.selectionStart; var end = elements.messageInput.selectionEnd; var text = elements.messageInput.value; var selected = text.slice(start, end); elements.messageInput.value = text.slice(0, start) + before + selected + after + text.slice(end); elements.messageInput.focus(); var newPos = start + before.length + selected.length; elements.messageInput.setSelectionRange(newPos, newPos); autoResize(); } function insertLink() { var start = elements.messageInput.selectionStart; var end = elements.messageInput.selectionEnd; var text = elements.messageInput.value; var selected = text.slice(start, end); var linkText = selected || 'link text'; elements.messageInput.value = text.slice(0, start) + '[' + linkText + '](https://)' + text.slice(end); elements.messageInput.focus(); var urlStart = start + linkText.length + 3; elements.messageInput.setSelectionRange(urlStart, urlStart + 8); autoResize(); } function setStatus(text, isError) { elements.status.textContent = text; elements.status.classList.toggle('error', !!isError); } function updateConnectionStatus(status) { elements.connectionStatus.className = 'connection-status ' + status; } function showToast(message, type) { var toast = document.createElement('div'); toast.className = 'toast ' + (type || 'info'); toast.textContent = message; elements.toastContainer.appendChild(toast); setTimeout(function () { toast.remove(); }, 3200); } function scrollToBottom() { if (state.autoScroll) { elements.messages.scrollTop = elements.messages.scrollHeight; } } function playNotificationSound() { try { if (!state.audioCtx) { state.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } var ctx = state.audioCtx; var oscillator = ctx.createOscillator(); var gainNode = ctx.createGain(); oscillator.connect(gainNode); gainNode.connect(ctx.destination); oscillator.frequency.value = 800; oscillator.type = 'sine'; gainNode.gain.value = 0.08; oscillator.start(); oscillator.stop(ctx.currentTime + 0.08); } catch (_) {} } function formatFileSize(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; } function escapeHtml(text) { var div = document.createElement('div'); div.textContent = text == null ? '' : String(text); return div.innerHTML; } function toggleDrawer(side) { var wantLeft = side === 'left'; var isLeftOpen = elements.appContainer.classList.contains('drawer-left'); var isRightOpen = elements.appContainer.classList.contains('drawer-right'); if (wantLeft) { if (isLeftOpen) { closeDrawers(); return; } elements.appContainer.classList.add('drawer-left'); elements.appContainer.classList.remove('drawer-right'); } else { if (isRightOpen) { closeDrawers(); return; } elements.appContainer.classList.add('drawer-right'); elements.appContainer.classList.remove('drawer-left'); } elements.drawerBackdrop.classList.remove('hidden'); elements.drawerBackdrop.setAttribute('aria-hidden', 'false'); } function closeDrawers() { elements.appContainer.classList.remove('drawer-left', 'drawer-right'); elements.drawerBackdrop.classList.add('hidden'); elements.drawerBackdrop.setAttribute('aria-hidden', 'true'); } setInterval(updateTypingIndicator, 1000); if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();