// static/script.js
(function() {
'use strict';
// =============================================
// State
// =============================================
const State = {
token: localStorage.getItem('token'),
user: null,
ws: null,
wsState: 'disconnected',
messages: new Map(),
messageOrder: [],
cursor: null,
hasMore: true,
replyTo: null,
pendingFile: null,
typingUsers: new Map(),
onlineUsers: new Map(),
isAtBottom: true,
theme: localStorage.getItem('theme') || 'light',
customColors: {
self: localStorage.getItem('custom-self') || '#2563EB',
other: localStorage.getItem('custom-other') || '#F3F4F6',
text: localStorage.getItem('custom-text') || '#111827'
},
prefs: {
autoScroll: localStorage.getItem('pref-autoscroll') !== 'false',
showScrollBtn: localStorage.getItem('pref-scrollbtn') !== 'false',
showLatestBar: localStorage.getItem('pref-latestbar') !== 'false'
},
latestUnseen: null
};
// =============================================
// DOM Cache
// =============================================
const DOM = {
authScreen: document.getElementById('auth-screen'),
chatScreen: document.getElementById('chat-screen'),
loginForm: document.getElementById('login-form'),
signupForm: document.getElementById('signup-form'),
authTabs: document.querySelectorAll('.auth-tab'),
authError: document.getElementById('auth-error'),
authErrorText: document.getElementById('auth-error-text'),
authLoading: document.getElementById('auth-loading'),
headerDisplayName: document.getElementById('header-display-name'),
headerAvatar: document.getElementById('header-avatar'),
headerAvatarPlaceholder: document.getElementById('header-avatar-placeholder'),
headerStatus: document.getElementById('header-status'),
onlineBadge: document.getElementById('online-badge'),
connectionStatus: document.getElementById('connection-status'),
connectionText: document.getElementById('connection-text'),
messagesList: document.getElementById('messages-list'),
messagesScroll: document.getElementById('messages-scroll'),
emptyState: document.getElementById('empty-state'),
loadOlderBtn: document.getElementById('load-older-btn'),
scrollBottomBtn: document.getElementById('scroll-bottom-btn'),
latestMessageBar: document.getElementById('latest-message-bar'),
lmbAvatar: document.getElementById('lmb-avatar'),
lmbName: document.getElementById('lmb-name'),
lmbText: document.getElementById('lmb-text'),
typingIndicator: document.getElementById('typing-indicator'),
typingText: document.getElementById('typing-text'),
replyPreview: document.getElementById('reply-preview'),
replyUser: document.getElementById('reply-user'),
replyText: document.getElementById('reply-text'),
composeForm: document.getElementById('compose-form'),
messageInput: document.getElementById('message-input'),
sendBtn: document.getElementById('send-btn'),
attachBtn: document.getElementById('attach-btn'),
emojiBtn: document.getElementById('emoji-btn'),
fileInput: document.getElementById('file-input'),
uploadPreview: document.getElementById('upload-preview'),
uploadFilename: document.getElementById('upload-filename'),
cancelUpload: document.getElementById('cancel-upload'),
uploadProgress: document.getElementById('upload-progress'),
progressFill: document.getElementById('progress-fill'),
progressText: document.getElementById('progress-text'),
settingsModal: document.getElementById('settings-modal'),
onlineModal: document.getElementById('online-modal'),
emojiPicker: document.getElementById('emoji-picker'),
emojiGrid: document.getElementById('emoji-grid'),
settingsDisplayName: document.getElementById('settings-display-name'),
settingsUsername: document.getElementById('settings-username'),
settingsAvatarPreview: document.getElementById('settings-avatar-preview'),
settingsAvatarImg: document.getElementById('settings-avatar-img'),
avatarInput: document.getElementById('avatar-input'),
avatarUploadBtn: document.getElementById('avatar-upload-btn'),
themeOptions: document.querySelectorAll('.theme-option'),
customThemeSettings: document.getElementById('custom-theme-settings'),
customSelfColor: document.getElementById('custom-self-color'),
customOtherColor: document.getElementById('custom-other-color'),
customTextColor: document.getElementById('custom-text-color'),
selfColorHex: document.getElementById('self-color-hex'),
otherColorHex: document.getElementById('other-color-hex'),
textColorHex: document.getElementById('text-color-hex'),
toggleAutoScroll: document.getElementById('toggle-autoscroll'),
toggleScrollBtn: document.getElementById('toggle-scroll-btn'),
toggleLatestBar: document.getElementById('toggle-latest-bar'),
onlineBtn: document.getElementById('online-btn'),
onlineUsersList: document.getElementById('online-users-list'),
onlineTotal: document.getElementById('online-total'),
toastContainer: document.getElementById('toast-container')
};
const API = window.location.origin;
// =============================================
// File URL helper
// =============================================
function fileUrl(filePath) {
return `${API}/api/download/${encodeURIComponent(filePath)}?t=${encodeURIComponent(State.token)}`;
}
// =============================================
// Theme
// =============================================
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
State.theme = theme;
localStorage.setItem('theme', theme);
if (theme === 'custom') {
const s = State.customColors.self;
const o = State.customColors.other;
const t = State.customColors.text;
document.documentElement.style.setProperty('--bubble-self', s);
document.documentElement.style.setProperty('--bubble-other', o);
document.documentElement.style.setProperty('--bubble-other-text', t);
const isLight = (c) => {
const r = parseInt(c.slice(1,3),16), g = parseInt(c.slice(3,5),16), b = parseInt(c.slice(5,7),16);
return (0.299*r + 0.587*g + 0.114*b) > 150;
};
document.documentElement.style.setProperty('--bubble-self-text', isLight(s) ? '#111827' : '#FFFFFF');
} else {
document.documentElement.style.removeProperty('--bubble-self');
document.documentElement.style.removeProperty('--bubble-other');
document.documentElement.style.removeProperty('--bubble-other-text');
document.documentElement.style.removeProperty('--bubble-self-text');
}
}
applyTheme(State.theme);
// =============================================
// Preferences
// =============================================
function applyPrefs() {
if (DOM.toggleAutoScroll) DOM.toggleAutoScroll.checked = State.prefs.autoScroll;
if (DOM.toggleScrollBtn) DOM.toggleScrollBtn.checked = State.prefs.showScrollBtn;
if (DOM.toggleLatestBar) DOM.toggleLatestBar.checked = State.prefs.showLatestBar;
updateScrollUI();
}
function savePrefs() {
localStorage.setItem('pref-autoscroll', State.prefs.autoScroll);
localStorage.setItem('pref-scrollbtn', State.prefs.showScrollBtn);
localStorage.setItem('pref-latestbar', State.prefs.showLatestBar);
}
if (DOM.toggleAutoScroll) {
DOM.toggleAutoScroll.addEventListener('change', () => {
State.prefs.autoScroll = DOM.toggleAutoScroll.checked;
savePrefs();
if (State.prefs.autoScroll) {
scrollToBottom();
hideLatestBar();
}
});
}
if (DOM.toggleScrollBtn) {
DOM.toggleScrollBtn.addEventListener('change', () => {
State.prefs.showScrollBtn = DOM.toggleScrollBtn.checked;
savePrefs();
updateScrollUI();
});
}
if (DOM.toggleLatestBar) {
DOM.toggleLatestBar.addEventListener('change', () => {
State.prefs.showLatestBar = DOM.toggleLatestBar.checked;
savePrefs();
if (!State.prefs.showLatestBar) hideLatestBar();
});
}
// =============================================
// Timestamp updater
// =============================================
function startTimestampUpdater() {
setInterval(() => {
document.querySelectorAll('.message-time[data-ts]').forEach(el => {
el.textContent = formatTime(parseInt(el.dataset.ts));
});
}, 30000);
}
// =============================================
// Toast
// =============================================
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
const icons = { success:'check-circle', error:'exclamation-circle', warning:'exclamation-triangle', info:'info-circle' };
toast.innerHTML = `${message}`;
DOM.toastContainer.appendChild(toast);
setTimeout(() => toast.remove(), 4000);
}
// =============================================
// Auth
// =============================================
DOM.authTabs.forEach(tab => {
tab.addEventListener('click', () => {
const target = tab.dataset.tab;
DOM.authTabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
DOM.loginForm.classList.toggle('active', target === 'login');
DOM.signupForm.classList.toggle('active', target === 'signup');
DOM.authError.style.display = 'none';
});
});
DOM.loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
await login(
document.getElementById('login-username').value.trim(),
document.getElementById('login-password').value
);
});
DOM.signupForm.addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('signup-username').value.trim();
const display = document.getElementById('signup-display').value.trim() || username;
const password = document.getElementById('signup-password').value;
await signup(username, password, display);
});
async function login(username, password) {
DOM.authLoading.classList.remove('hidden');
hideAuthError();
try {
const res = await fetch(`${API}/api/auth/login?username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Login failed');
handleAuthSuccess(data);
} catch (err) {
showAuthError(err.message);
} finally {
DOM.authLoading.classList.add('hidden');
}
}
async function signup(username, password, display) {
DOM.authLoading.classList.remove('hidden');
hideAuthError();
try {
const res = await fetch(`${API}/api/auth/signup?username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}&display_name=${encodeURIComponent(display)}`, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Signup failed');
handleAuthSuccess(data);
} catch (err) {
showAuthError(err.message);
} finally {
DOM.authLoading.classList.add('hidden');
}
}
function handleAuthSuccess(data) {
State.token = data.token;
State.user = data.user;
localStorage.setItem('token', State.token);
showChat();
connectWebSocket();
}
function showAuthError(msg) {
DOM.authErrorText.textContent = msg;
DOM.authError.style.display = 'flex';
}
function hideAuthError() {
DOM.authError.style.display = 'none';
}
// =============================================
// WebSocket
// =============================================
function connectWebSocket() {
if (!State.token) return;
if (State.ws && State.ws.readyState === WebSocket.OPEN) return;
State.wsState = 'connecting';
updateConnectionUI();
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
State.ws = new WebSocket(`${proto}//${location.host}/ws?token=${encodeURIComponent(State.token)}`);
State.ws.onopen = () => {
State.wsState = 'connected';
updateConnectionUI();
loadMessages();
requestOnlineUsers();
};
State.ws.onmessage = (e) => {
try { handleWSMessage(JSON.parse(e.data)); } catch(err) { console.error('WS parse error', err); }
};
State.ws.onclose = () => {
if (State.wsState === 'connected') {
State.wsState = 'reconnecting';
updateConnectionUI();
setTimeout(connectWebSocket, 3000);
} else {
State.wsState = 'disconnected';
updateConnectionUI();
}
};
State.ws.onerror = () => {
State.wsState = 'disconnected';
updateConnectionUI();
};
}
function updateConnectionUI() {
DOM.connectionStatus.className = 'connection-status';
const map = {
connected: ['connected', 'Connected'],
reconnecting: ['reconnecting', 'Reconnecting...'],
disconnected: ['disconnected', 'Disconnected'],
connecting: ['reconnecting', 'Connecting...']
};
const [cls, txt] = map[State.wsState] || ['disconnected', 'Disconnected'];
DOM.connectionStatus.classList.add(cls);
DOM.connectionText.textContent = txt;
}
function handleWSMessage(msg) {
switch (msg.type) {
case 'connection_established':
State.user = {
id: msg.user_id,
username: msg.username,
display_name: msg.display_name,
avatar_path: msg.avatar_path
};
State.onlineUsers.clear();
msg.online_users.forEach(u => State.onlineUsers.set(u.id, u));
updateHeader();
updateOnlineBadge();
break;
case 'new_message': {
const m = msg.message;
if (State.messages.has(m.id)) {
const ex = State.messages.get(m.id);
ex.status = m.status;
updateMessageBubble(m.id);
return;
}
State.messages.set(m.id, m);
insertMessageIdSorted(m.id);
renderMessage(m, true);
if (State.isAtBottom) {
if (State.prefs.autoScroll) scrollToBottom();
} else {
// Show latest message bar if not at bottom
State.latestUnseen = m;
showLatestBar(m);
}
if (document.visibilityState === 'visible' && State.isAtBottom) {
sendMarkRead(m.id);
}
if (document.visibilityState !== 'visible' && Notification.permission === 'granted') {
new Notification(m.sender_display_name || m.sender_username, {
body: m.content || 'Sent a file',
icon: '/static/icon.png'
});
}
break;
}
case 'messages_loaded': {
const sorted = [...msg.messages].sort((a, b) => a.id - b.id);
sorted.forEach(m => {
if (!State.messages.has(m.id)) {
State.messages.set(m.id, m);
insertMessageIdSorted(m.id);
}
});
State.hasMore = msg.has_more;
if (msg.next_cursor) State.cursor = msg.next_cursor;
renderAllMessages();
DOM.loadOlderBtn.classList.toggle('hidden', !msg.has_more);
break;
}
case 'user_status':
if (msg.status === 'online') {
State.onlineUsers.set(msg.user_id, {
id: msg.user_id,
username: msg.username,
display_name: msg.display_name,
avatar_path: msg.avatar_path
});
} else {
State.onlineUsers.delete(msg.user_id);
}
updateOnlineBadge();
break;
case 'typing_indicator':
if (msg.is_typing) State.typingUsers.set(msg.user_id, msg);
else State.typingUsers.delete(msg.user_id);
updateTypingIndicator();
break;
case 'message_edited':
if (State.messages.has(msg.message_id)) {
const m = State.messages.get(msg.message_id);
m.content = msg.content;
m.is_edited = true;
updateMessageBubble(msg.message_id);
}
break;
case 'message_deleted':
if (State.messages.has(msg.message_id)) {
State.messages.get(msg.message_id).is_deleted = true;
updateMessageBubble(msg.message_id);
}
break;
case 'message_read':
if (State.messages.has(msg.message_id)) {
const m = State.messages.get(msg.message_id);
if (m.sender_id === State.user.id) {
m.status = 'read';
updateMessageBubble(msg.message_id);
}
}
break;
case 'online_users':
State.onlineUsers.clear();
msg.users.forEach(u => State.onlineUsers.set(u.id, u));
updateOnlineBadge();
if (!DOM.onlineModal.classList.contains('hidden')) renderOnlineUsers();
break;
case 'error':
showToast(msg.message, 'error');
break;
}
}
function insertMessageIdSorted(id) {
let lo = 0, hi = State.messageOrder.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (State.messageOrder[mid] < id) lo = mid + 1;
else hi = mid;
}
if (State.messageOrder[lo] !== id) {
State.messageOrder.splice(lo, 0, id);
}
}
// =============================================
// Latest message bar
// =============================================
function showLatestBar(msg) {
if (!State.prefs.showLatestBar || !DOM.latestMessageBar) return;
const name = msg.sender_display_name || msg.sender_username || 'Unknown';
const text = msg.content || (msg.file_name ? '๐ ' + msg.file_name : '๐ File');
// Avatar
if (msg.sender_avatar_path) {
DOM.lmbAvatar.innerHTML = ``;
} else {
DOM.lmbAvatar.textContent = name.charAt(0).toUpperCase();
DOM.lmbAvatar.style.background = 'var(--primary)';
}
DOM.lmbName.textContent = name + ':';
DOM.lmbText.textContent = truncate(text, 40);
DOM.latestMessageBar.classList.remove('hidden');
}
function hideLatestBar() {
if (DOM.latestMessageBar) DOM.latestMessageBar.classList.add('hidden');
State.latestUnseen = null;
}
if (DOM.latestMessageBar) {
DOM.latestMessageBar.addEventListener('click', () => {
scrollToBottom();
hideLatestBar();
});
}
// =============================================
// Scroll UI
// =============================================
function updateScrollUI() {
if (!State.isAtBottom) {
if (State.prefs.showScrollBtn) {
DOM.scrollBottomBtn.classList.remove('hidden');
} else {
DOM.scrollBottomBtn.classList.add('hidden');
}
} else {
DOM.scrollBottomBtn.classList.add('hidden');
hideLatestBar();
}
}
DOM.messagesScroll.addEventListener('scroll', () => {
const { scrollTop, scrollHeight, clientHeight } = DOM.messagesScroll;
State.isAtBottom = (scrollHeight - scrollTop - clientHeight) < 80;
updateScrollUI();
if (State.isAtBottom) {
hideLatestBar();
if (State.latestUnseen) sendMarkRead(State.latestUnseen.id);
}
});
DOM.scrollBottomBtn.addEventListener('click', () => {
scrollToBottom();
hideLatestBar();
});
function scrollToBottom() {
DOM.messagesScroll.scrollTop = DOM.messagesScroll.scrollHeight;
State.isAtBottom = true;
DOM.scrollBottomBtn.classList.add('hidden');
}
// =============================================
// Message Actions
// =============================================
function loadMessages() {
if (State.ws?.readyState === WebSocket.OPEN) {
State.ws.send(JSON.stringify({ type: 'load_messages', limit: 50 }));
}
}
function requestOnlineUsers() {
if (State.ws?.readyState === WebSocket.OPEN) {
State.ws.send(JSON.stringify({ type: 'get_online_users' }));
}
}
function sendMarkRead(messageId) {
if (State.ws?.readyState === WebSocket.OPEN) {
State.ws.send(JSON.stringify({ type: 'mark_read', up_to_message_id: messageId }));
}
}
// =============================================
// Rendering
// =============================================
function buildSenderAvatarHTML(msg) {
const name = escapeHtml(msg.sender_display_name || msg.sender_username || '?');
const initial = (msg.sender_display_name || msg.sender_username || '?').charAt(0).toUpperCase();
if (msg.sender_avatar_path) {
return `
`;
}
return `