1774 lines
58 KiB
JavaScript
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();
|
|
}
|
|
})();
|