(() => { 'use strict'; const chatContainer = document.querySelector('.chat-container'); if (!chatContainer) { return; } const boardId = chatContainer.dataset.boardId; const basePath = chatContainer.dataset.basePath || ''; const currentUsername = chatContainer.dataset.currentUsername || ''; const usernamesScript = document.getElementById('chat-usernames'); let allUsernames = []; if (usernamesScript) { try { allUsernames = JSON.parse(usernamesScript.textContent || '[]'); } catch (err) { console.error('Failed to parse chat usernames:', err); allUsernames = []; } } let ws; let autocompleteActive = false; let replyToId = -1; let reconnectAttempts = 0; let reconnectTimeout; let isUserAtBottom = true; let unreadCount = 0; const maxReconnectDelay = 30000; const typingUsers = new Set(); function updateConnectionStatus(status) { const dot = document.getElementById('connection-dot'); const text = document.getElementById('connection-text'); if (!dot || !text) { return; } dot.className = 'connection-dot ' + status; if (status === 'connected') { text.textContent = 'Connected'; reconnectAttempts = 0; } else if (status === 'connecting') { text.textContent = 'Connecting...'; } else if (status === 'disconnected') { text.textContent = 'Disconnected'; } } function connectWebSocket() { if (!boardId) { return; } updateConnectionStatus('connecting'); ws = new WebSocket('ws://' + window.location.host + basePath + '/chat/?ws=true&id=' + boardId); ws.onopen = function() { updateConnectionStatus('connected'); reconnectAttempts = 0; }; ws.onmessage = function(event) { const msg = JSON.parse(event.data); if (msg.type === 'typing') { showTypingIndicator(msg.username); return; } appendMessage(msg); }; ws.onclose = function() { updateConnectionStatus('disconnected'); console.log('WebSocket closed, reconnecting...'); scheduleReconnect(); }; ws.onerror = function(error) { console.error('WebSocket error:', error); updateConnectionStatus('disconnected'); }; } function scheduleReconnect() { if (reconnectTimeout) { clearTimeout(reconnectTimeout); } const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), maxReconnectDelay); reconnectAttempts++; console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts})...`); reconnectTimeout = setTimeout(connectWebSocket, delay); } function sendMessage() { const input = document.getElementById('chat-input-text'); if (!input) { return; } const content = input.value.trim(); if (content === '') { return; } const msg = { type: 'message', content: content, replyTo: replyToId }; if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(msg)); input.value = ''; cancelReply(); } else { console.error('WebSocket is not open. Current state:', ws ? ws.readyState : 'undefined'); alert('Cannot send message: Not connected to chat server'); } } function sendTypingIndicator() { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'typing', username: currentUsername })); } } function showTypingIndicator(username) { if (username === currentUsername) { return; } typingUsers.add(username); updateTypingDisplay(); setTimeout(() => { typingUsers.delete(username); updateTypingDisplay(); }, 3000); } function updateTypingDisplay() { const indicator = document.getElementById('typing-indicator'); const usersSpan = document.getElementById('typing-users'); if (!indicator || !usersSpan) { return; } if (typingUsers.size === 0) { indicator.classList.remove('visible'); } else { const users = Array.from(typingUsers); if (users.length === 1) { usersSpan.textContent = `${users[0]} is typing`; } else if (users.length === 2) { usersSpan.textContent = `${users[0]} and ${users[1]} are typing`; } else { usersSpan.textContent = `${users[0]}, ${users[1]}, and ${users.length - 2} other${users.length - 2 > 1 ? 's' : ''} are typing`; } indicator.classList.add('visible'); } } function appendMessage(msg) { const messages = document.getElementById('chat-messages'); if (!messages) { return; } if (document.getElementById('msg-' + msg.id)) { return; } const wasAtBottom = isUserAtBottom; const msgDiv = document.createElement('div'); let highlightClass = ''; if (msg.mentions && msg.mentions.includes(currentUsername)) { highlightClass = ' chat-message-highlighted'; } msgDiv.className = 'chat-message' + highlightClass; msgDiv.id = 'msg-' + msg.id; msgDiv.dataset.user = msg.username; msgDiv.dataset.reply = msg.replyTo || 0; let pfpHTML = '
'; if (msg.pfpFileId && msg.pfpFileId.Valid) { pfpHTML = `PFP`; } const replyHTML = msg.replyTo > 0 ? `
Replying to message...
` : ''; msgDiv.innerHTML = `
${pfpHTML} ${msg.username} ${new Date(msg.timestamp).toLocaleString()}
${replyHTML}
${msg.content}
Reply
`; messages.appendChild(msgDiv); applyGrouping(); if (wasAtBottom) { messages.scrollTop = messages.scrollHeight; unreadCount = 0; updateUnreadBadge(); } else if (msg.username !== currentUsername) { unreadCount++; updateUnreadBadge(); } } function updateUnreadBadge() { const badge = document.getElementById('unread-badge'); if (!badge) { return; } if (unreadCount > 0) { badge.textContent = unreadCount > 99 ? '99+' : unreadCount; badge.style.display = 'flex'; } else { badge.style.display = 'none'; } } function jumpToBottom() { const messages = document.getElementById('chat-messages'); if (!messages) { return; } messages.scrollTo({ top: messages.scrollHeight, behavior: 'smooth' }); unreadCount = 0; updateUnreadBadge(); } function checkScrollPosition() { const messages = document.getElementById('chat-messages'); const jumpButton = document.getElementById('jump-to-bottom'); if (!messages || !jumpButton) { return; } const threshold = 100; isUserAtBottom = messages.scrollHeight - messages.scrollTop - messages.clientHeight < threshold; if (isUserAtBottom) { jumpButton.classList.remove('visible'); unreadCount = 0; updateUnreadBadge(); } else { jumpButton.classList.add('visible'); } } function replyToMessage(id, username) { replyToId = id; const replyIndicator = document.getElementById('reply-indicator'); const replyUsernameSpan = document.getElementById('reply-username'); if (!replyIndicator || !replyUsernameSpan) { return; } replyUsernameSpan.textContent = `Replying to ${username}`; replyIndicator.style.display = 'flex'; const input = document.getElementById('chat-input-text'); if (input) { input.focus(); } } function cancelReply() { replyToId = -1; const replyIndicator = document.getElementById('reply-indicator'); if (replyIndicator) { replyIndicator.style.display = 'none'; } } function scrollToMessage(id) { const msgElement = document.getElementById('msg-' + id); if (msgElement) { msgElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); msgElement.style.transition = 'background-color 0.5s'; msgElement.style.backgroundColor = '#f582ae'; setTimeout(() => { msgElement.style.backgroundColor = ''; }, 1000); } } function showAutocompletePopup(suggestions, x, y) { const popup = document.getElementById('autocomplete-popup'); if (!popup) { return; } popup.innerHTML = ''; popup.style.position = 'fixed'; popup.style.left = x + 'px'; popup.style.top = y + 'px'; popup.style.display = 'block'; autocompleteActive = true; suggestions.forEach(username => { const item = document.createElement('div'); item.className = 'autocomplete-item'; item.textContent = username; item.onmousedown = (e) => { e.preventDefault(); completeMention(username); popup.style.display = 'none'; autocompleteActive = false; }; popup.appendChild(item); }); } function completeMention(username) { const input = document.getElementById('chat-input-text'); if (!input) { return; } const text = input.value; const caretPos = input.selectionStart; const textBeforeCaret = text.substring(0, caretPos); const atIndex = textBeforeCaret.lastIndexOf('@'); if (atIndex !== -1) { const prefix = text.substring(0, atIndex); const suffix = text.substring(caretPos); input.value = prefix + '@' + username + ' ' + suffix; const newCaretPos = prefix.length + 1 + username.length + 1; input.focus(); input.setSelectionRange(newCaretPos, newCaretPos); } } function getCaretCoordinates(element, position) { const mirrorDivId = 'input-mirror-div'; const div = document.createElement('div'); div.id = mirrorDivId; document.body.appendChild(div); const style = window.getComputedStyle(element); const properties = ['border', 'boxSizing', 'fontFamily', 'fontSize', 'fontWeight', 'letterSpacing', 'lineHeight', 'padding', 'textDecoration', 'textIndent', 'textTransform', 'whiteSpace', 'wordSpacing', 'wordWrap', 'width']; properties.forEach(prop => { div.style[prop] = style[prop]; }); div.style.position = 'absolute'; div.style.top = '-9999px'; div.style.left = '0px'; div.textContent = element.value.substring(0, position); const span = document.createElement('span'); span.textContent = element.value.substring(position) || '.'; div.appendChild(span); const coords = { top: span.offsetTop, left: span.offsetLeft }; document.body.removeChild(div); return coords; } function applyGrouping() { const container = document.getElementById('chat-messages'); if (!container) { return; } const msgs = Array.from(container.querySelectorAll('.chat-message')).filter(el => el.id.startsWith('msg-')); for (let i = 0; i < msgs.length; i++) { const curr = msgs[i]; curr.classList.remove('grouped'); if (i === 0) { continue; } const prev = msgs[i - 1]; const currUser = curr.dataset.user; const prevUser = prev.dataset.user; const currReply = parseInt(curr.dataset.reply, 10) || -1; if (currUser === prevUser && (currReply <= 0)) { curr.classList.add('grouped'); } } } function handleAutocompleteInput(e) { const input = e.target; const text = input.value; const caretPos = input.selectionStart; const popup = document.getElementById('autocomplete-popup'); if (!popup) { return; } const textBeforeCaret = text.substring(0, caretPos); const atIndex = textBeforeCaret.lastIndexOf('@'); if (atIndex !== -1 && (atIndex === 0 || /\s/.test(text.charAt(atIndex - 1)))) { const query = textBeforeCaret.substring(atIndex + 1); if (!/\s/.test(query)) { const suggestions = allUsernames.filter(u => u.toLowerCase().startsWith(query.toLowerCase())).slice(0, 10); if (suggestions.length > 0 && query.length > 0) { const coords = getCaretCoordinates(input, atIndex); const rect = input.getBoundingClientRect(); showAutocompletePopup(suggestions, rect.left + coords.left, rect.top + coords.top + 20); } else { popup.style.display = 'none'; autocompleteActive = false; } return; } } popup.style.display = 'none'; autocompleteActive = false; } function handleAutocompleteBlur() { setTimeout(() => { if (!document.querySelector('.autocomplete-popup:hover')) { const popup = document.getElementById('autocomplete-popup'); if (popup) { popup.style.display = 'none'; } autocompleteActive = false; } }, 150); } function handleMessageKeydown(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } } function setupDraftAutoSave(chatInput) { if (!chatInput) { return; } const draftKey = `draft_chat_${boardId}`; let draftSaveTimeout; const existingDraft = loadDraft(draftKey); const draftTimestamp = getDraftTimestamp(draftKey); if (existingDraft && draftTimestamp) { const sevenDaysAgo = Date.now() - (7 * 24 * 60 * 60 * 1000); if (draftTimestamp > sevenDaysAgo) { const indicator = showDraftIndicator( existingDraft, draftTimestamp, (content) => { chatInput.value = content; chatInput.focus(); }, () => { clearDraft(draftKey); } ); const chatInputContainer = document.querySelector('.chat-input'); if (chatInputContainer && chatInputContainer.parentNode) { chatInputContainer.parentNode.insertBefore(indicator, chatInputContainer); } } else { clearDraft(draftKey); } } chatInput.addEventListener('input', () => { clearTimeout(draftSaveTimeout); draftSaveTimeout = setTimeout(() => { const content = chatInput.value.trim(); if (content) { saveDraft(draftKey, content); } else { clearDraft(draftKey); } }, 2000); }); } function setupTypingIndicator(chatInput) { if (!chatInput) { return; } let lastTypingTime = 0; chatInput.addEventListener('input', () => { const now = Date.now(); if (now - lastTypingTime > 2000) { sendTypingIndicator(); lastTypingTime = now; } }); } function setupSendMessageDraftClear(chatInput) { if (!chatInput) { return; } const draftKey = `draft_chat_${boardId}`; const originalSendMessage = window.sendMessage; window.sendMessage = function() { const content = chatInput.value.trim(); if (content !== '') { clearDraft(draftKey); } if (typeof originalSendMessage === 'function') { originalSendMessage(); } else { sendMessage(); } }; } function initChat() { connectWebSocket(); const messagesContainer = document.getElementById('chat-messages'); if (messagesContainer) { messagesContainer.scrollTop = messagesContainer.scrollHeight; applyGrouping(); messagesContainer.addEventListener('scroll', checkScrollPosition); } const jumpButton = document.getElementById('jump-to-bottom'); if (jumpButton) { jumpButton.addEventListener('click', jumpToBottom); } const chatInput = document.getElementById('chat-input-text'); if (chatInput) { chatInput.addEventListener('input', handleAutocompleteInput); chatInput.addEventListener('blur', handleAutocompleteBlur); chatInput.addEventListener('keydown', handleMessageKeydown); } setupTypingIndicator(chatInput); setupDraftAutoSave(chatInput); setupSendMessageDraftClear(chatInput); checkScrollPosition(); } window.sendMessage = sendMessage; window.replyToMessage = replyToMessage; window.cancelReply = cancelReply; window.scrollToMessage = scrollToMessage; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initChat); } else { initChat(); } })();