630 lines
21 KiB
JavaScript
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);
|
|
}
|
|
} |