InfinityChat / static /script.js
smodusermc's picture
Update static/script.js
a9d0c4b verified
Raw History Blame
49.8 kB
// 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'
}
};
// =============================================
// DOM Cache
// =============================================
const DOM = {
// Screens
authScreen: document.getElementById('auth-screen'),
chatScreen: document.getElementById('chat-screen'),
// Auth
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'),
// Header
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'),
// Connection
connectionStatus: document.getElementById('connection-status'),
connectionText: document.getElementById('connection-text'),
// Messages
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'),
// Typing
typingIndicator: document.getElementById('typing-indicator'),
typingText: document.getElementById('typing-text'),
// Reply
replyPreview: document.getElementById('reply-preview'),
replyUser: document.getElementById('reply-user'),
replyText: document.getElementById('reply-text'),
// Compose
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'),
// Upload
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'),
// Modals
settingsModal: document.getElementById('settings-modal'),
onlineModal: document.getElementById('online-modal'),
emojiPicker: document.getElementById('emoji-picker'),
emojiGrid: document.getElementById('emoji-grid'),
// Settings
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'),
// Online users modal
onlineBtn: document.getElementById('online-btn'),
onlineUsersList: document.getElementById('online-users-list'),
onlineTotal: document.getElementById('online-total'),
// Toast
toastContainer: document.getElementById('toast-container')
};
const API = window.location.origin;
// =============================================
// Theme
// =============================================
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
State.theme = theme;
localStorage.setItem('theme', theme);
if (theme === 'custom') {
const selfColor = State.customColors.self;
const otherColor = State.customColors.other;
const textColor = State.customColors.text;
document.documentElement.style.setProperty('--bubble-self', selfColor);
document.documentElement.style.setProperty('--bubble-other', otherColor);
document.documentElement.style.setProperty('--bubble-other-text', textColor);
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(selfColor) ? '#111827' : '#FFFFFF');
} else {
// Remove inline custom properties to let CSS variables take over
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);
// =============================================
// Toast Notifications
// =============================================
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
let icon = 'info-circle';
if (type === 'success') icon = 'check-circle';
else if (type === 'error') icon = 'exclamation-circle';
else if (type === 'warning') icon = 'exclamation-triangle';
toast.innerHTML = `<i class="fas fa-${icon}"></i><span>${message}</span>`;
DOM.toastContainer.appendChild(toast);
setTimeout(() => toast.remove(), 4000);
}
// =============================================
// Authentication
// =============================================
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();
const username = document.getElementById('login-username').value.trim();
const password = document.getElementById('login-password').value;
await login(username, password);
});
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:';
const url = `${proto}//${location.host}/ws?token=${encodeURIComponent(State.token)}`;
State.ws = new WebSocket(url);
State.ws.onopen = () => {
State.wsState = 'connected';
updateConnectionUI();
loadMessages();
requestOnlineUsers();
};
State.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
handleWSMessage(msg);
} catch (e) {
console.error('WS parse error', e);
}
};
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() {
const status = DOM.connectionStatus;
status.className = 'connection-status';
switch (State.wsState) {
case 'connected':
status.classList.add('connected');
DOM.connectionText.textContent = 'Connected';
break;
case 'reconnecting':
status.classList.add('reconnecting');
DOM.connectionText.textContent = 'Reconnecting...';
break;
default:
status.classList.add('disconnected');
DOM.connectionText.textContent = 'Disconnected';
}
}
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
};
msg.online_users.forEach(u => State.onlineUsers.set(u.id, u));
updateHeader();
updateOnlineBadge();
break;
case 'new_message':
if (State.messages.has(msg.message.id)) return;
State.messages.set(msg.message.id, msg.message);
State.messageOrder.push(msg.message.id);
renderMessage(msg.message);
if (State.isAtBottom) scrollToBottom();
if (document.visibilityState === 'visible') sendMarkRead(msg.message.id);
if (document.visibilityState !== 'visible' && Notification.permission === 'granted') {
new Notification(`${msg.message.sender_display_name}`, {
body: msg.message.content || 'Sent a file',
icon: '/static/icon.png'
});
}
break;
case 'messages_loaded':
msg.messages.forEach(m => {
if (!State.messages.has(m.id)) {
State.messages.set(m.id, m);
State.messageOrder.unshift(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 msgObj = State.messages.get(msg.message_id);
msgObj.content = msg.content;
msgObj.is_edited = true;
updateMessageBubble(msg.message_id);
}
break;
case 'message_deleted':
if (State.messages.has(msg.message_id)) {
const msgObj = State.messages.get(msg.message_id);
msgObj.is_deleted = true;
updateMessageBubble(msg.message_id);
}
break;
case 'message_read':
if (State.messages.has(msg.message_id) && State.messages.get(msg.message_id).sender_id === State.user.id) {
State.messages.get(msg.message_id).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');
}
}
// =============================================
// Message Actions
// =============================================
function loadMessages() {
if (State.ws?.readyState === WebSocket.OPEN) {
State.ws.send(JSON.stringify({ type: 'load_messages', limit: 50 }));
}
}
function sendMarkRead(messageId) {
if (State.ws?.readyState === WebSocket.OPEN) {
State.ws.send(JSON.stringify({ type: 'mark_read', up_to_message_id: messageId }));
}
}
// =============================================
// Rendering
// =============================================
function renderMessage(msg) {
if (DOM.messagesList.querySelector(`[data-id="${msg.id}"]`)) return;
const wrapper = document.createElement('div');
wrapper.className = `message-wrapper ${msg.sender_id === State.user.id ? 'self' : 'other'}`;
wrapper.dataset.id = msg.id;
wrapper.innerHTML = buildMessageHTML(msg);
DOM.messagesList.appendChild(wrapper);
}
function buildMessageHTML(msg) {
if (msg.is_deleted) return '<div class="message-bubble deleted">This message was deleted</div>';
const isSelf = msg.sender_id === State.user?.id;
let html = '';
// reply indicator
if (msg.reply_to_id && State.messages.has(msg.reply_to_id)) {
const reply = State.messages.get(msg.reply_to_id);
html += `<div class="reply-indicator"><i class="fas fa-reply"></i><span>${reply.sender_username}: ${truncate(reply.content, 50)}</span></div>`;
}
html += `<div class="message-bubble">`;
html += `<div class="message-text">${escapeHtml(msg.content)}</div>`;
if (msg.file_path) html += buildAttachmentHTML(msg);
html += `<div class="message-meta">`;
html += `<span class="message-time">${formatTime(msg.timestamp_ms)}</span>`;
if (msg.is_edited) html += ' <span class="edited-label">edited</span>';
if (isSelf) {
const icon = msg.status === 'read' ? '<i class="fas fa-check-double"></i>' : '<i class="fas fa-check"></i>';
html += `<span class="message-status ${msg.status==='read'?'read':''}">${icon}</span>`;
}
html += `</div><div class="message-actions">`;
html += `<button onclick="InfinityChat.replyTo(${msg.id})"><i class="fas fa-reply"></i></button>`;
if (isSelf) {
html += `<button onclick="InfinityChat.editMessage(${msg.id})"><i class="fas fa-pen"></i></button>`;
html += `<button onclick="InfinityChat.deleteMessage(${msg.id})"><i class="fas fa-trash"></i></button>`;
}
html += `</div></div>`;
return html;
}
function buildAttachmentHTML(msg) {
const url = `${API}/api/download/${encodeURIComponent(msg.file_path)}?token=${encodeURIComponent(State.token)}`;
if (msg.file_type && msg.file_type.startsWith('image/')) {
return `<div class="attachment"><img src="${url}" alt="${msg.file_name}" loading="lazy" onclick="window.open(this.src)"></div>`;
} else if (msg.file_type && msg.file_type.startsWith('video/')) {
return `<div class="attachment"><video controls preload="metadata"><source src="${url}" type="${msg.file_type}"></video></div>`;
} else if (msg.file_type && msg.file_type.startsWith('audio/')) {
return `<div class="attachment"><audio controls src="${url}"></audio></div>`;
} else {
return `<div class="attachment-file" onclick="window.open('${url}')">
<i class="fas fa-file"></i>
<div class="file-info">
<div class="file-name">${msg.file_name || 'File'}</div>
<div class="file-size">${formatFileSize(msg.file_size)}</div>
</div>
</div>`;
}
}
function updateMessageBubble(id) {
const el = DOM.messagesList.querySelector(`[data-id="${id}"]`);
if (!el) return;
const msg = State.messages.get(id);
if (msg) el.innerHTML = buildMessageHTML(msg);
}
function renderAllMessages() {
DOM.messagesList.innerHTML = '';
if (State.messageOrder.length === 0) {
DOM.emptyState.style.display = 'flex';
} else {
DOM.emptyState.style.display = 'none';
State.messageOrder.forEach(id => {
const msg = State.messages.get(id);
if (msg) renderMessage(msg);
});
}
if (State.isAtBottom) scrollToBottom();
}
function scrollToBottom() {
DOM.messagesScroll.scrollTop = DOM.messagesScroll.scrollHeight;
}
// =============================================
// Scroll detection
// =============================================
DOM.messagesScroll.addEventListener('scroll', () => {
const { scrollTop, scrollHeight, clientHeight } = DOM.messagesScroll;
State.isAtBottom = (scrollHeight - scrollTop - clientHeight) < 60;
DOM.scrollBottomBtn.classList.toggle('hidden', State.isAtBottom);
});
DOM.scrollBottomBtn.addEventListener('click', scrollToBottom);
// =============================================
// Compose
// =============================================
DOM.composeForm.addEventListener('submit', (e) => {
e.preventDefault();
sendMessage();
});
DOM.messageInput.addEventListener('input', () => {
const hasContent = DOM.messageInput.value.trim().length > 0 || !!State.pendingFile;
DOM.sendBtn.disabled = !hasContent;
if (hasContent) sendTyping(true);
autoResize();
});
function sendMessage() {
const content = DOM.messageInput.value.trim();
if (!content && !State.pendingFile) return;
if (!State.ws || State.ws.readyState !== WebSocket.OPEN) {
showToast('Not connected', 'warning');
return;
}
const payload = {
type: 'send_message',
content: content,
reply_to_id: State.replyTo ? State.replyTo.id : null,
client_id: generateId()
};
if (State.pendingFile) {
payload.file_path = State.pendingFile.file_path;
payload.file_type = State.pendingFile.file_type;
payload.file_name = State.pendingFile.file_name;
payload.file_size = State.pendingFile.file_size;
}
State.ws.send(JSON.stringify(payload));
DOM.messageInput.value = '';
clearReply();
clearPendingFile();
autoResize();
sendTyping(false);
}
function sendTyping(isTyping) {
if (!State.ws || State.ws.readyState !== WebSocket.OPEN) return;
clearTimeout(window.__typingTimer);
if (isTyping) {
State.ws.send(JSON.stringify({ type: 'typing', is_typing: true }));
window.__typingTimer = setTimeout(() => sendTyping(false), 2000);
} else {
State.ws.send(JSON.stringify({ type: 'typing', is_typing: false }));
}
}
function updateTypingIndicator() {
const users = Array.from(State.typingUsers.values()).filter(u => u.user_id !== State.user.id);
DOM.typingIndicator.classList.toggle('hidden', users.length === 0);
if (users.length > 0) {
DOM.typingText.textContent = users.map(u => u.display_name).join(', ') + ' typing...';
}
}
// =============================================
// Reply
// =============================================
function replyTo(messageId) {
const msg = State.messages.get(messageId);
if (!msg) return;
State.replyTo = msg;
DOM.replyUser.textContent = msg.sender_username;
DOM.replyText.textContent = truncate(msg.content, 50);
DOM.replyPreview.classList.remove('hidden');
DOM.messageInput.focus();
}
function clearReply() {
State.replyTo = null;
DOM.replyPreview.classList.add('hidden');
}
document.addEventListener('click', (e) => {
if (e.target.id === 'cancel-reply' || e.target.closest('#cancel-reply')) {
clearReply();
}
});
// =============================================
// Edit / Delete
// =============================================
function editMessage(messageId) {
const msg = State.messages.get(messageId);
if (!msg) return;
const newContent = prompt('Edit message:', msg.content);
if (newContent !== null && newContent.trim() !== '') {
if (State.ws?.readyState === WebSocket.OPEN) {
State.ws.send(JSON.stringify({ type: 'edit_message', message_id: messageId, content: newContent.trim() }));
}
}
}
function deleteMessage(messageId) {
if (confirm('Permanently delete this message?')) {
if (State.ws?.readyState === WebSocket.OPEN) {
State.ws.send(JSON.stringify({ type: 'delete_message', message_id: messageId }));
}
}
}
// =============================================
// File Upload (Chunked)
// =============================================
DOM.attachBtn.addEventListener('click', () => DOM.fileInput.click());
DOM.fileInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
await uploadChunked(file);
e.target.value = '';
});
async function uploadChunked(file) {
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const uploadId = generateId();
DOM.uploadProgress.classList.remove('hidden');
updateProgress(0, file.name);
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const blob = file.slice(start, end);
const formData = new FormData();
formData.append('file', blob);
const url = `${API}/api/upload/chunk?chunk_index=${i}&total_chunks=${totalChunks}&file_name=${encodeURIComponent(file.name)}&file_type=${encodeURIComponent(file.type || 'application/octet-stream')}&file_size=${file.size}&upload_id=${uploadId}`;
try {
const res = await fetch(url, {
method: 'POST',
body: formData,
headers: { 'X-Auth-Token': State.token }
});
if (!res.ok) throw new Error('Upload failed');
const data = await res.json();
updateProgress(((i + 1) / totalChunks) * 100, file.name);
if (data.status === 'complete') {
State.pendingFile = {
file_path: data.file_path,
file_type: data.file_type,
file_name: data.file_name,
file_size: data.file_size
};
showUploadPreview(data.file_name);
DOM.uploadProgress.classList.add('hidden');
return;
}
} catch (err) {
showToast('Upload failed', 'error');
DOM.uploadProgress.classList.add('hidden');
break;
}
}
DOM.uploadProgress.classList.add('hidden');
}
function updateProgress(percent, filename) {
DOM.progressFill.style.width = `${percent}%`;
DOM.progressText.textContent = `${Math.round(percent)}%`;
if (filename) DOM.uploadFilename.textContent = filename;
}
function showUploadPreview(filename) {
DOM.uploadPreview.classList.remove('hidden');
DOM.uploadFilename.textContent = filename;
DOM.sendBtn.disabled = false;
}
function clearPendingFile() {
State.pendingFile = null;
DOM.uploadPreview.classList.add('hidden');
}
DOM.cancelUpload.addEventListener('click', clearPendingFile);
// =============================================
// Emoji Picker (basic)
// =============================================
DOM.emojiBtn.addEventListener('click', (e) => {
e.stopPropagation();
DOM.emojiPicker.classList.toggle('hidden');
if (!DOM.emojiPicker.classList.contains('hidden')) {
renderEmojis('smileys');
}
});
document.addEventListener('click', (e) => {
if (!DOM.emojiPicker.contains(e.target) && e.target !== DOM.emojiBtn) {
DOM.emojiPicker.classList.add('hidden');
}
});
const emojiSets = {
smileys: ['😀','😃','😄','😁','😅','😂','🤣','😊','😇','🙂','😉','😌','😍','🥰','😘','😗','😋','😛','😜','🤪','😝','🤑','🤗','🤭','🤫','🤔','🤐','🤨','😐','😑','😶','😏','😒','🙄','😬','😮','🤥','😌','😔','😪','🤤','😴','😷','🤒','🤕','🤢','🤮','🤧','😵','🤯','🥵','🥶','😎','🤓','🧐','😕','😟','🙁','😮','😯','😲','😳','🥺','😦','😧','😨','😰','😥','😢','😭','😱','😖','😣','😞','😓','😩','😫','🥱','😤','😡','😠','🤬','😈','👿','💀','☠️','💩','🤡','👹','👺','👻','👽','👾','🤖'],
people: ['👶','🧒','👦','👧','🧑','👱','👨','🧔','👩','🧓','👴','👵','👲','👳','🧕','👮','👷','💂','🕵️','🧑‍⚕️','👨‍⚕️','👩‍⚕️','🧑‍🎓','👨‍🎓','👩‍🎓','🧑‍🏫','👨‍🏫','👩‍🏫','🧑‍⚖️','👨‍⚖️','👩‍⚖️','🧑‍🌾','👨‍🌾','👩‍🌾','🧑‍🍳','👨‍🍳','👩‍🍳','🧑‍🔧','👨‍🔧','👩‍🔧','🧑‍🏭','👨‍🏭','👩‍🏭','🧑‍💼','👨‍💼','👩‍💼','🧑‍🔬','👨‍🔬','👩‍🔬','🧑‍💻','👨‍💻','👩‍💻','🧑‍🎤','👨‍🎤','👩‍🎤','🧑‍🎨','👨‍🎨','👩‍🎨','🧑‍✈️','👨‍✈️','👩‍✈️','🧑‍🚀','👨‍🚀','👩‍🚀','🧑‍🚒','👨‍🚒','👩‍🚒','👮‍♀️','👮‍♂️','🕵️‍♀️','🕵️‍♂️','💂‍♀️','💂‍♂️','👷‍♀️','👷‍♂️'],
animals: ['🐶','🐱','🐭','🐹','🐰','🦊','🐻','🐼','🐻‍❄️','🐨','🐯','🦁','🐮','🐷','🐸','🐵','🙈','🙉','🙊','🐒','🐔','🐧','🐦','🐤','🐣','🐥','🦆','🦅','🦉','🦇','🐺','🐗','🐴','🦄','🐝','🪱','🐛','🦋','🐌','🐞','🐜','🪰','🪲','🪳','🦟','🦗','🕷️','🦂','🐢','🐍','🦎','🦖','🦕','🐙','🦑','🦐','🦞','🦀','🐡','🐠','🐟','🐬','🐳','🐋','🦈','🐊','🐅','🐆','🦓','🦍','🦧','🐘','🦛','🦏','🐪','🐫','🦒','🦘','🐃','🐂','🐄','🐎','🐖','🐏','🐑','🦙','🐐','🦌','🐕','🐩','🦮','🐕‍🦺','🐈','🐈‍⬛','🐓','🦃','🦤','🦚','🦜','🦢','🦩','🕊️','🐇','🦝','🦨','🦡','🦦','🦥','🐁','🐀','🐿️','🦔','🐾','🐉','🐲'],
food: ['🍏','🍎','🍐','🍊','🍋','🍌','🍉','🍇','🍓','🫐','🍈','🍒','🍑','🥭','🍍','🥥','🥝','🍅','🍆','🥑','🥦','🥬','🥒','🌶️','🌽','🥕','🫒','🧄','🧅','🥔','🍠','🥐','🍞','🥖','🥨','🧀','🥚','🍳','🧈','🥞','🧇','🥓','🥩','🍗','🍖','🦴','🌭','🍔','🍟','🍕','🫓','🥪','🥙','🧆','🌮','🌯','🫔','🥗','🥘','🫕','🥫','🍝','🍜','🍲','🍛','🍣','🍱','🥟','🦪','🍤','🍙','🍚','🍘','🍥','🥠','🥮','🍢','🍡','🍧','🍨','🍦','🥧','🧁','🍰','🎂','🍮','🍭','🍬','🍫','🍿','🍩','🍪','🌰','🥜','🍯','🥛','🍼','🫖','☕','🍵','🧃','🥤','🧋','🍶','🍺','🍻','🥂','🍷','🥃','🍸','🍹','🧉','🍾','🧊','🥄','🍴','🍽️','🥣','🥡','🥢','🧂'],
activities: ['⚽','🏀','🏈','⚾','🥎','🎾','🏐','🏉','🥏','🎱','🪀','🏓','🏸','🏒','🏑','🥍','🏏','🪃','🥅','⛳','🪁','🏹','🎣','🤿','🥊','🥋','🎽','🛹','🛼','🛷','⛸️','🥌','🎿','⛷️','🏂','🪂','🏋️','🤼','🤸','🤺','⛹️','🤾','🏌️','🏇','🧘','🏄','🏊','🤽','🚣','🧗','🚵','🚴','🏆','🥇','🥈','🥉','🏅','🎖️','🏵️','🎗️','🎫','🎟️','🎪','🤹','🎭','🩰','🎨','🎬','🎤','🎧','🎼','🎹','🥁','🪘','🎷','🎺','🪗','🎸','🪕','🎻','🎲','♟️','🎯','🎳','🎮','🎰','🧩'],
objects: ['⌚','📱','💻','⌨️','🖥️','🖨️','🖱️','🖲️','🕹️','🗜️','💽','💾','💿','📀','📼','📷','📸','📹','🎥','📽️','🎞️','📞','☎️','📟','📠','📺','📻','🎙️','🎚️','🎛️','🧭','⏱️','⏲️','⏰','🕰️','⌛','📡','🔋','🔌','💡','🔦','🕯️','🪔','🧯','🛢️','💸','💵','💴','💶','💷','🪙','💰','💳','💎','⚖️','🪜','🧰','🪛','🔧','🔨','⚒️','🛠️','⛏️','🪚','🔩','⚙️','🪤','🧱','⛓️','🧲','🔫','💣','🧨','🪓','🔪','🗡️','⚔️','🛡️','🚬','⚰️','🪦','⚱️','🏺','🔮','📿','🧿','💈','⚗️','🔭','🔬','🕳️','🩻','🩹','🩺','💊','💉','🩸','🧬','🦠','🧫','🧪','🌡️','🧹','🪠','🧺','🧻','🚽','🚰','🚿','🛁','🛀','🧼','🪥','🪒','🧽','🪣','🧴','🪢','🧷','🧹','🧺','🧻','🧼','🪥','🪒','🧽','🪣','🧴','🪢','🧷'],
symbols: ['❤️','🧡','💛','💚','💙','💜','🖤','🤍','🤎','💔','❣️','💕','💞','💓','💗','💖','💘','💝','💟','☮️','✝️','☪️','🕉️','☸️','✡️','🔯','🕎','☯️','☦️','🛐','⛎','♈','♉','♊','♋','♌','♍','♎','♏','♐','♑','♒','♓','🆔','⚛️','🉑','☢️','☣️','📴','📳','🈶','🈚','🈸','🈺','🈷️','✴️','🆚','💮','🉐','㊙️','㊗️','🈴','🈵','🈹','🈲','🅰️','🅱️','🆎','🆑','🅾️','🆘','❌','⭕','🛑','⛔','📛','🚫','💯','💢','♨️','🚷','🚯','🚳','🚱','🔞','📵','🚭','❗','❕','❓','❔','‼️','⁉️','🔅','🔆','〽️','⚠️','🚸','🔱','⚜️','🔰','♻️','✅','🈯','💹','❇️','✳️','❎','🌐','💠','Ⓜ️','🌀','💤','🏧','🚾','♿','🅿️','🛗','🈳','🈂️','🛂','🛃','🛄','🛅','🚰','🚹','🚺','🚼','⚧','🚻','🚮','🎦','📶','🈁','🔣','ℹ️','🔤','🔡','🔠','🆖','🆗','🆙','🆒','🆕','🆓','0️⃣','1️⃣','2️⃣','3️⃣','4️⃣','5️⃣','6️⃣','7️⃣','8️⃣','9️⃣','🔟','🔢','#️⃣','*️⃣','⏏️','▶️','⏸️','⏯️','⏹️','⏺️','⏭️','⏮️','⏩','⏪','⏫','⏬','◀️','🔼','🔽','➡️','⬅️','⬆️','⬇️','↗️','↘️','↙️','↖️','↕️','↔️','↪️','↩️','⤴️','⤵️','🔀','🔁','🔂','🔄','🔃','🎵','🎶','➕','➖','➗','✖️','♾️','💲','💱','™️','©️','®️','〰️','➰','➿','🔚','🔙','🔛','🔝','🔜','✔️','☑️','🔘','🔴','🟠','🟡','🟢','🔵','🟣','⚫','⚪','🟤','🔺','🔻','🔸','🔹','🔶','🔷','🔳','🔲','▪️','▫️','◾','◽','◼️','◻️','🟥','🟧','🟨','🟩','🟦','🟪','⬛','⬜','🟫'],
flags: ['🏁','🚩','🎌','🏴','🏳️','🏳️‍🌈','🏳️‍⚧️','🏴‍☠️','🇿🇦','🇸🇽','🇸🇰','🇸🇮','🇸🇬','🇸🇪','🇸🇩','🇸🇷','🇸🇸','🇸🇹','🇸🇻','🇸🇾','🇸🇿','🇹🇨','🇹🇩','🇹🇫','🇹🇬','🇹🇭','🇹🇯','🇹🇰','🇹🇱','🇹🇲','🇹🇳','🇹🇴','🇹🇷','🇹🇹','🇹🇻','🇹🇼','🇹🇿','🇺🇦','🇺🇬','🇺🇳','🇺🇸','🇺🇾','🇺🇿','🇻🇦','🇻🇨','🇻🇪','🇻🇬','🇻🇮','🇻🇳','🇻🇺','🇼🇫','🇼🇸','🇽🇰','🇾🇪','🇾🇹','🇿🇦','🇿🇲','🇿🇼']
};
document.querySelectorAll('.emoji-cat').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
document.querySelectorAll('.emoji-cat').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderEmojis(btn.dataset.cat);
});
});
function renderEmojis(category) {
const emojis = emojiSets[category] || [];
DOM.emojiGrid.innerHTML = emojis.map(e => `<button class="emoji-item">${e}</button>`).join('');
DOM.emojiGrid.querySelectorAll('.emoji-item').forEach(btn => {
btn.addEventListener('click', () => {
DOM.messageInput.value += btn.textContent;
DOM.messageInput.focus();
DOM.messageInput.dispatchEvent(new Event('input'));
DOM.emojiPicker.classList.add('hidden');
});
});
}
// =============================================
// Load More Messages
// =============================================
DOM.loadOlderBtn.addEventListener('click', () => {
if (State.ws?.readyState === WebSocket.OPEN && State.hasMore && State.cursor) {
State.ws.send(JSON.stringify({ type: 'load_messages', cursor: State.cursor, limit: 50 }));
}
});
// =============================================
// Online Users Modal
// =============================================
DOM.onlineBtn.addEventListener('click', () => {
renderOnlineUsers();
DOM.onlineModal.classList.remove('hidden');
});
document.querySelectorAll('[data-close="online-modal"]').forEach(btn => {
btn.addEventListener('click', () => DOM.onlineModal.classList.add('hidden'));
});
function updateOnlineBadge() {
DOM.onlineBadge.textContent = State.onlineUsers.size;
}
function renderOnlineUsers() {
DOM.onlineUsersList.innerHTML = '';
if (State.onlineUsers.size === 0) {
DOM.onlineUsersList.innerHTML = '<div class="loading-state"><span>No users online</span></div>';
DOM.onlineTotal.textContent = '0 users online';
return;
}
State.onlineUsers.forEach(u => {
const div = document.createElement('div');
div.className = 'user-item';
div.innerHTML = `<div class="user-avatar"><i class="fas fa-user-circle"></i></div>
<div class="user-info">
<div class="user-name">${u.display_name || u.username}</div>
<div class="user-status-text">${u.status || 'online'}</div>
</div>`;
DOM.onlineUsersList.appendChild(div);
});
DOM.onlineTotal.textContent = `${State.onlineUsers.size} users online`;
}
// =============================================
// Settings Modal
// =============================================
document.getElementById('settings-btn').addEventListener('click', openSettings);
document.querySelectorAll('[data-close="settings-modal"]').forEach(btn => {
btn.addEventListener('click', () => DOM.settingsModal.classList.add('hidden'));
});
document.getElementById('save-settings').addEventListener('click', saveSettings);
function openSettings() {
DOM.settingsDisplayName.value = State.user?.display_name || '';
DOM.settingsUsername.value = State.user?.username || '';
if (State.user?.avatar_path) {
DOM.settingsAvatarImg.src = `${API}/api/download/${encodeURIComponent(State.user.avatar_path)}?token=${encodeURIComponent(State.token)}`;
DOM.settingsAvatarImg.classList.remove('hidden');
DOM.settingsAvatarPreview.classList.add('hidden');
} else {
DOM.settingsAvatarImg.classList.add('hidden');
DOM.settingsAvatarPreview.classList.remove('hidden');
}
// Theme selection
DOM.themeOptions.forEach(opt => {
opt.classList.toggle('active', opt.dataset.theme === State.theme);
});
DOM.customThemeSettings.classList.toggle('hidden', State.theme !== 'custom');
if (State.theme === 'custom') {
DOM.customSelfColor.value = State.customColors.self;
DOM.customOtherColor.value = State.customColors.other;
DOM.customTextColor.value = State.customColors.text;
DOM.selfColorHex.textContent = State.customColors.self;
DOM.otherColorHex.textContent = State.customColors.other;
DOM.textColorHex.textContent = State.customColors.text;
}
DOM.settingsModal.classList.remove('hidden');
}
async function saveSettings() {
const newName = DOM.settingsDisplayName.value.trim();
if (newName && newName !== State.user?.display_name) {
await fetch(`${API}/api/profile?display_name=${encodeURIComponent(newName)}`, {
method: 'PATCH',
headers: { 'X-Auth-Token': State.token }
});
State.user.display_name = newName;
updateHeader();
showToast('Profile updated', 'success');
}
// Avatar
const avatarFile = DOM.avatarInput.files[0];
if (avatarFile) {
const formData = new FormData();
formData.append('file', avatarFile);
const res = await fetch(`${API}/api/profile/avatar`, {
method: 'POST',
body: formData,
headers: { 'X-Auth-Token': State.token }
});
if (res.ok) {
const data = await res.json();
State.user.avatar_path = data.avatar_path;
DOM.settingsAvatarImg.src = `${API}/api/download/${encodeURIComponent(data.avatar_path)}?token=${encodeURIComponent(State.token)}`;
DOM.settingsAvatarImg.classList.remove('hidden');
DOM.settingsAvatarPreview.classList.add('hidden');
showToast('Avatar updated', 'success');
}
}
// Theme
const selectedTheme = document.querySelector('.theme-option.active')?.dataset.theme || 'light';
if (selectedTheme !== State.theme || selectedTheme === 'custom') {
if (selectedTheme === 'custom') {
State.customColors.self = DOM.customSelfColor.value;
State.customColors.other = DOM.customOtherColor.value;
State.customColors.text = DOM.customTextColor.value;
localStorage.setItem('custom-self', State.customColors.self);
localStorage.setItem('custom-other', State.customColors.other);
localStorage.setItem('custom-text', State.customColors.text);
}
applyTheme(selectedTheme);
}
DOM.settingsModal.classList.add('hidden');
}
// Custom color picker listeners
DOM.customSelfColor.addEventListener('input', () => DOM.selfColorHex.textContent = DOM.customSelfColor.value);
DOM.customOtherColor.addEventListener('input', () => DOM.otherColorHex.textContent = DOM.customOtherColor.value);
DOM.customTextColor.addEventListener('input', () => DOM.textColorHex.textContent = DOM.customTextColor.value);
// Theme selection clicks
DOM.themeOptions.forEach(opt => {
opt.addEventListener('click', () => {
DOM.themeOptions.forEach(o => o.classList.remove('active'));
opt.classList.add('active');
DOM.customThemeSettings.classList.toggle('hidden', opt.dataset.theme !== 'custom');
if (opt.dataset.theme === 'custom') {
DOM.customSelfColor.value = State.customColors.self;
DOM.customOtherColor.value = State.customColors.other;
DOM.customTextColor.value = State.customColors.text;
DOM.selfColorHex.textContent = State.customColors.self;
DOM.otherColorHex.textContent = State.customColors.other;
DOM.textColorHex.textContent = State.customColors.text;
}
});
});
// Avatar upload button
DOM.avatarUploadBtn.addEventListener('click', () => DOM.avatarInput.click());
DOM.avatarInput.addEventListener('change', () => {
const file = DOM.avatarInput.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
DOM.settingsAvatarImg.src = e.target.result;
DOM.settingsAvatarImg.classList.remove('hidden');
DOM.settingsAvatarPreview.classList.add('hidden');
};
reader.readAsDataURL(file);
}
});
// =============================================
// Utility
// =============================================
function generateId() {
return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);
}
function truncate(str, n) {
if (!str) return '';
return str.length > n ? str.substr(0, n - 1) + '…' : str;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatTime(ms) {
const date = new Date(ms);
const now = new Date();
const diff = now - date;
if (diff < 60000) return 'now';
if (date.toDateString() === now.toDateString()) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' +
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function formatFileSize(bytes) {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
function autoResize() {
DOM.messageInput.style.height = 'auto';
DOM.messageInput.style.height = Math.min(DOM.messageInput.scrollHeight, 120) + 'px';
}
// =============================================
// Initialization
// =============================================
async function init() {
if (State.token) {
try {
const res = await fetch(`${API}/api/auth/verify`, {
headers: { 'X-Auth-Token': State.token }
});
if (res.ok) {
const data = await res.json();
State.user = data.user;
showChat();
connectWebSocket();
return;
}
} catch (e) {}
}
showAuth();
}
function showAuth() {
DOM.authScreen.classList.add('active');
DOM.chatScreen.classList.remove('active');
}
function showChat() {
DOM.authScreen.classList.remove('active');
DOM.chatScreen.classList.add('active');
updateHeader();
}
function updateHeader() {
if (State.user) {
DOM.headerDisplayName.textContent = State.user.display_name;
}
}
// Password visibility toggle
document.querySelectorAll('.input-toggle').forEach(btn => {
btn.addEventListener('click', () => {
const targetId = btn.dataset.toggle;
const input = document.getElementById(targetId);
if (input) {
input.type = input.type === 'password' ? 'text' : 'password';
const icon = btn.querySelector('i');
if (icon) icon.className = input.type === 'password' ? 'fas fa-eye' : 'fas fa-eye-slash';
}
});
});
// Logout button
document.getElementById('logout-btn').addEventListener('click', async () => {
if (State.token) {
await fetch(`${API}/api/auth/logout`, {
method: 'POST',
headers: { 'X-Auth-Token': State.token }
});
}
if (State.ws) State.ws.close();
State.ws = null;
localStorage.removeItem('token');
State.token = null;
State.user = null;
State.messages.clear();
State.messageOrder = [];
State.onlineUsers.clear();
DOM.messagesList.innerHTML = '';
DOM.emptyState.style.display = 'flex';
showAuth();
updateOnlineBadge();
});
// Request notification permission
if (window.Notification && Notification.permission === 'default') {
Notification.requestPermission();
}
// Expose public methods
window.InfinityChat = {
replyTo,
editMessage,
deleteMessage
};
init();
})();