(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 = '
'; } else if (state.markdown && typeof marked !== 'undefined') { var raw = marked.parse(msg.content); bodyHtml = sanitizeHtml(raw); } else { bodyHtml = formatMessageSimple(msg.content); } div.innerHTML = '$1');
escaped = escaped.replace(/\*\*([^*]+)\*\*/g, '$1');
escaped = escaped.replace(/\*([^*]+)\*/g, '$1');
escaped = escaped.replace(/~~([^~]+)~~/g, '