// ============================================================================
// 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 ? '
Accept Reject
' : ''}
`;
}
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 = '';
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 += ` Chat `;
} else {
actions += actionBtn('edit', 'fas fa-pen', 'Edit');
actions += actionBtn('delete', 'fas fa-trash', 'Delete');
}
actions += actionBtn('copy', 'fas fa-copy', 'Copy');
return `
${avatar}
${senderName}
${content}${meta}
${actions}
`;
}
function actionBtn(action, icon, label) {
return ` `;
}
function avatarSmallHTML(msg) {
if (msg.sender_avatar_path) {
return ` `;
}
return `${initialOf(displayNameOf(msg))}
`;
}
function buildMetaHTML(msg, isSelf) {
let s = ``;
s += `${formatTime(msg.timestamp_ms)} `;
if (msg.is_edited) s += `edited `;
if (isSelf) {
const read = msg.status === 'read' || (msg.reader_count > 0);
s += `
`;
}
s += `
`;
return s;
}
function buildReplyQuote(msg) {
const target = State.messages.get(msg.reply_to_id);
const tn = target ? escapeHtml(displayNameOf(target)) : escapeHtml('Unknown');
let text;
if (!target) {
text = 'Message unavailable ';
} else {
const tomb = (State.tombstoned.get(target.conversation_id) || new Set()).has(target.id);
if (tomb || target.is_deleted) text = 'deleted message ';
else text = escapeHtml(truncate(plainPreview(target), 80));
}
return `
${tn}
${text}
`;
}
function plainPreview(m) {
let txt = m.content ? stripMarkdown(m.content.replace(/ ]*>/gi, ' ')).replace(/\s+/g, ' ').trim() : '';
if (!txt && m.file_path) {
txt = (m.file_type || '').startsWith('image/') ? '📷 Photo'
: (m.file_type || '').startsWith('video/') ? '🎬 Video'
: (m.file_type || '').startsWith('audio/') ? '🎵 Audio'
: '📎 ' + (m.file_name || 'File');
}
return txt;
}
function buildAttachmentHTML(msg) {
const url = fileUrl(msg.file_path);
const ft = (msg.file_type || '').toLowerCase();
const fname = escapeHtml(msg.file_name || 'File');
const opener = `data-url="${escapeHtml(url)}"`;
if (ft.startsWith('image/')) {
return ``;
}
if (ft.startsWith('video/')) {
return `
`;
}
if (ft.startsWith('audio/')) {
return ``;
}
const icon = ft.includes('pdf') ? 'fa-file-pdf' : ft.startsWith('text') ? 'fa-file-alt' : 'fa-file';
return `
${fname}
${formatFileSize(msg.file_size)}
`;
}
// ---------------- content formatting ----------------
const TOKEN_PLACEHOLDER = '\u0001';
function renderInlineContent(raw) {
let text = String(raw).replace(/\r\n/g, '\n');
const parts = [];
const ph = (i) => TOKEN_PLACEHOLDER + i + TOKEN_PLACEHOLDER;
// fenced code blocks ``` ... ``` (their contents are never re-parsed)
text = text.replace(/```([\s\S]*?)```/g, (whole, code) => {
const clean = code.replace(/^\n/, '').replace(/\n$/, '');
parts.push(`${escapeHtml(clean)} `);
return ph(parts.length - 1);
});
// html tags (the requested embed feature)
text = text.replace(/ ]*>/gi, (whole) => {
const srcMatch = whole.match(/\bsrc\s*=\s*["']([^"']+)["']/i);
const src = srcMatch && srcMatch[1];
if (src && isAllowedImageSrc(src)) {
const s = escapeHtml(src);
parts.push(` `);
} else {
// not a safe embed - show it as literal text (escaped exactly once)
parts.push(escapeHtml(whole));
}
return ph(parts.length - 1);
});
// auto-linkify http(s) URLs while the text is still raw, so query
// strings with & / entities stay intact inside hrefs
text = text.replace(/(https?:\/\/[^\s<>"']+)/gi, (url) => {
let clean = url;
const trailing = clean.match(/[.,!?;:)\]}]+$/);
if (trailing) clean = clean.slice(0, -trailing[0].length);
if (!/^https?:\/\/\S+\.\S+/.test(clean) && !/^https?:\/\/localhost/i.test(clean)) return url;
const c = escapeHtml(clean);
parts.push(`${c} `);
return ph(parts.length - 1) + (trailing ? trailing[0] : '');
});
// escape everything once, then apply light markdown on the escaped text
// (placeholders contain no markdown characters, so they stay inert),
// and only then splice the generated HTML (code/img/link) back in.
let out = escapeHtml(text);
out = out.replace(/\*\*([^*\n]+)\*\*/g, '$1 ');
out = out.replace(/`([^`\n]+)`/g, '$1');
out = out.replace(/(^|[\s(>])\*([^*\n]+)\*(?=[\s.,!?;:)\n]|$)/g, '$1$2 ');
parts.forEach((html, i) => {
out = out.split(ph(i)).join(html);
});
return out;
}
function isAllowedImageSrc(src) {
if (!src) return false;
if (src.startsWith('/api/download/')) return true;
if (/^https?:\/\//i.test(src)) return true;
if (/^data:image\/(png|jpe?g|gif|webp);base64,/i.test(src)) return src.length < 200000;
return false;
}
// ---------------- DOM patching ----------------
function rowEl(id) {
return DOM.messagesList.querySelector(`.message-row[data-id="${id}"]`);
}
function updateMessageDom(id, asTombstone = false) {
const el = rowEl(id);
const msg = State.messages.get(id);
if (!el || !msg) return;
const isSelf = msg.sender_id === State.user?.id;
const tomb = asTombstone || (State.tombstoned.get(msg.conversation_id) || new Set()).has(id);
el.className = 'message-row ' + (isSelf ? 'self' : 'other') + (tomb ? ' tomb' : '');
el.innerHTML = buildMessageHTML(msg, isSelf);
// keep hug class if previous row same author
const prev = el.previousElementSibling;
if (prev && prev.classList && prev.classList.contains('message-row')) {
const prevId = parseInt(prev.dataset.id, 10);
const prevMsg = State.messages.get(prevId);
if (prevMsg && sameAuthor(msg, prevMsg)) el.classList.add('hug');
}
}
function removeMessageDom(id) {
const el = rowEl(id);
if (el) el.remove();
// clean dangling day chips
DOM.messagesList.querySelectorAll('.day-separator').forEach((chip) => {
const next = chip.nextElementSibling;
if (!next || next.classList.contains('day-separator')) chip.remove();
});
if (!DOM.messagesList.querySelector('.message-row') &&
!DOM.messagesList.querySelector('.deleted-note')) {
renderEmptyLoading(State.activeConvId);
}
}
function updateTick(id) {
const el = rowEl(id);
if (!el) return;
const status = el.querySelector('.message-status');
if (status) {
status.classList.add('read');
const i = status.querySelector('i');
if (i) i.className = 'fas fa-check-double';
}
}
function appendMessageDom(m) {
const ids = getConv(m.conversation_id).ids;
const prevId = ids.length > 1 ? ids[ids.length - 2] : null;
const prevMsg = prevId ? State.messages.get(prevId) : null;
const dayChanged = !prevMsg || dayKey(prevMsg.timestamp_ms) !== dayKey(m.timestamp_ms);
if (dayChanged) {
DOM.messagesList.appendChild(dayChip(dayKey(m.timestamp_ms), m.timestamp_ms));
}
const row = buildMessageRow(m, prevMsg);
if (prevMsg && !dayChanged && sameAuthor(m, prevMsg)) row.classList.add('hug');
DOM.messagesList.appendChild(row);
DOM.emptyState.style.display = 'none';
}
// ============================================================
// Scroll UI
// ============================================================
function updateScrollUI() {
if (!State.isAtBottom) {
DOM.scrollBottomBtn.classList.toggle('hidden', !State.prefs.showScrollBtn);
} else {
DOM.scrollBottomBtn.classList.add('hidden');
hideLatestBar();
}
}
DOM.messagesScroll.addEventListener('scroll', () => {
const { scrollTop, scrollHeight, clientHeight } = DOM.messagesScroll;
State.isAtBottom = (scrollHeight - scrollTop - clientHeight) < 90;
updateScrollUI();
if (State.isAtBottom) hideLatestBar();
});
DOM.scrollBottomBtn.addEventListener('click', () => { scrollToBottom(); hideLatestBar(); });
function scrollToBottom() {
requestAnimationFrame(() => {
DOM.messagesScroll.scrollTop = DOM.messagesScroll.scrollHeight;
State.isAtBottom = true;
updateScrollUI();
});
}
// ============================================================
// Latest message bar
// ============================================================
function showLatestBar(m) {
if (!State.prefs.showLatestBar || !DOM.latestMessageBar) return;
const name = displayNameOf(m);
if (m.sender_avatar_path) {
DOM.lmbAvatar.innerHTML = ` `;
} else {
DOM.lmbAvatar.textContent = initialOf(name);
}
DOM.lmbName.textContent = name + ':';
DOM.lmbText.textContent = truncate(plainPreview(m), 40);
DOM.latestMessageBar.classList.remove('hidden');
}
function hideLatestBar() {
if (DOM.latestMessageBar) DOM.latestMessageBar.classList.add('hidden');
State.latestUnseen = null;
}
DOM.latestMessageBar.addEventListener('click', () => {
scrollToBottom();
hideLatestBar();
markActiveRead(true);
});
// ============================================================
// Message row actions (event delegation)
// ============================================================
DOM.messagesList.addEventListener('click', (e) => {
const row = e.target.closest('.message-row');
if (!row) return;
const msgId = parseInt(row.dataset.id, 10);
const msg = State.messages.get(msgId);
if (!msg) return;
const actionEl = e.target.closest('[data-action]');
if (!actionEl) return;
const action = actionEl.dataset.action;
const urlEl = e.target.closest('.js-zoom, [data-url]');
const zoomUrl = urlEl ? (urlEl.dataset.url || urlEl.getAttribute('src')) : null;
switch (action) {
case 'reply':
replyTo(msgId);
break;
case 'startchat':
openConvModal(msg.sender_id, displayNameOf(msg));
break;
case 'edit':
if (msg.sender_id === State.user?.id) startInlineEdit(msgId);
break;
case 'delete':
if (msg.sender_id === State.user?.id) requestDelete(msg);
break;
case 'copy':
copyMessage(msg);
break;
case 'receipts':
openReceipts(msgId);
break;
case 'goto-reply': {
const targetId = parseInt(actionEl.dataset.reply, 10);
const targetRow = rowEl(targetId);
if (targetRow) {
targetRow.scrollIntoView({ behavior: 'smooth', block: 'center' });
targetRow.style.transition = 'background 0.8s';
targetRow.style.background = 'var(--primary-light)';
setTimeout(() => { targetRow.style.background = ''; }, 1200);
} else {
showToast('That message is no longer loaded', 'info');
}
break;
}
case 'open-file':
if (zoomUrl) window.open(zoomUrl, '_blank', 'noopener');
break;
}
});
// image zoom (attachment or embedded)
document.addEventListener('click', (e) => {
const img = e.target.closest('img.js-zoom');
if (!img) return;
e.stopPropagation();
const url = img.dataset.url || img.getAttribute('src');
const name = img.dataset.name || 'image';
DOM.lightboxImg.src = url;
DOM.lightboxImg.referrerPolicy = 'no-referrer';
DOM.lightboxName.textContent = name;
DOM.lightboxDownload.href = url;
openModal(DOM.lightboxModal);
});
document.addEventListener('click', (e) => {
const fileCard = e.target.closest('.attachment-file');
if (fileCard && !e.target.closest('[data-action]')) {
const a = fileCard.querySelector('a, [data-url]');
if (a && a.dataset.url) window.open(a.dataset.url, '_blank', 'noopener');
}
});
function copyMessage(msg) {
const text = plainPreview(msg);
const done = () => showToast('Copied to clipboard', 'success');
const fallback = () => {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); done(); } catch (err) { showToast('Could not copy', 'error'); }
ta.remove();
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done).catch(fallback);
} else fallback();
}
// ============================================================
// Reply
// ============================================================
function replyTo(messageId) {
const msg = State.messages.get(messageId);
if (!msg) return;
State.replyTo = msg;
DOM.replyUser.textContent = displayNameOf(msg);
DOM.replyText.textContent = truncate(plainPreview(msg) || '📎 attachment', 60);
DOM.replyPreview.classList.remove('hidden');
DOM.messageInput.focus();
}
function clearReply() {
State.replyTo = null;
DOM.replyPreview.classList.add('hidden');
}
document.addEventListener('click', (e) => {
if (e.target.closest('#cancel-reply')) clearReply();
});
// ============================================================
// Edit (inline, no more prompt())
// ============================================================
function startInlineEdit(messageId) {
if (State.editingId) cancelInlineEdit();
const msg = State.messages.get(messageId);
if (!msg) return;
State.editingId = messageId;
const el = rowEl(messageId);
if (!el) return;
const bubble = el.querySelector('.message-bubble');
bubble.innerHTML = ``;
const ta = bubble.querySelector('textarea');
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
bubble.querySelector('[data-edit="save"]').addEventListener('click', () => saveInlineEdit(messageId, ta.value));
bubble.querySelector('[data-edit="cancel"]').addEventListener('click', () => cancelInlineEdit());
ta.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveInlineEdit(messageId, ta.value); }
if (e.key === 'Escape') cancelInlineEdit();
});
}
function cancelInlineEdit() {
const id = State.editingId;
State.editingId = null;
if (id && State.messages.has(id)) updateMessageDom(id);
}
async function saveInlineEdit(id, content) {
content = content.trim();
if (!content) { showToast('Message cannot be empty', 'warning'); return; }
const msg = State.messages.get(id);
if (!msg) return;
try {
const res = await fetch(`${API}/api/messages/${id}?content=${encodeURIComponent(content)}`, {
method: 'PATCH',
headers: { 'X-Auth-Token': State.token }
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || 'Edit failed');
msg.content = content;
msg.is_edited = true;
State.editingId = null;
updateMessageDom(id);
const convId = msg.conversation_id;
const summary = convById(convId);
if (summary && summary.last_message_id === id) {
summary.last_message_preview = truncate(content.replace(/\s+/g, ' '), 90);
renderSidebar();
}
} catch (err) {
showToast(err.message, 'error');
}
}
// ============================================================
// Delete (HTTP - reliable, never relies on a fragile WS send)
// ============================================================
async function requestDelete(msg) {
const ok = await showConfirm({
title: 'Delete message',
message: 'This permanently deletes the message for everyone. This cannot be undone.',
okText: 'Delete'
});
if (!ok) return;
try {
const res = await fetch(`${API}/api/messages/${msg.id}`, {
method: 'DELETE',
headers: { 'X-Auth-Token': State.token }
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || 'Delete failed');
}
removeMessageDom(msg.id);
State.messages.delete(msg.id);
pruneConvId(msg.conversation_id, msg.id);
if (State.replyTo && State.replyTo.id === msg.id) clearReply();
showToast('Message deleted', 'success');
const summary = convById(msg.conversation_id);
if (summary && summary.last_message_id === msg.id) {
summary.last_message_preview = '';
summary.last_message_ts = null;
renderSidebar();
}
} catch (err) {
showToast(err.message, 'error');
}
}
// ============================================================
// Compose / send
// ============================================================
DOM.composeForm.addEventListener('submit', (e) => { e.preventDefault(); sendMessage(); });
DOM.messageInput.addEventListener('input', () => {
const hasContent = DOM.messageInput.value.trim().length > 0 || !!State.pendingFile;
DOM.sendBtn.disabled = !hasContent;
sendTyping(true);
autoResize();
});
DOM.messageInput.addEventListener('keydown', (e) => {
const enterSends = State.prefs.enterSend;
if (e.key === 'Enter' && !e.shiftKey && enterSends) {
e.preventDefault();
sendMessage();
} else if (e.key === 'Enter' && !e.shiftKey && !enterSends) {
// plain Enter = newline; Ctrl/Cmd+Enter still sends
if (e.ctrlKey || e.metaKey) { e.preventDefault(); sendMessage(); }
}
});
DOM.messageInput.addEventListener('blur', () => sendTyping(false));
function sendMessage() {
const content = DOM.messageInput.value.trim();
if (!content && !State.pendingFile) return;
if (!State.activeConvId) { showToast('No conversation selected', 'warning'); return; }
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 `
${av}
${escapeHtml(displayNameOf(u))}
${escapeHtml(u.username)}${count}${u.online ? ' · online' : ''}${u.blocked ? ' · blocked' : ''}
`;
}).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 += `
${avatarHTML(c, 36)}
${escapeHtml(dmLabel(c))}
${previewHTML(c)}
`;
});
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 => `${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}
${escapeHtml(me ? 'You' : displayNameOf(u))}
Online
`;
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))} `}
${escapeHtml(displayNameOf(u))}
${escapeHtml(u.username)}
Unblock
`).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 `${escapeHtml(initialOf(name))} `;
}
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 + `#${escapeHtml(body)} `);
html = html.replace(/(^|[\s(])(@([A-Za-z0-9_]{1,30}))/g,
(m, pre, handle, body) => pre + `@${escapeHtml(body)} `);
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 = ` `;
else if (video) c = ` `;
else c = ` ${escapeHtml(name)} `;
return `${c}
`;
}).join('');
return `${items}
`;
}
function socialPostHtml(post) {
if (!post) return '';
const author = post.author || {};
const isOwn = State.user && author.id === State.user.id;
const repostLabel = post.reposter
? ` ${escapeHtml(post.reposter.username || '')} reposted
`
: (post.reposter_id ? ` Reposted
` : '');
const quote = post.quote ? `
${escapeHtml(displayNameOf(post.quote.author))}
@${escapeHtml(post.quote.author.username || '')}
${linkifySocial(post.quote.body || '')}
` : '';
const actions = `
${socialCount(post.reply_count || 0)}
${socialCount(post.repost_count || 0)}
${socialCount(post.like_count || 0)}
${socialCount(post.bookmark_count || 0)}
${isOwn ? ` ` : ''}
${isOwn ? ` ` : ''}
${!isOwn ? ` ` : ''}
`;
return `
${socialAvatarHtml(author, 'social-avatar-img')}
${repostLabel}
${escapeHtml(displayNameOf(author))}
@${escapeHtml(author.username || '')}
· ${socialTime(post.created_at_ms)}
${post.edited_at_ms ? '
· edited ' : ''}
${linkifySocial(post.body || '')}
${socialMediaHtml(post.media)}
${quote}
${actions}
`;
}
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 `
${socialAvatarHtml(u)}
${escapeHtml(displayNameOf(u))} @${escapeHtml(u.username || '')}
${following ? 'Following' : 'Follow'}
`;
}
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 ? '' : `${p.is_following ? 'Following' : 'Follow'} `;
const header = `
${p.avatar_path ? `
` : `
${escapeHtml(initialOf(displayNameOf(p)))} `}
${escapeHtml(displayNameOf(p))} @${escapeHtml(p.username)} ${followBtn}
${p.bio ? `
${linkifySocial(p.bio)}
` : ''}
${socialCount(p.posts_count || 0)} Posts
${socialCount(p.followers_count || 0)} Followers
${socialCount(p.following_count || 0)} Following
`;
renderSocialPosts([], _el('social-feed'));
_el('social-feed').innerHTML = header + (posts.posts && posts.posts.length ? posts.posts.map(socialPostHtml).join('') : '
No posts yet ');
_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 => `
${socialAvatarHtml(n.actor, 'social-avatar-img')}
${escapeHtml(displayNameOf(n.actor))}
@${escapeHtml(n.actor.username || '')} ${escapeHtml(n.type)}d
${socialTime(n.created_at_ms)}
`).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 => `${'#'} #${escapeHtml(t.tag)} ${socialCount(t.count)} posts `).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 ? '
No results ' : ''));
} 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();
})();