Files
BridgeSwarm/examples/chat-advanced/app.js
T
Raven Scott a130d0c32f
CI / Build & Test (push) Successful in 8m33s
Updates
2026-07-27 01:31:58 -04:00

1774 lines
58 KiB
JavaScript

(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 = '<div class="file-attachment">' +
'<span class="file-icon">📄</span>' +
'<div><strong>' + escapeHtml(msg.file.name) + '</strong><br>' +
'<span class="muted">' + formatFileSize(msg.file.size || 0) + '</span></div>' +
'<button type="button" class="btn-download" data-file-id="' + escapeHtml(msg.file.id) + '">Download</button>' +
'</div>';
} else if (state.markdown && typeof marked !== 'undefined') {
var raw = marked.parse(msg.content);
bodyHtml = sanitizeHtml(raw);
} else {
bodyHtml = formatMessageSimple(msg.content);
}
div.innerHTML =
'<div class="avatar" style="background:' + color + '">' + escapeHtml((msg.author || '?')[0].toUpperCase()) + '</div>' +
'<div class="message-content">' +
'<div class="message-header"><span class="message-author">' + escapeHtml(msg.author) + '</span>' +
'<span class="message-time">' + escapeHtml(time) + '</span></div>' +
'<div class="message-text">' + bodyHtml + '</div></div>';
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, '<code>$1</code>');
escaped = escaped.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
escaped = escaped.replace(/\*([^*]+)\*/g, '<em>$1</em>');
escaped = escaped.replace(/~~([^~]+)~~/g, '<s>$1</s>');
escaped = escaped.replace(/\n/g, '<br>');
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 =
'<span class="user-avatar" style="background:' + color + '">' + escapeHtml(nick[0].toUpperCase()) + '</span>' +
'<span class="user-name">' + escapeHtml(nick) + '</span>' +
'<span class="user-status online" title="Online"></span>';
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 =
'<span class="room-icon">#</span>' +
'<span class="room-name">' + escapeHtml(name) + '</span>' +
'<span class="room-count">' + count + '</span>';
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 =
'<span class="file-icon">📄</span>' +
'<span class="file-name">' + escapeHtml(file.name) + '</span>' +
'<span class="file-meta">' + formatFileSize(file.size || 0) + (ready ? '' : ' · pending') + '</span>';
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();
}
})();