import Hyperswarm from 'hyperswarm'; import crypto from 'hypercore-crypto'; import b4a from 'b4a'; import ServeDrive from 'serve-drive'; import Hyperdrive from 'hyperdrive'; import Corestore from 'corestore'; import { EventEmitter } from 'events'; import fs from "fs"; import handleCommand from './commands.js'; const agentAvatarPath = './assets/agent.png'; let agentAvatar = ''; const storagePath = `./storage/`; const store = new Corestore(storagePath); const drive = new Hyperdrive(store); await drive.ready(); // Load the agent avatar once when the app starts if (fs.existsSync(agentAvatarPath)) { const avatarBuffer = fs.readFileSync(agentAvatarPath); agentAvatar = `data:image/png;base64,${b4a.toString(avatarBuffer, 'base64')}`; } let activeRooms = []; const eventEmitter = new EventEmitter(); // Define servePort at the top level let servePort; // Object to store all the information we want to save let config = { userName: '', userAvatar: '', rooms: [], registeredUsers: {} }; // Store messages for each room let messagesStore = {}; // Function to get a random port between 49152 and 65535 function getRandomPort() { return Math.floor(Math.random() * (65535 - 49152 + 1)) + 49152; } function currentTopic() { return document.querySelector('#chat-room-topic').innerText; } function getCurrentPeerCount() { const topic = currentTopic(); const room = activeRooms.find(room => room.topic === topic); return room ? room.swarm.connections.size : 0; } function updatePeerCount() { const peerCountElement = document.querySelector('#peers-count'); if (peerCountElement) { peerCountElement.textContent = getCurrentPeerCount(); // Display the actual peer count } } async function joinRoom(topicStr) { const topicBuffer = b4a.from(topicStr, 'hex'); addRoomToList(topicStr); await joinSwarm(topicBuffer); } async function createRoom(alias) { const topicBuffer = crypto.randomBytes(32); const topic = b4a.toString(topicBuffer, 'hex'); addRoomToList(topic, alias); await joinSwarm(topicBuffer); } async function listFiles() { const files = []; for await (const entry of drive.readdir('/files')) { files.push(entry); } return files; } async function deleteFile(filename) { await drive.del(`/files/${filename}`); } async function initialize() { try { servePort = getRandomPort(); const serve = new ServeDrive({ port: servePort, get: ({ key, filename, version }) => drive }); await serve.ready(); console.log('Listening on http://localhost:' + serve.address().port); // Event listeners setup setupEventListeners(); const configExists = fs.existsSync("./config.json"); if (configExists) { loadConfigFromFile(); renderRoomList(); await connectToAllRooms(); } if (!configExists) { document.querySelector('#register').classList.remove('hidden'); } eventEmitter.on('onMessage', async (messageObj) => { handleIncomingMessage(messageObj); }); document.addEventListener("DOMContentLoaded", (event) => { hljs.highlightAll(); }); } catch (error) { console.error('Error during initialization:', error); } } function setupEventListeners() { const registerForm = document.querySelector('#register-form'); const selectAvatarButton = document.querySelector('#select-avatar'); const createChatRoomButton = document.querySelector('#create-chat-room'); const joinChatRoomButton = document.querySelector('#join-chat-room'); const messageForm = document.querySelector('#message-form'); const toggleSetupBtn = document.querySelector('#toggle-setup-btn'); const removeRoomBtn = document.querySelector('#remove-room-btn'); const attachFileButton = document.getElementById('attach-file'); const fileInput = document.getElementById('file-input'); const talkButton = document.getElementById('talk-btn'); if (registerForm) { registerForm.addEventListener('submit', registerUser); } if (selectAvatarButton) { selectAvatarButton.addEventListener('click', () => { document.querySelector('#avatar-file').click(); }); } if (createChatRoomButton) { createChatRoomButton.addEventListener('click', createChatRoom); } if (joinChatRoomButton) { joinChatRoomButton.addEventListener('click', joinChatRoom); } if (messageForm) { messageForm.addEventListener('submit', sendMessage); } if (toggleSetupBtn) { toggleSetupBtn.addEventListener('click', toggleSetupView); } if (removeRoomBtn) { removeRoomBtn.addEventListener('click', () => { const topic = currentTopic(); leaveRoom(topic); }); } if (attachFileButton) { attachFileButton.addEventListener('click', () => fileInput.click()); } if (fileInput) { fileInput.addEventListener('change', handleFileInput); } if (talkButton) { setupTalkButton(); } } function handleIncomingMessage(messageObj) { console.log('Received message:', messageObj); // Debugging log if (messageObj.type === 'icon') { const username = messageObj.username; if (messageObj.avatar) { const avatarBuffer = b4a.from(messageObj.avatar, 'base64'); drive.put(`/icons/${username}.png`, avatarBuffer); updateIcon(username, avatarBuffer); } else { console.error('Received icon message with missing avatar data:', messageObj); } } else if (messageObj.type === 'file') { if (messageObj.file && messageObj.fileName) { const fileBuffer = b4a.from(messageObj.file, 'base64'); drive.put(`/files/${messageObj.fileName}`, fileBuffer).then(() => { const fileUrl = `http://localhost:${servePort}/files/${messageObj.fileName}`; addFileMessage(messageObj.name, messageObj.fileName, updatePortInUrl(fileUrl), messageObj.fileType, updatePortInUrl(messageObj.avatar), messageObj.topic); }); } else { console.error('Received file message with missing file data or fileName:', messageObj); } } else if (messageObj.type === 'message') { onMessageAdded(messageObj.name, messageObj.message, messageObj.avatar, messageObj.topic, messageObj.timestamp); } else if (messageObj.type === 'audio') { const audioBuffer = b4a.from(messageObj.audio, 'base64'); const filePath = `/audio/${Date.now()}.webm`; drive.put(filePath, audioBuffer).then(() => { const audioUrl = `http://localhost:${servePort}${filePath}`; addAudioMessage(messageObj.name, audioUrl, messageObj.avatar, messageObj.topic); }); } else { console.error('Received unknown message type:', messageObj); } } async function handleConnection(connection, info) { console.log('New connection', info); // Sending the icon immediately upon connection const iconBuffer = await drive.get(`/icons/${config.userName}.png`); if (iconBuffer) { const iconMessage = JSON.stringify({ type: 'icon', username: config.userName, avatar: b4a.toString(iconBuffer, 'base64'), timestamp: Date.now() }); console.log('Sending icon to new peer:', iconMessage); connection.write(iconMessage); } else { console.error('Icon not found for user:', config.userName); } connection.on('data', (data) => { const messageObj = JSON.parse(data.toString()); eventEmitter.emit('onMessage', messageObj); }); connection.on('close', () => { console.log('Connection closed', info); updatePeerCount(); }); connection.on('error', (error) => { console.error('Connection error', error); if (error.code === 'ETIMEDOUT') { retryConnection(info.topicBuffer); } }); updatePeerCount(); } function retryConnection(topicBuffer) { if (!topicBuffer || !topicBuffer.buffer) { console.error('Invalid topicBuffer:', topicBuffer); return; } const topic = b4a.toString(topicBuffer, 'hex'); const room = activeRooms.find(room => room.topic === topic); if (room) { console.log('Retrying connection to room:', topic); room.swarm.leave(topicBuffer); joinSwarm(topicBuffer).catch((error) => { console.error('Failed to rejoin room after timeout:', error); }); } } function setupTalkButton() { const talkButton = document.getElementById('talk-btn'); if (!talkButton) return; let mediaRecorder; let audioChunks = []; talkButton.addEventListener('mousedown', async () => { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); mediaRecorder = new MediaRecorder(stream); mediaRecorder.start(); mediaRecorder.addEventListener('dataavailable', event => { audioChunks.push(event.data); }); mediaRecorder.addEventListener('stop', async () => { const audioBlob = new Blob(audioChunks); audioChunks = []; const arrayBuffer = await audioBlob.arrayBuffer(); const buffer = new Uint8Array(arrayBuffer); const topic = currentTopic(); const filePath = `/audio/${Date.now()}.webm`; await drive.put(filePath, buffer); const audioUrl = `http://localhost:${servePort}${filePath}`; const audioMessage = { type: 'audio', name: config.userName, avatar: updatePortInUrl(config.userAvatar), topic: topic, timestamp: Date.now(), audio: b4a.toString(buffer, 'base64'), audioType: audioBlob.type }; console.log('Sending audio message:', audioMessage); // Debugging log const peers = [...activeRooms.find(room => room.topic === topic).swarm.connections]; for (const peer of peers) { peer.write(JSON.stringify(audioMessage)); } addAudioMessage(config.userName, audioUrl, config.userAvatar, topic); }); }); talkButton.addEventListener('mouseup', () => { if (mediaRecorder) { mediaRecorder.stop(); } }); talkButton.addEventListener('mouseleave', () => { if (mediaRecorder && mediaRecorder.state === 'recording') { mediaRecorder.stop(); } }); } function registerUser(e) { e.preventDefault(); const regUsername = document.querySelector('#reg-username').value; if (config.registeredUsers[regUsername]) { alert('Username already taken. Please choose another.'); return; } const avatarFile = document.querySelector('#avatar-file').files[0]; if (avatarFile) { const reader = new FileReader(); reader.onload = async (event) => { const buffer = new Uint8Array(event.target.result); await drive.put(`/icons/${regUsername}.png`, buffer); config.userAvatar = `http://localhost:${servePort}/icons/${regUsername}.png`; // Set the correct URL config.registeredUsers[regUsername] = `http://localhost:${servePort}/icons/${regUsername}.png`; // Use placeholder URL writeConfigToFile("./config.json"); continueRegistration(regUsername); }; reader.readAsArrayBuffer(avatarFile); } else { continueRegistration(regUsername); } } async function continueRegistration(regUsername) { const loadingDiv = document.querySelector('#loading'); const setupDiv = document.querySelector('#setup'); if (!regUsername) { alert('Please enter a username.'); return; } config.userName = regUsername; setupDiv.classList.remove('hidden'); document.querySelector('#register').classList.add('hidden'); loadingDiv.classList.add('hidden'); const randomTopic = crypto.randomBytes(32); document.querySelector('#chat-room-topic').innerText = truncateHash(b4a.toString(randomTopic, 'hex')); writeConfigToFile("./config.json"); } async function createChatRoom() { const topicBuffer = crypto.randomBytes(32); const topic = b4a.toString(topicBuffer, 'hex'); addRoomToList(topic); await joinSwarm(topicBuffer); } async function joinChatRoom(e) { e.preventDefault(); const topicStr = document.querySelector('#join-chat-room-topic').value.trim(); // Validate the topic string const isValidTopic = /^[0-9a-fA-F]{64}$/.test(topicStr); if (!isValidTopic) { alert('Invalid topic format. Please enter a 64-character hexadecimal string.'); return; } const topicBuffer = b4a.from(topicStr, 'hex'); addRoomToList(topicStr); await joinSwarm(topicBuffer); } async function joinSwarm(topicBuffer) { const topic = b4a.toString(topicBuffer, 'hex'); if (!activeRooms.some(room => room.topic === topic)) { try { const swarm = new Hyperswarm(); const discovery = swarm.join(topicBuffer, { client: true, server: true }); await discovery.flushed(); swarm.on('connection', (connection, info) => { handleConnection(connection, info); }); activeRooms.push({ topic, swarm, discovery }); console.log('Joined room:', topic); // Debugging log renderMessagesForRoom(topic); updatePeerCount(); } catch (error) { console.error('Error joining swarm for topic:', topic, error); } } } function addRoomToList(topic, alias) { const roomList = document.querySelector('#room-list'); const roomItem = document.createElement('li'); roomItem.textContent = alias || truncateHash(topic); roomItem.dataset.topic = topic; roomItem.addEventListener('dblclick', () => enterEditMode(roomItem)); roomItem.addEventListener('click', () => switchRoom(topic)); roomList.appendChild(roomItem); config.rooms.push({ topic, alias: alias || truncateHash(topic) }); writeConfigToFile("./config.json"); } function enterEditMode(roomItem) { const originalText = roomItem.textContent; const topic = roomItem.dataset.topic; roomItem.innerHTML = ''; const input = document.createElement('input'); input.type = 'text'; input.value = originalText; input.style.maxWidth = '100%'; // Add this line to set the max width input.style.boxSizing = 'border-box'; // Add this line to ensure padding and border are included in the width roomItem.appendChild(input); input.focus(); input.addEventListener('blur', () => { exitEditMode(roomItem, input, topic); }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { exitEditMode(roomItem, input, topic); } else if (e.key === 'Escape') { roomItem.textContent = originalText; } }); } function exitEditMode(roomItem, input, topic) { const newAlias = input.value.trim(); if (newAlias) { roomItem.textContent = newAlias; // Update the config with the new alias const roomConfig = config.rooms.find(room => room.topic === topic); if (roomConfig) { roomConfig.alias = newAlias; writeConfigToFile("./config.json"); } // Check if the edited room is the current room in view if (currentTopic() === topic) { const chatRoomName = document.querySelector('#chat-room-name'); if (chatRoomName) { chatRoomName.innerText = newAlias; } } } else { roomItem.textContent = truncateHash(topic); } } function switchRoom(topic) { console.log('Switching to room:', topic); // Debugging log const chatRoomTopic = document.querySelector('#chat-room-topic'); const chatRoomName = document.querySelector('#chat-room-name'); if (chatRoomTopic) { chatRoomTopic.innerText = topic; // Set full topic here } else { console.error('Element #chat-room-topic not found'); } if (chatRoomName) { // Update the room name in the header const room = config.rooms.find(r => r.topic === topic); const roomName = room ? room.alias : truncateHash(topic); chatRoomName.innerText = roomName; } else { console.error('Element #chat-room-name not found'); } clearMessages(); renderMessagesForRoom(topic); updatePeerCount(); // Show chat UI elements document.querySelector('#chat').classList.remove('hidden'); document.querySelector('#setup').classList.add('hidden'); } function leaveRoom(topic) { const roomIndex = activeRooms.findIndex(room => room.topic === topic); if (roomIndex !== -1) { const room = activeRooms[roomIndex]; room.swarm.destroy(); activeRooms.splice(roomIndex, 1); } const roomItem = document.querySelector(`li[data-topic="${topic}"]`); if (roomItem) { roomItem.remove(); } config.rooms = config.rooms.filter(e => e.topic !== topic); writeConfigToFile("./config.json"); if (activeRooms.length > 0) { switchRoom(activeRooms[0].topic); } else { document.querySelector('#chat').classList.add('hidden'); document.querySelector('#setup').classList.remove('hidden'); } } async function sendMessage(e) { e.preventDefault(); const message = document.querySelector('#message').value; document.querySelector('#message').value = ''; const topic = currentTopic(); const timestamp = Date.now(); if (message.startsWith('~')) { // Handle command await handleCommand(message, { eventEmitter, currentTopic, clearMessages, addMessage: (from, message, avatar, topic) => onMessageAdded(from, message, avatar, topic, timestamp), joinRoom, leaveRoom, createRoom, listFiles, deleteFile }); return; } console.log('Sending message:', message); // Debugging log onMessageAdded(config.userName, message, config.userAvatar, topic, timestamp); const messageObj = JSON.stringify({ type: 'message', name: config.userName, avatar: config.userAvatar, topic: topic, timestamp: timestamp, message }); const peers = [...activeRooms.find(room => room.topic === topic).swarm.connections]; for (const peer of peers) { peer.write(messageObj); } } async function handleFileInput(event) { const file = event.target.files[0]; if (file) { const reader = new FileReader(); reader.onload = async (event) => { const buffer = new Uint8Array(event.target.result); const filePath = `/files/${file.name}`; await drive.put(filePath, buffer); const fileUrl = `http://localhost:${servePort}${filePath}`; const topic = currentTopic(); const fileMessage = { type: 'file', name: config.userName, avatar: updatePortInUrl(config.userAvatar), topic: topic, timestamp: Date.now(), fileName: file.name, file: b4a.toString(buffer, 'base64'), fileType: file.type }; console.log('Sending file message:', fileMessage); // Debugging log const peers = [...activeRooms.find(room => room.topic === topic).swarm.connections]; for (const peer of peers) { peer.write(JSON.stringify(fileMessage)); } addFileMessage(config.userName, file.name, fileUrl, file.type, config.userAvatar, topic); }; reader.readAsArrayBuffer(file); } } function addFileMessage(from, fileName, fileUrl, fileType, avatar, topic) { console.log('Adding file message:', { from, fileName, fileUrl, fileType, avatar, topic }); // Debugging log const $div = document.createElement('div'); $div.classList.add('message'); const $img = document.createElement('img'); $img.src = updatePortInUrl(avatar) || 'https://via.placeholder.com/40'; $img.classList.add('avatar'); $div.appendChild($img); const $content = document.createElement('div'); $content.classList.add('message-content'); const $header = document.createElement('div'); $header.classList.add('message-header'); $header.textContent = from; $content.appendChild($header); if (fileType.startsWith('image/')) { const $image = document.createElement('img'); $image.src = updatePortInUrl(fileUrl); $image.alt = fileName; $image.classList.add('attached-image'); $content.appendChild($image); } else { const $fileButton = document.createElement('button'); $fileButton.textContent = `Download File: ${fileName}`; $fileButton.onclick = function() { const $fileLink = document.createElement('a'); $fileLink.href = fileUrl; $fileLink.download = fileName; $fileLink.click(); }; $content.appendChild($fileButton); } $div.appendChild($content); // Only render the message if it's for the current room if (currentTopic() === topic) { document.querySelector('#messages').appendChild($div); scrollToBottom(); } else { console.log(`Message for topic ${topic} not rendered because current topic is ${currentTopic()}`); // Debugging log } } function scrollToBottom() { var container = document.getElementById("messages-container"); container.scrollTop = container.scrollHeight; } function onMessageAdded(from, message, avatar, topic, timestamp) { console.log('Adding message:', { from, message, avatar, topic }); // Debugging log const messageObj = { from, message, avatar, timestamp: timestamp || Date.now() }; // Add the message to the store addMessageToStore(topic, messageObj); // Only render messages for the current room if (currentTopic() === topic) { const $div = document.createElement('div'); $div.classList.add('message'); const $img = document.createElement('img'); $img.src = updatePortInUrl(avatar) || 'https://via.placeholder.com/40'; // Default to a placeholder image if avatar URL is not provided $img.classList.add('avatar'); $div.appendChild($img); const $content = document.createElement('div'); $content.classList.add('message-content'); const $header = document.createElement('div'); $header.classList.add('message-header'); $header.textContent = from; const $text = document.createElement('div'); $text.classList.add('message-text'); if (message.includes('Available files:')) { const files = message.split('\n').slice(1); // Skip the "Available files:" line const fileList = document.createElement('ul'); files.forEach(file => { file = file.replace("- ", "") const listItem = document.createElement('li'); const fileButton = document.createElement('button'); fileButton.textContent = file.trim(); fileButton.onclick = () => downloadFile(file.trim()); const deleteButton = document.createElement('button'); deleteButton.textContent = 'Delete'; deleteButton.onclick = () => { console.log("file to delete: ", file); deleteFile(file); listItem.remove(); }; listItem.appendChild(fileButton); listItem.appendChild(deleteButton); fileList.appendChild(listItem); }); $text.appendChild(fileList); } else { const md = window.markdownit({ highlight: function (str, lang) { if (lang && hljs.getLanguage(lang)) { try { return hljs.highlight(str, { language: lang }).value; } catch (__) {} } return ''; // use external default escaping } }); const markdownContent = md.render(message); $text.innerHTML = markdownContent; } $content.appendChild($header); $content.appendChild($text); $div.appendChild($content); document.querySelector('#messages').appendChild($div); scrollToBottom(); } else { console.log(`Message for topic ${topic} not rendered because current topic is ${currentTopic()}`); // Debugging log } } function downloadFile(filename) { const fileUrl = `http://localhost:${servePort}/files/${filename}`; const a = document.createElement('a'); a.href = fileUrl; a.download = filename; a.click(); } function addAudioMessage(from, audioUrl, avatar, topic) { console.log('Adding audio message:', { from, audioUrl, avatar, topic }); // Debugging log const $div = document.createElement('div'); $div.classList.add('message'); const $img = document.createElement('img'); $img.src = updatePortInUrl(avatar) || 'https://via.placeholder.com/40'; $img.classList.add('avatar'); $div.appendChild($img); const $content = document.createElement('div'); $content.classList.add('message-content'); const $header = document.createElement('div'); $header.classList.add('message-header'); $header.textContent = from; $content.appendChild($header); const $audio = document.createElement('audio'); $audio.controls = true; if (from !== config.userName) { $audio.autoplay = true; // Add autoplay attribute for peers only } $audio.src = updatePortInUrl(audioUrl); $audio.classList.add('attached-audio'); $content.appendChild($audio); $div.appendChild($content); // Only render the message if it's for the current room if (currentTopic() === topic) { document.querySelector('#messages').appendChild($div); scrollToBottom(); } else { console.log(`Message for topic ${topic} not rendered because current topic is ${currentTopic()}`); // Debugging log } } function truncateHash(hash) { return `${hash.slice(0, 6)}...${hash.slice(-6)}`; } async function updateIcon(username, avatarBuffer) { const userIcon = document.querySelector(`img[src*="${username}.png"]`); if (userIcon) { const avatarBlob = new Blob([avatarBuffer], { type: 'image/png' }); const avatarUrl = URL.createObjectURL(avatarBlob); userIcon.src = updatePortInUrl(avatarUrl); config.userAvatar = avatarUrl; writeConfigToFile("./config.json"); } } function clearMessagesCMD() { const messagesContainer = document.querySelector('#messages'); while (messagesContainer.firstChild) { messagesContainer.removeChild(messagesContainer.firstChild); } // Clear the messages from the store for the current room const topic = currentTopic(); messagesStore[topic] = []; } function clearMessages() { const messagesContainer = document.querySelector('#messages'); while (messagesContainer.firstChild) { messagesContainer.removeChild(messagesContainer.firstChild); } // Clear the messages from the store for the current room // const topic = currentTopic(); // messagesStore[topic] = []; } function toggleSetupView() { const setupDiv = document.querySelector('#setup'); setupDiv.classList.toggle('hidden'); } function writeConfigToFile(filePath) { fs.writeFile(filePath, JSON.stringify(config), (err) => { if (err) return console.error(err); console.log("File has been created"); }); } function updatePortInUrl(url) { if (!url) return url; const urlObject = new URL(url); if(!urlObject.host.startsWith("localhost")) return urlObject.toString(); urlObject.port = servePort; return urlObject.toString(); } function renderRoomList() { const roomList = document.querySelector('#room-list'); roomList.innerHTML = ''; config.rooms.forEach(room => { const roomItem = document.createElement('li'); roomItem.textContent = room.alias || truncateHash(room.topic); roomItem.dataset.topic = room.topic; roomItem.addEventListener('dblclick', () => enterEditMode(roomItem)); roomItem.addEventListener('click', () => switchRoom(room.topic)); roomList.appendChild(roomItem); }); } function renderMessagesForRoom(topic) { console.log('Rendering messages for room:', topic); // Debugging log // Clear the message area clearMessages(); // Fetch and render messages for the selected room const messages = getMessagesForRoom(topic); messages.forEach(message => { onMessageAdded(message.from, message.message, message.avatar, topic, message.timestamp); }); } function getMessagesForRoom(topic) { return messagesStore[topic] || []; } function addMessageToStore(topic, messageObj) { if (!messagesStore[topic]) { messagesStore[topic] = []; } // Check for duplicates using a combination of message content and timestamp const isDuplicate = messagesStore[topic].some(msg => msg.from === messageObj.from && msg.message === messageObj.message && msg.timestamp === messageObj.timestamp ); if (!isDuplicate) { messagesStore[topic].push(messageObj); } else { console.log('Duplicate message detected:', messageObj); // Debugging log } } function loadConfigFromFile() { config = JSON.parse(fs.readFileSync("./config.json", 'utf8')); console.log("Read config from file:", config); // Update port in URLs config.userAvatar = updatePortInUrl(config.userAvatar); config.rooms.forEach(room => { room.alias = room.alias || truncateHash(room.topic); }); for (let user in config.registeredUsers) { config.registeredUsers[user] = updatePortInUrl(config.registeredUsers[user]); } } async function connectToAllRooms() { // Connect to all rooms on startup for (const room of config.rooms) { const topicBuffer = b4a.from(room.topic, 'hex'); await joinSwarm(topicBuffer); } } // Call this function when loading the rooms initially renderRoomList(); initialize();