// ============================================================================ // 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 = '3.0.0'; // ============================================================ // Safe storage // Storage access can throw in sandboxed iframes / private mode / // when cookies are blocked - that must never kill the whole app. // Falls back to an in-memory store for the lifetime of the tab. // ============================================================ const memStore = new Map(); const store = { get(k) { try { return localStorage.getItem(k); } catch (e) { return memStore.has(k) ? memStore.get(k) : null; } }, set(k, v) { try { localStorage.setItem(k, v); } catch (e) { memStore.set(k, v); } }, remove(k) { try { localStorage.removeItem(k); } catch (e) { memStore.delete(k); } } }; // ============================================================ // State // ============================================================ const State = { token: store.get('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(), blockedUserIds: new Set(), // users this account has blocked hiddenUserIds: new Set(), // users hiding chats in either block direction isAtBottom: true, latestUnseen: null, unreadTotal: 0, theme: store.get('theme') || 'light', customColors: { self: store.get('custom-self') || '#2563EB', other: store.get('custom-other') || '#F3F4F6', text: store.get('custom-text') || '#111827' }, prefs: { autoScroll: store.get('pref-autoscroll') !== 'false', showScrollBtn: store.get('pref-scrollbtn') !== 'false', showLatestBar: store.get('pref-latestbar') !== 'false', sound: store.get('pref-sound') !== 'false', enterSend: store.get('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'), privateChatBtn: $('private-chat-btn'), groupBtn: $('group-btn'), groupModal: $('group-modal'), groupNameInput: $('group-name-input'), groupUserSearch: $('group-user-search'), groupUserResults: $('group-user-results'), groupSelectedList: $('group-selected-list'), createGroupBtn: $('create-group-btn'), convOptionsBtn: $('conv-options-btn'), convOptionsModal: $('conv-options-modal'), convOptionsTitle: $('conv-options-title'), renameConvBtn: $('rename-conv-btn'), addGroupMembersBtn: $('add-group-members-btn'), deleteConvBtn: $('delete-conv-btn'), blockConvUserBtn: $('block-conv-user-btn'), leaveConvBtn: $('leave-conv-btn'), refreshBlocksBtn: $('refresh-blocks-btn'), blockedUsersList: $('blocked-users-list'), settingsDisplayName: $('settings-display-name'), settingsUsername: $('settings-username'), settingsBio: $('settings-bio'), settingsLocation: $('settings-location'), settingsWebsite: $('settings-website'), 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'), convUserSearch: $('conv-user-search'), 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 debounce(fn, ms) { let t = null; return function (...args) { clearTimeout(t); t = setTimeout(() => fn.apply(this, args), ms); }; } 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; store.set('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() { store.set('pref-autoscroll', State.prefs.autoScroll); store.set('pref-scrollbtn', State.prefs.showScrollBtn); store.set('pref-latestbar', State.prefs.showLatestBar); store.set('pref-sound', State.prefs.sound); store.set('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; store.set('token', State.token); showChat(); connectWebSocket(); initSocial(); } 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); // periodic presence refresh so a missed status event can't leave the // online list/badge stale (e.g. after a background tab is throttled) setInterval(() => { if (wsOpen()) wsSend({ type: 'get_online_users' }); }, 60000); 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 conversationTouchesBlocked(summary) { if (!summary || summary.id === GLOBAL_CONV) return false; if (summary.type === 'dm' && summary.peer && State.hiddenUserIds.has(summary.peer.id)) return true; if (Array.isArray(summary.members) && summary.members.some(m => State.hiddenUserIds.has(m.id))) return true; return false; } function upsertConversation(summary) { if (conversationTouchesBlocked(summary)) { State.conversations.delete(summary.id); return; } 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 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', (e) => { if (e.target.closest('[data-invite]')) return; switchConversation(parseInt(el.dataset.id, 10)); closeSidebar(); }); }); DOM.convList.querySelectorAll('[data-invite]').forEach(btn => { btn.addEventListener('click', async () => { const item = btn.closest('.conv-item'); const cid = parseInt(item.dataset.id, 10); const action = btn.dataset.invite; await respondToInvite(cid, action); }); }); 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 isGroup = !!c.is_group; const name = isGroup ? (c.custom_name || c.title || 'Group Chat') : (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 pending = c.is_pending ? 'Invite' : ''; const timeCls = unread > 0 ? ' unread' : ''; const pendingCls = c.is_pending ? ' pending' : ''; let avatar = avatarHTML(c, 44); return `
${avatar}
${escapeHtml(name)} ${pending}
${escapeHtml(time)} ${unreadHtml}
${previewHTML(c)}
${pending ? '
' : ''}
`; } function dmLabel(c) { if (c.custom_name) return c.custom_name; 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.is_group) { return `
`; } 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); const conv = convById(convId); renderConversationHeader(convId); renderSidebar(); if (conv && conv.is_pending) { cache.loading = false; renderPendingInvite(convId); } else 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 renderPendingInvite(convId) { DOM.messagesList.innerHTML = ''; if (DOM.emptyState.parentNode !== DOM.messagesList) DOM.messagesList.appendChild(DOM.emptyState); const conv = convById(convId); const name = conv ? (conv.custom_name || conv.title || (conv.peer ? displayNameOf(conv.peer) : 'this chat')) : 'this chat'; DOM.emptyIcon.innerHTML = ''; DOM.emptyTitle.textContent = 'Invite pending'; DOM.emptySub.textContent = `Accept the invite for ${name} from the sidebar to start chatting.`; 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; const isGroup = !!conv.is_group; if (dm && peer && !isGroup) { 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); if (conv.peer_removed) { DOM.convSubtitle.innerHTML = ' They no longer want to chat'; } else if (conv.is_pending) { DOM.convSubtitle.innerHTML = ' Invite pending — accept to start chatting'; } else { DOM.convSubtitle.innerHTML = online ? ' Online' : escapeHtml(formatLastSeen(peer.last_seen)); } DOM.onlineBtn.style.display = ''; DOM.convAvatarPh.style.display = ''; DOM.convAvatar.style.display = ''; } else if (isGroup) { DOM.convTitle.textContent = conv.custom_name || conv.title || 'Group Chat'; const gm = conv.members && conv.members.length ? conv.members.filter(m => m.status === 'accepted') : []; const onlineCount = gm.filter(m => m.online).length; DOM.convAvatar.classList.add('hidden'); DOM.convAvatarPh.classList.remove('hidden'); DOM.convAvatarPh.className = 'conv-avatar-ph avatar-c2'; DOM.convAvatarPh.innerHTML = ''; DOM.convPresenceDot.classList.add('hidden'); const pending = conv.is_pending ? ' — invite pending' : ''; const label = `${gm.length} member${gm.length === 1 ? '' : 's'} · ${onlineCount} online${pending}`; DOM.convSubtitle.innerHTML = ` ${escapeHtml(label)}`; 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 = onlineCount(); DOM.convSubtitle.innerHTML = ` ${meCount} online`; DOM.onlineBtn.style.display = ''; } DOM.convOptionsBtn.style.display = convId === GLOBAL_CONV ? 'none' : ''; } 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); updateAllPeerPresence(); requestOnlineUsers(); if (State.activeConvId) renderConversationHeader(State.activeConvId); if (!DOM.onlineModal.classList.contains('hidden')) renderOnlineUsers(); } 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(); updateAllPeerPresence(); loadBlockedUsers(); if (!DOM.onlineModal.classList.contains('hidden')) renderOnlineUsers(); // 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); } if (msg.invite_rejected && msg.rejected_user_id && msg.rejected_user_id !== State.user?.id) { showToast('The invite was declined', 'info'); } break; case 'conversation_deleted': { const cid = msg.conversation_id; State.conversations.delete(cid); State.convCache.delete(cid); State.messages.forEach(m => { if (m.conversation_id === cid) State.messages.delete(m.id); }); if (State.activeConvId === cid) switchConversation(GLOBAL_CONV); renderSidebar(); showToast('The chat was deleted', 'info'); break; } case 'conversation_left': { const cid = msg.conversation_id; if (msg.user_id === State.user?.id) { State.conversations.delete(cid); State.convCache.delete(cid); if (State.activeConvId === cid) switchConversation(GLOBAL_CONV); } else if (msg.conversation) { upsertConversation(msg.conversation); if (msg.reason === 'deleted') { const peer = msg.conversation.peer; showToast(`${peer ? displayNameOf(peer) : 'The other person'} no longer wants to chat`, 'info'); } } renderSidebar(); break; } case 'block_changed': { // A block hides all non-global conversations with that person on // both sides (rows stay in the database). On unblock the hidden // chats reappear after a refresh. if (msg.blocked) { State.hiddenUserIds.add(msg.blocker_id); hideConversationsWithUser(msg.blocker_id); renderSidebar(); showToast('Conversations with this person are now hidden', 'info'); } else { // Removing the hidden mark is safe: the server still enforces // any block that remains in the other direction. State.hiddenUserIds.delete(msg.blocker_id); requestConversations(); } 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)} started — waiting for them to accept`, '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': { const updated = { id: msg.user_id, username: msg.username, display_name: msg.display_name, avatar_path: msg.avatar_path }; if (msg.status === 'online') { State.onlineUsers.set(msg.user_id, updated); applyUserProfileToConversations(updated); } else { State.onlineUsers.delete(msg.user_id); } updateOnlineBadge(); updatePeerPresence(msg.user_id, msg.status === 'online'); renderSidebar(); 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 'profile_updated': { const updated = { id: msg.user_id, username: msg.username, display_name: msg.display_name, avatar_path: msg.avatar_path, }; if (updated.id === State.user?.id) { State.user.display_name = updated.display_name; State.user.avatar_path = updated.avatar_path; updateSidebarMe(); } State.onlineUsers.set(updated.id, updated); updateOnlineBadge(); applyUserProfileToConversations(updated); updateAllPeerPresence(); renderSidebar(); if (State.activeConvId) renderConversationHeader(State.activeConvId); 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 applyUserProfileToConversations(user) { if (!user || !user.id) return; State.conversations.forEach(c => { if (c.type === 'dm' && c.peer && c.peer.id === user.id) { if (user.username) c.peer.username = user.username; if (user.display_name) c.peer.display_name = user.display_name; if ('avatar_path' in user) c.peer.avatar_path = user.avatar_path; } }); } function hideConversationsWithUser(userId) { if (!userId) return; const activeWasHidden = State.conversations.has(State.activeConvId) && State.activeConvId !== GLOBAL_CONV && (() => { const c = State.conversations.get(State.activeConvId); if (!c) return false; return (c.type === 'dm' && c.peer && c.peer.id === userId) || (Array.isArray(c.members) && c.members.some(m => m.id === userId)); })(); State.conversations.forEach((c, cid) => { if (cid === GLOBAL_CONV) return; const isDm = c.type === 'dm' && c.peer && c.peer.id === userId; const isGroup = Array.isArray(c.members) && c.members.some(m => m.id === userId); if (isDm || isGroup) State.conversations.delete(cid); }); if (activeWasHidden && State.activeConvId !== GLOBAL_CONV) switchConversation(GLOBAL_CONV); } 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; } const active = convById(State.activeConvId); if (active && active.is_pending) { showToast('Accept the invite first', '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; DOM.convUserSearch.classList.add('hidden'); DOM.newChatBtn.style.display = ''; renderConvModal(existing); openModal(DOM.convModal); } function openPrivateChatPicker() { DOM.convModal.dataset.peerId = ''; DOM.convModal.dataset.peerName = ''; DOM.convModalDesc.innerHTML = 'Start a private chat with someone'; DOM.convUserSearch.classList.remove('hidden'); DOM.convUserSearch.value = ''; DOM.newChatBtn.style.display = 'none'; DOM.convModalList.innerHTML = '
Loading users…
'; loadPrivateUsers(''); openModal(DOM.convModal); } function renderPrivateUsers(users) { if (!users || !users.length) { DOM.convModalList.innerHTML = '
No one found
'; return; } DOM.convModalList.innerHTML = users.filter(u => u.id !== State.user?.id).map(u => { const existing = [...State.conversations.values()].filter( c => c.type === 'dm' && c.peer && c.peer.id === u.id ); const count = existing.length ? ` · ${existing.length} chat${existing.length === 1 ? '' : 's'}` : ''; const av = u.avatar_path ? `` : `${initialOf(displayNameOf(u))}`; return ``; }).join('') || '
No one found
'; DOM.convModalList.querySelectorAll('[data-pick-user]').forEach(btn => { btn.addEventListener('click', () => { const u = users.find(x => x.id === parseInt(btn.dataset.pickUser, 10)); if (!u) return; if (u.blocked) { showToast('You have blocked this user', 'warning'); return; } openConvModal(u.id, displayNameOf(u)); }); }); } async function loadPrivateUsers(query = '') { try { const data = await apiGet(`/api/users?query=${encodeURIComponent(query)}`); renderPrivateUsers(data.users || []); } catch (err) { DOM.convModalList.innerHTML = `
${escapeHtml(err.message)}
`; } } DOM.privateChatBtn.addEventListener('click', openPrivateChatPicker); DOM.convUserSearch.addEventListener('input', debounce(() => { loadPrivateUsers(DOM.convUserSearch.value.trim()); }, 250)); 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 — waiting for them to accept`, '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 uploadFileChunked(file, onProgress) { if (file.size > 500 * 1024 * 1024) { throw new Error('File too large (max 500MB)'); } const CHUNK_SIZE = 5 * 1024 * 1024; const totalChunks = Math.max(1, Math.ceil(file.size / CHUNK_SIZE)); const uploadId = generateId(); if (onProgress) onProgress(0, file.name, file.size, totalChunks); 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}`; 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(); const percent = ((i + 1) / totalChunks) * 100; if (onProgress) onProgress(percent, file.name, file.size, totalChunks); if (data.status === 'complete') { return { file_path: data.file_path, file_type: data.file_type || file.type, file_name: data.file_name, file_size: data.file_size }; } } throw new Error('Upload did not complete'); } async function uploadChunked(file) { try { DOM.uploadProgress.classList.remove('hidden'); updateProgress(0, file.name, file.size); State.pendingFile = await uploadFileChunked(file, (pct) => updateProgress(pct, file.name, file.size)); showUploadPreview(State.pendingFile); DOM.uploadProgress.classList.add('hidden'); DOM.sendBtn.disabled = false; DOM.messageInput.focus(); } catch (err) { showToast('Upload failed: ' + err.message, 'error'); 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 onlineCount() { return State.onlineUsers.size; } function updateOnlineBadge() { const count = onlineCount(); DOM.onlineBadge.textContent = count; DOM.onlineBadge.classList.toggle('hidden', count === 0); } function renderOnlineUsers() { DOM.onlineUsersList.innerHTML = ''; const count = onlineCount(); if (count === 0) { DOM.onlineUsersList.innerHTML = '
No users online
'; DOM.onlineTotal.textContent = '0 users online'; return; } State.onlineUsers.forEach(u => { const me = u.id === State.user?.id; 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 = `${count} user${count === 1 ? '' : 's'} online`; } // ============================================================ // Group chats, invites, conversation options and blocking // ============================================================ const groupSelected = new Set(); let groupUsersCache = []; async function apiGet(url) { const res = await fetch(`${API}${url}`, { headers: { 'X-Auth-Token': State.token } }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.detail || `Request failed (${res.status})`); return data; } async function apiPost(url, body) { const res = await fetch(`${API}${url}`, { method: 'POST', headers: { 'X-Auth-Token': State.token }, body }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.detail || `Request failed (${res.status})`); return data; } async function apiPatch(url) { const res = await fetch(`${API}${url}`, { method: 'PATCH', headers: { 'X-Auth-Token': State.token } }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.detail || `Request failed (${res.status})`); return data; } async function apiDelete(url) { const res = await fetch(`${API}${url}`, { method: 'DELETE', headers: { 'X-Auth-Token': State.token } }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.detail || `Request failed (${res.status})`); return data; } async function respondToInvite(cid, action) { try { const path = action === 'accept' ? `accept` : `reject`; const res = await fetch(`${API}/api/conversations/${cid}/${path}`, { method: 'POST', headers: { 'X-Auth-Token': State.token } }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.detail || 'Could not update invite'); if (action === 'accept') { upsertConversation(data.conversation); switchConversation(cid); showToast('Invite accepted', 'success'); } else { State.conversations.delete(cid); State.convCache.delete(cid); renderSidebar(); showToast('Invite declined', 'info'); } } catch (err) { showToast(err.message, 'error'); } } function renderGroupUserResults(list) { if (!list || !list.length) { DOM.groupUserResults.innerHTML = '
No results
'; return; } DOM.groupUserResults.innerHTML = ''; list.forEach(u => { if (u.id === State.user?.id) return; const selected = groupSelected.has(u.id); const div = document.createElement('div'); div.className = 'conv-modal-item group-user-item' + (selected ? ' selected' : ''); div.dataset.uid = u.id; const av = u.avatar_path ? `` : `${initialOf(displayNameOf(u))}`; div.innerHTML = `
${av}
${escapeHtml(displayNameOf(u))}
${escapeHtml(u.username)}${u.online ? ' · online' : ''}${u.blocked ? ' · blocked' : ''}
`; div.addEventListener('click', () => { if (u.blocked) { showToast('You have blocked this user', 'warning'); return; } if (groupSelected.has(u.id)) groupSelected.delete(u.id); else groupSelected.add(u.id); renderGroupUserResults(list); renderGroupSelected(); }); DOM.groupUserResults.appendChild(div); }); } function renderGroupSelected() { const people = groupUsersCache.filter(u => groupSelected.has(u.id)); if (!people.length) { DOM.groupSelectedList.innerHTML = ''; return; } DOM.groupSelectedList.innerHTML = people.map(u => `${escapeHtml(displayNameOf(u))}` ).join(''); DOM.groupSelectedList.querySelectorAll('[data-remove-group]').forEach(btn => { btn.addEventListener('click', () => { groupSelected.delete(parseInt(btn.dataset.removeGroup, 10)); renderGroupSelected(); renderGroupUserResults(groupUsersCache); }); }); } async function loadGroupUsers(query = '') { try { const data = await apiGet(`/api/users?query=${encodeURIComponent(query)}`); groupUsersCache = data.users || []; renderGroupUserResults(groupUsersCache); renderGroupSelected(); } catch (err) { showToast(err.message, 'error'); } } function openGroupModal() { groupSelected.clear(); DOM.groupNameInput.value = ''; DOM.groupNameInput.hidden = false; DOM.groupNameInput.closest('.form-group').hidden = false; DOM.groupUserSearch.value = ''; DOM.groupUserSearch.placeholder = 'Search by name…'; DOM.createGroupBtn.dataset.mode = 'create'; DOM.createGroupBtn.innerHTML = ' Create Group'; loadGroupUsers(); openModal(DOM.groupModal); } DOM.groupBtn.addEventListener('click', openGroupModal); DOM.groupUserSearch.addEventListener('input', debounce(() => { loadGroupUsers(DOM.groupUserSearch.value.trim()); }, 250)); DOM.createGroupBtn.addEventListener('click', async () => { const mode = DOM.createGroupBtn.dataset.mode || 'create'; if (mode === 'add-members') { const cid = parseInt(DOM.groupModal.dataset.groupId, 10); const ids = [...groupSelected].join(','); if (!ids) { showToast('Select people to add', 'warning'); return; } DOM.createGroupBtn.disabled = true; try { await apiPost(`/api/conversations/${cid}/members?member_ids=${encodeURIComponent(ids)}`); closeModal(DOM.groupModal); const data = await apiGet('/api/conversations'); data.conversations.forEach(c => upsertConversation(c)); if (State.activeConvId === cid) renderConversationHeader(cid); showToast('Invites sent', 'success'); } catch (err) { showToast(err.message, 'error'); } finally { DOM.createGroupBtn.disabled = false; } return; } const name = DOM.groupNameInput.value.trim(); if (!name) { showToast('Enter a group name', 'warning'); return; } if (!groupSelected.size) { showToast('Select at least one person', 'warning'); return; } DOM.createGroupBtn.disabled = true; DOM.createGroupBtn.innerHTML = '
Creating…'; try { const ids = [...groupSelected].join(','); const data = await apiPost(`/api/conversations/group?name=${encodeURIComponent(name)}&member_ids=${encodeURIComponent(ids)}`); upsertConversation(data.conversation); closeModal(DOM.groupModal); switchConversation(data.conversation.id); showToast('Group created and people invited', 'success'); } catch (err) { showToast(err.message, 'error'); } finally { DOM.createGroupBtn.disabled = false; DOM.createGroupBtn.innerHTML = ' Create Group'; } }); function openConvOptions() { const conv = convById(State.activeConvId); if (!conv || State.activeConvId === GLOBAL_CONV) return; const pending = !!conv.is_pending; DOM.renameConvBtn.style.display = pending ? 'none' : ''; DOM.addGroupMembersBtn.style.display = conv.is_group && !pending ? '' : 'none'; DOM.deleteConvBtn.style.display = (!conv.is_group && conv.peer && !pending) ? '' : 'none'; DOM.blockConvUserBtn.style.display = (!conv.is_group && conv.peer) ? '' : 'none'; DOM.leaveConvBtn.style.display = (conv.is_group && !pending) ? '' : 'none'; DOM.convOptionsTitle.innerHTML = ` ${escapeHtml(conv.custom_name || conv.title || 'Chat Options')}`; openModal(DOM.convOptionsModal); } DOM.convOptionsBtn.addEventListener('click', openConvOptions); DOM.renameConvBtn.addEventListener('click', async () => { const conv = convById(State.activeConvId); if (!conv) return; const name = prompt('Rename this chat (everyone in the chat sees it):', conv.custom_name || conv.title || ''); if (!name || !name.trim()) return; closeModal(DOM.convOptionsModal); try { const data = await apiPatch(`/api/conversations/${conv.id}?name=${encodeURIComponent(name.trim())}`); upsertConversation(data.conversation); renderConversationHeader(State.activeConvId); showToast('Chat renamed', 'success'); } catch (err) { showToast(err.message, 'error'); } }); DOM.addGroupMembersBtn.addEventListener('click', async () => { const conv = convById(State.activeConvId); closeModal(DOM.convOptionsModal); if (!conv || !conv.is_group) return; groupSelected.clear(); DOM.groupNameInput.value = ''; DOM.groupNameInput.hidden = true; DOM.groupNameInput.closest('.form-group').hidden = true; DOM.groupUserSearch.value = ''; DOM.groupUserSearch.placeholder = 'Add more people…'; DOM.createGroupBtn.innerHTML = ' Add Members'; DOM.createGroupBtn.dataset.mode = 'add-members'; DOM.groupModal.dataset.groupId = conv.id; loadGroupUsers(); openModal(DOM.groupModal); }); DOM.deleteConvBtn.addEventListener('click', async () => { const conv = convById(State.activeConvId); closeModal(DOM.convOptionsModal); if (!conv || conv.is_group || !conv.peer) return; const ok = await showConfirm({ title: 'Delete private chat', message: `Delete this chat with ${displayNameOf(conv.peer)}? It will be hidden for you. If they also delete it, it is removed for everyone. They will be told that you no longer want to chat.`, okText: 'Delete Chat' }); if (!ok) return; try { const data = await apiDelete(`/api/conversations/${conv.id}`); State.conversations.delete(conv.id); State.convCache.delete(conv.id); if (State.activeConvId === conv.id) switchConversation(GLOBAL_CONV); renderSidebar(); showToast(data.status === 'deleted' ? 'Chat deleted' : 'Chat hidden for you', 'success'); requestConversations(); } catch (err) { showToast(err.message, 'error'); } }); DOM.blockConvUserBtn.addEventListener('click', async () => { const conv = convById(State.activeConvId); closeModal(DOM.convOptionsModal); if (!conv || !conv.peer) return; if (!await showConfirm({ title: 'Block person', message: `Block ${displayNameOf(conv.peer)}? They cannot message or add you to private/group chats, and all chats with them will be hidden until you unblock them. You can still talk in Global Chat.`, okText: 'Block' })) return; try { await apiPost(`/api/users/${conv.peer.id}/block`); State.blockedUserIds.add(conv.peer.id); State.hiddenUserIds.add(conv.peer.id); hideConversationsWithUser(conv.peer.id); renderSidebar(); showToast('User blocked', 'success'); requestConversations(); } catch (err) { showToast(err.message, 'error'); } }); DOM.leaveConvBtn.addEventListener('click', async () => { const conv = convById(State.activeConvId); closeModal(DOM.convOptionsModal); if (!conv || !conv.is_group) return; const isOwner = conv.is_owner; const ok = await showConfirm({ title: isOwner ? 'Delete group' : 'Leave group', message: isOwner ? 'This permanently deletes the group and its messages for everyone. This cannot be undone.' : 'Leave this group? You can be added again later if a member invites you.', okText: isOwner ? 'Delete Group' : 'Leave Group' }); if (!ok) return; try { await apiDelete(`/api/conversations/${conv.id}`); State.conversations.delete(conv.id); State.convCache.delete(conv.id); if (State.activeConvId === conv.id) switchConversation(GLOBAL_CONV); renderSidebar(); showToast(isOwner ? 'Group deleted' : 'Left group', 'success'); } catch (err) { showToast(err.message, 'error'); } }); DOM.refreshBlocksBtn.addEventListener('click', loadBlockedUsers); async function loadBlockedUsers() { try { const data = await apiGet('/api/users/blocked'); const users = data.users || []; State.blockedUserIds.clear(); users.forEach(u => State.blockedUserIds.add(u.id)); State.hiddenUserIds = new Set(State.blockedUserIds); if (!users.length) { DOM.blockedUsersList.innerHTML = '
Nobody is blocked.
'; return; } DOM.blockedUsersList.innerHTML = users.map(u => `
${u.avatar_path ? `` : `${initialOf(displayNameOf(u))}`}
`).join(''); DOM.blockedUsersList.querySelectorAll('[data-unblock]').forEach(btn => { btn.addEventListener('click', async () => { try { await apiDelete(`/api/users/${btn.dataset.unblock}/block`); const unblockedId = parseInt(btn.dataset.unblock, 10); State.blockedUserIds.delete(unblockedId); State.hiddenUserIds.delete(unblockedId); showToast('User unblocked', 'success'); loadBlockedUsers(); requestConversations(); } catch (err) { showToast(err.message, 'error'); } }); }); } catch (err) { DOM.blockedUsersList.innerHTML = `
${escapeHtml(err.message)}
`; } } // ============================================================ // Settings // ============================================================ function openSettings() { DOM.settingsDisplayName.value = State.user?.display_name || ''; DOM.settingsUsername.value = State.user?.username || ''; DOM.settingsBio.value = State.user?.bio || ''; DOM.settingsLocation.value = State.user?.location || ''; DOM.settingsWebsite.value = State.user?.website || ''; 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(); loadBlockedUsers(); openModal(DOM.settingsModal); } DOM.settingsBtn.addEventListener('click', openSettings); DOM.meSettingsBtn.addEventListener('click', openSettings); async function saveSettings() { const newName = DOM.settingsDisplayName.value.trim(); const newBio = DOM.settingsBio.value.trim(); const newLocation = DOM.settingsLocation.value.trim(); const newWebsite = DOM.settingsWebsite.value.trim(); const profileChanged = (newName && newName !== State.user?.display_name) || newBio !== (State.user?.bio || '') || newLocation !== (State.user?.location || '') || newWebsite !== (State.user?.website || ''); if (profileChanged) { try { const params = []; if (newName && newName !== State.user?.display_name) params.push('display_name=' + encodeURIComponent(newName)); if (newBio !== (State.user?.bio || '')) params.push('bio=' + encodeURIComponent(newBio)); if (newLocation !== (State.user?.location || '')) params.push('location=' + encodeURIComponent(newLocation)); if (newWebsite !== (State.user?.website || '')) params.push('website=' + encodeURIComponent(newWebsite)); const res = await fetch(`${API}/api/profile?${params.join('&')}`, { method: 'PATCH', headers: { 'X-Auth-Token': State.token } }); if (res.ok) { const data = await res.json(); if (data.user) { State.user = data.user; } if (newName) State.user.display_name = newName; State.user.bio = newBio; State.user.location = newLocation; State.user.website = newWebsite; updateSidebarMe(); renderSidebar(); renderConversationHeader(State.activeConvId); updateSocialMe(); showToast('Profile updated', 'success'); } else { const data = await res.json().catch(() => ({})); throw new Error(data.detail || 'Failed'); } } catch (e) { showToast('Failed to update profile', '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; store.set('custom-self', State.customColors.self); store.set('custom-other', State.customColors.other); store.set('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 // ============================================================ // ============================================================ // Social feed page (Twitter/X-like) // ============================================================ const MAX_POST_MEDIA = 4; const DEFAULT_SOCIAL_SETTINGS = { autoplay: false, muted: true, hideReposts: false, compact: false, live: true, badges: true, defaultFeed: 'home' }; function loadSocialSettings() { let out = { ...DEFAULT_SOCIAL_SETTINGS }; try { const raw = store.get('social-settings'); if (raw) Object.assign(out, JSON.parse(raw)); } catch (e) { /* defaults */ } return out; } function saveSocialSettings() { store.set('social-settings', JSON.stringify(SocialState.settings)); } const SocialState = { inited: false, view: 'home', feedCursor: null, media: [], modalMedia: [], modalMode: 'compose', replyTarget: null, quoteTarget: null, profileUser: null, posts: new Map(), following: new Set(), isSearching: false, searchTimer: null, pollTimer: null, editingPost: null, settings: loadSocialSettings() }; function _el(id) { return document.getElementById(id); } function socialInitial(name) { return initialOf(name || '?'); } function socialAvatarHtml(user, cls) { const url = user && user.avatar_path ? fileUrl(user.avatar_path) : ''; if (url) return ``; const name = displayNameOf(user); return ``; } function socialTime(ms) { if (!ms) return ''; const d = new Date(ms), n = new Date(); const diff = n - d; if (diff < 60000) return 'now'; if (diff < 3600000) return Math.floor(diff/60000) + 'm'; if (d.toDateString() === n.toDateString()) return d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}); if (diff < 86400000 * 7) return d.toLocaleDateString([], {weekday:'short'}) + ' ' + d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}); return d.toLocaleDateString([], {month:'short', day:'numeric'}); } function socialCount(n) { if (!n) return '0'; if (n >= 1000000) return (n/1000000).toFixed(1).replace(/\.0$/,'') + 'M'; if (n >= 1000) return (n/1000).toFixed(1).replace(/\.0$/,'') + 'K'; return String(n); } function linkifySocial(text) { if (!text) return ''; let html = escapeHtml(text); html = html.replace(/"/g, '"').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); html = html.replace(/(^|[\s(])(#([A-Za-z0-9_]+))/g, (m, pre, tag, body) => pre + ``); html = html.replace(/(^|[\s(])(@([A-Za-z0-9_]{1,30}))/g, (m, pre, handle, body) => pre + ``); html = html.replace(/(https?:\/\/[^\s<]+)/g, (u) => `${escapeHtml(u)}`); return html; } function socialMediaHtml(media) { if (!media || !media.length) return ''; const items = media.map(m => { const fp = m.file_path || ''; const name = (m.file_name || fp || '').split('/').pop(); const type = (m.file_type || '').toLowerCase(); const img = type.startsWith('image/') || /\.(jpg|jpeg|png|gif|webp|bmp|avif)$/i.test(fp); const video = type.startsWith('video/') || /\.(mp4|webm|mov|ogg|m4v|mpeg|mpg)$/i.test(fp); let c = ''; const videoAttrs = SocialState.settings.autoplay ? (SocialState.settings.muted ? ' autoplay muted loop ' : ' autoplay ') : ''; if (img) c = `${escapeHtml(name)}`; else if (video) c = ``; else c = ``; return ``; }).join(''); return ``; } function socialPostHtml(post) { if (!post) return ''; const author = post.author || {}; const isOwn = State.user && author.id === State.user.id; const repostLabel = post.reposter ? `` : (post.reposter_id ? `` : ''); const quote = post.quote ? ` ` : ''; const actions = ` `; return ``; } function renderSocialPosts(posts, container) { const el = container || _el('social-feed'); if (!el) return; if (!posts || !posts.length) { const empty = document.createElement('div'); empty.className = 'social-feed-empty'; empty.innerHTML = '

Nothing here yet

Follow people or make the first post.

'; el.innerHTML = ''; el.appendChild(empty); return; } posts.forEach(p => SocialState.posts.set(p.id, p)); el.innerHTML = posts.map(socialPostHtml).join(''); } async function openSocial() { if (!State.user) return; _el('social-screen').classList.add('active'); DOM.chatScreen.classList.remove('active'); updateSocialMe(); if (!SocialState.inited) initSocial(); const feed = SocialState.settings.defaultFeed === 'explore' ? 'explore' : SocialState.view || 'home'; SocialState.view = feed; await SocialLoad.view(feed); startSocialPoll(); const hashMatch = location.hash.match(/^#social\/(\d+)$/); if (hashMatch) { try { const data = await apiGet(`/api/social/posts/${hashMatch[1]}`); renderSocialPosts([data.post]); _el('social-compose').style.display='none'; } catch(e) {} } } function backToChat() { SocialState.isSearching = false; SocialState.profileUser = null; stopSocialPoll(); _el('social-screen').classList.remove('active'); DOM.chatScreen.classList.add('active'); updateSidebarMe(); } function startSocialPoll() { stopSocialPoll(); if (!SocialState.settings.live) return; SocialState.pollTimer = setInterval(async () => { if (!_el('social-screen').classList.contains('active') || SocialState.isSearching) return; if (SocialState.view !== 'home' && SocialState.view !== 'explore') return; try { await SocialLoad.feed(SocialState.view, true); } catch (e) { /* non-fatal */ } }, 15000); } function stopSocialPoll() { if (SocialState.pollTimer) { clearInterval(SocialState.pollTimer); SocialState.pollTimer = null; } } function updateSocialMe() { if (!State.user) return; _el('social-me-name').textContent = displayNameOf(State.user); _el('social-me-username').textContent = '@' + (State.user.username || ''); const av = _el('social-me-avatar'), ph = _el('social-me-avatar-ph'); if (State.user.avatar_path) { av.src = fileUrl(State.user.avatar_path); av.classList.remove('hidden'); ph.classList.add('hidden'); } else { av.classList.add('hidden'); ph.classList.remove('hidden'); ph.textContent = socialInitial(displayNameOf(State.user)); } const cAv = _el('social-compose-avatar'), cPh = _el('social-compose-avatar-ph'); if (State.user.avatar_path) { cAv.src = fileUrl(State.user.avatar_path); cAv.classList.remove('hidden'); cPh.classList.add('hidden'); } else { cAv.classList.add('hidden'); cPh.classList.remove('hidden'); cPh.textContent = socialInitial(displayNameOf(State.user)); } } const SOCIAL_MEDIA_EXTS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.avif', '.mp4', '.webm', '.mov', '.m4v', '.ogg', '.mpeg', '.mpg']; function isSocialMediaFile(file) { if (!file) return false; const t = (file.type || '').toLowerCase(); const ext = ('.' + (file.name || '').split('.').pop().toLowerCase()); return t.startsWith('image/') || t.startsWith('video/') || SOCIAL_MEDIA_EXTS.includes(ext); } async function uploadSocialFile(file, onProgress) { if (!file) return null; if (!isSocialMediaFile(file)) throw new Error('Only images and videos can be attached to posts'); if (file.size > 200 * 1024 * 1024) throw new Error('Social media files must be under 200MB'); if (!State.user) throw new Error('Not signed in'); return await uploadFileChunked(file, onProgress); } function socialPreviewMedia() { const list = SocialState.media; const box = _el('social-media-preview'); if (!list.length) { box.classList.add('hidden'); box.innerHTML = ''; return; } box.classList.remove('hidden'); box.innerHTML = list.map((m, i) => ` `).join(''); } async function addSocialMedia(files, modal) { if (!files || !files.length) return; const progress = modal ? _el('social-compose-modal-progress') : _el('social-upload-progress'); const fill = progress ? progress.querySelector('.social-progress-fill') : null; const label = progress ? progress.querySelector('span') : null; if (progress) progress.classList.remove('hidden'); try { const target = modal ? SocialState.modalMedia : SocialState.media; for (const f of Array.from(files)) { if (!isSocialMediaFile(f)) { showToast(f.name + ' is not an image or video', 'warning'); continue; } if (target.length >= MAX_POST_MEDIA) { showToast('A post can contain at most ' + MAX_POST_MEDIA + ' media files', 'warning'); break; } const up = await uploadSocialFile(f, (pct, name) => { if (fill) fill.style.width = Math.round(pct) + '%'; if (label) label.textContent = 'Uploading ' + name + '… ' + Math.round(pct) + '%'; }); if (!up) continue; target.push({ file_path: up.file_path, file_name: up.file_name, file_type: up.file_type, file_size: up.file_size, preview_url: URL.createObjectURL(f) }); if (fill) fill.style.width = '0%'; } socialPreviewMedia(); if (modal) socialPreviewModalMedia(); } catch (e) { showToast(e.message, 'error'); } finally { if (progress) progress.classList.add('hidden'); if (fill) fill.style.width = '0%'; if (label) label.textContent = ''; if (modal) _el('social-compose-modal-media').value = ''; else _el('social-media-input').value = ''; } } function socialPreviewModalMedia() { const list = SocialState.modalMedia; const box = _el('social-compose-modal-media-preview'); if (!list.length) { box.classList.add('hidden'); box.innerHTML = ''; return; } box.classList.remove('hidden'); box.innerHTML = list.map((m, i) => ` `).join(''); } async function submitSocialPost() { const body = _el('social-post-input').value.trim(); if (!body && !SocialState.media.length) return; SocialState.media = SocialState.media.slice(0, MAX_POST_MEDIA); const mediaPayload = SocialState.media.map((m) => { const { preview_url, ...rest } = m; return rest; }); const url = `/api/social/posts?body=${encodeURIComponent(body)}&media_json=${encodeURIComponent(JSON.stringify(mediaPayload))}`; try { const data = await apiPost(url); if (data.post) { _el('social-post-input').value = ''; SocialState.media = []; socialPreviewMedia(); _el('social-post-btn').disabled = true; showToast('Posted!', 'success'); await SocialLoad.view(SocialState.view, true); } } catch (e) { showToast(e.message, 'error'); } } async function submitModalSocialPost() { const body = _el('social-compose-modal-input').value.trim(); const media = SocialState.modalMedia; if (!body && !media.length) return; try { let data; if (SocialState.editingPost) { if (!body.trim()) throw new Error('Post body cannot be empty'); data = await apiPatch(`/api/social/posts/${SocialState.editingPost.id}?body=${encodeURIComponent(body)}`); } else { const q = []; q.push('body=' + encodeURIComponent(body)); if (media.length) q.push('media_json=' + encodeURIComponent(JSON.stringify(media.map((m) => { const { preview_url, ...rest } = m; return rest; })))); if (SocialState.replyTarget) q.push('reply_to_id=' + socialInt(SocialState.replyTarget.id)); if (SocialState.quoteTarget) q.push('quote_id=' + socialInt(SocialState.quoteTarget.id)); data = await apiPost(`/api/social/posts?${q.join('&')}`); } if (data.post) { const wasEdit = !!SocialState.editingPost; closeModal(_el('social-compose-modal')); SocialState.editingPost = null; SocialState.modalMedia = []; SocialState.replyTarget = null; SocialState.quoteTarget = null; _el('social-compose-modal-input').value = ''; _el('social-compose-modal-context').classList.add('hidden'); socialPreviewModalMedia(); showToast(wasEdit ? 'Post updated!' : 'Posted!', 'success'); await SocialLoad.view(SocialState.view, true); } } catch (e) { showToast(e.message, 'error'); } } function openSocialEditModal(post) { if (!post) return; SocialState.editingPost = post; SocialState.modalMode = 'edit'; SocialState.replyTarget = null; SocialState.quoteTarget = null; _el('social-compose-modal-title').innerHTML = ' Edit Post'; _el('social-compose-modal-input').value = post.body || ''; _el('social-compose-modal-context').classList.add('hidden'); _el('social-compose-modal-media').value = ''; SocialState.modalMedia = []; socialPreviewModalMedia(); openModal(_el('social-compose-modal')); setTimeout(() => _el('social-compose-modal-input').focus(), 50); } function copySocialPostLink(post) { const url = `${location.origin}/#social/${post.id}`; const done = () => showToast('Post link copied', 'success'); if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(url).then(done).catch(() => { prompt('Post link', url); done(); }); } else { prompt('Post link', url); done(); } } function openSocialLightbox(name, url) { if (!url) return; DOM.lightboxName.textContent = name || 'Media'; DOM.lightboxImg.src = url; DOM.lightboxDownload.href = url; openModal(DOM.lightboxModal); } function socialInt(v) { const n = Number(v); return Number.isFinite(n) ? Math.floor(n) : null; } function openSocialComposeModal(mode, target) { SocialState.editingPost = null; SocialState.modalMode = mode || 'compose'; SocialState.replyTarget = mode === 'reply' ? target : null; SocialState.quoteTarget = mode === 'quote' ? target : null; const context = _el('social-compose-modal-context'); if (SocialState.replyTarget || SocialState.quoteTarget) { const post = SocialState.replyTarget || SocialState.quoteTarget; context.classList.remove('hidden'); const label = SocialState.replyTarget ? 'Replying to' : 'Quoting'; context.innerHTML = `${label} @${escapeHtml(post.author?.username || '')} — ${escapeHtml(truncate(post.body || '', 120))}`; } else context.classList.add('hidden'); _el('social-compose-modal-title').innerHTML = mode === 'reply' ? ' Reply' : mode === 'quote' ? ' Quote' : ' Compose'; openModal(_el('social-compose-modal')); setTimeout(() => _el('social-compose-modal-input').focus(), 50); } function userCardHtml(u) { const following = !!u.is_following; return ``; } async function toggleFollow(username, btn) { try { const data = await apiPost(`/api/social/users/${encodeURIComponent(username)}/follow`); if (data.following) { SocialState.following.add(username.toLowerCase()); } else { SocialState.following.delete(username.toLowerCase()); } document.querySelectorAll(`[data-follow="${CSS.escape(username)}"], [data-profile-follow="${CSS.escape(username)}"]`).forEach(b => { b.textContent = data.following ? 'Following' : 'Follow'; b.classList.toggle('following', data.following); }); await SocialLoad.suggestions(); } catch (e) { showToast(e.message, 'error'); } } async function deleteSocialPost(id) { try { await apiDelete(`/api/social/posts/${id}`); const el = document.querySelector(`[data-post-id="${id}"]`); if (el) el.remove(); showToast('Post deleted', 'success'); } catch (e) { showToast(e.message, 'error'); } } async function socialAction(id, action) { try { if (action === 'delete') { await deleteSocialPost(id); return; } const url = `/api/social/posts/${id}/${action}`; const data = await apiPost(url); const post = SocialState.posts.get(Number(id)); if (post) { if (action === 'like') { post.liked = data.liked; post.like_count = data.like_count; } if (action === 'repost') { post.reposted = data.reposted; post.repost_count = data.repost_count; } if (action === 'bookmark') { post.bookmarked = data.bookmarked; post.bookmark_count = data.bookmark_count || post.bookmark_count; } } const card = document.querySelector(`[data-post-id="${id}"]`); if (card) { const b = card.querySelector(`[data-action="${action}"]`); if (b) { if (action === 'like') b.classList.toggle('active', data.liked); if (action === 'repost') b.classList.toggle('active', data.reposted); if (action === 'bookmark') b.classList.toggle('active', data.bookmarked); const span = b.querySelector('span'); if (span && action !== 'bookmark') span.textContent = socialCount(data.like_count != null ? data.like_count : data.repost_count); } } } catch (e) { showToast(e.message, 'error'); } } async function openProfile(username) { if (!username) return; try { SocialState.profileUser = { username }; SocialState.isSearching = false; const prof = await apiGet(`/api/social/profile/${encodeURIComponent(username)}`); const posts = await apiGet(`/api/social/users/${encodeURIComponent(username)}/posts`); SocialState.profileUser = prof.profile; SocialState.posts.clear(); (posts.posts || []).forEach(p => SocialState.posts.set(p.id, p)); _el('social-compose').style.display = 'none'; _el('social-page-title').textContent = '@' + username; _el('social-page-sub').textContent = displayNameOf(prof.profile) + "'s posts"; document.querySelectorAll('.social-nav-link').forEach(l => l.classList.toggle('active', l.dataset.socialView === 'profile' && SocialState.user?.username === username)); const p = prof.profile; const followBtn = p.is_self ? '' : ``; const header = ` `; renderSocialPosts([], _el('social-feed')); _el('social-feed').innerHTML = header + (posts.posts && posts.posts.length ? posts.posts.map(socialPostHtml).join('') : ''); _el('social-search-input').value = ''; _el('social-search-clear').classList.add('hidden'); } catch (e) { showToast(e.message, 'error'); } } const SocialLoad = { async view(name, force) { SocialState.isSearching = false; SocialState.profileUser = null; SocialState.view = name; document.querySelectorAll('.social-nav-link').forEach(l => l.classList.toggle('active', l.dataset.socialView === name)); const titles = { home: ['Home', 'Posts from people you follow'], explore: ['Explore', 'Discover what is happening'], notifications: ['Notifications', 'Your social activity'], bookmarks: ['Bookmarks', 'Your saved posts'], profile: ['My Profile', 'Your public profile'] }; const t = titles[name] || ['Home', '']; _el('social-page-title').textContent = t[0]; _el('social-page-sub').textContent = t[1]; _el('social-compose').style.display = (name === 'home' || name === 'explore') ? '' : 'none'; _el('social-search-input').value = ''; _el('social-search-clear').classList.add('hidden'); if (name === 'notifications') { await this.notifications(); return; } if (name === 'bookmarks') { await this.bookmarks(); return; } if (name === 'profile') { await openProfile(State.user.username); return; } const feed = name === 'explore' ? 'explore' : 'home'; await this.feed(feed, force); }, async feed(feed, force) { const loading = _el('social-loading'); loading.classList.remove('hidden'); try { const data = await apiGet(`/api/social/feed?feed=${feed}&limit=50`); if (!force) SocialState.posts.clear(); let posts = data.posts || []; if (feed === 'home' && SocialState.settings.hideReposts) posts = posts.filter(p => !p.reposter_id); renderSocialPosts(posts); } catch (e) { showToast(e.message, 'error'); } finally { loading.classList.add('hidden'); } }, async notifications() { try { const data = await apiGet(`/api/social/notifications`); const list = data.notifications || []; if (!list.length) { renderSocialPosts([], _el('social-feed')); return; } const unread = list.filter(n => !n.read).length; _el('social-unread-badge').textContent = unread; _el('social-unread-badge').classList.toggle('hidden', !unread || !SocialState.settings.badges); _el('social-feed').innerHTML = list.map(n => ` `).join(''); await apiPost(`/api/social/notifications/read`); _el('social-unread-badge').classList.add('hidden'); } catch (e) { showToast(e.message, 'error'); } }, async bookmarks() { try { const data = await apiGet(`/api/social/bookmarks`); renderSocialPosts(data.posts || []); } catch (e) { showToast(e.message, 'error'); } }, async trending() { try { const data = await apiGet(`/api/social/trending`); const box = _el('social-trending-list'); if (!data.trending || !data.trending.length) { box.innerHTML = '
No trends yet
'; return; } box.innerHTML = data.trending.slice(0, 8).map(t => ``).join(''); } catch (e) { /* non-fatal */ } }, async suggestions() { try { const data = await apiGet(`/api/social/suggestions?limit=5`); const box = _el('social-suggest-list'); if (!data.users || !data.users.length) { box.innerHTML = '
You are all caught up
'; return; } box.innerHTML = data.users.map(userCardHtml).join(''); } catch (e) { box.innerHTML = '
Could not load suggestions
'; } } }; async function socialSearch(q) { q = (q || '').trim(); _el('social-search-clear').classList.toggle('hidden', !q); if (!q) { await SocialLoad.view(SocialState.view || 'home'); return; } SocialState.isSearching = true; _el('social-compose').style.display = 'none'; _el('social-page-title').textContent = 'Search'; _el('social-page-sub').textContent = 'Results for "' + q + '"'; try { const data = await apiGet(`/api/social/search?q=${encodeURIComponent(q)}`); (data.posts || []).forEach(p => SocialState.posts.set(p.id, p)); const users = (data.users || []).map(userCardHtml).join(''); const posts = (data.posts || []).map(socialPostHtml).join(''); _el('social-feed').innerHTML = (users ? `` : '') + (posts ? `` : (!users && !posts ? '' : '')); } catch (e) { showToast(e.message, 'error'); } } function initSocial() { if (SocialState.inited) return; SocialState.inited = true; updateSocialMe(); const sc = _el('social-screen'); if (!sc) return; _el('social-back-btn').addEventListener('click', backToChat); _el('social-btn')?.addEventListener('click', openSocial); _el('social-compose-nav-btn')?.addEventListener('click', () => openSocialComposeModal()); _el('social-post-btn')?.addEventListener('click', submitSocialPost); document.querySelectorAll('.social-nav-link').forEach(l => l.addEventListener('click', () => SocialLoad.view(l.dataset.socialView))); _el('social-media-input')?.addEventListener('change', e => addSocialMedia(e.target.files, false)); _el('social-compose-modal-media')?.addEventListener('change', e => addSocialMedia(e.target.files, true)); _el('social-compose-modal-submit')?.addEventListener('click', submitModalSocialPost); _el('social-search-input')?.addEventListener('input', debounce(e => socialSearch(e.target.value), 350)); _el('social-search-clear')?.addEventListener('click', () => { _el('social-search-input').value=''; socialSearch(''); }); _el('social-settings-btn')?.addEventListener('click', openSocialSettings); _el('social-settings-save')?.addEventListener('click', closeSocialSettingsAndSave); _el('social-post-input')?.addEventListener('input', () => { _el('social-post-btn').disabled = !_el('social-post-input').value.trim() && !SocialState.media.length; _el('social-post-count').textContent = _el('social-post-input').value.length ? `${_el('social-post-input').value.length}/28000` : ''; }); _el('social-feed')?.addEventListener('click', async (e) => { const fbtn = e.target.closest('[data-follow]'), pbtn = e.target.closest('[data-profile-follow]'); if (fbtn) { await toggleFollow(fbtn.dataset.follow, fbtn); return; } if (pbtn) { await toggleFollow(pbtn.dataset.profileFollow, pbtn); return; } const profile = e.target.closest('[data-username]'); if (profile && !e.target.closest('.sp-action') && !e.target.closest('.social-follow-btn')) { openProfile(profile.dataset.username); return; } const mention = e.target.closest('[data-username]'); if (mention) { openProfile(mention.dataset.username); return; } const hashtag = e.target.closest('[data-hashtag]'); if (hashtag) { socialSearch('#' + decodeURIComponent(hashtag.dataset.hashtag)); _el('social-search-input').value = '#' + decodeURIComponent(hashtag.dataset.hashtag); return; } const mediaImg = e.target.closest('[data-media-image]'); if (mediaImg && e.target.tagName.toLowerCase() === 'img') { openSocialLightbox(mediaImg.dataset.mediaName, mediaImg.dataset.mediaImage); return; } const actionBtn = e.target.closest('[data-action]'); if (actionBtn) { const id = Number(actionBtn.dataset.id), action = actionBtn.dataset.action; if (action === 'reply') { const post = SocialState.posts.get(id); openSocialComposeModal('reply', post); return; } if (action === 'quote') { const post = SocialState.posts.get(id); openSocialComposeModal('quote', post); return; } if (action === 'edit') { const post = SocialState.posts.get(id); openSocialEditModal(post); return; } if (action === 'share') { const post = SocialState.posts.get(id); copySocialPostLink(post); return; } if (action === 'profile') { const un = actionBtn.dataset.username; openProfile(un); return; } await socialAction(id, action); return; } const notif = e.target.closest('.social-notification'); if (notif && notif.dataset.postId) { try { const data = await apiGet(`/api/social/posts/${notif.dataset.postId}`); SocialState.posts.set(data.post.id, data.post); renderSocialPosts([data.post]); _el('social-compose').style.display='none'; } catch (e2) { showToast(e2.message, 'error'); } } }); _el('social-media-preview')?.addEventListener('click', (e) => { const b = e.target.closest('[data-remove-media]'); if (b) { SocialState.media.splice(Number(b.dataset.removeMedia), 1); socialPreviewMedia(); } }); _el('social-compose-modal-media-preview')?.addEventListener('click', (e) => { const b = e.target.closest('[data-remove-modal-media]'); if (b) { SocialState.modalMedia.splice(Number(b.dataset.removeModalMedia), 1); socialPreviewModalMedia(); } }); _el('social-logout-btn')?.addEventListener('click', () => { const logoutBtn = document.getElementById('logout-btn'); if (logoutBtn) logoutBtn.click(); }); _el('social-feed')?.classList.toggle('social-feed-compact', !!SocialState.settings.compact); _el('social-unread-badge')?.classList.toggle('hidden', !SocialState.settings.badges); if (location.hash.startsWith('#social/')) { const id = Number(location.hash.split('/').pop()); if (id) { apiGet(`/api/social/posts/${id}`).then(d => { SocialState.posts.set(d.post.id, d.post); renderSocialPosts([d.post]); _el('social-compose').style.display = 'none'; }).catch(() => {}); } } SocialLoad.trending(); SocialLoad.suggestions(); } function openSocialSettings() { const s = SocialState.settings; _el('social-setting-autoplay').checked = !!s.autoplay; _el('social-setting-muted').checked = !!s.muted; _el('social-setting-hide-reposts').checked = !!s.hideReposts; _el('social-setting-compact').checked = !!s.compact; _el('social-setting-live').checked = !!s.live; _el('social-setting-badges').checked = !!s.badges; _el('social-setting-default').value = s.defaultFeed || 'home'; openModal(_el('social-settings-modal')); } function applySocialSettings() { const s = SocialState.settings; saveSocialSettings(); const feed = _el('social-feed'); if (feed) feed.classList.toggle('social-feed-compact', !!s.compact); _el('social-unread-badge').classList.toggle('hidden', !s.badges); if (_el('social-screen').classList.contains('active')) { if (s.live) startSocialPoll(); else stopSocialPoll(); SocialLoad.view(SocialState.view, true); } } function closeSocialSettingsAndSave() { const s = SocialState.settings; s.autoplay = _el('social-setting-autoplay').checked; s.muted = _el('social-setting-muted').checked; s.hideReposts = _el('social-setting-hide-reposts').checked; s.compact = _el('social-setting-compact').checked; s.live = _el('social-setting-live').checked; s.badges = _el('social-setting-badges').checked; s.defaultFeed = _el('social-setting-default').value; applySocialSettings(); closeModal(_el('social-settings-modal')); showToast('Social settings saved', 'success'); } 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(); initSocial(); return; } } catch (e) { /* fall through to auth screen */ } store.remove('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'); const sc = document.getElementById('social-screen'); if (sc) sc.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'); store.remove('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.blockedUserIds.clear(); State.hiddenUserIds.clear(); State.typingUsers.clear(); State.activeConvId = null; State.cursor = null; State.replyTo = null; State.pendingFile = null; State.latestUnseen = null; State.editingId = null; groupSelected.clear(); 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.groupModal.classList.add('hidden'); DOM.convOptionsModal.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; store.remove('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(); })();