Dnishe/static/js/chat.js
2026-06-08 21:56:14 +00:00

630 lines
21 KiB
JavaScript

// ====================
// Timezone utilities
// ====================
function getUserTimezone() {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
function parseUTCToLocal(utcTimestamp) {
if (!utcTimestamp) return new Date();
let isoString = utcTimestamp.replace(' ', 'T');
if (!isoString.endsWith('Z') && !isoString.includes('+')) {
isoString += 'Z';
}
return new Date(isoString);
}
function formatLocalTime(utcTimestamp) {
const localDate = parseUTCToLocal(utcTimestamp);
const dateStr = localDate.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric'
});
const timeStr = localDate.toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit'
});
return { dateStr, timeStr };
}
// ====================
// Messaging and chat related logic
// ====================
let socket = null;
let isSending = false;
function connectSocketIO() {
if (!token) return;
if (socket) {
socket.disconnect();
}
console.log('Connecting to SocketIO...');
try {
// Connect to the same host/port as the HTTP requests
const socketUrl = window.location.origin;
socket = io(socketUrl, {
transports: ['websocket', 'polling']
});
socket.on('connect', () => {
console.log('SocketIO connected');
});
socket.on('new_message', (message) => {
console.log('SocketIO: New message received:', message);
handleSocketMessage(message);
});
socket.on('user_ban_status', (data) => {
console.log('SocketIO: User ban status:', data);
if (data.user_id == userId && data.is_banned) {
// Current user was banned, disconnect
alert('You have been banned from the chat.');
logout();
}
});
socket.on('delete_message', (data) => {
console.log('SocketIO: Delete message:', data);
// Remove the message from the UI
const messageElement = document.querySelector(`[data-msg-id="${data.id}"]`);
if (messageElement) {
messageElement.remove();
}
});
socket.on('system_notification', (data) => {
console.log('SocketIO: System notification:', data);
// Show system notification
showBrowserNotification('System Notification', data.message, null, null);
// Could also display in chat if needed
});
socket.on('disconnect', () => {
console.log('SocketIO disconnected');
});
socket.on('connect_error', (error) => {
console.error('SocketIO connection error:', error);
// Try to reconnect after a delay
setTimeout(() => {
if (!socket.connected) {
console.log('Retrying SocketIO connection...');
connectSocketIO();
}
}, 5000);
});
} catch (e) {
console.error('SocketIO: Error creating connection:', e);
}
}
function handleSocketMessage(message) {
if (message.sender_id == userId) return;
let key = 'global';
if (message.channel_id) {
key = conversationKey('c', message.channel_id);
} else if (message.is_global != 1) {
const otherUserId = message.sender_id == userId ? message.receiver_id : message.sender_id;
key = conversationKey('d', otherUserId);
}
if (shouldNotifyMessage(message, key)) {
let title = '';
let body = '';
if (message.channel_id) {
let channelName = 'Channel';
let serverName = 'Server';
servers.forEach(s => {
const ch = s.channels.find(c => c.id == message.channel_id);
if (ch) {
channelName = ch.name;
serverName = s.name;
}
});
title = `${serverName} - #${channelName}`;
} else if (message.is_global == 1) {
title = 'Global Chat';
} else {
title = `DM from ${message.username}`;
}
let contentPreview = '';
try {
const parsed = JSON.parse(message.content);
contentPreview = parsed.text || parsed.file?.filename || '[File]';
} catch (e) {
contentPreview = message.content;
}
body = contentPreview.substring(0, 100);
if (contentPreview.length > 100) body += '...';
showBrowserNotification(title, body, message.avatar_url || null, null);
markMessageNotified(message.id);
}
const ts = new Date(message.timestamp).getTime();
if (!lastSeen[key] || ts > lastSeen[key]) {
unreadCounts[key] = (unreadCounts[key] || 0) + 1;
lastSeen[key] = ts;
renderUnreadIndicators();
}
// Add the message to the UI if it's for the current view
//console.log('Attempting to display message:', message, 'currentChannelId:', currentChannelId, 'currentDMUserId:', currentDMUserId);
displayMessages([message]);
}
function disconnectSocketIO() {
if (socket) {
socket.disconnect();
socket = null;
}
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
async function requestNotificationPermission() {
if (!('Notification' in window)) {
console.log('This browser does not support notifications');
return false;
}
if (Notification.permission === 'granted') {
return true;
}
if (Notification.permission !== 'denied') {
const permission = await Notification.requestPermission();
notificationPermission = permission;
return permission === 'granted';
}
return false;
}
function showBrowserNotification(title, body, icon = null, onClick = null) {
if (notificationPermission !== 'granted') {
return;
}
const options = {
body: body,
icon: icon || '/static/avatars/avatar_1.png',
badge: '/static/avatars/avatar_1.png',
tag: title,
requireInteraction: false
};
const notification = new Notification(title, options);
if (onClick) {
notification.onclick = function() {
window.focus();
onClick();
notification.close();
};
}
setTimeout(() => {
notification.close();
}, 5000);
}
function shouldNotifyMessage(msg, conversationKey) {
if (msg.sender_id == userId) return false;
const isCurrentConversation = (
(currentDMUserId && conversationKey === `d${currentDMUserId}`) ||
(currentChannelId && conversationKey === `c${currentChannelId}`) ||
(!currentDMUserId && !currentChannelId && conversationKey === 'global')
);
if (isCurrentConversation && document.visibilityState === 'visible') {
return false;
}
const msgKey = `msg_${msg.id}`;
if (lastNotifiedMessages[msgKey]) {
return false;
}
return true;
}
function markMessageNotified(msgId) {
lastNotifiedMessages[`msg_${msgId}`] = Date.now();
const keys = Object.keys(lastNotifiedMessages);
if (keys.length > 100) {
const cutoff = Date.now() - (24 * 60 * 60 * 1000);
keys.forEach(key => {
if (lastNotifiedMessages[key] < cutoff) {
delete lastNotifiedMessages[key];
}
});
}
}
function renderMessageContent(content) {
let parsed;
try {
parsed = JSON.parse(content);
} catch (e) {
parsed = null;
}
if (parsed && parsed.file) {
const file = parsed.file;
let html = '';
if (parsed.text) {
html += escapeHtml(parsed.text) + '<br/>';
}
const url = file.url;
const mime = file.mimetype || '';
if (mime.startsWith('image/')) {
html += `<img src="${url}" class="msg-image"/>`;
} else if (mime.startsWith('video/')) {
html += `<video controls class="msg-video"><source src="${url}" type="${mime}"></video>`;
} else if (mime.startsWith('audio/')) {
html += `<audio controls class="msg-audio"><source src="${url}" type="${mime}"></audio>`;
} else {
html += `<a href="${url}" download="${escapeHtml(file.filename)}">${escapeHtml(file.filename)}</a> (${formatBytes(file.size)})`;
}
return html;
}
return escapeHtml(content);
}
async function sendMessage() {
if (isSending) return;
const text = messageInput.value.trim();
const file = fileInput ? fileInput.files[0] : null;
if (!text && !file) return;
isSending = true;
sendBtn.disabled = true;
sendBtn.classList.add('btn-disabled');
messageInput.readOnly = true;
messageInput.classList.add('input-disabled');
try {
let res;
if (file) {
const form = new FormData();
if (text) form.append('content', text);
form.append('file', file);
if (currentDMUserId) form.append('receiver_id', currentDMUserId);
if (currentChannelId) form.append('channel_id', currentChannelId);
if (replyToMessage) form.append('reply_to', replyToMessage.id);
res = await fetch(`${API_URL}/messages`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: form
});
} else {
const body = { content: text };
if (currentDMUserId) body.receiver_id = currentDMUserId;
if (currentChannelId) body.channel_id = currentChannelId;
if (replyToMessage) body.reply_to = replyToMessage.id;
res = await fetch(`${API_URL}/messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(body)
});
}
if (res && res.ok) {
messageInput.value = '';
fileInput.value = '';
filePreview.style.display = 'none';
cancelReply();
if (currentDMUserId) {
loadDM(currentDMUserId);
} else {
loadChannel(currentChannelId);
}
} else if (res) {
const err = await res.json();
console.error('sendMessage error response', err);
}
} catch (error) {
console.error('Error sending message:', error);
} finally {
isSending = false;
sendBtn.disabled = false;
sendBtn.classList.remove('btn-disabled');
messageInput.readOnly = false;
messageInput.classList.remove('input-disabled');
}
}
async function loadChannel(channelId) {
try {
let url = `${API_URL}/messages?channel_id=${channelId}`;
if (lastFetchedTs) url += `&since=${encodeURIComponent(lastFetchedTs)}`;
const res = await fetch(url, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const messages = await res.json();
displayMessages(messages, !!lastFetchedTs);
}
} catch (error) {
console.error('Error loading channel messages:', error);
}
}
async function loadGlobalChat() {
if (currentChannelId) return loadChannel(currentChannelId);
try {
const res = await fetch(`${API_URL}/messages`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const messages = await res.json();
displayMessages(messages);
}
} catch (error) {
console.error('Error loading messages:', error);
}
}
async function loadUsers() {
try {
const res = await fetch(`${API_URL}/users`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
allUsers = await res.json();
}
} catch (error) {
console.error('Error loading users:', error);
}
}
async function loadDM(userId) {
try {
const res = await fetch(`${API_URL}/dm/${userId}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const messages = await res.json();
displayMessages(messages);
}
} catch (error) {
console.error('Error loading DM:', error);
}
}
let lastFetchedTs = null;
let isAtBottom = true;
function displayMessages(messages, append = false) {
const existing = new Set();
[...messagesContainer.querySelectorAll('.message')].forEach(el => {
const id = el.getAttribute('data-msg-id');
if (id) existing.add(String(id));
});
messages.forEach(msg => {
//console.log('Processing message:', msg.id, 'channel_id:', msg.channel_id, 'currentChannelId:', currentChannelId, 'currentDMUserId:', currentDMUserId, 'is_global:', msg.is_global);
if (typeof currentChannelId !== 'undefined' && currentChannelId) {
if (String(msg.channel_id || '') !== String(currentChannelId)) {
console.log('Message filtered out: channel mismatch');
return;
}
} else if (typeof currentDMUserId !== 'undefined' && currentDMUserId) {
const rid = msg.receiver_id || msg.receiver || null;
if (!(String(msg.sender_id) === String(currentDMUserId) || String(rid) === String(currentDMUserId))) return;
} else {
if (msg.is_global === 0 || msg.is_global === '0') return;
}
const msgId = String(msg.id || msg._id || msg.timestamp);
if (existing.has(msgId)) {
return;
}
const msgEl = document.createElement('div');
msgEl.className = `message ${msg.sender_id == userId ? 'own' : ''}`;
msgEl.setAttribute('data-msg-id', msgId);
msgEl.setAttribute('data-sender-id', msg.sender_id);
let avatarHtml = '';
if (msg.avatar_url) {
avatarHtml = `<img class="msg-avatar" src="${msg.avatar_url}" alt="avatar"/>`;
}
let badgeHtml = '';
if (msg.badge) {
const badgeClass = msg.badge.toLowerCase().replace(/\s+/g, '-');
badgeHtml = `<span class="user-badge ${badgeClass}">${msg.badge}</span>`;
}
let pron = msg.pronouns ? `<span class="msg-pronouns"> (${msg.pronouns})</span>` : '';
const { dateStr, timeStr } = formatLocalTime(msg.timestamp);
const renderedContent = renderMessageContent(msg.content);
let replyIndicatorHtml = '';
if (msg.reply_to) {
replyIndicatorHtml = `<div class="reply-indicator" data-reply-to="${msg.reply_to}" style="cursor: pointer;">Replying to message #${msg.reply_to}</div>`;
}
// FIXED: Proper Telegram-style message structure
msgEl.innerHTML = `
<div class="message-actions">
<button class="msg-reply-btn" data-msg-id="${msgId}" data-username="${msg.username}" title="Reply">↩</button>
</div>
${avatarHtml}
<div class="message-body">
<div class="message-header">
<span class="message-author">${msg.username}${badgeHtml}${pron}</span>
<span class="message-time">${dateStr} ${timeStr}</span>
</div>
<div class="message-content-wrapper">
${replyIndicatorHtml}
<div class="message-content">${renderedContent}</div>
</div>
</div>
`;
messagesContainer.appendChild(msgEl);
//console.log('Message added to DOM:', msgId);
});
if (isAtBottom) {
requestAnimationFrame(() => {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
});
}
if (messages.length) {
const lastTs = new Date(messages[messages.length - 1].timestamp).toISOString();
lastFetchedTs = lastTs;
let key;
if (currentDMUserId) {
key = conversationKey('d', currentDMUserId);
} else if (currentChannelId) {
key = conversationKey('c', currentChannelId);
} else {
key = 'global';
}
lastSeen[key] = new Date(lastTs).getTime();
unreadCounts[key] = 0;
renderUnreadIndicators();
}
}
let autoRefreshInterval = null;
function startAutoRefresh() {
if (autoRefreshInterval) {
clearInterval(autoRefreshInterval);
}
autoRefreshInterval = setInterval(async () => {
const now = Date.now();
if (!window.lastFriendRefresh || now - window.lastFriendRefresh > 10000) {
await loadFriendRequests();
await loadFriends();
window.lastFriendRefresh = now;
}
}, 5000);
}
function stopAutoRefresh() {
if (autoRefreshInterval) {
clearInterval(autoRefreshInterval);
autoRefreshInterval = null;
}
window.lastFriendRefresh = null;
}
function renderUnreadIndicators() {
servers.forEach(s => {
s.channels.forEach(ch => {
const key = conversationKey('c', ch.id);
const count = unreadCounts[key];
const el = [...document.querySelectorAll('.channel')].find(n => n.textContent.includes(ch.name));
if (el) {
let badge = el.querySelector('.unread-badge');
if (count) {
if (!badge) {
badge = document.createElement('span');
badge.className = 'unread-badge';
el.appendChild(badge);
}
badge.textContent = count;
} else if (badge) {
badge.remove();
}
}
});
});
displayDMList();
}
function initChatListeners() {
sendBtn.addEventListener('click', () => {
if (isSending) return;
sendMessage();
});
messageInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
if (isSending) return;
e.preventDefault();
sendMessage();
}
});
attachBtn.addEventListener('click', () => {
fileInput.click();
});
fileInput.addEventListener('change', () => {
if (fileInput.files && fileInput.files[0]) {
const file = fileInput.files[0];
const name = file.name;
const size = formatBytes(file.size);
filePreviewText.textContent = `📎 ${name} (${size})`;
filePreview.style.display = 'flex';
}
});
filePreviewClear.addEventListener('click', () => {
fileInput.value = '';
filePreview.style.display = 'none';
});
messagesContainer.addEventListener('scroll', () => {
const { scrollTop, scrollHeight, clientHeight } = messagesContainer;
isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
});
messagesContainer.addEventListener('click', (e) => {
const replyBtn = e.target.closest('.msg-reply-btn');
if (replyBtn) {
const msgId = replyBtn.dataset.msgId;
const username = replyBtn.dataset.username;
const messageEl = replyBtn.closest('.message');
const contentEl = messageEl.querySelector('.message-content');
let content = contentEl ? contentEl.textContent : '';
if (content.length > 50) {
content = content.substring(0, 50) + '...';
}
startReply(msgId, username, content);
return;
}
const replyIndicator = e.target.closest('.reply-indicator');
if (replyIndicator) {
const replyToId = replyIndicator.dataset.replyTo;
if (replyToId) {
scrollToMessage(replyToId);
}
}
});
replyCancelBtn.addEventListener('click', cancelReply);
}
function startReply(msgId, username, content) {
replyToMessage = { id: msgId, username: username, content: content };
replyToUsername = username;
replyUsername.textContent = username;
replyPreviewText.textContent = content;
replyPreview.style.display = 'flex';
messageInput.focus();
}
function cancelReply() {
replyToMessage = null;
replyToUsername = null;
replyPreview.style.display = 'none';
replyUsername.textContent = '';
replyPreviewText.textContent = '';
}
function scrollToMessage(msgId) {
const targetMsg = messagesContainer.querySelector(`[data-msg-id="${msgId}"]`);
if (targetMsg) {
targetMsg.scrollIntoView({ behavior: 'smooth', block: 'center' });
targetMsg.style.transition = 'background-color 0.3s ease';
const originalBg = targetMsg.style.backgroundColor;
targetMsg.style.backgroundColor = 'rgba(82, 136, 193, 0.3)';
setTimeout(() => {
targetMsg.style.backgroundColor = originalBg;
}, 1500);
} else {
console.log('Message not found:', msgId);
}
}