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 = '
Loading…
'; - 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 `
- ${fname} -
`; - } - 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}
- `; - 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