diff --git "a/static/style.css" "b/static/style.css"
--- "a/static/style.css"
+++ "b/static/style.css"
@@ -1,2585 +1,1571 @@
-// ============================================================================
-// InfinityChat 2.0 — client
-// Conversations, private chats (up to 3 per person), instant read receipts,
-// inline image embeds (
tags / markdown), reliable edit/delete.
-// ============================================================================
-(function () {
- 'use strict';
-
- const APP_VERSION = '2.0.0';
-
- // ============================================================
- // State
- // ============================================================
- const State = {
- token: localStorage.getItem('token'),
- user: null, // {id, username, display_name, avatar_path}
- ws: null,
- wsState: 'disconnected',
- reconnectAttempt: 0,
- lastPong: Date.now(),
-
- conversations: new Map(), // convId -> summary object
- conversationOrder: [], // convIds sorted for the sidebar
-
- activeConvId: null, // conversation currently on screen
- convCache: new Map(), // convId -> {ids:[], cursor, hasMore, lastTs}
- messages: new Map(), // messageId -> message object
- tombstoned: new Map(), // convId -> Set(messageId) shown as "deleted"
-
- pendingSends: new Map(), // client_id -> payload (for resend after reconnect)
- replyTo: null, // message being replied to
- editingId: null, // message id currently being edited inline
- pendingFile: null, // uploaded file metadata waiting for send
- typingUsers: new Map(), // convId -> {userId -> {name, timer}}
- typingSentAt: 0,
- onlineUsers: new Map(),
- isAtBottom: true,
- latestUnseen: null,
- unreadTotal: 0,
-
- 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',
- sound: localStorage.getItem('pref-sound') !== 'false',
- enterSend: localStorage.getItem('pref-entersend') !== 'false'
- }
- };
-
- // ============================================================
- // DOM cache
- // ============================================================
- const $ = (id) => document.getElementById(id);
- const DOM = {
- authScreen: $('auth-screen'), chatScreen: $('chat-screen'),
- loginForm: $('login-form'), signupForm: $('signup-form'),
- authTabs: document.querySelectorAll('.auth-tab'),
- authError: $('auth-error'), authErrorText: $('auth-error-text'),
- authLoading: $('auth-loading'),
-
- sidebar: $('sidebar'), sidebarBackdrop: $('sidebar-backdrop'),
- sidebarOpenBtn: $('sidebar-open-btn'), sidebarCloseBtn: $('sidebar-close-btn'),
- sbMeName: $('sb-me-name'), sbMeAvatar: $('sb-me-avatar'), sbMeAvatarPh: $('sb-me-avatar-ph'),
- convList: $('conv-list'),
-
- convTitle: $('conv-title'), convSubtitle: $('conv-subtitle'),
- convAvatar: $('conv-avatar'), convAvatarPh: $('conv-avatar-ph'),
- convPresenceDot: $('conv-presence-dot'),
- onlineBadge: $('online-badge'),
-
- connectionStatus: $('connection-status'), connectionText: $('connection-text'),
- messagesList: $('messages-list'), messagesScroll: $('messages-scroll'),
- emptyState: $('empty-state'), emptyIcon: $('empty-icon'),
- emptyTitle: $('empty-title'), emptySub: $('empty-sub'),
- loadOlderBtn: $('load-older-btn'),
- scrollBottomBtn: $('scroll-bottom-btn'),
- latestMessageBar: $('latest-message-bar'),
- lmbAvatar: $('lmb-avatar'), lmbName: $('lmb-name'), lmbText: $('lmb-text'),
- typingIndicator: $('typing-indicator'), typingText: $('typing-text'),
- replyPreview: $('reply-preview'), replyUser: $('reply-user'), replyText: $('reply-text'),
- composeForm: $('compose-form'), messageInput: $('message-input'), sendBtn: $('send-btn'),
- attachBtn: $('attach-btn'), emojiBtn: $('emoji-btn'), fileInput: $('file-input'),
- uploadPreview: $('upload-preview'), uploadIcon: $('upload-icon'), uploadFilename: $('upload-filename'),
- uploadSize: $('upload-size'), cancelUpload: $('cancel-upload'),
- uploadProgress: $('upload-progress'), progressFill: $('progress-fill'), progressText: $('progress-text'),
- dropOverlay: $('drop-overlay'),
-
- settingsModal: $('settings-modal'), onlineModal: $('online-modal'),
- convModal: $('conv-modal'), receiptsModal: $('receipts-modal'),
- confirmModal: $('confirm-modal'), lightboxModal: $('lightbox-modal'),
- emojiPicker: $('emoji-picker'), emojiGrid: $('emoji-grid'),
-
- settingsDisplayName: $('settings-display-name'), settingsUsername: $('settings-username'),
- settingsAvatarPreview: $('settings-avatar-preview'), settingsAvatarImg: $('settings-avatar-img'),
- avatarInput: $('avatar-input'), avatarUploadBtn: $('avatar-upload-btn'),
- themeOptions: document.querySelectorAll('.theme-option'),
- customThemeSettings: $('custom-theme-settings'),
- customSelfColor: $('custom-self-color'), customOtherColor: $('custom-other-color'),
- customTextColor: $('custom-text-color'),
- selfColorHex: $('self-color-hex'), otherColorHex: $('other-color-hex'), textColorHex: $('text-color-hex'),
- toggleAutoScroll: $('toggle-autoscroll'), toggleScrollBtn: $('toggle-scroll-btn'),
- toggleLatestBar: $('toggle-latest-bar'), toggleSound: $('toggle-sound'),
- toggleEnterSend: $('toggle-enter-send'), notifyPermBtn: $('notify-perm-btn'),
- pwCurrent: $('pw-current'), pwNew: $('pw-new'), pwChangeBtn: $('pw-change-btn'), pwMsg: $('pw-msg'),
- onlineBtn: $('online-btn'), onlineUsersList: $('online-users-list'), onlineTotal: $('online-total'),
- settingsBtn: $('settings-btn'), logoutBtn: $('logout-btn'), meSettingsBtn: $('me-settings-btn'),
- sidebarLogoutBtn: $('sidebar-logout-btn'),
- convModalDesc: $('conv-modal-desc'), convModalList: $('conv-modal-list'), newChatBtn: $('new-chat-btn'),
- receiptsSummary: $('receipts-summary'), receiptsList: $('receipts-list'),
- confirmTitle: $('confirm-title'), confirmMessage: $('confirm-message'),
- confirmOkBtn: $('confirm-ok-btn'), confirmCancelBtn: $('confirm-cancel-btn'),
- lightboxImg: $('lightbox-img'), lightboxName: $('lightbox-name'), lightboxDownload: $('lightbox-download'),
- toastContainer: $('toast-container'), aboutVersion: $('about-version')
- };
-
- const API = window.location.origin;
- const GLOBAL_CONV = 1;
-
- // ============================================================
- // Small helpers
- // ============================================================
- 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) {
- if (text === null || text === undefined) return '';
- const div = document.createElement('div');
- div.textContent = String(text);
- return div.innerHTML;
- }
-
- function fileUrl(filePath) {
- return `${API}/api/download/${encodeURIComponent(filePath)}?t=${encodeURIComponent(State.token)}`;
- }
-
- function displayNameOf(u) {
- if (!u) return 'Unknown';
- // user-shaped objects (peers, online users, receipts)...
- if (u.display_name || u.username) return u.display_name || u.username;
- // ...or message-shaped objects (sender_display_name / sender_username)
- if (u.sender_display_name || u.sender_username) return u.sender_display_name || u.sender_username;
- return 'Unknown';
- }
-
- function avatarColorClass(seed) {
- let h = 0;
- const s = String(seed || '');
- for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
- return 'avatar-c' + (h % 6);
- }
-
- function initialOf(name) {
- return (name || '?').charAt(0).toUpperCase();
- }
-
- // ---------- time ----------
- function formatTime(ms) {
- if (!ms) return '';
- const date = new Date(ms);
- const now = new Date();
- const diff = now - date;
- if (diff < 45000) return 'now';
- if (diff < 3600000) return Math.max(1, Math.floor(diff / 60000)) + 'm';
- if (date.toDateString() === now.toDateString()) {
- return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
- }
- const yesterday = new Date(now);
- yesterday.setDate(yesterday.getDate() - 1);
- if (date.toDateString() === yesterday.toDateString()) {
- return 'Yesterday ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
- }
- if (diff < 6 * 86400000) {
- return date.toLocaleDateString([], { weekday: 'short' }) + ' ' +
- date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
- }
- return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' +
- date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
- }
-
- function formatDayLabel(ms) {
- const d = new Date(ms), now = new Date();
- if (d.toDateString() === now.toDateString()) return 'Today';
- const y = new Date(now);
- y.setDate(y.getDate() - 1);
- if (d.toDateString() === y.toDateString()) return 'Yesterday';
- if (d.getFullYear() === now.getFullYear()) {
- return d.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' });
- }
- return d.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
- }
-
- function formatLastSeen(sec) {
- if (!sec) return 'offline';
- const diff = Date.now() / 1000 - sec;
- if (diff < 60) return 'online';
- if (diff < 3600) return 'last seen ' + Math.floor(diff / 60) + 'm ago';
- if (diff < 86400) return 'last seen ' + Math.floor(diff / 3600) + 'h ago';
- return 'last seen ' + Math.floor(diff / 86400) + 'd ago';
- }
-
- function formatFileSize(bytes) {
- if (!bytes) return '0 B';
- const k = 1024, sizes = ['B', 'KB', 'MB', 'GB'];
- const i = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k)));
- return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
- }
-
- function dayKey(ms) {
- return new Date(ms).toDateString();
- }
-
- // ---------- toast ----------
- function showToast(message, type = 'info', ms = 4000) {
- 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 = `${escapeHtml(message)}`;
- DOM.toastContainer.appendChild(toast);
- setTimeout(() => toast.remove(), ms);
- }
-
- // ---------- modals ----------
- const openModals = [];
-
- function openModal(el) {
- if (!el) return;
- el.classList.remove('hidden');
- if (!openModals.includes(el)) openModals.push(el);
- document.body.style.overflow = 'hidden';
- }
-
- function closeModal(el) {
- if (!el) return;
- el.classList.add('hidden');
- const i = openModals.indexOf(el);
- if (i > -1) openModals.splice(i, 1);
- if (!openModals.length) document.body.style.overflow = '';
- // resolve any pending confirm() promise (backdrop / X / Escape path)
- if (el === DOM.confirmModal && el.__resolve) {
- const r = el.__resolve;
- el.__resolve = null;
- r(false);
- }
- }
-
- function closeTopModal() {
- const el = openModals[openModals.length - 1];
- if (el) closeModal(el);
- }
-
- // generic backdrop + [data-close] delegation
- document.addEventListener('click', (e) => {
- const closer = e.target.closest('[data-close]');
- if (closer) {
- const id = closer.dataset.close;
- closeModal(document.getElementById(id));
- if (id === 'settings-modal') applyPrefs();
- }
- });
- document.addEventListener('keydown', (e) => {
- if (e.key === 'Escape' && openModals.length) {
- if (State.editingId) { cancelInlineEdit(); return; }
- closeTopModal();
- }
- });
-
- // ---------- custom confirm (replaces blocking confirm()) ----------
- function showConfirm({ title = 'Are you sure?', message = '', okText = 'OK', danger = true }) {
- return new Promise((resolve) => {
- DOM.confirmTitle.innerHTML = ` ${escapeHtml(title)}`;
- DOM.confirmMessage.textContent = message;
- DOM.confirmOkBtn.className = `btn ${danger ? 'btn-danger' : 'btn-primary'}`;
- DOM.confirmOkBtn.innerHTML = ` ${escapeHtml(okText)}`;
-
- const done = (val) => {
- DOM.confirmOkBtn.removeEventListener('click', onOk);
- DOM.confirmCancelBtn.removeEventListener('click', onCancel);
- DOM.confirmModal.__resolve = null;
- closeModal(DOM.confirmModal);
- resolve(val);
- };
- const onOk = () => done(true);
- const onCancel = () => done(false);
- DOM.confirmOkBtn.addEventListener('click', onOk);
- DOM.confirmCancelBtn.addEventListener('click', onCancel);
- // backdrop / X button / Escape resolve as "cancel" via closeModal hook
- DOM.confirmModal.__resolve = (val) => {
- DOM.confirmOkBtn.removeEventListener('click', onOk);
- DOM.confirmCancelBtn.removeEventListener('click', onCancel);
- resolve(val);
- };
- openModal(DOM.confirmModal);
- });
- }
-
- // ============================================================
- // Theme & preferences
- // ============================================================
- function applyTheme(theme) {
- document.documentElement.setAttribute('data-theme', theme);
- State.theme = theme;
- localStorage.setItem('theme', theme);
- if (theme === 'custom') {
- const s = State.customColors.self, o = State.customColors.other, 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 {
- ['--bubble-self', '--bubble-other', '--bubble-other-text', '--bubble-self-text']
- .forEach(p => document.documentElement.style.removeProperty(p));
- }
- }
- applyTheme(State.theme);
-
- function applyPrefs() {
- DOM.toggleAutoScroll.checked = State.prefs.autoScroll;
- DOM.toggleScrollBtn.checked = State.prefs.showScrollBtn;
- DOM.toggleLatestBar.checked = State.prefs.showLatestBar;
- DOM.toggleSound.checked = State.prefs.sound;
- DOM.toggleEnterSend.checked = State.prefs.enterSend;
- DOM.notifyPermBtn.innerHTML = (window.Notification && Notification.permission === 'granted')
- ? ' Enabled'
- : ' Enable';
- updateScrollUI();
- }
-
- function savePrefs() {
- localStorage.setItem('pref-autoscroll', State.prefs.autoScroll);
- localStorage.setItem('pref-scrollbtn', State.prefs.showScrollBtn);
- localStorage.setItem('pref-latestbar', State.prefs.showLatestBar);
- localStorage.setItem('pref-sound', State.prefs.sound);
- localStorage.setItem('pref-entersend', State.prefs.enterSend);
- }
-
- [['toggle-autoscroll', 'autoScroll'], ['toggle-scroll-btn', 'showScrollBtn'],
- ['toggle-latest-bar', 'showLatestBar'], ['toggle-sound', 'sound'],
- ['toggle-enter-send', 'enterSend']].forEach(([id, key]) => {
- const el = document.getElementById(id);
- el.addEventListener('change', () => {
- State.prefs[key] = el.checked;
- savePrefs();
- if (key === 'autoScroll' && State.prefs.autoScroll) { scrollToBottom(); hideLatestBar(); }
- if (key === 'showScrollBtn') updateScrollUI();
- if (key === 'showLatestBar' && !State.prefs.showLatestBar) hideLatestBar();
- });
- });
-
- DOM.notifyPermBtn.addEventListener('click', async () => {
- if (!('Notification' in window)) { showToast('Notifications not supported here', 'warning'); return; }
- try {
- const perm = await Notification.requestPermission();
- DOM.notifyPermBtn.innerHTML = perm === 'granted'
- ? ' Enabled'
- : ' Blocked';
- if (perm === 'granted') showToast('Desktop notifications enabled', 'success');
- } catch (e) { /* ignore */ }
- });
-
- // notification sound (WebAudio - no asset needed)
- let audioCtx = null;
- function playChime() {
- if (!State.prefs.sound) return;
- try {
- audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
- if (audioCtx.state === 'suspended') audioCtx.resume();
- const now = audioCtx.currentTime;
- const notes = [880, 1174.66];
- notes.forEach((freq, i) => {
- const osc = audioCtx.createOscillator();
- const gain = audioCtx.createGain();
- osc.type = 'sine';
- osc.frequency.value = freq;
- const t0 = now + i * 0.09;
- gain.gain.setValueAtTime(0.0001, t0);
- gain.gain.exponentialRampToValueAtTime(0.12, t0 + 0.02);
- gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.16);
- osc.connect(gain).connect(audioCtx.destination);
- osc.start(t0); osc.stop(t0 + 0.2);
- });
- } catch (e) { /* audio unavailable */ }
- }
-
- // ============================================================
- // 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($('login-username').value.trim(), $('login-password').value);
- });
- DOM.signupForm.addEventListener('submit', async (e) => {
- e.preventDefault();
- const username = $('signup-username').value.trim();
- const display = $('signup-display').value.trim() || username;
- const password = $('signup-password').value;
- await signup(username, password, display);
- });
-
- async function postJson(url) {
- const res = await fetch(url, { method: 'POST' });
- const data = await res.json().catch(() => ({}));
- if (!res.ok) throw new Error(data.detail || `Request failed (${res.status})`);
- return data;
- }
-
- async function login(username, password) {
- DOM.authLoading.classList.remove('hidden');
- hideAuthError();
- try {
- const data = await postJson(
- `${API}/api/auth/login?username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`);
- 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 data = await postJson(
- `${API}/api/auth/signup?username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}&display_name=${encodeURIComponent(display)}`);
- 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 (heartbeat + resilient reconnect + pending resend)
- // ============================================================
- function connectWebSocket() {
- if (!State.token) return;
- if (State.ws && (State.ws.readyState === WebSocket.OPEN || State.ws.readyState === WebSocket.CONNECTING)) return;
- State.wsState = 'connecting';
- updateConnectionUI();
- const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
- const ws = new WebSocket(`${proto}//${location.host}/ws?token=${encodeURIComponent(State.token)}`);
- State.ws = ws;
-
- ws.onopen = () => {
- State.wsState = 'connected';
- State.reconnectAttempt = 0;
- updateConnectionUI();
- requestConversations();
- flushPendingSends();
- };
- ws.onmessage = (e) => {
- try { handleWSMessage(JSON.parse(e.data)); } catch (err) { console.error('WS parse error', err); }
- };
- ws.onclose = (ev) => {
- if (ev.code === 4001) {
- // token invalid/revoked -> log out
- forceLogout();
- return;
- }
- if (State.wsState === 'connected' || State.wsState === 'connecting') {
- scheduleReconnect();
- }
- };
- ws.onerror = () => {
- State.wsState = 'disconnected';
- updateConnectionUI();
- };
- }
-
- function scheduleReconnect() {
- State.wsState = 'reconnecting';
- updateConnectionUI();
- State.reconnectAttempt += 1;
- const base = Math.min(15000, 800 * Math.pow(1.7, State.reconnectAttempt - 1));
- const delay = base * (0.6 + Math.random() * 0.8);
- setTimeout(() => connectWebSocket(), delay);
- }
-
- // keepalive so proxies never kill an idle socket
- setInterval(() => {
- if (State.ws && State.ws.readyState === WebSocket.OPEN) {
- State.ws.send(JSON.stringify({ type: 'ping', t: Date.now() }));
- }
- }, 20000);
-
- function wsOpen() {
- return State.ws && State.ws.readyState === WebSocket.OPEN;
- }
-
- function wsSend(obj) {
- if (!wsOpen()) return false;
- try { State.ws.send(JSON.stringify(obj)); return true; } catch (e) { return false; }
- }
-
- function updateConnectionUI() {
- const map = {
- connected: ['connected', 'Connected'],
- reconnecting: ['reconnecting', 'Reconnecting…'],
- disconnected: ['disconnected', 'Disconnected'],
- connecting: ['reconnecting', 'Connecting…']
- };
- const [cls, txt] = map[State.wsState] || ['disconnected', 'Disconnected'];
- DOM.connectionStatus.className = 'connection-status ' + cls;
- DOM.connectionText.textContent = txt;
- }
-
- // ============================================================
- // Conversations / sidebar
- // ============================================================
- function convById(id) { return State.conversations.get(id); }
-
- function upsertConversation(summary) {
- State.conversations.set(summary.id, summary);
- if (!State.convCache.has(summary.id)) {
- State.convCache.set(summary.id, { ids: [], cursor: null, hasMore: true, lastTs: 0 });
- }
- renderSidebar();
- }
-
- function deleteConversationLocally(id) {
- // reserved: DM deletion UI may land in a later version
- }
-
- function convSortKey(c) {
- if (c.id === GLOBAL_CONV) return [0, 0];
- return [1, -(c.last_message_ts || 0)];
- }
-
- function renderSidebar() {
- State.conversationOrder = [...State.conversations.values()].sort((a, b) => {
- const ka = convSortKey(a), kb = convSortKey(b);
- return ka[0] - kb[0] || ka[1] - kb[1];
- });
-
- let html = '';
- let prevType = null;
- let unreadTotal = 0;
- State.conversationOrder.forEach((c) => {
- if (c.type === 'dm' && prevType !== 'dm') {
- html += '
Private Chats
';
- }
- prevType = c.type;
- unreadTotal += c.unread_count || 0;
- html += convItemHTML(c);
- });
- if (!State.conversationOrder.length) {
- html = 'Loading chats…
';
- }
- DOM.convList.innerHTML = html;
- DOM.convList.querySelectorAll('.conv-item').forEach((el) => {
- el.addEventListener('click', () => {
- switchConversation(parseInt(el.dataset.id, 10));
- closeSidebar();
- });
- });
- State.unreadTotal = unreadTotal;
- updateTitleUnread();
- }
-
- function updateTitleUnread() {
- const label = State.unreadTotal > 0 ? `(${State.unreadTotal > 99 ? '99+' : State.unreadTotal}) ` : '';
- document.title = label + 'InfinityChat';
- }
-
- function convItemHTML(c) {
- const isDm = c.type === 'dm';
- const name = isDm ? dmLabel(c) : (c.title || 'Global Chat');
- const active = c.id === State.activeConvId ? ' active' : '';
- const time = c.last_message_ts ? formatTime(c.last_message_ts) : '';
- const unread = Math.min(c.unread_count || 0, 99);
- const unreadHtml = unread > 0
- ? `${unread}`
- : '';
- const timeCls = unread > 0 ? ' unread' : '';
- let avatar = avatarHTML(c, 44);
- return ``;
- }
-
- function dmLabel(c) {
- const base = displayNameOf(c.peer);
- return c.dm_number && c.dm_number > 1 ? `${base} · ${c.dm_number}` : base;
- }
-
- function previewHTML(c) {
- let txt = c.last_message_preview || (c.id === GLOBAL_CONV ? 'No messages yet' : 'No messages yet');
- if (c.last_sender_id && !(c.last_sender_id === State.user?.id)) {
- const n = c.last_sender_name;
- if (c.type === 'global') txt = `${n}: ${txt}`;
- }
- return escapeHtml(truncate(txt, 60));
- }
-
- function avatarHTML(c, px) {
- if (c.type === 'dm' && c.peer) {
- const on = c.peer.online ? ' online' : '';
- let inner = ``;
- if (c.peer.avatar_path) {
- inner = `
${inner}`;
- } else {
- inner = `${initialOf(displayNameOf(c.peer))}${inner}`;
- }
- return `${inner}
`;
- }
- return `
`;
- }
-
- function requestConversations() {
- if (wsOpen()) wsSend({ type: 'request_conversations' });
- }
-
- function getConv(convId) {
- if (!State.convCache.has(convId)) {
- State.convCache.set(convId, { ids: [], cursor: null, hasMore: true, lastTs: 0 });
- }
- return State.convCache.get(convId);
- }
-
- function loadMessages(convId = State.activeConvId, opts = {}) {
- if (!wsOpen()) return;
- const cache = getConv(convId);
- const msg = { type: 'load_messages', conversation_id: convId, limit: 50 };
- if (opts.older && cache.cursor) msg.cursor = cache.cursor;
- wsSend(msg);
- }
-
- // ============================================================
- // Conversation switching
- // ============================================================
- function switchConversation(convId) {
- convId = parseInt(convId, 10) || GLOBAL_CONV;
- if (convId === State.activeConvId) return;
- if (State.editingId) cancelInlineEdit();
- clearReply(); // replies are conversation-scoped
- hideLatestBar();
- State.activeConvId = convId;
- State.latestUnseen = null;
- State.isAtBottom = true;
-
- const cache = getConv(convId);
- renderConversationHeader(convId);
- renderSidebar();
-
- if (cache.ids.length === 0) {
- cache.loading = true;
- loadMessages(convId);
- renderEmptyLoading(convId);
- } else {
- renderAllMessages(convId, { scrollBottom: true });
- }
- markActiveRead(true);
- DOM.messageInput.focus({ preventScroll: true });
- }
-
- function renderEmptyLoading(convId) {
- DOM.messagesList.innerHTML = '';
- // re-attach the empty-state element (innerHTML wipes it) before showing it
- if (DOM.emptyState.parentNode !== DOM.messagesList) DOM.messagesList.appendChild(DOM.emptyState);
- const conv = convById(convId);
- const dm = conv && conv.type === 'dm' && conv.peer;
- if (dm) {
- DOM.emptyIcon.innerHTML = '';
- DOM.emptyTitle.textContent = 'No messages yet';
- DOM.emptySub.textContent = `This is the start of your private chat with ${displayNameOf(conv.peer)}`;
- } else {
- DOM.emptyIcon.innerHTML = '';
- DOM.emptyTitle.textContent = 'No messages yet';
- DOM.emptySub.textContent = 'Say hello to the Global Chat below';
- }
- DOM.emptyState.style.display = 'flex';
- DOM.loadOlderBtn.classList.add('hidden');
- }
-
- function renderConversationHeader(convId) {
- const conv = convById(convId);
- if (!conv) return;
- const dm = conv.type === 'dm';
- const peer = conv.peer;
-
- if (dm && peer) {
- DOM.convTitle.textContent = dmLabel(conv);
- if (peer.avatar_path) {
- DOM.convAvatar.src = fileUrl(peer.avatar_path);
- DOM.convAvatar.classList.remove('hidden');
- DOM.convAvatarPh.classList.add('hidden');
- } else {
- DOM.convAvatar.classList.add('hidden');
- DOM.convAvatarPh.classList.remove('hidden');
- DOM.convAvatarPh.className = 'conv-avatar-ph ' + avatarColorClass(peer.username || peer.id);
- DOM.convAvatarPh.textContent = initialOf(displayNameOf(peer));
- }
- const online = peer.online || State.onlineUsers.has(peer.id);
- DOM.convPresenceDot.classList.remove('hidden');
- DOM.convPresenceDot.classList.toggle('offline', !online);
- DOM.convSubtitle.innerHTML = online
- ? ' Online'
- : escapeHtml(formatLastSeen(peer.last_seen));
- DOM.onlineBtn.style.display = '';
- } else {
- DOM.convTitle.textContent = 'Global Chat';
- DOM.convAvatar.classList.add('hidden');
- DOM.convAvatarPh.classList.remove('hidden');
- DOM.convAvatarPh.className = 'conv-avatar-ph';
- DOM.convAvatarPh.innerHTML = '';
- DOM.convPresenceDot.classList.add('hidden');
- const meCount = State.onlineUsers.has(State.user?.id) ? State.onlineUsers.size - 1 : State.onlineUsers.size;
- DOM.convSubtitle.innerHTML = ` ${Math.max(0, meCount)} online`;
- DOM.onlineBtn.style.display = '';
- }
- }
-
- function openSidebar() {
- DOM.sidebar.classList.add('open');
- DOM.sidebarBackdrop.classList.remove('hidden');
- }
- function closeSidebar() {
- DOM.sidebar.classList.remove('open');
- DOM.sidebarBackdrop.classList.add('hidden');
- }
- DOM.sidebarOpenBtn.addEventListener('click', openSidebar);
- DOM.sidebarCloseBtn.addEventListener('click', closeSidebar);
- DOM.sidebarBackdrop.addEventListener('click', closeSidebar);
-
- // ============================================================
- // Read receipts
- // ============================================================
- function latestMessageId(convId) {
- const ids = getConv(convId).ids;
- return ids.length ? ids[ids.length - 1] : null;
- }
-
- // coalesced, non-blocking "I have this chat open right now"
- let markReadTimer = null;
- function markActiveRead(force) {
- if (!wsOpen()) return;
- const convId = State.activeConvId;
- if (document.visibilityState !== 'visible' && !force) return;
- const upTo = latestMessageId(convId);
- if (!upTo) return;
- clearTimeout(markReadTimer);
- markReadTimer = setTimeout(() => {
- const summary = convById(convId);
- if (summary) {
- // optimistically zero the badge; server confirms via conversation_updated
- if (summary.unread_count) { summary.unread_count = 0; renderSidebar(); }
- }
- wsSend({ type: 'mark_read', up_to_message_id: upTo, conversation_id: convId });
- }, 120);
- }
-
- document.addEventListener('visibilitychange', () => {
- if (document.visibilityState === 'visible') {
- markActiveRead(true);
- if (State.activeConvId) renderConversationHeader(State.activeConvId);
- } else {
- updateTitleUnread();
- }
- });
-
- // clicking the tick = who read it, and when
- function openReceipts(messageId) {
- const msg = State.messages.get(messageId);
- if (!msg) return;
- DOM.receiptsList.innerHTML = '';
- DOM.receiptsSummary.textContent = '';
- openModal(DOM.receiptsModal);
- fetch(`${API}/api/messages/${messageId}/read-receipts`, { headers: { 'X-Auth-Token': State.token } })
- .then(async (res) => {
- if (!res.ok) {
- const data = await res.json().catch(() => ({}));
- throw new Error(data.detail || 'Failed');
- }
- return res.json();
- })
- .then((data) => renderReceipts(data, messageId))
- .catch((err) => {
- DOM.receiptsList.innerHTML = ` ${escapeHtml(err.message)}
`;
- });
- }
-
- function renderReceipts(data) {
- const n = data.reader_count || 0;
- if (data.is_dm && !data.not_read.length) {
- DOM.receiptsSummary.innerHTML = ` Read`;
- } else if (!data.is_dm && n > 0) {
- DOM.receiptsSummary.textContent = `Read by ${n} ${n === 1 ? 'user' : 'users'}`;
- } else if (data.not_read.length) {
- DOM.receiptsSummary.textContent = 'Not read yet';
- } else {
- DOM.receiptsSummary.textContent = `No one has read this yet`;
- }
-
- let html = '';
- if (!data.readers.length) {
- html = `No read receipts yet${data.is_dm && data.not_read.length ? ` — ${displayNameOf(data.not_read[0])} hasn't opened this chat since you sent it` : ''}
`;
- }
- data.readers.forEach(r => {
- const av = r.avatar_path
- ? `
`
- : `${initialOf(displayNameOf(r))}`;
- html += `
-
${av}
-
-
${escapeHtml(displayNameOf(r))}${r.online ? ' ' : ''}
-
Read ${new Date(r.read_at).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
-
-
-
`;
- });
- // In a DM, explicitly show who hasn't read yet
- data.not_read.forEach(r => {
- const av = r.avatar_path
- ? `
`
- : `${initialOf(displayNameOf(r))}`;
- html += `
-
${av}
-
-
${escapeHtml(displayNameOf(r))}
-
${r.online ? 'Online — has not read it yet' : 'Not read yet'}
-
-
-
`;
- });
- DOM.receiptsList.innerHTML = html;
- }
-
- // ============================================================
- // WS message dispatch
- // ============================================================
- function handleWSMessage(msg) {
- switch (msg.type) {
-
- case 'pong':
- break;
-
- 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));
- updateSidebarMe();
- updateOnlineBadge();
-
- // fresh session: adopt server conversation list, drop old caches
- State.conversations.clear();
- State.conversationOrder = [];
- State.convCache.clear();
- State.messages.clear();
- State.tombstoned.clear();
- DOM.messagesList.innerHTML = '';
- (msg.conversations || []).forEach(c => upsertConversation(c));
- if (!State.conversations.has(GLOBAL_CONV)) {
- upsertConversation({ id: GLOBAL_CONV, type: 'global', title: 'Global Chat', unread_count: 0 });
- }
- // jump back to where we were (default = Global Chat)
- const target = State.activeConvId && State.conversations.has(State.activeConvId)
- ? State.activeConvId
- : GLOBAL_CONV;
- State.activeConvId = null;
- switchConversation(target);
- break;
- }
-
- case 'conversations':
- msg.conversations.forEach(c => upsertConversation(c));
- if (State.activeConvId && State.conversations.has(State.activeConvId)) {
- renderConversationHeader(State.activeConvId);
- }
- break;
-
- case 'conversation_updated':
- upsertConversation(msg.conversation);
- if (msg.conversation.id === State.activeConvId) {
- renderConversationHeader(State.activeConvId);
- }
- break;
-
- case 'dm_created': {
- upsertConversation(msg.conversation);
- const peer = msg.conversation.peer;
- if (peer && peer.id !== State.user?.id) {
- showToast(`Private chat with ${displayNameOf(peer)} created`, 'success');
- switchConversation(msg.conversation.id);
- }
- break;
- }
-
- case 'new_message': {
- const m = msg.message;
- const fromMe = m.sender_id === State.user?.id;
- // Ack for a send from this tab
- if (fromMe && msg.client_id) {
- State.pendingSends.delete(msg.client_id);
- }
- handleNewMessage(m, !!msg.client_id);
- break;
- }
-
- case 'messages_loaded': {
- const convId = msg.conversation_id || GLOBAL_CONV;
- const cache = getConv(convId);
- cache.loading = false;
- const wasEmpty = cache.ids.length === 0;
- const sorted = [...msg.messages].sort((a, b) => a.id - b.id);
- sorted.forEach(m => {
- if (m.is_deleted) return;
- if (!State.messages.has(m.id)) {
- State.messages.set(m.id, m);
- } else {
- const old = State.messages.get(m.id);
- Object.assign(old, m);
- }
- if (!cache.ids.includes(m.id)) cache.ids.push(m.id);
- });
- cache.ids.sort((a, b) => a - b);
- cache.hasMore = msg.has_more;
- cache.cursor = msg.next_cursor;
- if (cache.ids.length) cache.lastTs = State.messages.get(cache.ids[cache.ids.length - 1]).timestamp_ms;
-
- if (convId === State.activeConvId) {
- const wasBottom = State.isAtBottom;
- const prevHeight = DOM.messagesScroll.scrollHeight;
- renderAllMessages(convId, {});
- if (wasEmpty && wasBottom) scrollToBottom();
- else if (!wasEmpty && !wasBottom && prevHeight) {
- DOM.messagesScroll.scrollTop += DOM.messagesScroll.scrollHeight - prevHeight;
- } else {
- scrollToBottom();
- }
- DOM.loadOlderBtn.classList.toggle('hidden', !msg.has_more || !cache.cursor);
- markActiveRead(); // respects visibility: a hidden tab must not mark messages read
- }
- break;
- }
-
- case 'typing_indicator': {
- if (!msg.conversation_id) break;
- if (msg.user_id === State.user?.id) break;
- // key per conversation so a "typing" in one chat never leaks into another
- const key = `${msg.conversation_id}:${msg.user_id}`;
- if (msg.is_typing) {
- const name = msg.display_name || msg.username;
- const existing = State.typingUsers.get(key);
- if (existing) clearTimeout(existing.timer);
- const timer = setTimeout(() => {
- State.typingUsers.delete(key);
- if (msg.conversation_id === State.activeConvId) updateTypingIndicator();
- }, 4000);
- State.typingUsers.set(key, { name, timer });
- } else {
- const existing = State.typingUsers.get(key);
- if (existing) clearTimeout(existing.timer);
- State.typingUsers.delete(key);
- }
- if (msg.conversation_id === State.activeConvId) updateTypingIndicator();
- 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();
- updatePeerPresence(msg.user_id, msg.status === 'online');
- if (State.activeConvId) renderConversationHeader(State.activeConvId);
- break;
- }
-
- case 'online_users': {
- State.onlineUsers.clear();
- msg.users.forEach(u => State.onlineUsers.set(u.id, u));
- updateOnlineBadge();
- updateAllPeerPresence();
- if (!DOM.onlineModal.classList.contains('hidden')) renderOnlineUsers();
- break;
- }
-
- case 'message_edited': {
- const mid = msg.message_id;
- if (State.messages.has(mid)) {
- const m = State.messages.get(mid);
- m.content = msg.content || m.content;
- m.is_edited = true;
- if (m.conversation_id === State.activeConvId) {
- updateMessageDom(mid);
- }
- }
- break;
- }
-
- case 'message_deleted': {
- const mid = msg.message_id;
- if (!State.messages.has(mid)) break;
- const m = State.messages.get(mid);
- if (m.sender_id === State.user?.id) {
- // my own delete echoed back - row already removed by REST flow
- removeMessageDom(mid);
- State.messages.delete(mid);
- pruneConvId(m.conversation_id, mid);
- } else {
- // someone else's message vanished -> session tombstone
- if (!State.tombstoned.has(m.conversation_id)) State.tombstoned.set(m.conversation_id, new Set());
- State.tombstoned.get(m.conversation_id).add(mid);
- if (m.conversation_id === State.activeConvId) {
- updateMessageDom(mid, true);
- }
- }
- break;
- }
-
- case 'message_read': {
- const mid = msg.message_id;
- const m = State.messages.get(mid);
- if (m && m.sender_id === State.user?.id) {
- m.status = 'read';
- if (m.conversation_id === State.activeConvId) updateTick(mid);
- }
- break;
- }
-
- case 'error': {
- if (msg.code === 'DUPLICATE' && msg.client_id) {
- // our resend landed twice - the server kept the first copy
- const pending = State.pendingSends.get(msg.client_id);
- State.pendingSends.delete(msg.client_id);
- const conv = (pending && pending.conversation_id) || GLOBAL_CONV;
- if (getConv(conv).ids.length) loadMessages(conv); // re-sync quietly
- break;
- }
- if (msg.code === 'RATE_LIMIT' && msg.client_id) {
- // keep the message queued and try again in a moment
- showToast('Sending too fast - retrying…', 'warning');
- clearTimeout(window.__retrySends);
- window.__retrySends = setTimeout(() => flushPendingSends(), 4000);
- break;
- }
- if (msg.client_id) {
- // fatal for this send - stop queueing it for replay
- State.pendingSends.delete(msg.client_id);
- }
- showToast(msg.message || 'Something went wrong', 'error');
- break;
- }
- }
- }
-
- function pruneConvId(convId, mid) {
- const cache = State.convCache.get(convId);
- if (cache) {
- const i = cache.ids.indexOf(mid);
- if (i > -1) cache.ids.splice(i, 1);
- }
- const ts = State.tombstoned.get(convId);
- if (ts) ts.delete(mid);
- }
-
- function updatePeerPresence(userId, online) {
- let changed = false;
- State.conversations.forEach(c => {
- if (c.type === 'dm' && c.peer && c.peer.id === userId) {
- c.peer.online = online;
- changed = true;
- }
- });
- if (changed) renderSidebar();
- if (State.activeConvId) renderConversationHeader(State.activeConvId);
- }
-
- function updateAllPeerPresence() {
- let changed = false;
- State.conversations.forEach(c => {
- if (c.type === 'dm' && c.peer) {
- const on = State.onlineUsers.has(c.peer.id);
- if (c.peer.online !== on) { c.peer.online = on; changed = true; }
- }
- });
- if (changed) {
- renderSidebar();
- renderConversationHeader(State.activeConvId);
- }
- }
-
- // ============================================================
- // New message handling
- // ============================================================
- function handleNewMessage(m, isOwnEcho) {
- if (!m || !m.conversation_id) return;
- const convId = m.conversation_id;
- const fromMe = m.sender_id === State.user?.id;
- const cache = getConv(convId);
-
- if (State.messages.has(m.id)) {
- // duplicate delivery (multi-tab / replay) - just refresh the tick/status
- const existing = State.messages.get(m.id);
- if (m.status) existing.status = m.status;
- if (convId === State.activeConvId) updateTick(m.id);
- return;
- }
-
- State.messages.set(m.id, m);
- if (!cache.ids.includes(m.id)) cache.ids.push(m.id);
- cache.ids.sort((a, b) => a - b);
- cache.lastTs = Math.max(cache.lastTs || 0, m.timestamp_ms);
- State.latestUnseen = null;
-
- // keep my own sidebar summary fresh when I send
- if (fromMe) {
- const summary = convById(convId);
- if (summary && (!summary.last_message_id || m.id >= summary.last_message_id)) {
- summary.last_message_id = m.id;
- summary.last_message_ts = m.timestamp_ms;
- summary.last_message_preview = previewFromMessage(m);
- summary.last_sender_id = m.sender_id;
- summary.last_sender_name = displayNameOf(m);
- renderSidebar();
- }
- }
-
- const convOpen = convId === State.activeConvId;
-
- if (convOpen) {
- if (cache.ids.length === 1) {
- renderAllMessages(convId, { scrollBottom: true });
- } else {
- appendMessageDom(m);
- // pinned at the bottom? stay pinned. otherwise follow the pref.
- if (State.isAtBottom || State.prefs.autoScroll) scrollToBottom();
- }
- DOM.emptyState.style.display = 'none';
- if (!State.isAtBottom && !State.prefs.autoScroll && !fromMe) {
- showLatestBar(m);
- }
- } else {
- // not looking at this conversation: keep list scroll position untouched
- if (!fromMe) {
- const summary = convById(convId);
- if (summary) {
- summary.unread_count = (summary.unread_count || 0) + 1;
- summary.last_message_id = m.id;
- summary.last_message_ts = m.timestamp_ms;
- summary.last_message_preview = previewFromMessage(m);
- summary.last_sender_id = m.sender_id;
- summary.last_sender_name = displayNameOf(m);
- }
- renderSidebar();
- }
- }
-
- if (!isOwnEcho && !fromMe && State.messages.get(m.id)) {
- const wasVisible = document.visibilityState === 'visible';
- const needNotify = !convOpen || !wasVisible;
- if (needNotify) {
- playChime();
- sendDesktopNotification(m, convId);
- } else {
- // chat is open in front of you -> instant read receipt
- markActiveRead(true);
- }
- }
-
- if (State.activeConvId && State.conversations.has(State.activeConvId)) {
- renderConversationHeader(State.activeConvId);
- }
- }
-
- function previewFromMessage(m) {
- if (m.content && m.content.trim()) {
- return stripMarkdown(m.content.trim().replace(/
]*>/gi, ' ')).replace(/\s+/g, ' ').slice(0, 90);
- }
- if ((m.file_type || '').startsWith('image/')) return '📷 Photo';
- if ((m.file_type || '').startsWith('video/')) return '🎬 Video';
- if ((m.file_type || '').startsWith('audio/')) return '🎵 Audio';
- return '📎 ' + (m.file_name || 'File');
- }
-
- function stripMarkdown(text) {
- // previews (sidebar / notifications / replies) read like the message,
- // minus the formatting markers
- return String(text)
- .replace(/```/g, ' ')
- .replace(/\*\*([^*]+)\*\*/g, '$1')
- .replace(/`([^`]+)`/g, '$1')
- .replace(/(^|[\s(>])\*([^*\n]+)\*(?=[\s.,!?;:)\n]|$)/g, '$1$2');
- }
-
- function sendDesktopNotification(m, convId) {
- if (!('Notification' in window) || Notification.permission !== 'granted') return;
- if (m.sender_id === State.user?.id) return;
- const conv = convById(convId);
- const title = conv && conv.type === 'dm'
- ? `${displayNameOf(conv.peer)} (private chat)`
- : displayNameOf(m);
- const body = m.content
- ? truncate(stripMarkdown(m.content.replace(/
]*>/gi, ' ')).replace(/\s+/g, ' '), 120)
- : previewFromMessage(m);
- try {
- const n = new Notification(title, { body, tag: 'ichat-' + convId });
- n.onclick = () => {
- window.focus();
- if (convId !== State.activeConvId) switchConversation(convId);
- n.close();
- };
- } catch (e) { /* notifications blocked */ }
- }
-
- // ============================================================
- // Rendering messages
- // ============================================================
- function renderAllMessages(convId, opts = {}) {
- const cache = getConv(convId);
- const frag = document.createDocumentFragment();
- const ids = cache.ids;
- DOM.messagesList.innerHTML = '';
-
- if (!ids.length) {
- renderEmptyLoading(convId);
- return;
- }
- DOM.emptyState.style.display = 'none';
-
- let prevMsg = null;
- let prevDay = null;
- ids.forEach((id) => {
- const msg = State.messages.get(id);
- if (!msg) return;
- if (dayKey(msg.timestamp_ms) !== prevDay) {
- frag.appendChild(dayChip(dayKey(msg.timestamp_ms), msg.timestamp_ms));
- prevDay = dayKey(msg.timestamp_ms);
- }
- frag.appendChild(buildMessageRow(msg, prevMsg));
- prevMsg = msg;
- });
- DOM.messagesList.appendChild(frag);
- DOM.loadOlderBtn.classList.toggle('hidden', !cache.hasMore || !cache.cursor);
-
- if (opts.scrollBottom) scrollToBottom();
- updateScrollUI();
- }
-
- // relative bubble times ("now", "5m") need refreshing while the chat is open
- setInterval(() => {
- document.querySelectorAll('.message-time[data-ts]').forEach((el) => {
- el.textContent = formatTime(parseInt(el.dataset.ts, 10));
- });
- }, 30000);
-
- function dayChip(key, ts) {
- const div = document.createElement('div');
- div.className = 'day-separator';
- div.dataset.day = key;
- div.innerHTML = `${escapeHtml(formatDayLabel(ts))}`;
- return div;
- }
-
- function sameAuthor(msg, prev) {
- if (!prev) return false;
- if (msg.sender_id !== prev.sender_id) return false;
- return (msg.timestamp_ms - prev.timestamp_ms) < 5 * 60 * 1000;
- }
-
- function buildMessageRow(msg, prevMsg) {
- const isSelf = msg.sender_id === State.user?.id;
- const row = document.createElement('div');
- row.className = 'message-row ' + (isSelf ? 'self' : 'other');
- row.dataset.id = msg.id;
- row.dataset.conv = msg.conversation_id;
- row.innerHTML = buildMessageHTML(msg, isSelf);
- if (sameAuthor(msg, prevMsg)) row.classList.add('hug');
- return row;
- }
-
- function buildMessageHTML(msg, isSelf) {
- // tombstone for others' deleted messages
- const tomb = (State.tombstoned.get(msg.conversation_id) || new Set()).has(msg.id);
- if (tomb || msg.is_deleted) {
- return ` This message was deleted
`;
- }
-
- const name = displayNameOf(msg);
- const senderName = isSelf ? '' :
- `${escapeHtml(name)}
`;
- const avatar = isSelf
- ? ''
- : `${avatarSmallHTML(msg)}
`;
-
- let content = '';
- if (msg.content) {
- content += `${renderInlineContent(msg.content)}
`;
- }
- if (msg.file_path) {
- content += buildAttachmentHTML(msg);
- }
- if (msg.reply_to_id) {
- content = buildReplyQuote(msg) + content;
- }
-
- const meta = buildMetaHTML(msg, isSelf);
-
- // actions
- let actions = '';
- actions += actionBtn('reply', 'fas fa-reply', 'Reply');
- if (!isSelf) {
- actions += ``;
- } else {
- actions += actionBtn('edit', 'fas fa-pen', 'Edit');
- actions += actionBtn('delete', 'fas fa-trash', 'Delete');
- }
- actions += actionBtn('copy', 'fas fa-copy', 'Copy');
-
- return `
- ${avatar}
-
- ${senderName}
-
${content}${meta}
-
${actions}
-
`;
- }
-
- function actionBtn(action, icon, label) {
- return ``;
- }
-
- function avatarSmallHTML(msg) {
- if (msg.sender_avatar_path) {
- return `
`;
- }
- return `${initialOf(displayNameOf(msg))}
`;
- }
-
- function buildMetaHTML(msg, isSelf) {
- let s = ``;
- s += `${formatTime(msg.timestamp_ms)}`;
- if (msg.is_edited) s += `edited`;
- if (isSelf) {
- const read = msg.status === 'read' || (msg.reader_count > 0);
- s += `
-
- `;
- }
- s += `
`;
- return s;
- }
-
- function buildReplyQuote(msg) {
- const target = State.messages.get(msg.reply_to_id);
- const tn = target ? escapeHtml(displayNameOf(target)) : escapeHtml('Unknown');
- let text;
- if (!target) {
- text = 'Message unavailable';
- } else {
- const tomb = (State.tombstoned.get(target.conversation_id) || new Set()).has(target.id);
- if (tomb || target.is_deleted) text = 'deleted message';
- else text = escapeHtml(truncate(plainPreview(target), 80));
- }
- return `
-
- ${tn}
- ${text}
- `;
- }
-
- function plainPreview(m) {
- let txt = m.content ? stripMarkdown(m.content.replace(/
]*>/gi, ' ')).replace(/\s+/g, ' ').trim() : '';
- if (!txt && m.file_path) {
- txt = (m.file_type || '').startsWith('image/') ? '📷 Photo'
- : (m.file_type || '').startsWith('video/') ? '🎬 Video'
- : (m.file_type || '').startsWith('audio/') ? '🎵 Audio'
- : '📎 ' + (m.file_name || 'File');
- }
- return txt;
- }
-
- function buildAttachmentHTML(msg) {
- const url = fileUrl(msg.file_path);
- const ft = (msg.file_type || '').toLowerCase();
- const fname = escapeHtml(msg.file_name || 'File');
- const opener = `data-url="${escapeHtml(url)}"`;
-
- if (ft.startsWith('image/')) {
- return `
-

-
`;
- }
- if (ft.startsWith('video/')) {
- return `
-
-
`;
- }
- if (ft.startsWith('audio/')) {
- return ``;
- }
- const icon = ft.includes('pdf') ? 'fa-file-pdf' : ft.startsWith('text') ? 'fa-file-alt' : 'fa-file';
- return `
-
-
-
${fname}
-
${formatFileSize(msg.file_size)}
-
-
-
`;
- }
-
- // ---------------- content formatting ----------------
- const TOKEN_PLACEHOLDER = '\u0001';
-
- function renderInlineContent(raw) {
- let text = String(raw).replace(/\r\n/g, '\n');
- const parts = [];
- const ph = (i) => TOKEN_PLACEHOLDER + i + TOKEN_PLACEHOLDER;
-
- // fenced code blocks ``` ... ``` (their contents are never re-parsed)
- text = text.replace(/```([\s\S]*?)```/g, (whole, code) => {
- const clean = code.replace(/^\n/, '').replace(/\n$/, '');
- parts.push(`${escapeHtml(clean)}
`);
- return ph(parts.length - 1);
- });
-
- // html
tags (the requested embed feature)
- text = text.replace(/
]*>/gi, (whole) => {
- const srcMatch = whole.match(/\bsrc\s*=\s*["']([^"']+)["']/i);
- const src = srcMatch && srcMatch[1];
- if (src && isAllowedImageSrc(src)) {
- const s = escapeHtml(src);
- parts.push(`
`);
- } else {
- // not a safe embed - show it as literal text (escaped exactly once)
- parts.push(escapeHtml(whole));
- }
- return ph(parts.length - 1);
- });
-
- // auto-linkify http(s) URLs while the text is still raw, so query
- // strings with & / entities stay intact inside hrefs
- text = text.replace(/(https?:\/\/[^\s<>"']+)/gi, (url) => {
- let clean = url;
- const trailing = clean.match(/[.,!?;:)\]}]+$/);
- if (trailing) clean = clean.slice(0, -trailing[0].length);
- if (!/^https?:\/\/\S+\.\S+/.test(clean) && !/^https?:\/\/localhost/i.test(clean)) return url;
- const c = escapeHtml(clean);
- parts.push(`${c}`);
- return ph(parts.length - 1) + (trailing ? trailing[0] : '');
- });
-
- // escape everything once, then apply light markdown on the escaped text
- // (placeholders contain no markdown characters, so they stay inert),
- // and only then splice the generated HTML (code/img/link) back in.
- let out = escapeHtml(text);
- out = out.replace(/\*\*([^*\n]+)\*\*/g, '$1');
- out = out.replace(/`([^`\n]+)`/g, '$1');
- out = out.replace(/(^|[\s(>])\*([^*\n]+)\*(?=[\s.,!?;:)\n]|$)/g, '$1$2');
- parts.forEach((html, i) => {
- out = out.split(ph(i)).join(html);
- });
- return out;
- }
-
- function isAllowedImageSrc(src) {
- if (!src) return false;
- if (src.startsWith('/api/download/')) return true;
- if (/^https?:\/\//i.test(src)) return true;
- if (/^data:image\/(png|jpe?g|gif|webp);base64,/i.test(src)) return src.length < 200000;
- return false;
- }
-
- // ---------------- DOM patching ----------------
- function rowEl(id) {
- return DOM.messagesList.querySelector(`.message-row[data-id="${id}"]`);
- }
-
- function updateMessageDom(id, asTombstone = false) {
- const el = rowEl(id);
- const msg = State.messages.get(id);
- if (!el || !msg) return;
- const isSelf = msg.sender_id === State.user?.id;
- const tomb = asTombstone || (State.tombstoned.get(msg.conversation_id) || new Set()).has(id);
- el.className = 'message-row ' + (isSelf ? 'self' : 'other') + (tomb ? ' tomb' : '');
- el.innerHTML = buildMessageHTML(msg, isSelf);
- // keep hug class if previous row same author
- const prev = el.previousElementSibling;
- if (prev && prev.classList && prev.classList.contains('message-row')) {
- const prevId = parseInt(prev.dataset.id, 10);
- const prevMsg = State.messages.get(prevId);
- if (prevMsg && sameAuthor(msg, prevMsg)) el.classList.add('hug');
- }
- }
-
- function removeMessageDom(id) {
- const el = rowEl(id);
- if (el) el.remove();
- // clean dangling day chips
- DOM.messagesList.querySelectorAll('.day-separator').forEach((chip) => {
- const next = chip.nextElementSibling;
- if (!next || next.classList.contains('day-separator')) chip.remove();
- });
- if (!DOM.messagesList.querySelector('.message-row') &&
- !DOM.messagesList.querySelector('.deleted-note')) {
- renderEmptyLoading(State.activeConvId);
- }
- }
-
- function updateTick(id) {
- const el = rowEl(id);
- if (!el) return;
- const status = el.querySelector('.message-status');
- if (status) {
- status.classList.add('read');
- const i = status.querySelector('i');
- if (i) i.className = 'fas fa-check-double';
- }
- }
-
- function appendMessageDom(m) {
- const ids = getConv(m.conversation_id).ids;
- const prevId = ids.length > 1 ? ids[ids.length - 2] : null;
- const prevMsg = prevId ? State.messages.get(prevId) : null;
- const dayChanged = !prevMsg || dayKey(prevMsg.timestamp_ms) !== dayKey(m.timestamp_ms);
-
- if (dayChanged) {
- DOM.messagesList.appendChild(dayChip(dayKey(m.timestamp_ms), m.timestamp_ms));
- }
- const row = buildMessageRow(m, prevMsg);
- if (prevMsg && !dayChanged && sameAuthor(m, prevMsg)) row.classList.add('hug');
- DOM.messagesList.appendChild(row);
- DOM.emptyState.style.display = 'none';
- }
-
- // ============================================================
- // Scroll UI
- // ============================================================
- function updateScrollUI() {
- if (!State.isAtBottom) {
- DOM.scrollBottomBtn.classList.toggle('hidden', !State.prefs.showScrollBtn);
- } else {
- DOM.scrollBottomBtn.classList.add('hidden');
- hideLatestBar();
- }
- }
-
- DOM.messagesScroll.addEventListener('scroll', () => {
- const { scrollTop, scrollHeight, clientHeight } = DOM.messagesScroll;
- State.isAtBottom = (scrollHeight - scrollTop - clientHeight) < 90;
- updateScrollUI();
- if (State.isAtBottom) hideLatestBar();
- });
-
- DOM.scrollBottomBtn.addEventListener('click', () => { scrollToBottom(); hideLatestBar(); });
-
- function scrollToBottom() {
- requestAnimationFrame(() => {
- DOM.messagesScroll.scrollTop = DOM.messagesScroll.scrollHeight;
- State.isAtBottom = true;
- updateScrollUI();
- });
- }
-
- // ============================================================
- // Latest message bar
- // ============================================================
- function showLatestBar(m) {
- if (!State.prefs.showLatestBar || !DOM.latestMessageBar) return;
- const name = displayNameOf(m);
- if (m.sender_avatar_path) {
- DOM.lmbAvatar.innerHTML = `
`;
- } else {
- DOM.lmbAvatar.textContent = initialOf(name);
- }
- DOM.lmbName.textContent = name + ':';
- DOM.lmbText.textContent = truncate(plainPreview(m), 40);
- DOM.latestMessageBar.classList.remove('hidden');
- }
-
- function hideLatestBar() {
- if (DOM.latestMessageBar) DOM.latestMessageBar.classList.add('hidden');
- State.latestUnseen = null;
- }
-
- DOM.latestMessageBar.addEventListener('click', () => {
- scrollToBottom();
- hideLatestBar();
- markActiveRead(true);
- });
-
- // ============================================================
- // Message row actions (event delegation)
- // ============================================================
- DOM.messagesList.addEventListener('click', (e) => {
- const row = e.target.closest('.message-row');
- if (!row) return;
- const msgId = parseInt(row.dataset.id, 10);
- const msg = State.messages.get(msgId);
- if (!msg) return;
-
- const actionEl = e.target.closest('[data-action]');
- if (!actionEl) return;
- const action = actionEl.dataset.action;
- const urlEl = e.target.closest('.js-zoom, [data-url]');
- const zoomUrl = urlEl ? (urlEl.dataset.url || urlEl.getAttribute('src')) : null;
-
- switch (action) {
- case 'reply':
- replyTo(msgId);
- break;
- case 'startchat':
- openConvModal(msg.sender_id, displayNameOf(msg));
- break;
- case 'edit':
- if (msg.sender_id === State.user?.id) startInlineEdit(msgId);
- break;
- case 'delete':
- if (msg.sender_id === State.user?.id) requestDelete(msg);
- break;
- case 'copy':
- copyMessage(msg);
- break;
- case 'receipts':
- openReceipts(msgId);
- break;
- case 'goto-reply': {
- const targetId = parseInt(actionEl.dataset.reply, 10);
- const targetRow = rowEl(targetId);
- if (targetRow) {
- targetRow.scrollIntoView({ behavior: 'smooth', block: 'center' });
- targetRow.style.transition = 'background 0.8s';
- targetRow.style.background = 'var(--primary-light)';
- setTimeout(() => { targetRow.style.background = ''; }, 1200);
- } else {
- showToast('That message is no longer loaded', 'info');
- }
- break;
- }
- case 'open-file':
- if (zoomUrl) window.open(zoomUrl, '_blank', 'noopener');
- break;
- }
- });
-
- // image zoom (attachment or embedded)
- document.addEventListener('click', (e) => {
- const img = e.target.closest('img.js-zoom');
- if (!img) return;
- e.stopPropagation();
- const url = img.dataset.url || img.getAttribute('src');
- const name = img.dataset.name || 'image';
- DOM.lightboxImg.src = url;
- DOM.lightboxImg.referrerPolicy = 'no-referrer';
- DOM.lightboxName.textContent = name;
- DOM.lightboxDownload.href = url;
- openModal(DOM.lightboxModal);
- });
-
- document.addEventListener('click', (e) => {
- const fileCard = e.target.closest('.attachment-file');
- if (fileCard && !e.target.closest('[data-action]')) {
- const a = fileCard.querySelector('a, [data-url]');
- if (a && a.dataset.url) window.open(a.dataset.url, '_blank', 'noopener');
- }
- });
-
- function copyMessage(msg) {
- const text = plainPreview(msg);
- const done = () => showToast('Copied to clipboard', 'success');
- const fallback = () => {
- const ta = document.createElement('textarea');
- ta.value = text;
- document.body.appendChild(ta);
- ta.select();
- try { document.execCommand('copy'); done(); } catch (err) { showToast('Could not copy', 'error'); }
- ta.remove();
- };
- if (navigator.clipboard && navigator.clipboard.writeText) {
- navigator.clipboard.writeText(text).then(done).catch(fallback);
- } else fallback();
- }
-
- // ============================================================
- // Reply
- // ============================================================
- function replyTo(messageId) {
- const msg = State.messages.get(messageId);
- if (!msg) return;
- State.replyTo = msg;
- DOM.replyUser.textContent = displayNameOf(msg);
- DOM.replyText.textContent = truncate(plainPreview(msg) || '📎 attachment', 60);
- 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.closest('#cancel-reply')) clearReply();
- });
-
- // ============================================================
- // Edit (inline, no more prompt())
- // ============================================================
- function startInlineEdit(messageId) {
- if (State.editingId) cancelInlineEdit();
- const msg = State.messages.get(messageId);
- if (!msg) return;
- State.editingId = messageId;
- const el = rowEl(messageId);
- if (!el) return;
- const bubble = el.querySelector('.message-bubble');
- bubble.innerHTML = ``;
- const ta = bubble.querySelector('textarea');
- ta.focus();
- ta.setSelectionRange(ta.value.length, ta.value.length);
-
- bubble.querySelector('[data-edit="save"]').addEventListener('click', () => saveInlineEdit(messageId, ta.value));
- bubble.querySelector('[data-edit="cancel"]').addEventListener('click', () => cancelInlineEdit());
- ta.addEventListener('keydown', (e) => {
- if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveInlineEdit(messageId, ta.value); }
- if (e.key === 'Escape') cancelInlineEdit();
- });
- }
-
- function cancelInlineEdit() {
- const id = State.editingId;
- State.editingId = null;
- if (id && State.messages.has(id)) updateMessageDom(id);
- }
-
- async function saveInlineEdit(id, content) {
- content = content.trim();
- if (!content) { showToast('Message cannot be empty', 'warning'); return; }
- const msg = State.messages.get(id);
- if (!msg) return;
- try {
- const res = await fetch(`${API}/api/messages/${id}?content=${encodeURIComponent(content)}`, {
- method: 'PATCH',
- headers: { 'X-Auth-Token': State.token }
- });
- const data = await res.json().catch(() => ({}));
- if (!res.ok) throw new Error(data.detail || 'Edit failed');
- msg.content = content;
- msg.is_edited = true;
- State.editingId = null;
- updateMessageDom(id);
- const convId = msg.conversation_id;
- const summary = convById(convId);
- if (summary && summary.last_message_id === id) {
- summary.last_message_preview = truncate(content.replace(/\s+/g, ' '), 90);
- renderSidebar();
- }
- } catch (err) {
- showToast(err.message, 'error');
- }
- }
-
- // ============================================================
- // Delete (HTTP - reliable, never relies on a fragile WS send)
- // ============================================================
- async function requestDelete(msg) {
- const ok = await showConfirm({
- title: 'Delete message',
- message: 'This permanently deletes the message for everyone. This cannot be undone.',
- okText: 'Delete'
- });
- if (!ok) return;
- try {
- const res = await fetch(`${API}/api/messages/${msg.id}`, {
- method: 'DELETE',
- headers: { 'X-Auth-Token': State.token }
- });
- if (!res.ok) {
- const data = await res.json().catch(() => ({}));
- throw new Error(data.detail || 'Delete failed');
- }
- removeMessageDom(msg.id);
- State.messages.delete(msg.id);
- pruneConvId(msg.conversation_id, msg.id);
- if (State.replyTo && State.replyTo.id === msg.id) clearReply();
- showToast('Message deleted', 'success');
- const summary = convById(msg.conversation_id);
- if (summary && summary.last_message_id === msg.id) {
- summary.last_message_preview = '';
- summary.last_message_ts = null;
- renderSidebar();
- }
- } catch (err) {
- showToast(err.message, 'error');
- }
- }
-
- // ============================================================
- // Compose / send
- // ============================================================
- 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;
- sendTyping(true);
- autoResize();
- });
-
- DOM.messageInput.addEventListener('keydown', (e) => {
- const enterSends = State.prefs.enterSend;
- if (e.key === 'Enter' && !e.shiftKey && enterSends) {
- e.preventDefault();
- sendMessage();
- } else if (e.key === 'Enter' && !e.shiftKey && !enterSends) {
- // plain Enter = newline; Ctrl/Cmd+Enter still sends
- if (e.ctrlKey || e.metaKey) { e.preventDefault(); sendMessage(); }
- }
- });
-
- DOM.messageInput.addEventListener('blur', () => sendTyping(false));
-
- function sendMessage() {
- const content = DOM.messageInput.value.trim();
- if (!content && !State.pendingFile) return;
- if (!State.activeConvId) { showToast('No conversation selected', 'warning'); return; }
- if (!wsOpen()) {
- showToast('Not connected - message queued, sending when reconnected', 'warning');
- }
- const clientId = generateId();
- const payload = {
- type: 'send_message',
- content: content,
- conversation_id: State.activeConvId,
- reply_to_id: State.replyTo ? State.replyTo.id : null,
- client_id: clientId
- };
- 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.pendingSends.set(clientId, payload);
- // only keep the most recent 30 queued messages
- while (State.pendingSends.size > 30) {
- State.pendingSends.delete(State.pendingSends.keys().next().value);
- }
-
- if (!wsSend(payload)) {
- // offline: keep queued; UI clears and message re-sends on reconnect
- showToast('Message queued - will send when you reconnect', 'warning');
- }
- DOM.messageInput.value = '';
- DOM.sendBtn.disabled = true;
- DOM.messageInput.dispatchEvent(new Event('input'));
- clearReply();
- clearPendingFile();
- sendTyping(false);
- autoResize();
- }
-
- function flushPendingSends() {
- const pending = [...State.pendingSends.entries()];
- pending.forEach(([clientId, payload]) => {
- if (payload.conversation_id === GLOBAL_CONV && !getConv(GLOBAL_CONV).ids.length) {
- // ensure lobby is loaded before replaying
- loadMessages(GLOBAL_CONV);
- }
- wsSend({ ...payload });
- });
- }
-
- function sendTyping(isTyping) {
- if (!wsOpen()) return;
- const now = Date.now();
- if (isTyping && now - State.typingSentAt < 1600) return;
- State.typingSentAt = now;
- wsSend({ type: 'typing', is_typing: isTyping, conversation_id: State.activeConvId });
- if (isTyping) {
- clearTimeout(window.__typingStop);
- window.__typingStop = setTimeout(() => sendTyping(false), 2200);
- }
- }
-
- function updateTypingIndicator() {
- const prefix = `${State.activeConvId}:`;
- const names = [...State.typingUsers.entries()]
- .filter(([k]) => k.startsWith(prefix))
- .map(([, v]) => v.name);
- DOM.typingIndicator.classList.toggle('hidden', names.length === 0);
- if (names.length) {
- DOM.typingText.textContent = names.join(', ') + (names.length === 1 ? ' is typing…' : ' are typing…');
- }
- }
-
- function autoResize() {
- DOM.messageInput.style.height = 'auto';
- DOM.messageInput.style.height = Math.min(DOM.messageInput.scrollHeight, 120) + 'px';
- }
-
- // ============================================================
- // Start private chat modal
- // ============================================================
- function openConvModal(peerId, peerName) {
- if (peerId === State.user?.id) { showToast('That is you!', 'info'); return; }
- const existing = [...State.conversations.values()].filter(
- c => c.type === 'dm' && c.peer && c.peer.id === peerId
- );
- DOM.convModalDesc.innerHTML = `Chat privately with ${escapeHtml(peerName)}` +
- (existing.length ? ` (${existing.length} of 3 existing)` : '');
- DOM.convModal.dataset.peerId = peerId;
- DOM.convModal.dataset.peerName = peerName;
- renderConvModal(existing);
- openModal(DOM.convModal);
- }
-
- function renderConvModal(existing) {
- let html = '';
- existing.forEach(c => {
- html += ``;
- });
- if (!existing.length) {
- html = 'No private chats with this person yet
';
- }
- DOM.convModalList.innerHTML = html;
- const atLimit = existing.length >= 3;
- DOM.newChatBtn.disabled = atLimit;
- DOM.newChatBtn.querySelector('i').className = atLimit ? 'fas fa-ban' : 'fas fa-plus';
- DOM.newChatBtn.lastChild.textContent = atLimit ? ' Limit Reached' : ' Start New Chat';
- if (atLimit) {
- const note = document.createElement('div');
- note.className = 'conv-modal-note';
- note.id = 'conv-limit-note';
- note.innerHTML = ' You already have 3 private chats with this person. The limit is 3 per person.';
- if (!DOM.convModalList.querySelector('#conv-limit-note')) DOM.convModalList.appendChild(note);
- } else {
- const note = DOM.convModalList.querySelector('#conv-limit-note');
- if (note) note.remove();
- }
- DOM.convModalList.querySelectorAll('[data-open-conv]').forEach(btn => {
- btn.addEventListener('click', () => {
- const cid = parseInt(btn.dataset.openConv, 10);
- closeModal(DOM.convModal);
- switchConversation(cid);
- });
- });
- }
-
- DOM.newChatBtn.addEventListener('click', async () => {
- const peerId = parseInt(DOM.convModal.dataset.peerId, 10);
- const peerName = DOM.convModal.dataset.peerName;
- if (!peerId) return;
- const btn = DOM.newChatBtn;
- btn.disabled = true;
- btn.innerHTML = ' Creating…';
- const finish = () => { btn.disabled = false; btn.innerHTML = ' Start New Chat'; };
- try {
- if (wsOpen()) {
- wsSend({ type: 'create_dm', user_id: peerId });
- // The dm_created event switches conversation automatically.
- // Close the modal: outcome arrives via ws in ms.
- closeModal(DOM.convModal);
- finish();
- return;
- }
- const res = await fetch(`${API}/api/conversations/dm?user_id=${peerId}`, {
- method: 'POST', headers: { 'X-Auth-Token': State.token }
- });
- const data = await res.json().catch(() => ({}));
- if (!res.ok) throw new Error(data.detail || 'Could not start chat');
- upsertConversation(data.conversation);
- closeModal(DOM.convModal);
- switchConversation(data.conversation.id);
- showToast(`Private chat with ${peerName} started`, 'success');
- } catch (err) {
- showToast(err.message, 'error');
- finish();
- }
- });
-
- // ============================================================
- // Uploads (attach, paste, drag & drop)
- // ============================================================
- DOM.attachBtn.addEventListener('click', () => DOM.fileInput.click());
- DOM.fileInput.addEventListener('change', async (e) => {
- const file = e.target.files[0];
- e.target.value = '';
- if (!file) return;
- await uploadChunked(file);
- });
-
- DOM.messageInput.addEventListener('paste', (e) => {
- const items = e.clipboardData && e.clipboardData.items;
- if (!items) return;
- for (const item of items) {
- if (item.type && item.type.startsWith('image/')) {
- e.preventDefault();
- const file = item.getAsFile();
- if (file) uploadChunked(file);
- break;
- }
- }
- });
-
- // drag & drop onto the chat window
- let dragDepth = 0;
- DOM.chatScreen.addEventListener('dragenter', (e) => {
- if (!e.dataTransfer || ![...(e.dataTransfer.types || [])].includes('Files')) return;
- dragDepth++;
- DOM.dropOverlay.classList.remove('hidden');
- });
- DOM.chatScreen.addEventListener('dragover', (e) => { e.preventDefault(); });
- DOM.chatScreen.addEventListener('dragleave', () => {
- dragDepth = Math.max(0, dragDepth - 1);
- if (!dragDepth) DOM.dropOverlay.classList.add('hidden');
- });
- DOM.chatScreen.addEventListener('drop', async (e) => {
- e.preventDefault();
- dragDepth = 0;
- DOM.dropOverlay.classList.add('hidden');
- const files = e.dataTransfer && e.dataTransfer.files;
- if (!files || !files.length) return;
- const file = files[0];
- if (files.length > 1) showToast('Attaching the first file (one per message)', 'info');
- await uploadChunked(file);
- });
-
- async function uploadChunked(file) {
- if (file.size > 500 * 1024 * 1024) {
- showToast('File too large (max 500MB)', 'error');
- return;
- }
- const CHUNK_SIZE = 5 * 1024 * 1024;
- const totalChunks = Math.max(1, Math.ceil(file.size / CHUNK_SIZE));
- const uploadId = generateId();
- DOM.uploadProgress.classList.remove('hidden');
- updateProgress(0, file.name, file.size);
-
- for (let i = 0; i < totalChunks; i++) {
- const blob = file.slice(i * CHUNK_SIZE, Math.min((i + 1) * CHUNK_SIZE, file.size));
- 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) {
- const data = await res.json().catch(() => ({}));
- throw new Error(data.detail || 'Upload failed');
- }
- const data = await res.json();
- updateProgress(((i + 1) / totalChunks) * 100, file.name, file.size);
- if (data.status === 'complete') {
- State.pendingFile = {
- file_path: data.file_path,
- file_type: data.file_type || file.type,
- file_name: data.file_name,
- file_size: data.file_size
- };
- showUploadPreview(State.pendingFile);
- DOM.uploadProgress.classList.add('hidden');
- DOM.sendBtn.disabled = false;
- DOM.messageInput.focus();
- return;
- }
- } catch (err) {
- showToast('Upload failed: ' + err.message, 'error');
- DOM.uploadProgress.classList.add('hidden');
- return;
- }
- }
- DOM.uploadProgress.classList.add('hidden');
- }
-
- function updateProgress(percent, filename, size) {
- DOM.progressFill.style.width = `${percent}%`;
- DOM.progressText.textContent = `${Math.round(percent)}%`;
- if (filename) DOM.uploadFilename.textContent = filename;
- if (size !== undefined) DOM.uploadSize.textContent = formatFileSize(size);
- }
-
- function showUploadPreview(pf) {
- const ft = (pf.file_type || '').toLowerCase();
- DOM.uploadIcon.className = 'fas ' + (ft.startsWith('image/') ? 'fa-image'
- : ft.startsWith('video/') ? 'fa-video'
- : ft.startsWith('audio/') ? 'fa-music' : 'fa-paperclip');
- DOM.uploadFilename.textContent = pf.file_name;
- DOM.uploadSize.textContent = formatFileSize(pf.file_size);
- DOM.uploadPreview.classList.remove('hidden');
- }
-
- function clearPendingFile() {
- State.pendingFile = null;
- DOM.uploadPreview.classList.add('hidden');
- }
-
- DOM.cancelUpload.addEventListener('click', () => {
- clearPendingFile();
- DOM.sendBtn.disabled = DOM.messageInput.value.trim().length === 0;
- });
-
- // ============================================================
- // Emoji picker
- // ============================================================
- 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 => ``).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 older
- // ============================================================
- DOM.loadOlderBtn.addEventListener('click', () => {
- const convId = State.activeConvId;
- const cache = getConv(convId);
- if (wsOpen() && cache.hasMore && cache.cursor && !cache.loading) {
- cache.loading = true;
- loadMessages(convId, { older: true });
- }
- });
-
- // ============================================================
- // Online users
- // ============================================================
- DOM.onlineBtn.addEventListener('click', () => {
- renderOnlineUsers();
- openModal(DOM.onlineModal);
- requestOnlineUsers();
- });
-
- function requestOnlineUsers() {
- if (wsOpen()) wsSend({ type: 'get_online_users' });
- }
-
- function updateOnlineBadge() {
- DOM.onlineBadge.textContent = State.onlineUsers.size;
- DOM.onlineBadge.classList.toggle('hidden', State.onlineUsers.size === 0);
- }
-
- function renderOnlineUsers() {
- DOM.onlineUsersList.innerHTML = '';
- if (!State.onlineUsers.size) {
- DOM.onlineUsersList.innerHTML = 'No users online
';
- DOM.onlineTotal.textContent = '0 users online';
- return;
- }
- State.onlineUsers.forEach(u => {
- if (u.id === State.user?.id) return; // don't list yourself
- const div = document.createElement('div');
- div.className = 'user-item';
- const av = u.avatar_path
- ? `
`
- : `${initialOf(displayNameOf(u))}`;
- div.innerHTML = `
- ${av}
-
-
${escapeHtml(displayNameOf(u))}
-
Online
-
`;
- DOM.onlineUsersList.appendChild(div);
- });
- DOM.onlineTotal.textContent = `${State.onlineUsers.size - (State.onlineUsers.has(State.user?.id) ? 1 : 0)} user${State.onlineUsers.size !== 1 ? 's' : ''} online`;
- }
-
- // ============================================================
- // Settings
- // ============================================================
- function openSettings() {
- DOM.settingsDisplayName.value = State.user?.display_name || '';
- DOM.settingsUsername.value = State.user?.username || '';
- DOM.pwCurrent.value = '';
- DOM.pwNew.value = '';
- DOM.pwMsg.textContent = '';
-
- if (State.user?.avatar_path) {
- DOM.settingsAvatarImg.src = fileUrl(State.user.avatar_path);
- DOM.settingsAvatarImg.classList.remove('hidden');
- DOM.settingsAvatarPreview.classList.add('hidden');
- } else {
- DOM.settingsAvatarImg.classList.add('hidden');
- DOM.settingsAvatarPreview.classList.remove('hidden');
- }
-
- 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.aboutVersion.textContent = APP_VERSION;
- applyPrefs();
- openModal(DOM.settingsModal);
- }
-
- DOM.settingsBtn.addEventListener('click', openSettings);
- DOM.meSettingsBtn.addEventListener('click', openSettings);
-
- async function saveSettings() {
- const newName = DOM.settingsDisplayName.value.trim();
- if (newName && newName !== State.user?.display_name) {
- try {
- const res = await fetch(`${API}/api/profile?display_name=${encodeURIComponent(newName)}`, {
- method: 'PATCH', headers: { 'X-Auth-Token': State.token }
- });
- if (res.ok) {
- State.user.display_name = newName;
- updateSidebarMe();
- renderSidebar();
- renderConversationHeader(State.activeConvId);
- showToast('Display name updated', 'success');
- } else {
- const data = await res.json().catch(() => ({}));
- throw new Error(data.detail || 'Failed');
- }
- } catch (e) { showToast('Failed to update name', 'error'); }
- }
-
- const avatarFile = DOM.avatarInput.files[0];
- if (avatarFile) {
- const formData = new FormData();
- formData.append('file', avatarFile);
- DOM.avatarUploadBtn.disabled = true;
- DOM.avatarUploadBtn.innerHTML = '';
- try {
- 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 = fileUrl(data.avatar_path);
- DOM.settingsAvatarImg.classList.remove('hidden');
- DOM.settingsAvatarPreview.classList.add('hidden');
- updateSidebarMe();
- renderSidebar();
- renderConversationHeader(State.activeConvId);
- showToast('Avatar updated', 'success');
- } else {
- const data = await res.json().catch(() => ({}));
- throw new Error(data.detail || 'Upload failed');
- }
- } catch (e) {
- showToast(e.message, 'error');
- } finally {
- DOM.avatarUploadBtn.disabled = false;
- DOM.avatarUploadBtn.innerHTML = ' Change Photo';
- }
- }
-
- State.prefs.autoScroll = DOM.toggleAutoScroll.checked;
- State.prefs.showScrollBtn = DOM.toggleScrollBtn.checked;
- State.prefs.showLatestBar = DOM.toggleLatestBar.checked;
- State.prefs.sound = DOM.toggleSound.checked;
- State.prefs.enterSend = DOM.toggleEnterSend.checked;
- savePrefs();
-
- const selectedTheme = document.querySelector('.theme-option.active')?.dataset.theme || 'light';
- 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);
- closeModal(DOM.settingsModal);
- }
-
- 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; });
-
- 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;
- }
- });
- });
-
- 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);
- }
- });
-
- document.getElementById('save-settings').addEventListener('click', saveSettings);
-
- DOM.pwChangeBtn.addEventListener('click', async () => {
- const current = DOM.pwCurrent.value;
- const next = DOM.pwNew.value;
- if (!current || next.length < 6) {
- DOM.pwMsg.textContent = 'Enter your current password and a new one (6+ characters)';
- DOM.pwMsg.className = 'form-hint error';
- return;
- }
- DOM.pwChangeBtn.disabled = true;
- DOM.pwChangeBtn.innerHTML = '';
- try {
- const res = await fetch(
- `${API}/api/profile/password?current_password=${encodeURIComponent(current)}&new_password=${encodeURIComponent(next)}`,
- { method: 'POST', headers: { 'X-Auth-Token': State.token } }
- );
- const data = await res.json().catch(() => ({}));
- if (!res.ok) throw new Error(data.detail || 'Failed');
- DOM.pwMsg.textContent = 'Password updated successfully';
- DOM.pwMsg.className = 'form-hint success';
- DOM.pwCurrent.value = '';
- DOM.pwNew.value = '';
- } catch (err) {
- DOM.pwMsg.textContent = err.message;
- DOM.pwMsg.className = 'form-hint error';
- } finally {
- DOM.pwChangeBtn.disabled = false;
- DOM.pwChangeBtn.innerHTML = ' Change Password';
- }
- });
-
- // ============================================================
- // Auth lifecycle
- // ============================================================
- async function init() {
- applyPrefs();
- DOM.aboutVersion.textContent = APP_VERSION;
- 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) { /* fall through to auth screen */ }
- localStorage.removeItem('token');
- State.token = null;
- }
- showAuth();
- }
-
- function showAuth() {
- State.wsState = 'disconnected';
- DOM.authScreen.classList.add('active');
- DOM.chatScreen.classList.remove('active');
- }
-
- function showChat() {
- DOM.authScreen.classList.remove('active');
- DOM.chatScreen.classList.add('active');
- updateSidebarMe();
- }
-
- function updateSidebarMe() {
- if (!State.user) return;
- DOM.sbMeName.textContent = displayNameOf(State.user);
- if (State.user.avatar_path) {
- DOM.sbMeAvatar.src = fileUrl(State.user.avatar_path);
- DOM.sbMeAvatar.classList.remove('hidden');
- DOM.sbMeAvatarPh.classList.add('hidden');
- } else {
- DOM.sbMeAvatar.classList.add('hidden');
- DOM.sbMeAvatarPh.classList.remove('hidden');
- }
- }
-
- function forceLogout() {
- showToast('Session expired - please sign in again', 'warning');
- localStorage.removeItem('token');
- State.token = null;
- State.wsState = 'disconnected';
- if (State.ws) { try { State.ws.close(); } catch (e) { /* noop */ } }
- State.ws = null;
- resetAllState();
- showAuth();
- }
-
- function resetAllState() {
- State.user = null;
- State.conversations.clear();
- State.conversationOrder = [];
- State.convCache.clear();
- State.messages.clear();
- State.tombstoned.clear();
- State.pendingSends.clear();
- State.onlineUsers.clear();
- State.typingUsers.clear();
- State.activeConvId = null;
- State.cursor = null;
- State.replyTo = null;
- State.pendingFile = null;
- State.latestUnseen = null;
- State.editingId = null;
- DOM.messagesList.innerHTML = '';
- DOM.convList.innerHTML = '';
- DOM.sidebar.classList.remove('open');
- DOM.sidebarBackdrop.classList.add('hidden');
- DOM.loadOlderBtn.classList.add('hidden');
- DOM.emptyState.style.display = 'flex';
- hideLatestBar();
- updateOnlineBadge();
- updateTitleUnread();
- clearReply();
- clearPendingFile();
- DOM.messageInput.value = '';
- DOM.sendBtn.disabled = true;
- DOM.uploadProgress.classList.add('hidden');
- DOM.onlineModal.classList.add('hidden');
- DOM.settingsModal.classList.add('hidden');
- DOM.convModal.classList.add('hidden');
- DOM.receiptsModal.classList.add('hidden');
- DOM.confirmModal.classList.add('hidden');
- DOM.lightboxModal.classList.add('hidden');
- DOM.emojiPicker.classList.add('hidden');
- openModals.length = 0;
- document.body.style.overflow = '';
- }
-
- function doLogout() {
- if (State.token) {
- fetch(`${API}/api/auth/logout`, { method: 'POST', headers: { 'X-Auth-Token': State.token } }).catch(() => { });
- }
- if (State.ws) { try { State.ws.close(); } catch (e) { /* noop */ } }
- State.ws = null;
- localStorage.removeItem('token');
- State.token = null;
- resetAllState();
- showAuth();
- }
-
- DOM.logoutBtn.addEventListener('click', () => {
- showConfirm({
- title: 'Log out',
- message: 'Log out of InfinityChat on this device?',
- okText: 'Log Out',
- danger: false
- }).then(ok => { if (ok) doLogout(); });
- });
- DOM.sidebarLogoutBtn.addEventListener('click', () => {
- showConfirm({
- title: 'Log out',
- message: 'Log out of InfinityChat on this device?',
- okText: 'Log Out',
- danger: false
- }).then(ok => { if (ok) doLogout(); });
- });
-
- document.querySelectorAll('.input-toggle').forEach(btn => {
- btn.addEventListener('click', () => {
- const input = document.getElementById(btn.dataset.toggle);
- 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';
- }
- });
- });
-
- // ============================================================
- // Go
- // ============================================================
- init();
-})();
\ No newline at end of file
+/* ============================================================
+ InfinityChat 2.0 — Full Design System
+ All colours flow from :root variables only.
+ ============================================================ */
+
+/* ---- Light Theme (default on :root) ---- */
+:root {
+ --primary: #2563EB;
+ --primary-hover: #1D4ED8;
+ --primary-light: #DBEAFE;
+ --primary-dark: #1E40AF;
+
+ --bg: #FFFFFF;
+ --bg-secondary: #F9FAFB;
+ --bg-tertiary: #F3F4F6;
+ --bg-elevated: #FFFFFF;
+
+ --text: #111827;
+ --text-secondary: #4B5563;
+ --text-tertiary: #9CA3AF;
+ --text-inverse: #FFFFFF;
+
+ --border: #E5E7EB;
+ --border-light: #F3F4F6;
+
+ --bubble-self: #2563EB;
+ --bubble-self-text: #FFFFFF;
+ --bubble-other: #F3F4F6;
+ --bubble-other-text: #111827;
+
+ --tick-read: #9BD9FF;
+
+ --header-bg: #FFFFFF;
+ --header-border: #E5E7EB;
+
+ --input-bg: #F3F4F6;
+ --input-border: #E5E7EB;
+ --input-text: #111827;
+ --input-placeholder:#9CA3AF;
+
+ --success: #10B981;
+ --warning: #F59E0B;
+ --error: #EF4444;
+ --info: #3B82F6;
+
+ --sidebar-bg: #F8FAFC;
+ --shadow-xs: 0 1px 2px rgba(0,0,0,0.05);
+ --shadow-sm: 0 1px 3px rgba(0,0,0,0.10), 0 1px 2px rgba(0,0,0,0.06);
+ --shadow-md: 0 4px 6px -1px rgba(0,0,0,0.10), 0 2px 4px -1px rgba(0,0,0,0.06);
+ --shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.10), 0 4px 6px -2px rgba(0,0,0,0.05);
+ --shadow-xl: 0 20px 25px -5px rgba(0,0,0,0.10), 0 10px 10px -5px rgba(0,0,0,0.04);
+
+ --radius-xs: 4px;
+ --radius-sm: 6px;
+ --radius-md: 8px;
+ --radius-lg: 12px;
+ --radius-xl: 16px;
+ --radius-2xl: 20px;
+ --radius-full: 9999px;
+
+ --transition-fast: 150ms cubic-bezier(0.4,0,0.2,1);
+ --transition: 200ms cubic-bezier(0.4,0,0.2,1);
+ --transition-slow: 300ms cubic-bezier(0.4,0,0.2,1);
+
+ --font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ --font-mono: 'SF Mono', 'Fira Code', monospace;
+
+ --header-height: 60px;
+ --sidebar-width: 300px;
+}
+
+/* ---- Dark Theme ---- */
+[data-theme="dark"] {
+ --primary: #60A5FA;
+ --primary-hover: #3B82F6;
+ --primary-light: #1E3A5F;
+ --primary-dark: #1D4ED8;
+
+ --bg: #0F172A;
+ --bg-secondary: #1E293B;
+ --bg-tertiary: #334155;
+ --bg-elevated: #1E293B;
+
+ --text: #F1F5F9;
+ --text-secondary: #94A3B8;
+ --text-tertiary: #64748B;
+ --text-inverse: #0F172A;
+
+ --border: #334155;
+ --border-light: #1E293B;
+
+ --bubble-self: #3B82F6;
+ --bubble-self-text: #FFFFFF;
+ --bubble-other: #334155;
+ --bubble-other-text: #F1F5F9;
+
+ --tick-read: #BFDBFE;
+
+ --header-bg: #1E293B;
+ --header-border: #334155;
+
+ --input-bg: #334155;
+ --input-border: #475569;
+ --input-text: #F1F5F9;
+ --input-placeholder:#64748B;
+
+ --sidebar-bg: #111C2E;
+
+ --shadow-xs: 0 1px 2px rgba(0,0,0,0.4);
+ --shadow-sm: 0 1px 3px rgba(0,0,0,0.5);
+ --shadow-md: 0 4px 6px rgba(0,0,0,0.5);
+ --shadow-lg: 0 10px 15px rgba(0,0,0,0.5);
+ --shadow-xl: 0 20px 25px rgba(0,0,0,0.5);
+}
+
+/* ---- Reset ---- */
+*, *::before, *::after { margin:0; padding:0; box-sizing:border-box; }
+
+html {
+ font-size: 16px;
+ -webkit-text-size-adjust: 100%;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+body {
+ font-family: var(--font-family);
+ font-size: 0.9375rem;
+ line-height: 1.5;
+ color: var(--text);
+ background: var(--bg);
+ overflow: hidden;
+ height: 100vh;
+ height: 100dvh;
+ width: 100vw;
+}
+
+button { font-family: inherit; }
+button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visible {
+ outline: 2px solid var(--primary);
+ outline-offset: 1px;
+}
+
+/* ---- Scrollbar ---- */
+::-webkit-scrollbar { width:6px; height:6px; }
+::-webkit-scrollbar-track { background: transparent; }
+::-webkit-scrollbar-thumb { background: var(--border); border-radius: var(--radius-full); }
+::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); }
+
+/* ---- Typography ---- */
+h1,h2,h3,h4,h5,h6 { font-weight:600; line-height:1.3; color: var(--text); }
+p { color: var(--text-secondary); }
+a { color: var(--primary); text-decoration:none; }
+a:hover { text-decoration: underline; }
+
+.text-secondary { color: var(--text-secondary); }
+.text-success { color: var(--success); }
+.text-error { color: var(--error); }
+.text-warning { color: var(--warning); }
+.text-primary { color: var(--primary); }
+
+/* ---- Screens ---- */
+.screen { display:none; width:100%; height:100%; }
+.screen.active { display:flex; }
+#chat-screen { flex-direction:column; }
+.hidden { display:none !important; }
+
+/* ---- Buttons ---- */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ padding: 0.625rem 1.25rem;
+ font-size: 0.875rem;
+ font-weight: 500;
+ font-family: inherit;
+ border: 1px solid transparent;
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ white-space: nowrap;
+ line-height: 1.4;
+}
+.btn:disabled { opacity:0.6; cursor:not-allowed; pointer-events:none; }
+.btn:active:not(:disabled) { transform: scale(0.98); }
+
+.btn-primary {
+ background: var(--primary);
+ color: var(--text-inverse);
+ border-color: var(--primary);
+ box-shadow: var(--shadow-xs);
+}
+.btn-primary:hover:not(:disabled) {
+ background: var(--primary-hover);
+ border-color: var(--primary-hover);
+ box-shadow: var(--shadow-sm);
+ transform: translateY(-1px);
+}
+.btn-danger {
+ background: var(--error);
+ color: #fff;
+ border-color: var(--error);
+}
+.btn-danger:hover:not(:disabled) { filter: brightness(1.08); }
+
+.btn-secondary {
+ background: var(--bg-elevated);
+ color: var(--text);
+ border-color: var(--border);
+ box-shadow: var(--shadow-xs);
+}
+.btn-secondary:hover:not(:disabled) {
+ background: var(--bg-tertiary);
+ border-color: var(--text-tertiary);
+}
+
+.btn-ghost {
+ background: transparent;
+ color: var(--text-secondary);
+ border-color: transparent;
+}
+.btn-ghost:hover:not(:disabled) {
+ background: var(--bg-tertiary);
+ color: var(--text);
+}
+
+.btn-sm { padding:0.375rem 0.75rem; font-size:0.8125rem; border-radius:var(--radius-sm); }
+.btn-block { width:100%; justify-content:center; }
+
+.btn-send {
+ flex-shrink: 0;
+ width: 42px;
+ height: 42px;
+ padding: 0;
+ border-radius: var(--radius-full);
+ background: var(--primary);
+ color: #fff;
+ border: none;
+ box-shadow: var(--shadow-sm);
+}
+.btn-send:hover:not(:disabled) {
+ background: var(--primary-hover);
+ box-shadow: var(--shadow-md);
+ transform: scale(1.05);
+}
+.btn-send:disabled {
+ background: var(--bg-tertiary);
+ color: var(--text-tertiary);
+ box-shadow: none;
+ transform: none;
+}
+
+.btn-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ background: transparent;
+ border: none;
+ border-radius: var(--radius-md);
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ font-size: 1.0625rem;
+ position: relative;
+ flex-shrink: 0;
+}
+.btn-icon:hover { background: var(--bg-tertiary); color: var(--text); }
+.btn-icon:active { transform: scale(0.94); }
+.btn-icon-sm { width:32px; height:32px; font-size:0.875rem; }
+
+.badge {
+ position: absolute;
+ top: 2px; right: 2px;
+ min-width: 18px; height: 18px;
+ padding: 0 4px;
+ background: var(--error);
+ color: #fff;
+ font-size: 0.6875rem;
+ font-weight: 600;
+ border-radius: var(--radius-full);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 2px solid var(--header-bg);
+}
+.badge.hidden { display:none !important; }
+
+/* ---- Forms ---- */
+.form-group { margin-bottom: 1rem; }
+.form-group label {
+ display: block;
+ margin-bottom: 0.25rem;
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: var(--text);
+}
+.form-group label i { margin-right:0.25rem; color:var(--text-secondary); }
+.input-wrapper { position:relative; display:flex; align-items:center; }
+.form-input {
+ width: 100%;
+ padding: 0.75rem 1rem;
+ font-size: 0.9375rem;
+ font-family: inherit;
+ color: var(--input-text);
+ background: var(--input-bg);
+ border: 1.5px solid var(--input-border);
+ border-radius: var(--radius-md);
+ outline: none;
+ transition: all var(--transition-fast);
+ line-height: 1.5;
+}
+.form-input:focus {
+ border-color: var(--primary);
+ box-shadow: 0 0 0 3px var(--primary-light);
+ background: var(--bg-elevated);
+}
+.form-input:disabled { opacity:0.6; cursor:not-allowed; }
+.form-input::placeholder { color: var(--input-placeholder); }
+
+.input-toggle {
+ position: absolute;
+ right: 0.5rem;
+ width: 32px; height: 32px;
+ background: transparent;
+ border: none;
+ color: var(--text-tertiary);
+ cursor: pointer;
+ font-size: 0.875rem;
+ border-radius: var(--radius-sm);
+ display: flex; align-items: center; justify-content: center;
+}
+.input-toggle:hover { color: var(--text); background: var(--bg-tertiary); }
+
+.form-hint { display:block; margin-top:0.25rem; font-size:0.75rem; color:var(--text-tertiary); }
+.form-hint.error { color: var(--error); }
+.form-hint.success { color: var(--success); }
+.label-hint { font-weight:400; font-size:0.75rem; color:var(--text-tertiary); }
+
+/* Password strength */
+.password-strength { display:flex; align-items:center; gap:0.5rem; margin-bottom:1rem; }
+.strength-bar { flex:1; height:4px; background:var(--bg-tertiary); border-radius:var(--radius-full); overflow:hidden; }
+.strength-fill { height:100%; width:0; border-radius:var(--radius-full); transition:all var(--transition); }
+.strength-fill.weak { background:var(--error); width:25%; }
+.strength-fill.fair { background:var(--warning); width:50%; }
+.strength-fill.good { background:var(--info); width:75%; }
+.strength-fill.strong { background:var(--success); width:100%; }
+.strength-text { font-size:0.75rem; font-weight:500; color:var(--text-secondary); min-width:50px; }
+
+/* Alert */
+.alert {
+ display: none;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.75rem 1rem;
+ border-radius: var(--radius-md);
+ font-size: 0.875rem;
+ margin-top: 1rem;
+}
+.alert.visible { display:flex; }
+.alert-error { background:#FEF2F2; color:#B91C1C; border:1px solid #FECACA; }
+[data-theme="dark"] .alert-error { background:#2D1515; color:#FCA5A5; border-color:#7F1D1D; }
+
+/* Spinner */
+.spinner {
+ width:24px; height:24px;
+ border:3px solid var(--border);
+ border-top-color: var(--primary);
+ border-radius:50%;
+ animation:spin 0.8s linear infinite;
+}
+.spinner-sm { width:16px; height:16px; border-width:2px; }
+@keyframes spin { to { transform:rotate(360deg); } }
+
+/* ======= AUTH SCREEN ======= */
+#auth-screen {
+ background: var(--bg-secondary);
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 1rem;
+ overflow-y: auto;
+}
+.auth-bg { position:fixed; inset:0; pointer-events:none; overflow:hidden; z-index:0; }
+.auth-bg-circle {
+ position:absolute; border-radius:50%;
+ background: var(--primary-light);
+ opacity: 0.5; filter:blur(70px);
+}
+.auth-bg-circle-1 { width:350px; height:350px; top:-120px; right:-80px; }
+.auth-bg-circle-2 { width:250px; height:250px; bottom:-100px; left:-60px; }
+.auth-bg-circle-3 { width:180px; height:180px; top:45%; left:38%; }
+
+.auth-container {
+ position: relative;
+ z-index: 1;
+ width: 100%;
+ max-width: 420px;
+ background: var(--bg-elevated);
+ border-radius: var(--radius-2xl);
+ box-shadow: var(--shadow-xl);
+ padding: 2.5rem 2rem;
+ border: 1px solid var(--border);
+}
+.auth-header { text-align:center; margin-bottom:2rem; }
+.auth-logo {
+ width:64px; height:64px;
+ margin:0 auto 1rem;
+ background: var(--primary);
+ color: #fff;
+ border-radius: var(--radius-xl);
+ display:flex; align-items:center; justify-content:center;
+ font-size:1.75rem;
+ box-shadow: var(--shadow-lg);
+}
+.auth-title { font-size:1.75rem; font-weight:700; color:var(--text); margin-bottom:0.25rem; }
+.auth-subtitle { font-size:0.875rem; color:var(--text-secondary); }
+.auth-tabs {
+ display:flex; gap:0.25rem;
+ margin-bottom:1.5rem;
+ background: var(--bg-tertiary);
+ padding:0.25rem;
+ border-radius: var(--radius-md);
+}
+.auth-tab {
+ flex:1;
+ display:flex; align-items:center; justify-content:center; gap:0.4rem;
+ padding:0.5rem 1rem;
+ font-size:0.875rem; font-weight:500;
+ color: var(--text-secondary);
+ background: transparent;
+ border:none; border-radius:var(--radius-sm);
+ cursor:pointer;
+ transition: all var(--transition-fast);
+}
+.auth-tab.active {
+ background: var(--bg-elevated);
+ color: var(--text);
+ box-shadow: var(--shadow-sm);
+}
+.auth-form { display:none; }
+.auth-form.active { display:block; }
+.auth-form .btn-block { margin-top:0.5rem; }
+.auth-loading {
+ display:flex; align-items:center; justify-content:center;
+ gap:0.5rem; margin-top:1rem; color:var(--text-secondary);
+}
+.auth-loading.hidden { display:none !important; }
+
+/* ============================================================
+ APP LAYOUT (sidebar + chat)
+ ============================================================ */
+.app-layout { display:flex; width:100%; height:100%; min-height:0; }
+
+/* ---- Sidebar ---- */
+.sidebar {
+ width: var(--sidebar-width);
+ min-width: var(--sidebar-width);
+ height: 100%;
+ background: var(--sidebar-bg);
+ border-right: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ z-index: 40;
+}
+.sidebar-header {
+ display:flex; align-items:center; justify-content:space-between;
+ padding: 0.75rem 1rem 0.5rem;
+}
+.sidebar-brand { display:flex; align-items:center; gap:0.6rem; }
+.sidebar-logo {
+ width:34px; height:34px; margin:0;
+ font-size:1rem; border-radius: var(--radius-md);
+ box-shadow: var(--shadow-sm);
+}
+.sidebar-brand-name { font-size:1.05rem; font-weight:700; color:var(--text); }
+.sidebar-close { display:none; }
+
+.sidebar-me {
+ display:flex; align-items:center; gap:0.6rem;
+ margin: 0 0.75rem 0.75rem;
+ padding: 0.6rem 0.75rem;
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+}
+.me-avatar-wrap { position:relative; flex-shrink:0; }
+.me-avatar, .me-avatar-ph {
+ width:40px; height:40px; border-radius:50%;
+ object-fit:cover; display:block;
+}
+.me-avatar-ph {
+ background: linear-gradient(135deg, var(--primary), #9333EA);
+ color:#fff; font-size:1rem;
+ display:flex; align-items:center; justify-content:center;
+}
+.me-avatar-ph i { font-size: 1.15rem; }
+.me-info { flex:1; min-width:0; }
+.me-name {
+ font-size:0.9rem; font-weight:600; color:var(--text);
+ white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
+}
+.me-status { display:flex; align-items:center; gap:0.3rem; font-size:0.72rem; color:var(--text-secondary); }
+.status-dot { width:8px; height:8px; border-radius:50%; background:var(--text-tertiary); display:inline-block; }
+.status-dot.online { background:var(--success); }
+
+.conv-list { flex:1; overflow-y:auto; padding: 0.25rem 0.5rem 0.75rem; }
+
+.conv-section-label {
+ font-size:0.68rem; font-weight:700; text-transform:uppercase; letter-spacing:0.06em;
+ color:var(--text-tertiary);
+ padding: 0.85rem 0.75rem 0.3rem;
+}
+
+.conv-item {
+ display:flex; align-items:center; gap:0.7rem;
+ width:100%;
+ padding: 0.55rem 0.75rem;
+ margin-bottom: 2px;
+ background: transparent;
+ border: none;
+ border-radius: var(--radius-lg);
+ cursor:pointer;
+ text-align:left;
+ transition: background var(--transition-fast);
+ position:relative;
+}
+.conv-item:hover { background: var(--bg-tertiary); }
+.conv-item.active { background: var(--primary-light); }
+.conv-item.active .conv-name { color: var(--primary); font-weight:600; }
+
+.conv-avatar {
+ width:44px; height:44px; border-radius:50%;
+ background: var(--bg-tertiary);
+ color: var(--text-secondary);
+ display:flex; align-items:center; justify-content:center;
+ font-size:1.1rem; font-weight:700;
+ flex-shrink:0; overflow:hidden;
+ position:relative;
+}
+.conv-avatar img { width:100%; height:100%; object-fit:cover; }
+.conv-avatar.avatar-c0 { background: linear-gradient(135deg,#2563EB,#7C3AED); color:#fff; }
+.conv-avatar.avatar-c1 { background: linear-gradient(135deg,#059669,#0EA5E9); color:#fff; }
+.conv-avatar.avatar-c2 { background: linear-gradient(135deg,#D97706,#DB2777); color:#fff; }
+.conv-avatar.avatar-c3 { background: linear-gradient(135deg,#4338CA,#0EA5E9); color:#fff; }
+.conv-avatar.avatar-c4 { background: linear-gradient(135deg,#DC2626,#F59E0B); color:#fff; }
+.conv-avatar.avatar-c5 { background: linear-gradient(135deg,#0891B2,#65A30D); color:#fff; }
+
+.conv-presence {
+ position:absolute; right:-1px; bottom:-1px;
+ width:11px; height:11px; border-radius:50%;
+ background:var(--text-tertiary);
+ border:2px solid var(--bg-elevated);
+}
+.conv-presence.online { background: var(--success); }
+
+.conv-info { flex:1; min-width:0; }
+.conv-top, .conv-bottom { display:flex; align-items:center; justify-content:space-between; gap:0.4rem; }
+.conv-name {
+ font-size:0.9rem; font-weight:600; color:var(--text);
+ white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
+}
+.conv-time { font-size:0.68rem; color:var(--text-tertiary); flex-shrink:0; }
+.conv-time.unread { color: var(--primary); font-weight:600; }
+.conv-preview {
+ font-size:0.78rem; color:var(--text-secondary);
+ white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
+ flex:1; min-width:0;
+}
+.conv-item.active .conv-preview { color: var(--text); }
+.conv-badge {
+ min-width:19px; height:19px; padding:0 5px;
+ background: var(--primary); color:#fff;
+ border-radius: var(--radius-full);
+ font-size:0.68rem; font-weight:700;
+ display:flex; align-items:center; justify-content:center;
+ flex-shrink:0;
+}
+.conv-empty {
+ padding: 1rem 0.9rem;
+ font-size: 0.78rem;
+ color: var(--text-tertiary);
+ text-align: center;
+ line-height: 1.5;
+}
+
+.sidebar-footer { padding:0.6rem 0.75rem; border-top:1px solid var(--border); }
+
+/* ---- Main column ---- */
+.chat-main {
+ flex:1; min-width:0; height:100%;
+ display:flex; flex-direction:column;
+ background: var(--bg);
+}
+
+/* ======= CHAT HEADER ======= */
+.chat-header {
+ display:flex; align-items:center; justify-content:space-between;
+ padding:0 0.9rem;
+ height: var(--header-height);
+ background: var(--header-bg);
+ border-bottom:1px solid var(--header-border);
+ flex-shrink:0;
+ z-index:10;
+ gap: 0.5rem;
+}
+.header-section { display:flex; align-items:center; gap:0.5rem; min-width:0; }
+.header-left { flex:1; min-width:0; }
+.header-right { flex-shrink:0; }
+.sidebar-open { display:none; }
+
+.conv-avatar-wrap { position:relative; flex-shrink:0; width:42px; height:42px; }
+.conv-avatar-img, .conv-avatar-ph {
+ width:42px; height:42px; border-radius:50%;
+ object-fit:cover; display:block;
+}
+.conv-avatar-ph {
+ background: linear-gradient(135deg, var(--primary), #9333EA);
+ color:#fff;
+ display:flex; align-items:center; justify-content:center;
+ font-size:1.05rem;
+}
+.conv-avatar-ph i { font-size: 1.15rem; }
+.presence-dot {
+ position:absolute; right:-1px; bottom:-1px;
+ width:12px; height:12px; border-radius:50%;
+ background: var(--success);
+ border: 2px solid var(--header-bg);
+ z-index:1;
+}
+.presence-dot.offline { background: var(--text-tertiary); }
+
+.header-info { display:flex; flex-direction:column; min-width:0; }
+.header-name {
+ font-size:1rem; font-weight:600; color:var(--text); line-height:1.3;
+ white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
+}
+.header-status {
+ font-size:0.75rem;
+ color:var(--text-secondary);
+ display:flex; align-items:center; gap:0.25rem;
+ white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
+}
+
+/* ======= CONNECTION STATUS ======= */
+.connection-status {
+ height:28px;
+ display:flex; align-items:center; gap:0.5rem;
+ padding:0 1rem;
+ font-size:0.75rem;
+ background: var(--bg-secondary);
+ border-bottom:1px solid var(--border-light);
+ color: var(--text-secondary);
+ flex-shrink:0;
+ transition: background 0.3s ease;
+}
+.connection-status.connected { color:var(--success); }
+.connection-status.reconnecting { color:var(--warning); background: rgba(245,158,11,0.06); }
+.connection-status.disconnected { color:var(--error); background: rgba(239,68,68,0.06); }
+
+/* ======= MESSAGES ======= */
+.messages-container {
+ flex:1; overflow:hidden; position:relative;
+ background:
+ radial-gradient(1200px 500px at 85% -10%, var(--primary-light) 0%, transparent 60%);
+}
+[data-theme="dark"] .messages-container {
+ background:
+ radial-gradient(1000px 460px at 85% -10%, rgba(37,99,235,0.16) 0%, transparent 60%);
+}
+.messages-scroll {
+ height:100%; overflow-y:auto;
+ padding:0.75rem 1rem 1.5rem;
+ scroll-behavior: smooth;
+}
+.load-more { text-align:center; margin-bottom:0.75rem; }
+.messages-list {
+ display:flex; flex-direction:column;
+ gap: 1px; min-height:100%;
+ max-width: 900px;
+ margin: 0 auto;
+}
+.empty-state {
+ display:flex; flex-direction:column;
+ align-items:center; justify-content:center;
+ padding:4rem 1rem; text-align:center;
+ color: var(--text-secondary);
+ flex:1;
+}
+.empty-icon { font-size:3.5rem; color:var(--text-tertiary); margin-bottom:1.25rem; }
+.empty-state h3 { font-size:1.125rem; color:var(--text); margin-bottom:0.25rem; }
+.empty-state p { color:var(--text-secondary); font-size:0.9rem; }
+
+.scroll-bottom-btn {
+ position:absolute; bottom:1rem; right:1rem;
+ width:36px; height:36px;
+ background: var(--bg-elevated);
+ border:1px solid var(--border);
+ border-radius:var(--radius-full);
+ box-shadow: var(--shadow-md);
+ display:flex; align-items:center; justify-content:center;
+ cursor:pointer;
+ transition: all var(--transition-fast);
+ z-index:5; color:var(--text-secondary);
+}
+.scroll-bottom-btn:hover { background:var(--bg-tertiary); transform:scale(1.05); }
+
+/* Day separators */
+.day-separator {
+ display:flex; align-items:center; justify-content:center;
+ margin: 1.1rem 0 0.7rem;
+}
+.day-separator:first-child { margin-top: 0.4rem; }
+.day-chip {
+ padding: 0.22rem 0.8rem;
+ font-size: 0.72rem; font-weight: 600;
+ color: var(--text-secondary);
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-full);
+ box-shadow: var(--shadow-xs);
+}
+
+/* ======= MESSAGE BUBBLES (v2 layout) ======= */
+.message-row {
+ display:flex;
+ align-items:flex-end;
+ gap: 0.5rem;
+ margin: 0.18rem 0;
+ animation: msgIn 0.22s ease;
+ position: relative;
+}
+@keyframes msgIn { from { opacity:0; transform:translateY(6px);} to { opacity:1; transform:translateY(0);} }
+
+.message-row.self { justify-content: flex-end; }
+
+.msg-avatar-col { width:30px; flex-shrink:0; align-self:flex-end; display:flex; }
+.msg-avatar, .msg-avatar-ph {
+ width:30px; height:30px;
+ border-radius:50%;
+ object-fit:cover;
+ flex-shrink:0;
+}
+.msg-avatar-ph {
+ background: linear-gradient(135deg, var(--primary), #9333EA);
+ color:#fff;
+ display:flex; align-items:center; justify-content:center;
+ font-size:0.78rem; font-weight:700;
+ border: none;
+}
+/* keep a gap row reserved for own messages so stacked messages align */
+.message-row.self .msg-avatar-col { visibility:hidden; }
+
+.msg-stack {
+ display:flex; flex-direction:column;
+ max-width: min(72%, 620px);
+ min-width: 0;
+}
+.message-row.self .msg-stack { align-items:flex-end; }
+.message-row.other .msg-stack { align-items:flex-start; }
+
+.msg-sender-name {
+ font-size:0.74rem; font-weight:600;
+ color: var(--primary-dark);
+ margin: 0.25rem 0.35rem 0.15rem;
+ max-width: 100%;
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+}
+[data-theme="dark"] .msg-sender-name { color: var(--primary); }
+.message-row.self .msg-sender-name { display:none; }
+
+/* consecutive messages from the same person hug each other */
+.message-row.hug { margin: 1px 0; }
+.message-row.hug .msg-sender-name,
+.message-row.hug .msg-avatar-col { visibility:hidden; height:0; margin:0; }
+
+.message-bubble {
+ position:relative;
+ width: fit-content;
+ max-width: 100%;
+ min-width: 40px;
+ padding: 0.5rem 0.78rem 0.42rem;
+ border-radius: var(--radius-xl);
+ word-wrap:break-word;
+ overflow-wrap:break-word;
+ white-space:pre-wrap;
+ line-height:1.5;
+ box-shadow: var(--shadow-xs);
+ font-size: 0.9375rem;
+}
+.message-row.self .message-bubble {
+ background: var(--bubble-self);
+ color: var(--bubble-self-text);
+ border-bottom-right-radius: var(--radius-sm);
+ border-top-right-radius: var(--radius-lg);
+}
+.message-row.other .message-bubble {
+ background: var(--bubble-other);
+ color: var(--bubble-other-text);
+ border-bottom-left-radius: var(--radius-sm);
+ border-top-left-radius: var(--radius-lg);
+}
+
+/* bubble content spacing */
+.message-text { min-width: 0; }
+.message-text:not(:last-child) { padding-bottom: 0.15rem; }
+.message-text > :last-child { margin-bottom: 0; }
+
+/* inline links / embeds inside text */
+.message-text a {
+ color: inherit;
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ text-decoration-color: currentColor;
+ opacity: 0.9;
+ word-break: break-all;
+}
+.message-row.self .message-text a { color: var(--bubble-self-text); }
+.message-row.other .message-text a { color: var(--primary); opacity:1; }
+
+.message-text code {
+ font-family: var(--font-mono);
+ font-size: 0.85em;
+ background: rgba(127,127,127,0.18);
+ padding: 0.1em 0.4em;
+ border-radius: var(--radius-xs);
+}
+.message-text pre.code-block {
+ background: rgba(0,0,0,0.18);
+ padding: 0.6rem 0.75rem;
+ border-radius: var(--radius-md);
+ overflow-x: auto;
+ margin: 0.25rem 0;
+ white-space: pre;
+ font-family: var(--font-mono);
+ font-size: 0.8rem;
+}
+.message-text pre.code-block code { background: none; padding: 0; }
+
+.embedded-img {
+ display: block;
+ max-width: min(320px, 55vw);
+ max-height: 300px;
+ width: auto;
+ height: auto;
+ margin: 0.25rem auto 0.15rem;
+ border-radius: var(--radius-md);
+ cursor: zoom-in;
+ object-fit: contain;
+}
+.message-text + .embedded-img { margin-top: 0.45rem; }
+
+/* attachment media sizing (keeps natural aspect ratio) */
+.attachment { margin-top: 0.35rem; border-radius: var(--radius-md); overflow:hidden; }
+.attachment:first-child { margin-top: 0.1rem; }
+.attachment img {
+ display:block;
+ max-width: min(340px, 60vw);
+ max-height: 300px;
+ width: auto;
+ height: auto;
+ border-radius: var(--radius-md);
+ cursor: zoom-in;
+ object-fit: contain;
+ background: rgba(0,0,0,0.05);
+}
+.attachment video {
+ display:block;
+ max-width: min(340px, 60vw);
+ max-height: 300px;
+ border-radius: var(--radius-md);
+ background: #000;
+}
+.attachment audio { width: min(280px, 60vw); display:block; }
+
+.attachment-file {
+ display:flex; align-items:center; gap:0.65rem;
+ min-width: min(230px, 60vw);
+ padding:0.55rem 0.75rem;
+ background: var(--bg-elevated);
+ border:1px solid var(--border);
+ border-radius:var(--radius-lg);
+ cursor:pointer;
+ transition:all var(--transition-fast);
+ color:var(--text);
+ box-shadow: var(--shadow-xs);
+}
+.attachment-file:hover { background:var(--bg-tertiary); border-color:var(--primary); transform: translateY(-1px); }
+.message-row.self .attachment-file { background: rgba(255,255,255,0.14); border-color: rgba(255,255,255,0.25); color:#fff; }
+.attachment-file .file-card-icon { font-size:1.5rem; color:var(--primary); flex-shrink:0; }
+.message-row.self .attachment-file .file-card-icon { color: #fff; }
+.file-info { flex:1; min-width:0; }
+.file-name { font-size:0.85rem; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
+.file-size { font-size:0.72rem; color:var(--text-tertiary); }
+.message-row.self .file-size { color: rgba(255,255,255,0.72); }
+
+/* Reply quote inside a bubble */
+.reply-quote {
+ display:flex;
+ align-items: baseline;
+ gap: 0.35rem;
+ padding: 0.32rem 0.6rem;
+ margin: 0 0 0.35rem;
+ border-left: 3px solid currentColor;
+ border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
+ background: rgba(127,127,127,0.14);
+ font-size: 0.8rem;
+ cursor: pointer;
+ opacity: 0.92;
+ min-width: 0;
+}
+.reply-quote:hover { opacity: 1; }
+.reply-quote .reply-q-name { font-weight: 700; flex-shrink: 0; }
+.reply-q-text {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.reply-q-text:empty { display:none; }
+
+/* time inside bubbles */
+.message-time { flex-shrink: 0; white-space: nowrap; }
+
+/* zoomable images (attachment + embedded) */
+img.js-zoom { cursor: zoom-in; }
+
+/* "Chat" pill on other people's messages */
+.startchat-label { font-size: inherit; }
+@media (max-width:480px) {
+ .startchat-label { display: none; }
+ .msg-action-start { padding: 0 0.45rem; }
+}
+
+/* Meta row: time + ticks INSIDE the bubble, bottom right */
+.message-meta {
+ display:flex; align-items:center; justify-content:flex-end;
+ gap: 0.32rem;
+ margin-top: 0.18rem;
+ font-size: 0.65rem;
+ line-height: 1;
+ float: none;
+ user-select: none;
+}
+.message-row.self .message-meta { color: rgba(255,255,255,0.72); }
+.message-row.other .message-meta { color: var(--text-tertiary); }
+
+.edited-label { font-style:italic; opacity:0.85; }
+.message-status {
+ display:inline-flex; align-items:center;
+ cursor: pointer;
+ border-radius: var(--radius-full);
+ padding: 2px;
+}
+.message-status:hover { background: rgba(127,127,127,0.18); }
+.message-status.read i { color: var(--tick-read); }
+.message-row.other .message-status { display:none; }
+.message-status .fas { font-size: 0.72rem; }
+
+/* tombstone for someone else's deleted message (session-only) */
+.deleted-note {
+ align-self:center;
+ display:flex; align-items:center; gap:0.4rem;
+ margin: 0.35rem 0;
+ padding: 0.25rem 1rem;
+ font-size:0.72rem;
+ font-style:italic;
+ color: var(--text-tertiary);
+ background: var(--bg-tertiary);
+ border-radius: var(--radius-full);
+ max-width: 80%;
+}
+
+/* ======= MESSAGE ACTIONS ======= */
+.message-actions {
+ display:flex; align-items:center; gap:3px;
+ margin-top: 2px;
+ padding: 0 0.15rem;
+ opacity: 0;
+ transition: opacity var(--transition-fast);
+ pointer-events: none;
+}
+.message-row:hover .message-actions,
+.message-row:focus-within .message-actions,
+.message-actions.force-visible { opacity: 1; pointer-events: auto; }
+
+.msg-action {
+ display:inline-flex; align-items:center; justify-content:center;
+ width:26px; height:26px;
+ background:transparent; border:none;
+ border-radius: var(--radius-sm);
+ cursor:pointer; font-size:0.72rem;
+ transition: all var(--transition-fast);
+ color: var(--text-tertiary);
+}
+.msg-action:hover { background: var(--bg-tertiary); color: var(--text); transform: scale(1.1); }
+.msg-action-start {
+ width:auto; padding: 0 0.55rem; gap: 0.3rem;
+ font-size: 0.72rem; font-weight: 600;
+ color: var(--primary);
+ border-radius: var(--radius-full);
+}
+.msg-action-start:hover { background: var(--primary-light); color: var(--primary); }
+
+/* inline edit box */
+.inline-edit-box { display:flex; flex-direction:column; gap:0.4rem; min-width: min(420px, 68vw); }
+.inline-edit-box textarea {
+ resize:none; width:100%;
+ padding:0.55rem 0.7rem;
+ border-radius: var(--radius-md);
+ border:1.5px solid var(--primary);
+ background: var(--bg-elevated);
+ color: var(--text);
+ font-family: inherit; font-size:0.9rem;
+ outline:none;
+ min-height: 60px;
+}
+.inline-edit-actions { display:flex; justify-content:flex-end; gap:0.4rem; }
+
+/* ======= TYPING INDICATOR ======= */
+.typing-indicator {
+ display:flex; align-items:center; gap:0.5rem;
+ padding:0.15rem 1rem;
+ height:30px;
+ background: var(--bg-secondary);
+ border-top:1px solid var(--border-light);
+ flex-shrink:0;
+ color: var(--text-secondary);
+}
+.typing-dots { display:flex; gap:3px; }
+.typing-dot {
+ width:6px; height:6px;
+ border-radius:50%;
+ background: var(--text-tertiary);
+ animation: typeBounce 1.4s infinite ease-in-out both;
+}
+.typing-dot:nth-child(1) { animation-delay:-0.32s; }
+.typing-dot:nth-child(2) { animation-delay:-0.16s; }
+@keyframes typeBounce { 0%,80%,100%{transform:scale(0);}40%{transform:scale(1);} }
+.typing-text { font-size:0.8125rem; color:var(--text-secondary); }
+
+/* ======= REPLY PREVIEW ======= */
+.reply-preview { flex-shrink:0; }
+.reply-bar {
+ display:flex; align-items:center; gap:0.75rem;
+ padding:0.45rem 1rem;
+ background: var(--bg-secondary);
+ border-top:1px solid var(--border-light);
+ color:var(--text);
+}
+.reply-icon { color:var(--primary); flex-shrink:0; }
+.reply-content { flex:1; min-width:0; }
+.reply-label { font-size:0.75rem; font-weight:600; color:var(--primary); display:block; }
+.reply-text { font-size:0.8125rem; color:var(--text-secondary); display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+
+/* ======= COMPOSE ======= */
+.compose-area {
+ flex-shrink:0;
+ border-top:1px solid var(--border);
+ background: var(--bg-elevated);
+}
+.upload-preview {
+ display:flex; align-items:center; justify-content:space-between;
+ padding:0.4rem 1rem;
+ background: var(--bg-secondary);
+ border-bottom:1px solid var(--border-light);
+ color:var(--text);
+ gap: 0.75rem;
+}
+.upload-info { display:flex; align-items:center; gap:0.6rem; font-size:0.85rem; min-width:0; }
+.upload-info > i { color: var(--primary); font-size:1.15rem; flex-shrink:0; }
+.upload-text { display:flex; flex-direction:column; min-width:0; }
+#upload-filename {
+ font-weight:500; color:var(--text);
+ white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width: 60vw;
+}
+.upload-size { font-size:0.7rem; color:var(--text-tertiary); }
+.compose-form { display:flex; align-items:flex-end; gap:0.4rem; padding:0.5rem 0.75rem; }
+.compose-tools { display:flex; gap:2px; padding-bottom:0.1rem; }
+.input-area { flex:1; }
+.message-input {
+ width:100%; resize:none;
+ padding:0.625rem 1rem;
+ font-size:0.9375rem; font-family:inherit;
+ color: var(--input-text);
+ background: var(--input-bg);
+ border:1.5px solid var(--input-border);
+ border-radius:var(--radius-2xl);
+ outline:none;
+ min-height:42px; max-height:120px;
+ line-height:1.5;
+ transition:all var(--transition-fast);
+ overflow-y:auto;
+}
+.message-input:focus {
+ border-color: var(--primary);
+ box-shadow:0 0 0 3px var(--primary-light);
+ background: var(--bg-elevated);
+}
+.message-input::placeholder { color:var(--input-placeholder); }
+.upload-progress {
+ display:flex; align-items:center; gap:0.75rem;
+ padding:0.45rem 1rem;
+ background:var(--bg-secondary);
+ border-top:1px solid var(--border-light);
+ color:var(--text);
+}
+.progress-track { flex:1; height:4px; background:var(--border); border-radius:var(--radius-full); overflow:hidden; }
+.progress-fill { height:100%; background:var(--primary); border-radius:var(--radius-full); transition:width var(--transition); }
+.progress-text { font-size:0.75rem; font-weight:500; color:var(--text-secondary); min-width:36px; text-align:right; }
+
+/* Drag & drop overlay */
+.drop-overlay {
+ position: fixed; inset: 0; z-index: 90;
+ display:flex; flex-direction:column; align-items:center; justify-content:center; gap: 1rem;
+ background: rgba(37,99,235,0.18);
+ backdrop-filter: blur(3px);
+ color: var(--primary-dark);
+ font-size: 1.1rem; font-weight: 600;
+ border: 3px dashed var(--primary);
+ pointer-events: none;
+}
+.drop-overlay i { font-size: 3rem; }
+
+/* ======= MODALS ======= */
+.modal {
+ position:fixed; inset:0; z-index:100;
+ display:flex; align-items:center; justify-content:center;
+ padding:1rem;
+}
+.modal.hidden { display:none !important; }
+.modal-backdrop {
+ position:absolute; inset:0;
+ background:rgba(0,0,0,0.55);
+ backdrop-filter:blur(4px);
+}
+.modal-container {
+ position:relative; width:100%;
+ max-width:520px; max-height:88vh;
+ background: var(--bg-elevated);
+ border-radius:var(--radius-xl);
+ box-shadow: var(--shadow-xl);
+ overflow:hidden;
+ display:flex; flex-direction:column;
+ border:1px solid var(--border);
+ color:var(--text);
+ animation: modalIn 0.2s ease;
+}
+@keyframes modalIn { from { opacity:0; transform: translateY(14px) scale(0.98); } to { opacity:1; transform:none; } }
+.modal-sm { max-width:400px; }
+.modal-header {
+ display:flex; align-items:center; justify-content:space-between;
+ padding:1rem 1.5rem;
+ border-bottom:1px solid var(--border);
+ background: var(--bg-elevated);
+}
+.modal-header h2 { font-size:1.0625rem; font-weight:600; color:var(--text); }
+.modal-header h2 i { margin-right:0.5rem; color:var(--primary); }
+.modal-body { padding:1.25rem 1.5rem; overflow-y:auto; flex:1; background:var(--bg-elevated); }
+.modal-footer {
+ display:flex; justify-content:flex-end; gap:0.5rem;
+ padding:1rem 1.5rem;
+ border-top:1px solid var(--border);
+ background: var(--bg-secondary);
+}
+
+/* Settings sections */
+.settings-section { margin-bottom:2rem; }
+.settings-section:last-child { margin-bottom:0; }
+.settings-section h3 {
+ font-size:0.75rem; font-weight:600;
+ text-transform:uppercase; letter-spacing:0.05em;
+ color:var(--text-secondary);
+ margin-bottom:1rem;
+ padding-bottom:0.5rem;
+ border-bottom:1px solid var(--border-light);
+}
+.avatar-section {
+ display:flex; align-items:center; gap:1rem;
+ margin-bottom:1rem; flex-wrap: wrap;
+}
+.settings-avatar {
+ width:72px; height:72px;
+ border-radius:var(--radius-full);
+ background: var(--bg-tertiary);
+ display:flex; align-items:center; justify-content:center;
+ font-size:2rem; color:var(--text-secondary);
+ overflow:hidden; flex-shrink:0;
+}
+.settings-avatar-img {
+ width:72px; height:72px;
+ border-radius:var(--radius-full);
+ object-fit:cover; flex-shrink:0;
+}
+
+/* Theme selector */
+.theme-selector { display:grid; grid-template-columns:repeat(3,1fr); gap:0.75rem; }
+.theme-option {
+ display:flex; flex-direction:column; align-items:center; gap:0.5rem;
+ padding:0.75rem; font-size:0.8rem; font-weight:500;
+ color:var(--text);
+ background:var(--bg-secondary);
+ border:2px solid var(--border);
+ border-radius:var(--radius-md);
+ cursor:pointer;
+ transition:all var(--transition-fast);
+ font-family: inherit;
+}
+.theme-option:hover { border-color:var(--text-tertiary); }
+.theme-option.active { border-color:var(--primary); background:var(--primary-light); color:var(--primary); }
+.theme-preview {
+ width:56px; height:38px;
+ border-radius:var(--radius-sm);
+ overflow:hidden; border:1px solid var(--border);
+}
+.theme-preview-header { height:8px; }
+.theme-preview-body { padding:3px; display:flex; flex-direction:column; gap:2px; }
+.theme-preview-message { height:6px; border-radius:3px; }
+.theme-preview-message.self { width:65%; align-self:flex-end; }
+.theme-preview-message.other { width:55%; }
+
+/* Light preview colours */
+.theme-preview-light { background:#FFFFFF; }
+.theme-preview-light .theme-preview-header { background:#F9FAFB; }
+.theme-preview-light .theme-preview-message.self { background:#2563EB; }
+.theme-preview-light .theme-preview-message.other { background:#F3F4F6; }
+/* Dark preview colours */
+.theme-preview-dark { background:#0F172A; }
+.theme-preview-dark .theme-preview-header { background:#1E293B; }
+.theme-preview-dark .theme-preview-message.self { background:#3B82F6; }
+.theme-preview-dark .theme-preview-message.other { background:#334155; }
+/* Custom preview */
+.theme-preview-custom { background: linear-gradient(135deg,#6366F1,#EC4899); }
+.theme-preview-custom .theme-preview-header { background:rgba(0,0,0,0.2); }
+.theme-preview-custom .theme-preview-message.self { background:#F9FAFB; }
+.theme-preview-custom .theme-preview-message.other { background:rgba(255,255,255,0.4); }
+
+.custom-theme-section {
+ margin-top:0.75rem; padding:1rem;
+ background:var(--bg-secondary);
+ border-radius:var(--radius-md);
+ border:1px solid var(--border);
+}
+.color-picker-row { display:grid; grid-template-columns:repeat(3,1fr); gap:1rem; }
+.color-picker-group label {
+ display:block; font-size:0.75rem; font-weight:500;
+ margin-bottom:0.4rem; color:var(--text);
+}
+.color-input-wrapper { display:flex; align-items:center; gap:0.4rem; }
+.color-input-wrapper input[type="color"] {
+ width:36px; height:36px;
+ border:1px solid var(--border); border-radius:var(--radius-sm);
+ padding:2px; cursor:pointer; background:none;
+}
+.color-hex { font-size:0.75rem; font-family:var(--font-mono); color:var(--text-secondary); }
+
+/* Settings toggles */
+.settings-toggle-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 0.5rem 0;
+ border-bottom: 1px solid var(--border-light);
+}
+.settings-toggle-row:last-child { border-bottom: none; }
+.settings-toggle-row label[for] {
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: var(--text);
+ margin: 0;
+ cursor: pointer;
+}
+.settings-toggle-row .toggle-hint {
+ font-size: 0.75rem;
+ color: var(--text-tertiary);
+ margin-top: 0.1rem;
+}
+.toggle-switch {
+ position: relative;
+ width: 44px;
+ height: 24px;
+ flex-shrink: 0;
+ cursor: pointer;
+}
+.toggle-switch input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+ position: absolute;
+}
+.toggle-slider {
+ position: absolute;
+ inset: 0;
+ background: var(--bg-tertiary);
+ border-radius: var(--radius-full);
+ border: 1px solid var(--border);
+ transition: all var(--transition-fast);
+ cursor: pointer;
+}
+.toggle-slider::before {
+ content: '';
+ position: absolute;
+ width: 18px;
+ height: 18px;
+ left: 2px;
+ top: 2px;
+ background: var(--text-tertiary);
+ border-radius: 50%;
+ transition: all var(--transition-fast);
+}
+.toggle-switch input:checked + .toggle-slider {
+ background: var(--primary);
+ border-color: var(--primary);
+}
+.toggle-switch input:checked + .toggle-slider::before {
+ transform: translateX(20px);
+ background: #fff;
+}
+
+/* About rows */
+.about-info { display:flex; flex-direction:column; gap:0; }
+.about-row {
+ display:flex; justify-content:space-between; align-items:center;
+ padding:0.625rem 0;
+ border-bottom:1px solid var(--border-light);
+ font-size:0.875rem; color:var(--text);
+}
+.about-row:last-child { border-bottom:none; }
+
+/* Online users modal */
+.online-users-list { max-height:320px; overflow-y:auto; background:var(--bg-elevated); }
+.loading-state { display:flex; align-items:center; justify-content:center; gap:0.5rem; padding:2rem; color:var(--text-secondary); }
+.user-item { display:flex; align-items:center; gap:0.75rem; padding:0.6rem 1.25rem; transition:background var(--transition-fast); color:var(--text); }
+.user-item:hover { background:var(--bg-secondary); }
+.user-avatar { width:40px; height:40px; border-radius:var(--radius-full); background:var(--bg-tertiary); display:flex; align-items:center; justify-content:center; color:var(--text-secondary); font-size:1.2rem; overflow:hidden; flex-shrink:0; }
+.user-avatar img { width:100%; height:100%; object-fit:cover; }
+.user-info { flex:1; min-width:0; }
+.user-name { font-size:0.875rem; font-weight:500; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
+.user-status-text { font-size:0.75rem; color:var(--text-secondary); }
+
+/* Start private chat modal */
+.conv-modal-desc { font-size:0.875rem; color:var(--text-secondary); margin-bottom:0.9rem; }
+.conv-modal-list { display:flex; flex-direction:column; gap:0.4rem; max-height:280px; overflow-y:auto; }
+.conv-modal-item {
+ display:flex; align-items:center; gap:0.7rem;
+ padding:0.55rem 0.8rem;
+ background:var(--bg-secondary);
+ border:1px solid var(--border);
+ border-radius:var(--radius-lg);
+ cursor:pointer;
+ transition: all var(--transition-fast);
+ text-align:left; width:100%;
+ font-family: inherit;
+ color: var(--text);
+}
+.conv-modal-item:hover { border-color: var(--primary); background: var(--primary-light); }
+.conv-modal-item .conv-avatar { width:36px; height:36px; font-size:0.85rem; }
+.conv-modal-item .mi-name { font-size:0.875rem; font-weight:600; }
+.conv-modal-item .mi-preview {
+ font-size:0.75rem; color:var(--text-secondary);
+ white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
+}
+.conv-modal-item .mi-info { flex:1; min-width:0; display:flex; flex-direction:column; }
+.conv-modal-item .mi-open { color: var(--primary); font-size:0.9rem; }
+.conv-modal-empty { text-align:center; color:var(--text-tertiary); font-size:0.8rem; padding:0.75rem; }
+.conv-modal-note { font-size:0.75rem; color:var(--warning); display:flex; align-items:center; gap:0.4rem; margin-top:0.7rem; }
+
+/* Read receipts modal */
+.receipts-summary { font-size:0.85rem; color:var(--text-secondary); margin-bottom:0.9rem; }
+.receipts-list { display:flex; flex-direction:column; gap:0.35rem; max-height:300px; overflow-y:auto; }
+.receipt-row {
+ display:flex; align-items:center; gap:0.7rem;
+ padding:0.5rem 0.7rem;
+ background:var(--bg-secondary);
+ border-radius:var(--radius-lg);
+}
+.receipt-row .user-avatar { width:34px; height:34px; font-size:1rem; }
+.receipt-row .ri-info { flex:1; min-width:0; }
+.receipt-row .ri-name { font-size:0.85rem; font-weight:600; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
+.receipt-row .ri-time { font-size:0.72rem; color:var(--text-tertiary); }
+.receipt-row .ri-flag { color:var(--success); font-size:0.8rem; }
+.receipt-row.unread-row { opacity:0.8; }
+.receipt-row.unread-row .ri-flag { color:var(--text-tertiary); }
+.receipts-empty { text-align:center; color:var(--text-tertiary); font-size:0.82rem; padding:1rem; }
+
+/* Confirm dialog */
+.confirm-message { color: var(--text); font-size:0.9375rem; line-height:1.55; }
+
+/* Lightbox */
+.lightbox-container {
+ position:relative;
+ max-width: 94vw;
+ max-height: 92vh;
+ display:flex; flex-direction:column;
+ background: rgba(15,23,42,0.92);
+ border-radius: var(--radius-xl);
+ overflow:hidden;
+ box-shadow: var(--shadow-xl);
+ border: 1px solid rgba(255,255,255,0.12);
+}
+.lightbox-header {
+ display:flex; align-items:center; justify-content:space-between;
+ padding:0.55rem 1rem;
+ background: rgba(15,23,42,0.85);
+ gap:0.75rem;
+}
+.lightbox-name {
+ color:#E2E8F0; font-size:0.85rem; font-weight:500;
+ white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
+}
+.lightbox-btn { color:#CBD5E1; }
+.lightbox-btn:hover { background:rgba(255,255,255,0.12); color:#fff; }
+.lightbox-img {
+ display:block;
+ max-width: 100%;
+ max-height: calc(92vh - 52px);
+ object-fit: contain;
+ background: #0F172A;
+}
+
+/* ======= EMOJI PICKER ======= */
+.emoji-picker {
+ position:fixed; bottom:84px; left:70px;
+ z-index:200;
+ background:var(--bg-elevated);
+ border:1px solid var(--border);
+ border-radius:var(--radius-lg);
+ box-shadow:var(--shadow-xl);
+ width:300px; max-height:320px;
+ overflow:hidden;
+ display:flex; flex-direction:column;
+}
+.emoji-header { border-bottom:1px solid var(--border); padding:0.4rem; background:var(--bg-secondary); }
+.emoji-categories { display:flex; gap:2px; }
+.emoji-cat {
+ width:34px; height:34px; border:none;
+ background:transparent; border-radius:var(--radius-sm);
+ cursor:pointer; color:var(--text-secondary);
+ font-size:0.8rem; transition:all var(--transition-fast);
+}
+.emoji-cat:hover { background:var(--bg-tertiary); color:var(--text); }
+.emoji-cat.active { background:var(--primary-light); color:var(--primary); }
+.emoji-grid {
+ padding:0.4rem;
+ display:grid; grid-template-columns:repeat(8,1fr);
+ gap:1px; overflow-y:auto; flex:1;
+ background:var(--bg-elevated);
+}
+.emoji-item {
+ width:34px; height:34px;
+ display:flex; align-items:center; justify-content:center;
+ font-size:1.2rem; cursor:pointer;
+ border-radius:var(--radius-sm);
+ border:none; background:transparent;
+ transition:background var(--transition-fast);
+}
+.emoji-item:hover { background:var(--bg-tertiary); }
+
+/* ======= LATEST MESSAGE BAR ======= */
+.latest-message-bar {
+ position:absolute;
+ bottom: 0.9rem;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 6;
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-full);
+ box-shadow: var(--shadow-lg);
+ padding: 0.4rem 1rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ cursor: pointer;
+ max-width: 300px;
+ transition: all var(--transition-fast);
+ font-size: 0.8125rem;
+ color: var(--text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.latest-message-bar:hover {
+ background: var(--bg-tertiary);
+ transform: translateX(-50%) translateY(-2px);
+ box-shadow: var(--shadow-xl);
+}
+.lmb-avatar {
+ width: 20px; height: 20px;
+ border-radius: 50%;
+ background: var(--primary);
+ color: #fff;
+ display: flex; align-items: center; justify-content: center;
+ font-size: 0.6rem; font-weight: 700;
+ flex-shrink: 0; overflow: hidden;
+}
+.lmb-avatar img { width:100%; height:100%; object-fit:cover; }
+.lmb-name { font-weight: 600; color: var(--primary); flex-shrink: 0; }
+.lmb-text { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color: var(--text-secondary); }
+
+/* ======= TOASTS ======= */
+.toast-container { position:fixed; top:1rem; right:1rem; z-index:300; display:flex; flex-direction:column; gap:0.5rem; max-width:340px; pointer-events:none; }
+.toast {
+ padding:0.75rem 1rem;
+ background:var(--bg-elevated);
+ border:1px solid var(--border);
+ border-radius:var(--radius-md);
+ box-shadow:var(--shadow-lg);
+ display:flex; align-items:center; gap:0.75rem;
+ font-size:0.875rem; color:var(--text);
+ animation:toastIn 0.3s ease;
+ pointer-events:auto;
+ max-width: 100%;
+}
+.toast-success { border-left:4px solid var(--success); }
+.toast-error { border-left:4px solid var(--error); }
+.toast-warning { border-left:4px solid var(--warning); }
+.toast-info { border-left:4px solid var(--info); }
+.toast-success i { color:var(--success); }
+.toast-error i { color:var(--error); }
+.toast-warning i { color:var(--warning); }
+.toast-info i { color:var(--info); }
+.toast span { overflow-wrap:anywhere; }
+@keyframes toastIn { from{transform:translateX(100%);opacity:0;} to{transform:translateX(0);opacity:1;} }
+
+/* ======= RESPONSIVE ======= */
+@media (max-width:900px) {
+ .sidebar {
+ position: fixed;
+ top: 0; left: 0; bottom: 0;
+ transform: translateX(-105%);
+ transition: transform var(--transition-slow);
+ box-shadow: var(--shadow-xl);
+ border-right: none;
+ }
+ .sidebar.open { transform: translateX(0); }
+ .sidebar-close { display:flex; }
+ .sidebar-open { display:inline-flex; }
+ .sidebar-backdrop {
+ position: fixed; inset: 0; z-index: 35;
+ background: rgba(0,0,0,0.5);
+ }
+ .emoji-picker { left:10px; right:10px; bottom:84px; width:auto; }
+}
+
+@media (max-width:640px) {
+ .auth-container { padding:1.5rem 1.25rem; border-radius:var(--radius-xl); }
+ .msg-stack { max-width: 88%; }
+ .msg-avatar-col { display:none; }
+ .modal { padding:0; align-items:flex-end; }
+ .modal-container { max-height:88vh; border-radius:var(--radius-xl) var(--radius-xl) 0 0; max-width:100%; }
+ .color-picker-row { grid-template-columns:1fr; }
+ .theme-selector { grid-template-columns:repeat(3,1fr); }
+ .messages-scroll { padding:0.6rem 0.6rem 1.25rem; }
+ .header-status { max-width: 40vw; }
+ .message-actions { opacity: 1; pointer-events: auto; } /* touch devices */
+}
+
+@media (min-width:768px) {
+ .msg-stack { max-width: 68%; }
+}
+@media (min-width:1100px) {
+ .msg-stack { max-width: 62%; }
+}
+
+/* Desktop hover affordance */
+@media (hover: none) {
+ .message-actions { opacity: 1; pointer-events: auto; }
+}
\ No newline at end of file