565 lines
18 KiB
JavaScript
565 lines
18 KiB
JavaScript
(() => {
|
|
'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 = '<div class="chat-message-pfp" style="background-color: #001858;"></div>';
|
|
if (msg.pfpFileId && msg.pfpFileId.Valid) {
|
|
pfpHTML = `<img src="${basePath}/file?id=${msg.pfpFileId.Int64}&t=${new Date().getTime()}" alt="PFP" class="chat-message-pfp">`;
|
|
}
|
|
const replyHTML = msg.replyTo > 0 ? `<div class="chat-message-reply" onclick="scrollToMessage(${msg.replyTo})">Replying to message...</div>` : '';
|
|
|
|
msgDiv.innerHTML = `
|
|
<div class="chat-message-header">
|
|
${pfpHTML}
|
|
<span class="chat-message-username">${msg.username}</span>
|
|
<span class="chat-message-timestamp">${new Date(msg.timestamp).toLocaleString()}</span>
|
|
</div>
|
|
${replyHTML}
|
|
<div class="chat-message-content">${msg.content}</div>
|
|
<div class="post-actions">
|
|
<a href="javascript:void(0)" onclick="replyToMessage(${msg.id}, '${msg.username}')">Reply</a>
|
|
</div>
|
|
`;
|
|
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();
|
|
}
|
|
})();
|