diff --git a/public/chat.js b/public/chat.js index ec1dd17..deb6920 100644 --- a/public/chat.js +++ b/public/chat.js @@ -1,420 +1,422 @@ -const socket = io({ - reconnection: true, - reconnectionDelay: 1000, - reconnectionAttempts: 5, - timeout: 20000, -}); - -const urlParams = new URLSearchParams(window.location.search); -const username = urlParams.get('name'); -const room = urlParams.get('room'); - -// Ensure elements exist before accessing them -const roomNameElement = document.getElementById('roomName'); -if (roomNameElement) { - roomNameElement.textContent = room; -} - -// Connection status indicators -let isConnected = false; -let reconnectAttempts = 0; - -// Handle connection events -socket.on('connect', () => { - console.log('Connected to server'); - isConnected = true; - reconnectAttempts = 0; - updateConnectionStatus('connected'); - socket.emit('joinRoom', { username, room }); -}); - -socket.on('disconnect', (reason) => { - console.log('Disconnected from server:', reason); - isConnected = false; - updateConnectionStatus('disconnected'); -}); - -socket.on('reconnect', () => { - console.log('Reconnected to server'); - isConnected = true; - updateConnectionStatus('connected'); - socket.emit('joinRoom', { username, room }); -}); - -socket.on('reconnect_attempt', (attemptNumber) => { - console.log('Reconnection attempt:', attemptNumber); - reconnectAttempts = attemptNumber; - updateConnectionStatus('reconnecting'); -}); - -socket.on('reconnect_error', (error) => { - console.log('Reconnection error:', error); - updateConnectionStatus('error'); -}); - -socket.on('reconnect_failed', () => { - console.log('Reconnection failed'); - updateConnectionStatus('failed'); -}); - -// Network status detection -window.addEventListener('online', () => { - console.log('Network connection restored'); - updateConnectionStatus('reconnecting'); -}); - -window.addEventListener('offline', () => { - console.log('Network connection lost'); - updateConnectionStatus('disconnected'); -}); - -// Update connection status UI -function updateConnectionStatus(status) { - console.log('Updating connection status to:', status); - - let statusElement = document.getElementById('connectionStatus'); - if (!statusElement) { - const container = document.querySelector('.discord-navbar .container-fluid'); - if (!container) { - console.error('Navbar container not found'); - return; - } - - const statusDiv = document.createElement('div'); - statusDiv.id = 'connectionStatus'; - statusDiv.className = 'connection-status'; - container.appendChild(statusDiv); - statusElement = statusDiv; - console.log('Created connection status element'); - } - - // Clear any existing classes - statusElement.className = 'connection-status'; - - switch (status) { - case 'connected': - statusElement.innerHTML = ' Connected'; - statusElement.classList.add('text-success'); - break; - case 'disconnected': - statusElement.innerHTML = ' Disconnected'; - statusElement.classList.add('text-warning'); - break; - case 'reconnecting': - statusElement.innerHTML = ` Reconnecting... (${reconnectAttempts})`; - statusElement.classList.add('text-info'); - break; - case 'error': - statusElement.innerHTML = ' Connection Error'; - statusElement.classList.add('text-danger'); - break; - case 'failed': - statusElement.innerHTML = ' Connection Failed'; - statusElement.classList.add('text-danger'); - break; - default: - console.warn('Unknown connection status:', status); - } - - // Force a reflow to ensure the change is visible - statusElement.offsetHeight; -} - -let isTabActive = true; -let notificationPermission = false; - -socket.on('roomUsers', ({ room, users }) => { - outputUsers(users); -}); - -// Handle message history -socket.on('messageHistory', (history) => { - console.log('Received message history:', history.length, 'messages'); - const chatMessages = document.querySelector('#chatMessages'); - - if (!chatMessages) { - console.error('Chat messages container not found'); - return; - } - - // Clear existing messages - chatMessages.innerHTML = ''; - - // Add historical messages - if (Array.isArray(history)) { - history.forEach(message => { - outputMessage(message, false); // false = don't scroll to bottom - }); - } - - // Scroll to bottom after loading history - scrollToBottom(); -}); - -socket.on('message', message => { - outputMessage(message); - - if (!isTabActive && notificationPermission && message.username !== username) { - new Notification('New Message', { - body: `${message.username}: ${message.text}`, - icon: '/path/to/your/icon.png' - }); - } -}); - -// Auto-resize textarea -function autoResizeTextarea(textarea) { - if (!textarea) return; - textarea.style.height = 'auto'; - textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px'; -} - -function outputMessage(message, shouldScroll = true) { - if (!message || !message.username || !message.text) { - console.error('Invalid message object:', message); - return; - } - - const div = document.createElement('div'); - div.classList.add('discord-message'); - - // Generate avatar initials safely - const initials = message.username.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2); - - div.innerHTML = ` -
${initials}
-
-
- ${message.username} - ${message.time || new Date().toLocaleTimeString()} -
-
${message.text}
-
- `; - - const chatMessages = document.querySelector('#chatMessages'); - if (chatMessages) { - chatMessages.appendChild(div); - - if (shouldScroll) { - scrollToBottom(); - } - } else { - console.error('Chat messages container not found'); - } -} - -function outputUsers(users) { - const participantsList = document.getElementById('participantsList'); - const mobileParticipantsList = document.getElementById('mobileParticipantsList'); - const memberCount = document.getElementById('memberCount'); - - if (!participantsList) { - console.error('Participants list container not found'); - return; - } - - if (!Array.isArray(users)) { - console.error('Invalid users array:', users); - return; - } - - const usersHTML = ` - ${users.map(user => ` -
  • -
    - -
    - ${user.username ? user.username.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) : '??'} -
    - ${user.username || 'Unknown User'} -
    -
  • - `).join('')} - `; - - participantsList.innerHTML = usersHTML; - - // Update mobile participants list if it exists - if (mobileParticipantsList) { - mobileParticipantsList.innerHTML = usersHTML; - } - - // Update member count badge - if (memberCount) { - if (users.length > 0) { - memberCount.textContent = users.length; - memberCount.style.display = 'block'; - } else { - memberCount.style.display = 'none'; - } - } -} - -function scrollToBottom() { - const chatMessages = document.querySelector('#chatMessages'); - if (chatMessages) { - chatMessages.scrollTop = chatMessages.scrollHeight; - } -} - -function showConnectionError() { - // Create or update error message - let errorDiv = document.getElementById('connectionError'); - if (!errorDiv) { - const inputArea = document.querySelector('.discord-input-area'); - const inputGroup = document.querySelector('.discord-input-group'); - - if (!inputArea || !inputGroup) { - console.error('Input area or input group not found'); - return; - } - - errorDiv = document.createElement('div'); - errorDiv.id = 'connectionError'; - errorDiv.className = 'alert alert-warning alert-dismissible fade show'; - errorDiv.innerHTML = ` - - Connection Lost! Please check your internet connection and try again. - - `; - inputArea.insertBefore(errorDiv, inputGroup); - } - - // Auto-hide after 5 seconds - setTimeout(() => { - if (errorDiv && errorDiv.parentNode) { - errorDiv.remove(); - } - }, 5000); -} - -// Wait for DOM to be ready before adding event listeners -document.addEventListener('DOMContentLoaded', () => { - const messageForm = document.getElementById('messageForm'); - if (messageForm) { - messageForm.addEventListener('submit', (e) => { - e.preventDefault(); - const msg = e.target.elements.messageInput.value.trim(); - if (msg && isConnected) { - socket.emit('chatMessage', msg); - - // Add to message history - if (window.chatEnhancements) { - window.chatEnhancements.addToHistory(msg); - } - - e.target.elements.messageInput.value = ''; - autoResizeTextarea(e.target.elements.messageInput); - } else if (!isConnected) { - // Show connection error - showConnectionError(); - } - }); - } - - // Add auto-resize functionality - const messageInput = document.getElementById('messageInput'); - if (messageInput) { - messageInput.addEventListener('input', () => { - autoResizeTextarea(messageInput); - }); - } - - // Leave chat button - const leaveChatBtn = document.getElementById('leaveChat'); - if (leaveChatBtn) { - leaveChatBtn.addEventListener('click', () => { - window.location = '/'; - }); - } - - // Mobile members toggle - const membersToggle = document.getElementById('membersToggle'); - const mobileMembersOverlay = document.getElementById('mobileMembersOverlay'); - const closeMembersOverlay = document.getElementById('closeMembersOverlay'); - - if (membersToggle && mobileMembersOverlay) { - membersToggle.addEventListener('click', () => { - mobileMembersOverlay.classList.add('show'); - // Copy members to mobile list - const participantsList = document.getElementById('participantsList'); - const mobileParticipantsList = document.getElementById('mobileParticipantsList'); - if (participantsList && mobileParticipantsList) { - mobileParticipantsList.innerHTML = participantsList.innerHTML; - } - }); - } - - if (closeMembersOverlay && mobileMembersOverlay) { - closeMembersOverlay.addEventListener('click', () => { - mobileMembersOverlay.classList.remove('show'); - }); - } - - // Close overlay when clicking outside - if (mobileMembersOverlay) { - mobileMembersOverlay.addEventListener('click', (e) => { - if (e.target === mobileMembersOverlay) { - mobileMembersOverlay.classList.remove('show'); - } - }); - } - - // Typing indicators - let typingTimer; - if (messageInput) { - messageInput.addEventListener('input', () => { - clearTimeout(typingTimer); - socket.emit('typing'); - - typingTimer = setTimeout(() => { - socket.emit('stopTyping'); - }, 1000); - }); - - messageInput.addEventListener('blur', () => { - clearTimeout(typingTimer); - socket.emit('stopTyping'); - }); - } -}); - -socket.on('typing', (username) => { - const typingIndicator = document.getElementById('typingIndicator'); - if (typingIndicator) { - typingIndicator.innerHTML = ` ${username} is typing...`; - typingIndicator.style.display = 'block'; - } -}); - -socket.on('stopTyping', () => { - const typingIndicator = document.getElementById('typingIndicator'); - if (typingIndicator) { - typingIndicator.style.display = 'none'; - } -}); - -window.addEventListener('focus', () => { - isTabActive = true; - socket.emit('updateStatus', 'online'); -}); - -window.addEventListener('blur', () => { - isTabActive = false; - socket.emit('updateStatus', 'offline'); -}); - -function requestNotificationPermission() { - if (!("Notification" in window)) { - alert("This browser does not support desktop notification"); - } else { - Notification.requestPermission().then(function (permission) { - if (permission === "granted") { - notificationPermission = true; - } - }); - } -} - -requestNotificationPermission(); \ No newline at end of file +const socket = io({ + reconnection: true, + reconnectionDelay: 1000, + reconnectionAttempts: 5, + timeout: 20000 +}); +window.socket = socket; + +const urlParams = new URLSearchParams(window.location.search); +const username = (urlParams.get('name') || 'Guest').trim(); +const room = (urlParams.get('room') || 'general').trim(); +const MAX_MESSAGE_CHARS = 8000; +const MAX_UPLOAD_SIZE = 5 * 1024 * 1024; + +let isConnected = false; +let reconnectAttempts = 0; +let isTabActive = true; +let notificationPermission = false; + +const roomNameElement = document.getElementById('roomName'); +if (roomNameElement) roomNameElement.textContent = room; + +function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function autoResizeTextarea(textarea) { + if (!textarea) return; + textarea.style.height = 'auto'; + textarea.style.height = `${Math.min(textarea.scrollHeight, 260)}px`; +} + +function scrollToBottom() { + const chatMessages = document.getElementById('chatMessages'); + if (chatMessages) { + chatMessages.scrollTop = chatMessages.scrollHeight; + } +} + +function showBanner(text, variant = 'warning') { + let errorDiv = document.getElementById('connectionError'); + const inputArea = document.querySelector('.discord-input-area'); + const inputGroup = document.querySelector('.discord-input-group'); + + if (!inputArea || !inputGroup) return; + + if (!errorDiv) { + errorDiv = document.createElement('div'); + errorDiv.id = 'connectionError'; + inputArea.insertBefore(errorDiv, inputGroup); + } + + errorDiv.className = `alert alert-${variant} alert-dismissible fade show m-2`; + errorDiv.innerHTML = ` + ${escapeHtml(text)} + + `; + + setTimeout(() => { + if (errorDiv && errorDiv.parentNode) errorDiv.remove(); + }, 4500); +} + +function updateConnectionStatus(status) { + let statusElement = document.getElementById('connectionStatus'); + if (!statusElement) { + const container = document.querySelector('.discord-navbar .container-fluid'); + if (!container) return; + statusElement = document.createElement('div'); + statusElement.id = 'connectionStatus'; + statusElement.className = 'connection-status'; + container.appendChild(statusElement); + } + + statusElement.className = 'connection-status'; + if (status === 'connected') { + statusElement.innerHTML = ' Connected'; + statusElement.classList.add('text-success'); + } else if (status === 'disconnected') { + statusElement.innerHTML = ' Disconnected'; + statusElement.classList.add('text-warning'); + } else if (status === 'reconnecting') { + statusElement.innerHTML = ` Reconnecting (${reconnectAttempts})`; + statusElement.classList.add('text-info'); + } else { + statusElement.innerHTML = ' Connection issue'; + statusElement.classList.add('text-danger'); + } +} + +function renderFileAttachment(file) { + if (!file || !file.data) return ''; + + const safeName = escapeHtml(file.name || 'attachment'); + const safeType = escapeHtml(file.type || 'application/octet-stream'); + const isImage = safeType.startsWith('image/'); + + return ` +
    +
    + + ${safeName} +
    + ${isImage ? `${safeName}` : '
    Download attachment
    '} +
    + `; +} + +function renderPoll(poll) { + if (!poll || !Array.isArray(poll.options)) return ''; + + const totalVotes = poll.options.reduce((sum, option) => sum + option.votes, 0); + return ` +
    +
    ${escapeHtml(poll.question)}
    +
    + ${poll.options.map(option => { + const percent = totalVotes ? Math.round((option.votes / totalVotes) * 100) : 0; + return ` + + `; + }).join('')} +
    +
    ${totalVotes} vote${totalVotes === 1 ? '' : 's'}
    +
    + `; +} + +function outputMessage(message, shouldScroll = true) { + if (!message || !message.username) return; + + const div = document.createElement('div'); + div.classList.add('discord-message'); + if (message.meta?.role === 'assistant') div.classList.add('assistant-message'); + if (message.meta?.role === 'system') div.classList.add('system-message'); + + const initials = message.username.split(' ').map(part => part[0]).join('').toUpperCase().slice(0, 2); + const safeText = escapeHtml(message.text || '').replace(/\n/g, '
    '); + + div.innerHTML = ` +
    ${initials || '??'}
    +
    +
    + ${escapeHtml(message.username)} + ${escapeHtml(message.time || new Date().toLocaleTimeString())} +
    +
    ${safeText}
    + ${message.type === 'file' ? renderFileAttachment(message.file) : ''} + ${message.type === 'poll' ? renderPoll(message.poll) : ''} +
    + `; + + const chatMessages = document.getElementById('chatMessages'); + if (!chatMessages) return; + + chatMessages.appendChild(div); + if (shouldScroll) scrollToBottom(); +} + +function updatePollMessage(poll) { + const card = document.querySelector(`.poll-card[data-poll-id="${poll.id}"]`); + if (!card) return; + + const totalVotes = poll.options.reduce((sum, option) => sum + option.votes, 0); + const options = card.querySelector('.poll-options'); + options.innerHTML = poll.options.map(option => { + const percent = totalVotes ? Math.round((option.votes / totalVotes) * 100) : 0; + return ` + + `; + }).join(''); + + const totalEl = card.querySelector('.poll-total'); + totalEl.textContent = `${totalVotes} vote${totalVotes === 1 ? '' : 's'}`; +} + +function outputUsers(users) { + const participantsList = document.getElementById('participantsList'); + const mobileParticipantsList = document.getElementById('mobileParticipantsList'); + const memberCount = document.getElementById('memberCount'); + if (!participantsList || !Array.isArray(users)) return; + + const usersHTML = users.map(user => ` +
  • +
    + +
    ${user.username ? user.username.split(' ').map(p => p[0]).join('').toUpperCase().slice(0, 2) : '??'}
    + ${escapeHtml(user.username || 'Unknown User')} +
    +
  • + `).join(''); + + participantsList.innerHTML = usersHTML; + if (mobileParticipantsList) mobileParticipantsList.innerHTML = usersHTML; + + if (memberCount) { + memberCount.textContent = users.length; + memberCount.style.display = users.length ? 'block' : 'none'; + } +} + +function sendMessage(messageInput) { + const message = messageInput.value.slice(0, MAX_MESSAGE_CHARS).trim(); + if (!message) return; + if (!isConnected) return showBanner('Connection lost. Message not sent.'); + + socket.emit('chatMessage', message); + messageInput.value = ''; + autoResizeTextarea(messageInput); + socket.emit('stopTyping'); + document.dispatchEvent(new CustomEvent('chat:message-sent', { detail: { length: message.length } })); +} + +function uploadFile(file) { + if (!file) return; + if (file.size > MAX_UPLOAD_SIZE) { + showBanner('File exceeds 5MB limit.'); + return; + } + + const reader = new FileReader(); + reader.onload = event => { + if (!isConnected) { + showBanner('Connection lost. Upload not sent.'); + return; + } + + socket.emit('fileUpload', { + name: file.name, + type: file.type || 'application/octet-stream', + size: file.size, + data: event.target.result + }); + + showBanner(`Uploaded ${file.name}`, 'success'); + }; + + reader.onerror = () => showBanner('Failed to read file for upload.'); + reader.readAsDataURL(file); +} + +socket.on('connect', () => { + isConnected = true; + reconnectAttempts = 0; + updateConnectionStatus('connected'); + socket.emit('joinRoom', { username, room }); +}); +socket.on('disconnect', () => { + isConnected = false; + updateConnectionStatus('disconnected'); +}); +socket.on('reconnect_attempt', attemptNumber => { + reconnectAttempts = attemptNumber; + updateConnectionStatus('reconnecting'); +}); +socket.on('reconnect_error', () => updateConnectionStatus('error')); +socket.on('reconnect_failed', () => updateConnectionStatus('failed')); + +window.addEventListener('online', () => updateConnectionStatus('reconnecting')); +window.addEventListener('offline', () => updateConnectionStatus('disconnected')); + +socket.on('roomUsers', ({ users }) => outputUsers(users)); +socket.on('messageHistory', history => { + const chatMessages = document.getElementById('chatMessages'); + if (!chatMessages) return; + chatMessages.innerHTML = ''; + if (Array.isArray(history)) history.forEach(message => outputMessage(message, false)); + scrollToBottom(); +}); +socket.on('message', message => { + outputMessage(message); + if (!isTabActive && notificationPermission && message.username !== username) { + new Notification('New Message', { body: `${message.username}: ${message.text}` }); + } +}); +socket.on('pollUpdate', payload => updatePollMessage(payload.poll)); +socket.on('uploadError', errorMessage => showBanner(errorMessage || 'Upload failed.')); +socket.on('pollError', errorMessage => showBanner(errorMessage || 'Poll action failed.')); + +socket.on('typing', currentUsername => { + const typingIndicator = document.getElementById('typingIndicator'); + if (!typingIndicator) return; + typingIndicator.innerHTML = ` ${escapeHtml(currentUsername)} is typing...`; + typingIndicator.style.display = 'block'; +}); +socket.on('stopTyping', () => { + const typingIndicator = document.getElementById('typingIndicator'); + if (typingIndicator) typingIndicator.style.display = 'none'; +}); + +window.addEventListener('focus', () => { + isTabActive = true; + socket.emit('updateStatus', 'online'); +}); +window.addEventListener('blur', () => { + isTabActive = false; + socket.emit('updateStatus', 'offline'); +}); + +function requestNotificationPermission() { + if (!('Notification' in window)) return; + Notification.requestPermission().then(permission => { + notificationPermission = permission === 'granted'; + }); +} + +function createPollFlow() { + const question = prompt('Poll question:'); + if (!question) return; + const rawOptions = prompt('Poll options (comma-separated, at least 2):'); + if (!rawOptions) return; + const options = rawOptions.split(',').map(text => text.trim()).filter(Boolean); + socket.emit('createPoll', { question, options }); +} + +function askAssistantFlow() { + const modeSelect = document.getElementById('assistantMode'); + const mode = modeSelect ? modeSelect.value : 'balanced'; + const promptText = prompt('Ask the assistant:'); + if (!promptText) return; + socket.emit('assistantPrompt', { prompt: promptText, mode }); +} + +document.addEventListener('click', event => { + const optionButton = event.target.closest('.poll-option'); + if (!optionButton) return; + + const card = optionButton.closest('.poll-card'); + if (!card) return; + + const pollId = card.dataset.pollId; + const optionIndex = Number(optionButton.dataset.optionIndex); + socket.emit('votePoll', { pollId, optionIndex }); +}); + +document.addEventListener('DOMContentLoaded', () => { + const messageForm = document.getElementById('messageForm'); + const messageInput = document.getElementById('messageInput'); + const leaveChatBtn = document.getElementById('leaveChat'); + const membersToggle = document.getElementById('membersToggle'); + const mobileMembersOverlay = document.getElementById('mobileMembersOverlay'); + const closeMembersOverlay = document.getElementById('closeMembersOverlay'); + const attachBtn = document.getElementById('attachBtn'); + const fileInput = document.getElementById('fileInput'); + const quickAskBot = document.getElementById('quickAskBot'); + const quickCreatePoll = document.getElementById('quickCreatePoll'); + + if (messageForm && messageInput) { + messageForm.addEventListener('submit', event => { + event.preventDefault(); + sendMessage(messageInput); + }); + + messageInput.addEventListener('input', () => { + if (messageInput.value.length > MAX_MESSAGE_CHARS) { + messageInput.value = messageInput.value.slice(0, MAX_MESSAGE_CHARS); + } + autoResizeTextarea(messageInput); + socket.emit('typing'); + }); + + messageInput.addEventListener('keydown', event => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + sendMessage(messageInput); + } + }); + + messageInput.addEventListener('blur', () => socket.emit('stopTyping')); + } + + if (attachBtn && fileInput) { + attachBtn.addEventListener('click', () => fileInput.click()); + fileInput.addEventListener('change', event => { + uploadFile(event.target.files[0]); + fileInput.value = ''; + }); + } + + if (quickAskBot) quickAskBot.addEventListener('click', askAssistantFlow); + if (quickCreatePoll) quickCreatePoll.addEventListener('click', createPollFlow); + + if (leaveChatBtn) { + leaveChatBtn.addEventListener('click', () => { + window.location = '/'; + }); + } + + if (membersToggle && mobileMembersOverlay) { + membersToggle.addEventListener('click', () => { + mobileMembersOverlay.classList.add('show'); + const participantsList = document.getElementById('participantsList'); + const mobileParticipantsList = document.getElementById('mobileParticipantsList'); + if (participantsList && mobileParticipantsList) { + mobileParticipantsList.innerHTML = participantsList.innerHTML; + } + }); + } + + if (closeMembersOverlay && mobileMembersOverlay) { + closeMembersOverlay.addEventListener('click', () => mobileMembersOverlay.classList.remove('show')); + } + + if (mobileMembersOverlay) { + mobileMembersOverlay.addEventListener('click', event => { + if (event.target === mobileMembersOverlay) mobileMembersOverlay.classList.remove('show'); + }); + } +}); + +requestNotificationPermission(); diff --git a/public/index.html b/public/index.html index 0ce2b30..71f9dbf 100644 --- a/public/index.html +++ b/public/index.html @@ -124,11 +124,11 @@

    Join a Server

    -

    About Chatify

    +

    About Chatify

    We're passionate about bringing people together through seamless communication. Our platform is designed to make group chats easy, fun, and accessible to everyone.

    -

    Founded in 2024, Chatify has quickly become a go-to solution for teams, friends, and communities looking to stay connected in real-time. Our dedicated team of I work all day to make sure it looks good.

    +

    Founded in 2024, Chatify has quickly become a go-to solution for teams, friends, and communities looking to stay connected in real-time. Our dedicated team works every day to keep the experience fast, stable, and welcoming for every community.

    Whether you're collaborating on a project, planning an event, or just catching up with friends, Chatify provides the tools you need to communicate effectively and build stronger connections.

    diff --git a/public/nexus-features.js b/public/nexus-features.js index 12aef8e..dc487fa 100644 --- a/public/nexus-features.js +++ b/public/nexus-features.js @@ -1,611 +1,107 @@ -// NexusChat Enhanced Features class NexusFeatures { - constructor() { - this.emojiPicker = null; - this.filePreview = null; - this.messageReactions = new Map(); - this.mentions = new Set(); - this.init(); - } - - init() { - this.setupFileUpload(); - this.setupEmojiPicker(); - this.setupMessageReactions(); - this.setupMentions(); - this.setupMessageSearch(); - this.setupVoiceMessages(); - this.setupMessageThreading(); - } - - setupFileUpload() { - const attachBtn = document.getElementById('attachBtn'); - const fileInput = document.getElementById('fileInput'); - - if (attachBtn && fileInput) { - attachBtn.addEventListener('click', () => { - fileInput.click(); - }); - - fileInput.addEventListener('change', (e) => { - const file = e.target.files[0]; - if (file) { - this.handleFileUpload(file); - } - }); - } - } - - handleFileUpload(file) { - // Create file preview - const filePreview = this.createFilePreview(file); - const messagesContainer = document.getElementById('chatMessages'); - messagesContainer.appendChild(filePreview); - - // Simulate upload progress - this.simulateUploadProgress(filePreview, file); - - // Scroll to bottom - this.scrollToBottom(); - } - - createFilePreview(file) { - const preview = document.createElement('div'); - preview.className = 'file-preview nexus-message'; - - const fileIcon = this.getFileIcon(file.type); - const fileSize = this.formatFileSize(file.size); - - preview.innerHTML = ` -
    - -
    -
    -
    - You - ${new Date().toLocaleTimeString()} -
    -
    -
    -
    ${fileIcon}
    -
    -
    ${file.name}
    -
    ${fileSize}
    -
    -
    -
    -
    -
    -
    -
    Uploading...
    -
    -
    -
    - `; - - return preview; - } - - getFileIcon(type) { - if (type.startsWith('image/')) return ''; - if (type.includes('pdf')) return ''; - if (type.includes('word') || type.includes('document')) return ''; - if (type.includes('text')) return ''; - return ''; - } - - formatFileSize(bytes) { - if (bytes === 0) return '0 Bytes'; - const k = 1024; - const sizes = ['Bytes', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; - } - - simulateUploadProgress(preview, file) { - const progressFill = preview.querySelector('.progress-fill'); - const uploadStatus = preview.querySelector('.upload-status'); - - let progress = 0; - const interval = setInterval(() => { - progress += Math.random() * 20; - if (progress >= 100) { - progress = 100; - clearInterval(interval); - uploadStatus.textContent = 'Upload complete!'; - progressFill.style.background = 'var(--discord-success)'; - } - progressFill.style.width = progress + '%'; - }, 200); - } - - setupEmojiPicker() { - const emojiBtn = document.getElementById('emojiBtn'); - - if (emojiBtn) { - emojiBtn.addEventListener('click', () => { - this.toggleEmojiPicker(); - }); - } - } - - toggleEmojiPicker() { - if (this.emojiPicker) { - this.emojiPicker.remove(); - this.emojiPicker = null; - return; - } - - this.emojiPicker = document.createElement('div'); - this.emojiPicker.className = 'nexus-emoji-picker'; - this.emojiPicker.innerHTML = ` -
    -
    -
    Choose an emoji
    - -
    -
    - - - - -
    -
    - ${this.getEmojiGrid('recent')} -
    -
    - `; - - const inputGroup = document.querySelector('.discord-input-group'); - inputGroup.style.position = 'relative'; - inputGroup.appendChild(this.emojiPicker); - - // Add category switching - this.emojiPicker.addEventListener('click', (e) => { - if (e.target.classList.contains('emoji-category')) { - const category = e.target.dataset.category; - this.switchEmojiCategory(category); - } else if (e.target.classList.contains('emoji-item')) { - this.insertEmoji(e.target.textContent); - this.emojiPicker.remove(); - this.emojiPicker = null; - } - }); - } - - switchEmojiCategory(category) { - const categories = this.emojiPicker.querySelectorAll('.emoji-category'); - categories.forEach(cat => cat.classList.remove('active')); - this.emojiPicker.querySelector(`[data-category="${category}"]`).classList.add('active'); - - const emojiGrid = this.emojiPicker.querySelector('#emojiGrid'); - emojiGrid.innerHTML = this.getEmojiGrid(category); - } - - getEmojiGrid(category) { - const emojiCategories = { - recent: ['😀', '😂', '❤️', '👍', '👎', '🎉', '🔥', '💯'], - smileys: ['😀', '😃', '😄', '😁', '😆', '😅', '😂', '🤣', '😊', '😇', '🙂', '🙃', '😉', '😌', '😍', '🥰'], - people: ['👤', '👥', '👨', '👩', '👦', '👧', '👶', '👴', '👵', '👨‍💼', '👩‍💼', '👨‍🎓', '👩‍🎓'], - objects: ['🎉', '🎊', '🎈', '🎁', '🏆', '🥇', '🥈', '🥉', '🏅', '🎖️', '⭐', '🌟', '💫', '✨', '🔥', '💯'] - }; - - const emojis = emojiCategories[category] || emojiCategories.smileys; - return emojis.map(emoji => - `${emoji}` - ).join(''); - } - - insertEmoji(emoji) { - const messageInput = document.getElementById('messageInput'); - const start = messageInput.selectionStart; - const end = messageInput.selectionEnd; - const text = messageInput.value; - - messageInput.value = text.substring(0, start) + emoji + text.substring(end); - messageInput.focus(); - messageInput.setSelectionRange(start + emoji.length, start + emoji.length); - - messageInput.dispatchEvent(new Event('input')); - } - - setupMessageReactions() { - document.addEventListener('click', (e) => { - if (e.target.classList.contains('message-reaction')) { - this.handleReaction(e.target); - } - }); - } - - handleReaction(reactionElement) { - const messageId = reactionElement.closest('.discord-message').dataset.messageId; - const emoji = reactionElement.textContent; - - if (!this.messageReactions.has(messageId)) { - this.messageReactions.set(messageId, new Map()); - } - - const reactions = this.messageReactions.get(messageId); - const currentCount = reactions.get(emoji) || 0; - reactions.set(emoji, currentCount + 1); - - this.updateReactionDisplay(reactionElement, currentCount + 1); - } - - updateReactionDisplay(reactionElement, count) { - const countSpan = reactionElement.querySelector('.reaction-count'); - if (countSpan) { - countSpan.textContent = count; - } else { - reactionElement.innerHTML = `${reactionElement.textContent} ${count}`; - } - reactionElement.classList.add('active'); - } - - setupMentions() { - const messageInput = document.getElementById('messageInput'); - - messageInput.addEventListener('input', (e) => { - const text = e.target.value; - const cursorPos = e.target.selectionStart; - - // Check for @ mention - const beforeCursor = text.substring(0, cursorPos); - const mentionMatch = beforeCursor.match(/@(\w*)$/); - - if (mentionMatch) { - this.showMentionSuggestions(mentionMatch[1]); - } else { - this.hideMentionSuggestions(); - } - }); - } - - showMentionSuggestions(query) { - // Get current users from the participants list - const participants = Array.from(document.querySelectorAll('#participantsList .discord-username')) - .map(el => el.textContent) - .filter(name => name.toLowerCase().includes(query.toLowerCase())); - - if (participants.length === 0) return; - - let mentionBox = document.getElementById('mentionSuggestions'); - if (!mentionBox) { - mentionBox = document.createElement('div'); - mentionBox.id = 'mentionSuggestions'; - mentionBox.className = 'mention-suggestions'; - document.querySelector('.discord-input-area').appendChild(mentionBox); - } - - mentionBox.innerHTML = participants.map(name => - `
    @${name}
    ` - ).join(''); - - mentionBox.style.display = 'block'; - - // Add click handlers - mentionBox.addEventListener('click', (e) => { - if (e.target.classList.contains('mention-item')) { - this.insertMention(e.target.dataset.name); - } - }); - } - - insertMention(name) { - const messageInput = document.getElementById('messageInput'); - const text = messageInput.value; - const cursorPos = messageInput.selectionStart; - - const beforeCursor = text.substring(0, cursorPos); - const afterCursor = text.substring(cursorPos); - - const newText = beforeCursor.replace(/@\w*$/, `@${name} `) + afterCursor; - messageInput.value = newText; - messageInput.focus(); - - this.hideMentionSuggestions(); - } - - hideMentionSuggestions() { - const mentionBox = document.getElementById('mentionSuggestions'); - if (mentionBox) { - mentionBox.style.display = 'none'; - } - } - - setupMessageSearch() { - // Add search functionality - const searchHTML = ` -
    -
    - - -
    -
    -
    - `; - - // Add search to sidebar - const sidebar = document.querySelector('.discord-sidebar .card-body'); - if (sidebar) { - sidebar.insertAdjacentHTML('afterbegin', searchHTML); - } - } - - setupVoiceMessages() { - // Add voice message functionality - const voiceBtn = document.createElement('button'); - voiceBtn.className = 'btn btn-outline-secondary me-2'; - voiceBtn.innerHTML = ''; - voiceBtn.title = 'Voice message'; - - const inputGroup = document.querySelector('.discord-input-group'); - inputGroup.insertBefore(voiceBtn, inputGroup.querySelector('#emojiBtn')); - - voiceBtn.addEventListener('click', () => { - this.toggleVoiceRecording(); - }); - } - - toggleVoiceRecording() { - // Placeholder for voice recording functionality - console.log('Voice recording toggle - would implement WebRTC here'); - } - - setupMessageThreading() { - // Add threading functionality - document.addEventListener('contextmenu', (e) => { - if (e.target.closest('.discord-message')) { - e.preventDefault(); - this.showMessageContextMenu(e); - } - }); - } - - showMessageContextMenu(e) { - const contextMenu = document.createElement('div'); - contextMenu.className = 'message-context-menu'; - contextMenu.innerHTML = ` -
    - Reply -
    -
    - React -
    -
    - Start Thread -
    - `; - - contextMenu.style.position = 'fixed'; - contextMenu.style.left = e.pageX + 'px'; - contextMenu.style.top = e.pageY + 'px'; - contextMenu.style.zIndex = '1000'; - - document.body.appendChild(contextMenu); - - // Remove on click outside - setTimeout(() => { - document.addEventListener('click', () => { - contextMenu.remove(); - }, { once: true }); - }, 0); - } - - scrollToBottom() { - const chatMessages = document.querySelector('#chatMessages'); - chatMessages.scrollTop = chatMessages.scrollHeight; - } + constructor() { + this.searchQuery = ''; + this.init(); + } + + init() { + this.setupComposerTools(); + this.setupSearch(); + this.setupMessageObserver(); + this.restoreDraft(); + } + + setupComposerTools() { + const inputArea = document.querySelector('.discord-input-area'); + const messageInput = document.getElementById('messageInput'); + if (!inputArea || !messageInput) return; + + const toolbar = document.createElement('div'); + toolbar.className = 'composer-toolbar'; + toolbar.innerHTML = ` +
    Enter sends • Shift+Enter adds line • /ai for quick bot prompt
    +
    0 / 8000
    + `; + + inputArea.insertBefore(toolbar, inputArea.firstChild); + + messageInput.addEventListener('input', () => { + const countElement = document.getElementById('composerCharCount'); + if (!countElement) return; + + countElement.textContent = `${messageInput.value.length} / 8000`; + countElement.classList.toggle('composer-char-count-warning', messageInput.value.length > 7000); + localStorage.setItem(this.getDraftKey(), messageInput.value); + }); + + document.addEventListener('chat:message-sent', () => { + localStorage.removeItem(this.getDraftKey()); + const countElement = document.getElementById('composerCharCount'); + if (!countElement) return; + countElement.textContent = '0 / 8000'; + countElement.classList.remove('composer-char-count-warning'); + }); + } + + setupSearch() { + const sidebarBody = document.querySelector('.discord-sidebar .card-body'); + if (!sidebarBody) return; + + const search = document.createElement('div'); + search.className = 'message-search-container'; + search.innerHTML = ` +
    + + +
    + `; + + sidebarBody.insertBefore(search, sidebarBody.firstChild); + + const input = search.querySelector('#messageSearch'); + input.addEventListener('input', event => { + this.searchQuery = event.target.value.trim().toLowerCase(); + this.filterMessages(); + }); + } + + setupMessageObserver() { + const chatMessages = document.getElementById('chatMessages'); + if (!chatMessages) return; + + const observer = new MutationObserver(() => { + this.filterMessages(); + }); + + observer.observe(chatMessages, { childList: true, subtree: true }); + } + + filterMessages() { + const messages = document.querySelectorAll('#chatMessages .discord-message'); + messages.forEach(message => { + const text = message.querySelector('.discord-message-text')?.textContent?.toLowerCase() || ''; + const visible = !this.searchQuery || text.includes(this.searchQuery); + message.classList.toggle('message-hidden', !visible); + }); + } + + restoreDraft() { + const messageInput = document.getElementById('messageInput'); + if (!messageInput) return; + + const draft = localStorage.getItem(this.getDraftKey()); + if (!draft) return; + + messageInput.value = draft; + messageInput.dispatchEvent(new Event('input')); + } + + getDraftKey() { + const roomName = document.getElementById('roomName')?.textContent || 'general'; + return `chatify:draft:${roomName}`; + } } -// Initialize when DOM is loaded document.addEventListener('DOMContentLoaded', () => { - window.nexusFeatures = new NexusFeatures(); + window.nexusFeatures = new NexusFeatures(); }); - -// Add enhanced CSS -const enhancedCSS = ` -.nexus-emoji-picker { - position: absolute; - bottom: 100%; - left: 0; - right: 0; - background: var(--discord-bg-secondary); - border: 1px solid var(--discord-border); - border-radius: var(--discord-radius-lg); - box-shadow: var(--discord-shadow-lg); - z-index: 1000; - margin-bottom: 0.5rem; - max-width: 400px; -} - -.emoji-picker-content { - padding: 1rem; -} - -.emoji-picker-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1rem; - padding-bottom: 0.5rem; - border-bottom: 1px solid var(--discord-border); -} - -.emoji-categories { - display: flex; - gap: 0.5rem; - margin-bottom: 1rem; -} - -.emoji-category { - background: transparent; - border: 1px solid var(--discord-border); - border-radius: var(--discord-radius); - padding: 0.5rem; - color: var(--discord-text-secondary); - cursor: pointer; - transition: var(--discord-transition); -} - -.emoji-category.active, -.emoji-category:hover { - background: var(--discord-accent); - color: white; -} - -.emoji-grid { - display: grid; - grid-template-columns: repeat(8, 1fr); - gap: 0.5rem; - max-height: 200px; - overflow-y: auto; -} - -.emoji-item { - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - cursor: pointer; - border-radius: var(--discord-radius); - transition: var(--discord-transition); - font-size: 1.2rem; -} - -.emoji-item:hover { - background: rgba(255, 255, 255, 0.1); - transform: scale(1.1); -} - -.file-preview { - margin-bottom: 1rem; - animation: slideInUp 0.3s ease; -} - -.file-upload-container { - background: rgba(255, 255, 255, 0.05); - border-radius: var(--discord-radius); - padding: 1rem; - margin-top: 0.5rem; -} - -.file-info { - display: flex; - align-items: center; - gap: 1rem; - margin-bottom: 1rem; -} - -.file-icon { - font-size: 2rem; - color: var(--discord-accent); -} - -.file-details { - flex: 1; -} - -.file-name { - font-weight: 600; - color: var(--discord-text); -} - -.file-size { - font-size: 0.8rem; - color: var(--discord-text-secondary); -} - -.upload-progress { - margin-top: 0.5rem; -} - -.progress-bar { - width: 100%; - height: 4px; - background: rgba(255, 255, 255, 0.2); - border-radius: 2px; - overflow: hidden; - margin-bottom: 0.5rem; -} - -.progress-fill { - height: 100%; - background: var(--discord-accent); - border-radius: 2px; - transition: width 0.3s ease; -} - -.upload-status { - font-size: 0.8rem; - color: var(--discord-text-secondary); -} - -.mention-suggestions { - position: absolute; - bottom: 100%; - left: 0; - right: 0; - background: var(--discord-bg-secondary); - border: 1px solid var(--discord-border); - border-radius: var(--discord-radius); - box-shadow: var(--discord-shadow); - z-index: 1000; - margin-bottom: 0.5rem; - max-height: 200px; - overflow-y: auto; -} - -.mention-item { - padding: 0.5rem 1rem; - cursor: pointer; - transition: var(--discord-transition); - color: var(--discord-text); -} - -.mention-item:hover { - background: rgba(255, 255, 255, 0.1); -} - -.message-context-menu { - background: var(--discord-bg-secondary); - border: 1px solid var(--discord-border); - border-radius: var(--discord-radius); - box-shadow: var(--discord-shadow-lg); - padding: 0.5rem 0; - min-width: 150px; -} - -.context-item { - padding: 0.5rem 1rem; - cursor: pointer; - transition: var(--discord-transition); - color: var(--discord-text); - display: flex; - align-items: center; - gap: 0.5rem; -} - -.context-item:hover { - background: rgba(255, 255, 255, 0.1); -} - -@keyframes slideInUp { - from { - opacity: 0; - transform: translateY(20px); - } - to { - opacity: 1; - transform: translateY(0); - } -} -`; - -// Add the enhanced CSS -const style = document.createElement('style'); -style.textContent = enhancedCSS; -document.head.appendChild(style); diff --git a/public/styles.css b/public/styles.css index 4f46f04..f24a10e 100644 --- a/public/styles.css +++ b/public/styles.css @@ -1231,4 +1231,187 @@ body.discord-theme { .glow-danger { box-shadow: 0 0 20px rgba(237, 66, 69, 0.3); -} \ No newline at end of file +} +/* Enhanced chat features */ +.chat-file { + margin-top: 0.65rem; + border: 1px solid var(--discord-border); + border-radius: var(--discord-radius); + background: rgba(255, 255, 255, 0.04); + padding: 0.75rem; +} + +.chat-file-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.chat-file-header a { + color: #8ea1ff; + text-decoration: none; + font-weight: 600; +} + +.chat-file-header a:hover { + text-decoration: underline; +} + +.chat-file-image { + width: 100%; + max-width: 320px; + border-radius: var(--discord-radius); + border: 1px solid var(--discord-border); +} + +.chat-file-meta { + font-size: 0.8rem; + color: var(--discord-text-secondary); +} + +.composer-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 1.5rem 0; + color: var(--discord-text-muted); + font-size: 0.8rem; +} + +.composer-hint { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.composer-char-count { + font-weight: 600; +} + +.composer-char-count-warning { + color: var(--discord-warning); +} + +.message-search-container { + padding: 0.75rem; + border-bottom: 1px solid var(--discord-border); +} + +.search-input-group { + display: flex; + align-items: center; + gap: 0.5rem; + background: var(--discord-bg); + border: 1px solid var(--discord-border); + border-radius: var(--discord-radius); + padding: 0.45rem 0.65rem; + color: var(--discord-text-muted); +} + +.search-input-group input { + width: 100%; + border: none; + outline: none; + background: transparent; + color: var(--discord-text); + font-size: 0.9rem; +} + +.message-hidden { + display: none; +} + +/* ChatGPT + Apple inspired refinements */ +.apple-chat-theme { + background: radial-gradient(circle at top, #1f2937, #0b1220 65%); +} + +.glass-navbar { + backdrop-filter: blur(14px); + background: rgba(18, 24, 37, 0.75); +} + +.chat-top-controls { + gap: 0.5rem; +} + +#assistantMode { + max-width: 190px; + background: rgba(255, 255, 255, 0.1); + color: #f1f5f9; + border: 1px solid rgba(255, 255, 255, 0.25); +} + +#assistantMode option { + color: #111827; +} + +.discord-message { + background: rgba(255, 255, 255, 0.01); + border-radius: 14px; + padding: 0.75rem; +} + +.assistant-message { + border: 1px solid rgba(52, 211, 153, 0.4); + background: linear-gradient(135deg, rgba(16, 185, 129, 0.15), rgba(30, 64, 175, 0.15)); +} + +.system-message { + border: 1px solid rgba(148, 163, 184, 0.3); + background: rgba(148, 163, 184, 0.1); +} + +.poll-card { + margin-top: 0.65rem; + border: 1px solid var(--discord-border); + border-radius: var(--discord-radius); + background: rgba(15, 23, 42, 0.7); + padding: 0.75rem; +} + +.poll-question { + font-weight: 600; + margin-bottom: 0.5rem; +} + +.poll-options { + display: grid; + gap: 0.4rem; +} + +.poll-option { + border: 1px solid rgba(148, 163, 184, 0.35); + background: rgba(148, 163, 184, 0.12); + color: var(--discord-text); + border-radius: 10px; + padding: 0.45rem 0.6rem; + display: flex; + justify-content: space-between; + align-items: center; + transition: var(--discord-transition); +} + +.poll-option:hover { + border-color: #60a5fa; + background: rgba(59, 130, 246, 0.18); +} + +.poll-option-votes { + color: var(--discord-text-secondary); + font-size: 0.82rem; +} + +.poll-total { + margin-top: 0.5rem; + color: var(--discord-text-muted); + font-size: 0.82rem; +} + +@media (max-width: 900px) { + #assistantMode { + max-width: 145px; + font-size: 0.78rem; + } +} diff --git a/server.js b/server.js index 5ba0e8d..fffdd9d 100644 --- a/server.js +++ b/server.js @@ -1,186 +1,402 @@ -const express = require('express'); -const path = require('path'); -const http = require('http'); -const socketio = require('socket.io'); -const { userJoin, getCurrentUser, userLeave, getRoomUsers, updateUserStatus } = require('./utils/users'); -const formatMessage = require('./utils/messages'); -const MessageHistory = require('./utils/messageHistory'); - -const app = express(); -const server = http.createServer(app); -const io = socketio(server); - -const port = process.env.PORT || 3000; -const botName = 'ChatifyBot'; - -// Initialize message history -const messageHistory = new MessageHistory(); - -// Middleware to parse JSON and urlencoded data -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); - -// Serve static files from the 'public' directory -app.use(express.static('public')); - -// Serve static files from the 'views' directory -app.use(express.static('views')); - -// Health check endpoint -app.get('/health', (req, res) => { - res.status(200).json({ - status: 'healthy', - timestamp: new Date().toISOString(), - uptime: process.uptime(), - memory: process.memoryUsage(), - version: require('./package.json').version - }); -}); - -// Serve index.html for the root route -app.get('/', (req, res) => { - res.sendFile(path.join(__dirname, 'public', 'index.html')); -}); - -// Handle chat room join -app.post('/join', (req, res) => { - const { name, roomCode } = req.body; - - if (name && roomCode) { - res.redirect(`/transfer-screen.html?redirect=${encodeURIComponent(`/chat.html?name=${encodeURIComponent(name)}&room=${encodeURIComponent(roomCode)}`)}`); - } else { - res.redirect('/?error=invalid'); - } -}); - // Run when client connects - io.on('connection', socket => { - console.log(`User connected: ${socket.id}`); - - socket.on('joinRoom', ({ username, room }) => { - const user = userJoin(socket.id, username, room); - socket.join(user.room); - - // Send message history to the user - const history = messageHistory.getMessages(user.room, 50); - socket.emit('messageHistory', history); - - // Welcome current user - const welcomeMessage = formatMessage(botName, `Welcome to ${user.room}, ${user.username}! 🎉`); - socket.emit('message', welcomeMessage); - messageHistory.addMessage(user.room, welcomeMessage); - - // Broadcast when a user connects - const joinMessage = formatMessage(botName, `${user.username} joined the server`); - socket.broadcast - .to(user.room) - .emit('message', joinMessage); - messageHistory.addMessage(user.room, joinMessage); - - // Send users and room info - io.to(user.room).emit('roomUsers', { - room: user.room, - users: getRoomUsers(user.room) - }); - - // Typing indicators - socket.on('typing', () => { - const user = getCurrentUser(socket.id); - if (user) { - socket.broadcast.to(user.room).emit('typing', user.username); - } - }); - - socket.on('stopTyping', () => { - const user = getCurrentUser(socket.id); - if (user) { - socket.broadcast.to(user.room).emit('stopTyping', user.username); - } - }); - }); - - // Listen for chatMessage - socket.on('chatMessage', msg => { - const user = getCurrentUser(socket.id); - if (user) { - const message = formatMessage(user.username, msg); - io.to(user.room).emit('message', message); - messageHistory.addMessage(user.room, message); - } - }); - - // Runs when client disconnects - socket.on('disconnect', (reason) => { - console.log(`User disconnected: ${socket.id}, reason: ${reason}`); - const user = userLeave(socket.id); - - if (user) { - const leaveMessage = formatMessage(botName, `${user.username} left the server`); - io.to(user.room).emit('message', leaveMessage); - messageHistory.addMessage(user.room, leaveMessage); - - // Send users and room info - io.to(user.room).emit('roomUsers', { - room: user.room, - users: getRoomUsers(user.room) - }); - } - }); - - // Handle reconnection - socket.on('reconnect', () => { - console.log(`User reconnected: ${socket.id}`); - // Update user status to online - const user = getCurrentUser(socket.id); - if (user) { - updateUserStatus(socket.id, 'online'); - io.to(user.room).emit('roomUsers', { - room: user.room, - users: getRoomUsers(user.room) - }); - } - }); - - socket.on('updateStatus', (status) => { - const user = updateUserStatus(socket.id, status); - if (user) { - io.to(user.room).emit('roomUsers', { - room: user.room, - users: getRoomUsers(user.room) - }); - } - }); - }); - -// Error handling -process.on('uncaughtException', (err) => { - console.error('Uncaught Exception:', err); - process.exit(1); -}); - -process.on('unhandledRejection', (reason, promise) => { - console.error('Unhandled Rejection at:', promise, 'reason:', reason); - process.exit(1); -}); - -// Graceful shutdown -process.on('SIGTERM', () => { - console.log('SIGTERM received, shutting down gracefully'); - server.close(() => { - console.log('Process terminated'); - process.exit(0); - }); -}); - -process.on('SIGINT', () => { - console.log('SIGINT received, shutting down gracefully'); - server.close(() => { - console.log('Process terminated'); - process.exit(0); - }); -}); - -// Start the server -server.listen(port, () => { - console.log(`Chatify server running on port ${port}`); - console.log(`Environment: ${process.env.NODE_ENV || 'development'}`); -}); +const express = require('express'); +const path = require('path'); +const http = require('http'); +const socketio = require('socket.io'); +const { userJoin, getCurrentUser, userLeave, getRoomUsers, updateUserStatus } = require('./utils/users'); +const formatMessage = require('./utils/messages'); +const MessageHistory = require('./utils/messageHistory'); + +const app = express(); +const server = http.createServer(app); +const io = socketio(server, { + maxHttpBufferSize: 6 * 1024 * 1024 +}); + +const port = process.env.PORT || 3000; +const botName = 'Chatify Assistant'; +const MAX_MESSAGE_LENGTH = 8000; +const MAX_FILE_SIZE = 5 * 1024 * 1024; +const GEMINI_API_KEY = process.env.GEMINI_API_KEY || 'AIzaSyDdb9CwRD5CpkC28n91zrE9WwMoFQTgwoY'; +const GEMINI_MODELS = ['gemini-3.0-flash', 'gemini-2.5-flash', 'gemini-2.0-flash']; + +const messageHistory = new MessageHistory(); +const roomPolls = new Map(); // room -> Map(pollId => poll) + +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); +app.use(express.static('public')); +app.use(express.static('views')); + +app.get('/health', (req, res) => { + res.status(200).json({ + status: 'healthy', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + memory: process.memoryUsage(), + version: require('./package.json').version + }); +}); + +app.get('/', (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'index.html')); +}); + +app.post('/join', (req, res) => { + const name = (req.body.name || '').trim(); + const roomCode = (req.body.roomCode || '').trim(); + + if (name && roomCode) { + res.redirect(`/transfer-screen.html?redirect=${encodeURIComponent(`/chat.html?name=${encodeURIComponent(name)}&room=${encodeURIComponent(roomCode)}`)}`); + return; + } + + res.redirect('/?error=invalid'); +}); + +function sanitizeText(value) { + return value + .toString() + .replace(/\r\n/g, '\n') + .replace(/\u0000/g, '') + .slice(0, MAX_MESSAGE_LENGTH) + .trim(); +} + +function sendRoomUsers(room) { + io.to(room).emit('roomUsers', { + room, + users: getRoomUsers(room) + }); +} + +function ensureRoomPolls(room) { + if (!roomPolls.has(room)) { + roomPolls.set(room, new Map()); + } + return roomPolls.get(room); +} + +function getFallbackReply(prompt) { + const normalized = prompt.toLowerCase(); + + if (normalized.includes('help')) { + return 'I can help summarize ideas, draft replies, create action plans, and assist with decisions. Try asking for a summary, roadmap, or polished response.'; + } + + if (normalized.includes('summary') || normalized.includes('summarize')) { + return 'Summary: your room supports long-form messages, file sharing, polls, and assistant prompts for faster decision-making.'; + } + + if (normalized.includes('roadmap') || normalized.includes('plan')) { + return 'Suggested plan: 1) Define goals 2) Open a poll 3) Share relevant files 4) Assign owners 5) Track updates in-thread.'; + } + + return 'Thanks! I can turn that into a cleaner draft, checklist, or decision poll if you want.'; +} + +async function generateAssistantReply({ prompt, mode, room, username }) { + const contextMessages = messageHistory + .getMessages(room, 12) + .filter(message => message.type === 'text') + .slice(-8) + .map(message => `${message.username}: ${message.text}`) + .join('\n'); + + const systemInstruction = `You are Chatify Assistant inside a group chat app. Keep answers useful, concise, and specific.\nMode: ${mode}.\nIf asked for plans/summaries, provide structured bullet points.\nUser: ${username}.`; + + const userPrompt = `Conversation context:\n${contextMessages || '(no recent context)'}\n\nCurrent user prompt:\n${prompt}`; + + const payload = { + contents: [{ role: 'user', parts: [{ text: userPrompt }] }], + systemInstruction: { role: 'system', parts: [{ text: systemInstruction }] }, + generationConfig: { + temperature: mode === 'creative' ? 1.0 : mode === 'concise' ? 0.4 : 0.7, + topP: 0.9, + maxOutputTokens: 450 + } + }; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 12000); + + try { + for (const model of GEMINI_MODELS) { + const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${GEMINI_API_KEY}`; + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload), + signal: controller.signal + }); + + if (!response.ok) { + continue; + } + + const data = await response.json(); + const text = data?.candidates?.[0]?.content?.parts?.map(part => part.text).join('\n').trim(); + if (text) { + clearTimeout(timeout); + return text.slice(0, MAX_MESSAGE_LENGTH); + } + } + + return getFallbackReply(prompt); + } catch (error) { + console.error('Assistant generation failed:', error.message); + return getFallbackReply(prompt); + } finally { + clearTimeout(timeout); + } +} + +async function emitAssistantReply({ socket, user, prompt, mode }) { + const safeMode = sanitizeText(mode || 'balanced').slice(0, 20) || 'balanced'; + const safePrompt = sanitizeText(prompt || ''); + if (!safePrompt) return; + + const responseText = await generateAssistantReply({ + prompt: safePrompt, + mode: safeMode, + room: user.room, + username: user.username + }); + + const botMessage = { + ...formatMessage(botName, responseText), + type: 'text', + meta: { role: 'assistant', mode: safeMode } + }; + + io.to(user.room).emit('message', botMessage); + messageHistory.addMessage(user.room, botMessage); +} + +io.on('connection', socket => { + console.log(`User connected: ${socket.id}`); + + socket.on('joinRoom', ({ username, room }) => { + const safeUsername = sanitizeText(username || 'Guest').slice(0, 30); + const safeRoom = sanitizeText(room || 'general').slice(0, 40); + + const user = userJoin(socket.id, safeUsername || 'Guest', safeRoom || 'general'); + socket.join(user.room); + + const history = messageHistory.getMessages(user.room, 120); + socket.emit('messageHistory', history); + + const welcomeMessage = { + ...formatMessage(botName, `Welcome to ${user.room}, ${user.username}. You can share files, create polls, and use the assistant tools from the top action bar.`), + type: 'text', + meta: { role: 'assistant' } + }; + socket.emit('message', welcomeMessage); + + const joinMessage = { + ...formatMessage(botName, `${user.username} joined the room`), + type: 'text', + meta: { role: 'system' } + }; + socket.broadcast.to(user.room).emit('message', joinMessage); + messageHistory.addMessage(user.room, joinMessage); + + sendRoomUsers(user.room); + }); + + socket.on('chatMessage', async rawMessage => { + const user = getCurrentUser(socket.id); + if (!user) return; + + const cleanedText = sanitizeText(rawMessage); + if (!cleanedText) return; + + const message = { + ...formatMessage(user.username, cleanedText), + type: 'text' + }; + + io.to(user.room).emit('message', message); + messageHistory.addMessage(user.room, message); + + if (cleanedText.startsWith('/ai ')) { + const prompt = cleanedText.slice(4).trim(); + await emitAssistantReply({ socket, user, prompt, mode: 'balanced' }); + } + }); + + socket.on('assistantPrompt', async payload => { + const user = getCurrentUser(socket.id); + if (!user || !payload) return; + + const prompt = sanitizeText(payload.prompt || ''); + const mode = sanitizeText(payload.mode || 'balanced').slice(0, 20); + if (!prompt) return; + + await emitAssistantReply({ socket, user, prompt, mode }); + }); + + socket.on('createPoll', payload => { + const user = getCurrentUser(socket.id); + if (!user || !payload) return; + + const question = sanitizeText(payload.question || '').slice(0, 240); + const options = Array.isArray(payload.options) + ? payload.options.map(option => sanitizeText(option).slice(0, 90)).filter(Boolean).slice(0, 6) + : []; + + if (!question || options.length < 2) { + socket.emit('pollError', 'A poll needs a question and at least 2 options.'); + return; + } + + const pollId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + const poll = { + id: pollId, + room: user.room, + createdBy: user.username, + question, + options: options.map((option, index) => ({ index, option, votes: 0 })), + voters: {} + }; + + const roomPollMap = ensureRoomPolls(user.room); + roomPollMap.set(pollId, poll); + + const pollMessage = { + ...formatMessage(user.username, `Poll: ${question}`), + type: 'poll', + poll + }; + + io.to(user.room).emit('message', pollMessage); + messageHistory.addMessage(user.room, pollMessage); + }); + + socket.on('votePoll', payload => { + const user = getCurrentUser(socket.id); + if (!user || !payload) return; + + const pollId = sanitizeText(payload.pollId || '').slice(0, 60); + const optionIndex = Number(payload.optionIndex); + const roomPollMap = ensureRoomPolls(user.room); + const poll = roomPollMap.get(pollId); + + if (!poll || Number.isNaN(optionIndex) || !poll.options[optionIndex]) { + socket.emit('pollError', 'Poll vote was invalid.'); + return; + } + + const previousVote = poll.voters[socket.id]; + if (previousVote !== undefined && poll.options[previousVote]) { + poll.options[previousVote].votes = Math.max(0, poll.options[previousVote].votes - 1); + } + + poll.voters[socket.id] = optionIndex; + poll.options[optionIndex].votes += 1; + + const pollMessage = { + ...formatMessage(botName, `${user.username} voted on: ${poll.question}`), + type: 'poll-update', + poll + }; + + io.to(user.room).emit('pollUpdate', pollMessage); + }); + + socket.on('fileUpload', payload => { + const user = getCurrentUser(socket.id); + if (!user || !payload) return; + + const name = sanitizeText(payload.name || 'attachment').slice(0, 120); + const type = sanitizeText(payload.type || 'application/octet-stream').slice(0, 100); + const size = Number(payload.size) || 0; + const data = typeof payload.data === 'string' ? payload.data : ''; + + if (!name || !data.startsWith('data:') || data.length < 30) { + socket.emit('uploadError', 'Invalid file payload.'); + return; + } + + if (size > MAX_FILE_SIZE) { + socket.emit('uploadError', `File is too large. Max size is ${Math.round(MAX_FILE_SIZE / (1024 * 1024))}MB.`); + return; + } + + const fileMessage = { + ...formatMessage(user.username, `${user.username} shared a file: ${name}`), + type: 'file', + file: { name, type, size, data } + }; + + io.to(user.room).emit('message', fileMessage); + messageHistory.addMessage(user.room, fileMessage); + }); + + socket.on('typing', () => { + const user = getCurrentUser(socket.id); + if (user) { + socket.broadcast.to(user.room).emit('typing', user.username); + } + }); + + socket.on('stopTyping', () => { + const user = getCurrentUser(socket.id); + if (user) { + socket.broadcast.to(user.room).emit('stopTyping', user.username); + } + }); + + socket.on('updateStatus', status => { + const user = updateUserStatus(socket.id, status); + if (user) sendRoomUsers(user.room); + }); + + socket.on('disconnect', reason => { + console.log(`User disconnected: ${socket.id}, reason: ${reason}`); + const user = userLeave(socket.id); + if (!user) return; + + const leaveMessage = { + ...formatMessage(botName, `${user.username} left the room`), + type: 'text', + meta: { role: 'system' } + }; + + io.to(user.room).emit('message', leaveMessage); + messageHistory.addMessage(user.room, leaveMessage); + sendRoomUsers(user.room); + }); +}); + +process.on('uncaughtException', err => { + console.error('Uncaught Exception:', err); + process.exit(1); +}); + +process.on('unhandledRejection', (reason, promise) => { + console.error('Unhandled Rejection at:', promise, 'reason:', reason); + process.exit(1); +}); + +process.on('SIGTERM', () => { + console.log('SIGTERM received, shutting down gracefully'); + server.close(() => { + console.log('Process terminated'); + process.exit(0); + }); +}); + +process.on('SIGINT', () => { + console.log('SIGINT received, shutting down gracefully'); + server.close(() => { + console.log('Process terminated'); + process.exit(0); + }); +}); + +server.listen(port, () => { + console.log(`Chatify server running on port ${port}`); + console.log(`Environment: ${process.env.NODE_ENV || 'development'}`); +}); diff --git a/utils/users.js b/utils/users.js index ecd3f14..fa401fc 100644 --- a/utils/users.js +++ b/utils/users.js @@ -1,37 +1,54 @@ -const users = []; - -function userJoin(id, username, room) { - const user = { id, username, room }; - users.push(user); - return user; -} - -function getCurrentUser(id) { - return users.find(user => user.id === id); -} - -function userLeave(id) { - const index = users.findIndex(user => user.id === id); - if (index !== -1) { - return users.splice(index, 1)[0]; - } -} - function getRoomUsers(room) { - return users.filter(user => user.room === room); - } - - function updateUserStatus(id, status) { - const user = users.find(user => user.id === id); - if (user) { - user.status = status; - } - return user; - } - - module.exports = { - userJoin, - getCurrentUser, - userLeave, - getRoomUsers, - updateUserStatus - }; +const users = []; + +function userJoin(id, username, room) { + const existingIndex = users.findIndex(user => user.id === id); + if (existingIndex !== -1) { + users.splice(existingIndex, 1); + } + + const cleanUsername = (username || 'Guest').toString().trim().slice(0, 30); + const cleanRoom = (room || 'general').toString().trim().slice(0, 40); + const user = { + id, + username: cleanUsername || 'Guest', + room: cleanRoom || 'general', + status: 'online' + }; + + users.push(user); + return user; +} + +function getCurrentUser(id) { + return users.find(user => user.id === id); +} + +function userLeave(id) { + const index = users.findIndex(user => user.id === id); + if (index !== -1) { + return users.splice(index, 1)[0]; + } + + return undefined; +} + +function getRoomUsers(room) { + return users.filter(user => user.room === room); +} + +function updateUserStatus(id, status) { + const user = users.find(candidate => candidate.id === id); + if (user) { + user.status = status; + } + + return user; +} + +module.exports = { + userJoin, + getCurrentUser, + userLeave, + getRoomUsers, + updateUserStatus +}; diff --git a/views/chat.html b/views/chat.html index d0fa3a4..1711510 100644 --- a/views/chat.html +++ b/views/chat.html @@ -9,10 +9,9 @@ - - -