diff --git "a/static/script.js" "b/static/script.js"
--- "a/static/script.js"
+++ "b/static/script.js"
@@ -1,24 +1,43 @@
-// static/script.js
-(function() {
+// ============================================================================
+// InfinityChat 2.0 — client
+// Conversations, private chats (up to 3 per person), instant read receipts,
+// inline image embeds (
tags / markdown), reliable edit/delete.
+// ============================================================================
+(function () {
'use strict';
- // =============================================
+ const APP_VERSION = '2.0.0';
+
+ // ============================================================
// State
- // =============================================
+ // ============================================================
const State = {
token: localStorage.getItem('token'),
- user: null,
+ user: null, // {id, username, display_name, avatar_path}
ws: null,
wsState: 'disconnected',
- messages: new Map(),
- messageOrder: [],
- cursor: null,
- hasMore: true,
- replyTo: null,
- pendingFile: null,
- typingUsers: new Map(),
+ reconnectAttempt: 0,
+ lastPong: Date.now(),
+
+ conversations: new Map(), // convId -> summary object
+ conversationOrder: [], // convIds sorted for the sidebar
+
+ activeConvId: null, // conversation currently on screen
+ convCache: new Map(), // convId -> {ids:[], cursor, hasMore, lastTs}
+ messages: new Map(), // messageId -> message object
+ tombstoned: new Map(), // convId -> Set(messageId) shown as "deleted"
+
+ pendingSends: new Map(), // client_id -> payload (for resend after reconnect)
+ replyTo: null, // message being replied to
+ editingId: null, // message id currently being edited inline
+ pendingFile: null, // uploaded file metadata waiting for send
+ typingUsers: new Map(), // convId -> {userId -> {name, timer}}
+ typingSentAt: 0,
onlineUsers: new Map(),
isAtBottom: true,
+ latestUnseen: null,
+ unreadTotal: 0,
+
theme: localStorage.getItem('theme') || 'light',
customColors: {
self: localStorage.getItem('custom-self') || '#2563EB',
@@ -28,128 +47,297 @@
prefs: {
autoScroll: localStorage.getItem('pref-autoscroll') !== 'false',
showScrollBtn: localStorage.getItem('pref-scrollbtn') !== 'false',
- showLatestBar: localStorage.getItem('pref-latestbar') !== 'false'
- },
- latestUnseen: null
+ showLatestBar: localStorage.getItem('pref-latestbar') !== 'false',
+ sound: localStorage.getItem('pref-sound') !== 'false',
+ enterSend: localStorage.getItem('pref-entersend') !== 'false'
+ }
};
- // =============================================
- // DOM Cache
- // =============================================
+ // ============================================================
+ // DOM cache
+ // ============================================================
+ const $ = (id) => document.getElementById(id);
const DOM = {
- authScreen: document.getElementById('auth-screen'),
- chatScreen: document.getElementById('chat-screen'),
- loginForm: document.getElementById('login-form'),
- signupForm: document.getElementById('signup-form'),
+ authScreen: $('auth-screen'), chatScreen: $('chat-screen'),
+ loginForm: $('login-form'), signupForm: $('signup-form'),
authTabs: document.querySelectorAll('.auth-tab'),
- authError: document.getElementById('auth-error'),
- authErrorText: document.getElementById('auth-error-text'),
- authLoading: document.getElementById('auth-loading'),
- headerDisplayName: document.getElementById('header-display-name'),
- headerAvatar: document.getElementById('header-avatar'),
- headerAvatarPlaceholder: document.getElementById('header-avatar-placeholder'),
- headerStatus: document.getElementById('header-status'),
- onlineBadge: document.getElementById('online-badge'),
- connectionStatus: document.getElementById('connection-status'),
- connectionText: document.getElementById('connection-text'),
- messagesList: document.getElementById('messages-list'),
- messagesScroll: document.getElementById('messages-scroll'),
- emptyState: document.getElementById('empty-state'),
- loadOlderBtn: document.getElementById('load-older-btn'),
- scrollBottomBtn: document.getElementById('scroll-bottom-btn'),
- latestMessageBar: document.getElementById('latest-message-bar'),
- lmbAvatar: document.getElementById('lmb-avatar'),
- lmbName: document.getElementById('lmb-name'),
- lmbText: document.getElementById('lmb-text'),
- typingIndicator: document.getElementById('typing-indicator'),
- typingText: document.getElementById('typing-text'),
- replyPreview: document.getElementById('reply-preview'),
- replyUser: document.getElementById('reply-user'),
- replyText: document.getElementById('reply-text'),
- composeForm: document.getElementById('compose-form'),
- messageInput: document.getElementById('message-input'),
- sendBtn: document.getElementById('send-btn'),
- attachBtn: document.getElementById('attach-btn'),
- emojiBtn: document.getElementById('emoji-btn'),
- fileInput: document.getElementById('file-input'),
- uploadPreview: document.getElementById('upload-preview'),
- uploadFilename: document.getElementById('upload-filename'),
- cancelUpload: document.getElementById('cancel-upload'),
- uploadProgress: document.getElementById('upload-progress'),
- progressFill: document.getElementById('progress-fill'),
- progressText: document.getElementById('progress-text'),
- settingsModal: document.getElementById('settings-modal'),
- onlineModal: document.getElementById('online-modal'),
- emojiPicker: document.getElementById('emoji-picker'),
- emojiGrid: document.getElementById('emoji-grid'),
- settingsDisplayName: document.getElementById('settings-display-name'),
- settingsUsername: document.getElementById('settings-username'),
- settingsAvatarPreview: document.getElementById('settings-avatar-preview'),
- settingsAvatarImg: document.getElementById('settings-avatar-img'),
- avatarInput: document.getElementById('avatar-input'),
- avatarUploadBtn: document.getElementById('avatar-upload-btn'),
+ authError: $('auth-error'), authErrorText: $('auth-error-text'),
+ authLoading: $('auth-loading'),
+
+ sidebar: $('sidebar'), sidebarBackdrop: $('sidebar-backdrop'),
+ sidebarOpenBtn: $('sidebar-open-btn'), sidebarCloseBtn: $('sidebar-close-btn'),
+ sbMeName: $('sb-me-name'), sbMeAvatar: $('sb-me-avatar'), sbMeAvatarPh: $('sb-me-avatar-ph'),
+ convList: $('conv-list'),
+
+ convTitle: $('conv-title'), convSubtitle: $('conv-subtitle'),
+ convAvatar: $('conv-avatar'), convAvatarPh: $('conv-avatar-ph'),
+ convPresenceDot: $('conv-presence-dot'),
+ onlineBadge: $('online-badge'),
+
+ connectionStatus: $('connection-status'), connectionText: $('connection-text'),
+ messagesList: $('messages-list'), messagesScroll: $('messages-scroll'),
+ emptyState: $('empty-state'), emptyIcon: $('empty-icon'),
+ emptyTitle: $('empty-title'), emptySub: $('empty-sub'),
+ loadOlderBtn: $('load-older-btn'),
+ scrollBottomBtn: $('scroll-bottom-btn'),
+ latestMessageBar: $('latest-message-bar'),
+ lmbAvatar: $('lmb-avatar'), lmbName: $('lmb-name'), lmbText: $('lmb-text'),
+ typingIndicator: $('typing-indicator'), typingText: $('typing-text'),
+ replyPreview: $('reply-preview'), replyUser: $('reply-user'), replyText: $('reply-text'),
+ composeForm: $('compose-form'), messageInput: $('message-input'), sendBtn: $('send-btn'),
+ attachBtn: $('attach-btn'), emojiBtn: $('emoji-btn'), fileInput: $('file-input'),
+ uploadPreview: $('upload-preview'), uploadIcon: $('upload-icon'), uploadFilename: $('upload-filename'),
+ uploadSize: $('upload-size'), cancelUpload: $('cancel-upload'),
+ uploadProgress: $('upload-progress'), progressFill: $('progress-fill'), progressText: $('progress-text'),
+ dropOverlay: $('drop-overlay'),
+
+ settingsModal: $('settings-modal'), onlineModal: $('online-modal'),
+ convModal: $('conv-modal'), receiptsModal: $('receipts-modal'),
+ confirmModal: $('confirm-modal'), lightboxModal: $('lightbox-modal'),
+ emojiPicker: $('emoji-picker'), emojiGrid: $('emoji-grid'),
+
+ settingsDisplayName: $('settings-display-name'), settingsUsername: $('settings-username'),
+ settingsAvatarPreview: $('settings-avatar-preview'), settingsAvatarImg: $('settings-avatar-img'),
+ avatarInput: $('avatar-input'), avatarUploadBtn: $('avatar-upload-btn'),
themeOptions: document.querySelectorAll('.theme-option'),
- customThemeSettings: document.getElementById('custom-theme-settings'),
- customSelfColor: document.getElementById('custom-self-color'),
- customOtherColor: document.getElementById('custom-other-color'),
- customTextColor: document.getElementById('custom-text-color'),
- selfColorHex: document.getElementById('self-color-hex'),
- otherColorHex: document.getElementById('other-color-hex'),
- textColorHex: document.getElementById('text-color-hex'),
- toggleAutoScroll: document.getElementById('toggle-autoscroll'),
- toggleScrollBtn: document.getElementById('toggle-scroll-btn'),
- toggleLatestBar: document.getElementById('toggle-latest-bar'),
- onlineBtn: document.getElementById('online-btn'),
- onlineUsersList: document.getElementById('online-users-list'),
- onlineTotal: document.getElementById('online-total'),
- toastContainer: document.getElementById('toast-container')
+ customThemeSettings: $('custom-theme-settings'),
+ customSelfColor: $('custom-self-color'), customOtherColor: $('custom-other-color'),
+ customTextColor: $('custom-text-color'),
+ selfColorHex: $('self-color-hex'), otherColorHex: $('other-color-hex'), textColorHex: $('text-color-hex'),
+ toggleAutoScroll: $('toggle-autoscroll'), toggleScrollBtn: $('toggle-scroll-btn'),
+ toggleLatestBar: $('toggle-latest-bar'), toggleSound: $('toggle-sound'),
+ toggleEnterSend: $('toggle-enter-send'), notifyPermBtn: $('notify-perm-btn'),
+ pwCurrent: $('pw-current'), pwNew: $('pw-new'), pwChangeBtn: $('pw-change-btn'), pwMsg: $('pw-msg'),
+ onlineBtn: $('online-btn'), onlineUsersList: $('online-users-list'), onlineTotal: $('online-total'),
+ settingsBtn: $('settings-btn'), logoutBtn: $('logout-btn'), meSettingsBtn: $('me-settings-btn'),
+ sidebarLogoutBtn: $('sidebar-logout-btn'),
+ convModalDesc: $('conv-modal-desc'), convModalList: $('conv-modal-list'), newChatBtn: $('new-chat-btn'),
+ receiptsSummary: $('receipts-summary'), receiptsList: $('receipts-list'),
+ confirmTitle: $('confirm-title'), confirmMessage: $('confirm-message'),
+ confirmOkBtn: $('confirm-ok-btn'), confirmCancelBtn: $('confirm-cancel-btn'),
+ lightboxImg: $('lightbox-img'), lightboxName: $('lightbox-name'), lightboxDownload: $('lightbox-download'),
+ toastContainer: $('toast-container'), aboutVersion: $('about-version')
};
const API = window.location.origin;
+ const GLOBAL_CONV = 1;
+
+ // ============================================================
+ // Small helpers
+ // ============================================================
+ function generateId() {
+ return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);
+ }
+
+ function truncate(str, n) {
+ if (!str) return '';
+ return str.length > n ? str.substr(0, n - 1) + '…' : str;
+ }
+
+ function escapeHtml(text) {
+ if (text === null || text === undefined) return '';
+ const div = document.createElement('div');
+ div.textContent = String(text);
+ return div.innerHTML;
+ }
- // =============================================
- // File URL helper
- // =============================================
function fileUrl(filePath) {
return `${API}/api/download/${encodeURIComponent(filePath)}?t=${encodeURIComponent(State.token)}`;
}
- // =============================================
- // Theme
- // =============================================
+ function displayNameOf(u) {
+ if (!u) return 'Unknown';
+ // user-shaped objects (peers, online users, receipts)...
+ if (u.display_name || u.username) return u.display_name || u.username;
+ // ...or message-shaped objects (sender_display_name / sender_username)
+ if (u.sender_display_name || u.sender_username) return u.sender_display_name || u.sender_username;
+ return 'Unknown';
+ }
+
+ function avatarColorClass(seed) {
+ let h = 0;
+ const s = String(seed || '');
+ for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
+ return 'avatar-c' + (h % 6);
+ }
+
+ function initialOf(name) {
+ return (name || '?').charAt(0).toUpperCase();
+ }
+
+ // ---------- time ----------
+ function formatTime(ms) {
+ if (!ms) return '';
+ const date = new Date(ms);
+ const now = new Date();
+ const diff = now - date;
+ if (diff < 45000) return 'now';
+ if (diff < 3600000) return Math.max(1, Math.floor(diff / 60000)) + 'm';
+ if (date.toDateString() === now.toDateString()) {
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ }
+ const yesterday = new Date(now);
+ yesterday.setDate(yesterday.getDate() - 1);
+ if (date.toDateString() === yesterday.toDateString()) {
+ return 'Yesterday ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ }
+ if (diff < 6 * 86400000) {
+ return date.toLocaleDateString([], { weekday: 'short' }) + ' ' +
+ date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ }
+ return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' +
+ date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ }
+
+ function formatDayLabel(ms) {
+ const d = new Date(ms), now = new Date();
+ if (d.toDateString() === now.toDateString()) return 'Today';
+ const y = new Date(now);
+ y.setDate(y.getDate() - 1);
+ if (d.toDateString() === y.toDateString()) return 'Yesterday';
+ if (d.getFullYear() === now.getFullYear()) {
+ return d.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' });
+ }
+ return d.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
+ }
+
+ function formatLastSeen(sec) {
+ if (!sec) return 'offline';
+ const diff = Date.now() / 1000 - sec;
+ if (diff < 60) return 'online';
+ if (diff < 3600) return 'last seen ' + Math.floor(diff / 60) + 'm ago';
+ if (diff < 86400) return 'last seen ' + Math.floor(diff / 3600) + 'h ago';
+ return 'last seen ' + Math.floor(diff / 86400) + 'd ago';
+ }
+
+ function formatFileSize(bytes) {
+ if (!bytes) return '0 B';
+ const k = 1024, sizes = ['B', 'KB', 'MB', 'GB'];
+ const i = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k)));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
+ }
+
+ function dayKey(ms) {
+ return new Date(ms).toDateString();
+ }
+
+ // ---------- toast ----------
+ function showToast(message, type = 'info', ms = 4000) {
+ const toast = document.createElement('div');
+ toast.className = `toast toast-${type}`;
+ const icons = { success: 'check-circle', error: 'exclamation-circle', warning: 'exclamation-triangle', info: 'info-circle' };
+ toast.innerHTML = `${escapeHtml(message)}`;
+ DOM.toastContainer.appendChild(toast);
+ setTimeout(() => toast.remove(), ms);
+ }
+
+ // ---------- modals ----------
+ const openModals = [];
+
+ function openModal(el) {
+ if (!el) return;
+ el.classList.remove('hidden');
+ if (!openModals.includes(el)) openModals.push(el);
+ document.body.style.overflow = 'hidden';
+ }
+
+ function closeModal(el) {
+ if (!el) return;
+ el.classList.add('hidden');
+ const i = openModals.indexOf(el);
+ if (i > -1) openModals.splice(i, 1);
+ if (!openModals.length) document.body.style.overflow = '';
+ // resolve any pending confirm() promise (backdrop / X / Escape path)
+ if (el === DOM.confirmModal && el.__resolve) {
+ const r = el.__resolve;
+ el.__resolve = null;
+ r(false);
+ }
+ }
+
+ function closeTopModal() {
+ const el = openModals[openModals.length - 1];
+ if (el) closeModal(el);
+ }
+
+ // generic backdrop + [data-close] delegation
+ document.addEventListener('click', (e) => {
+ const closer = e.target.closest('[data-close]');
+ if (closer) {
+ const id = closer.dataset.close;
+ closeModal(document.getElementById(id));
+ if (id === 'settings-modal') applyPrefs();
+ }
+ });
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape' && openModals.length) {
+ if (State.editingId) { cancelInlineEdit(); return; }
+ closeTopModal();
+ }
+ });
+
+ // ---------- custom confirm (replaces blocking confirm()) ----------
+ function showConfirm({ title = 'Are you sure?', message = '', okText = 'OK', danger = true }) {
+ return new Promise((resolve) => {
+ DOM.confirmTitle.innerHTML = ` ${escapeHtml(title)}`;
+ DOM.confirmMessage.textContent = message;
+ DOM.confirmOkBtn.className = `btn ${danger ? 'btn-danger' : 'btn-primary'}`;
+ DOM.confirmOkBtn.innerHTML = ` ${escapeHtml(okText)}`;
+
+ const done = (val) => {
+ DOM.confirmOkBtn.removeEventListener('click', onOk);
+ DOM.confirmCancelBtn.removeEventListener('click', onCancel);
+ DOM.confirmModal.__resolve = null;
+ closeModal(DOM.confirmModal);
+ resolve(val);
+ };
+ const onOk = () => done(true);
+ const onCancel = () => done(false);
+ DOM.confirmOkBtn.addEventListener('click', onOk);
+ DOM.confirmCancelBtn.addEventListener('click', onCancel);
+ // backdrop / X button / Escape resolve as "cancel" via closeModal hook
+ DOM.confirmModal.__resolve = (val) => {
+ DOM.confirmOkBtn.removeEventListener('click', onOk);
+ DOM.confirmCancelBtn.removeEventListener('click', onCancel);
+ resolve(val);
+ };
+ openModal(DOM.confirmModal);
+ });
+ }
+
+ // ============================================================
+ // Theme & preferences
+ // ============================================================
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
State.theme = theme;
localStorage.setItem('theme', theme);
if (theme === 'custom') {
- const s = State.customColors.self;
- const o = State.customColors.other;
- const t = State.customColors.text;
+ 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;
+ 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 {
- document.documentElement.style.removeProperty('--bubble-self');
- document.documentElement.style.removeProperty('--bubble-other');
- document.documentElement.style.removeProperty('--bubble-other-text');
- document.documentElement.style.removeProperty('--bubble-self-text');
+ ['--bubble-self', '--bubble-other', '--bubble-other-text', '--bubble-self-text']
+ .forEach(p => document.documentElement.style.removeProperty(p));
}
}
-
applyTheme(State.theme);
- // =============================================
- // Preferences
- // =============================================
function applyPrefs() {
- if (DOM.toggleAutoScroll) DOM.toggleAutoScroll.checked = State.prefs.autoScroll;
- if (DOM.toggleScrollBtn) DOM.toggleScrollBtn.checked = State.prefs.showScrollBtn;
- if (DOM.toggleLatestBar) DOM.toggleLatestBar.checked = State.prefs.showLatestBar;
+ 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();
}
@@ -157,59 +345,61 @@
localStorage.setItem('pref-autoscroll', State.prefs.autoScroll);
localStorage.setItem('pref-scrollbtn', State.prefs.showScrollBtn);
localStorage.setItem('pref-latestbar', State.prefs.showLatestBar);
+ localStorage.setItem('pref-sound', State.prefs.sound);
+ localStorage.setItem('pref-entersend', State.prefs.enterSend);
}
- if (DOM.toggleAutoScroll) {
- DOM.toggleAutoScroll.addEventListener('change', () => {
- State.prefs.autoScroll = DOM.toggleAutoScroll.checked;
+ [['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 (State.prefs.autoScroll) {
- scrollToBottom();
- hideLatestBar();
- }
+ if (key === 'autoScroll' && State.prefs.autoScroll) { scrollToBottom(); hideLatestBar(); }
+ if (key === 'showScrollBtn') updateScrollUI();
+ if (key === 'showLatestBar' && !State.prefs.showLatestBar) hideLatestBar();
});
- }
- if (DOM.toggleScrollBtn) {
- DOM.toggleScrollBtn.addEventListener('change', () => {
- State.prefs.showScrollBtn = DOM.toggleScrollBtn.checked;
- savePrefs();
- updateScrollUI();
- });
- }
- if (DOM.toggleLatestBar) {
- DOM.toggleLatestBar.addEventListener('change', () => {
- State.prefs.showLatestBar = DOM.toggleLatestBar.checked;
- savePrefs();
- if (!State.prefs.showLatestBar) hideLatestBar();
- });
- }
+ });
- // =============================================
- // Timestamp updater
- // =============================================
- function startTimestampUpdater() {
- setInterval(() => {
- document.querySelectorAll('.message-time[data-ts]').forEach(el => {
- el.textContent = formatTime(parseInt(el.dataset.ts));
- });
- }, 30000);
- }
+ 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 */ }
+ });
- // =============================================
- // Toast
- // =============================================
- function showToast(message, type = 'info') {
- 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 = `${message}`;
- DOM.toastContainer.appendChild(toast);
- setTimeout(() => toast.remove(), 4000);
+ // 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;
@@ -223,48 +413,43 @@
DOM.loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
- await login(
- document.getElementById('login-username').value.trim(),
- document.getElementById('login-password').value
- );
+ await login($('login-username').value.trim(), $('login-password').value);
});
-
DOM.signupForm.addEventListener('submit', async (e) => {
e.preventDefault();
- const username = document.getElementById('signup-username').value.trim();
- const display = document.getElementById('signup-display').value.trim() || username;
- const password = document.getElementById('signup-password').value;
+ 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 res = await fetch(`${API}/api/auth/login?username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`, { method: 'POST' });
- const data = await res.json();
- if (!res.ok) throw new Error(data.detail || 'Login failed');
+ 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');
- }
+ } catch (err) { showAuthError(err.message); }
+ finally { DOM.authLoading.classList.add('hidden'); }
}
async function signup(username, password, display) {
DOM.authLoading.classList.remove('hidden');
hideAuthError();
try {
- const res = await fetch(`${API}/api/auth/signup?username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}&display_name=${encodeURIComponent(display)}`, { method: 'POST' });
- const data = await res.json();
- if (!res.ok) throw new Error(data.detail || 'Signup failed');
+ 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');
- }
+ } catch (err) { showAuthError(err.message); }
+ finally { DOM.authLoading.classList.add('hidden'); }
}
function handleAuthSuccess(data) {
@@ -279,500 +464,1427 @@
DOM.authErrorText.textContent = msg;
DOM.authError.style.display = 'flex';
}
+ function hideAuthError() { DOM.authError.style.display = 'none'; }
- function hideAuthError() {
- DOM.authError.style.display = 'none';
- }
-
- // =============================================
- // WebSocket
- // =============================================
+ // ============================================================
+ // WebSocket (heartbeat + resilient reconnect + pending resend)
+ // ============================================================
function connectWebSocket() {
if (!State.token) return;
- if (State.ws && State.ws.readyState === WebSocket.OPEN) 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:';
- State.ws = new WebSocket(`${proto}//${location.host}/ws?token=${encodeURIComponent(State.token)}`);
- State.ws.onopen = () => {
+ const ws = new WebSocket(`${proto}//${location.host}/ws?token=${encodeURIComponent(State.token)}`);
+ State.ws = ws;
+
+ ws.onopen = () => {
State.wsState = 'connected';
+ State.reconnectAttempt = 0;
updateConnectionUI();
- loadMessages();
- requestOnlineUsers();
+ requestConversations();
+ flushPendingSends();
};
- State.ws.onmessage = (e) => {
- try { handleWSMessage(JSON.parse(e.data)); } catch(err) { console.error('WS parse error', err); }
+ ws.onmessage = (e) => {
+ try { handleWSMessage(JSON.parse(e.data)); } catch (err) { console.error('WS parse error', err); }
};
- State.ws.onclose = () => {
- if (State.wsState === 'connected') {
- State.wsState = 'reconnecting';
- updateConnectionUI();
- setTimeout(connectWebSocket, 3000);
- } else {
- State.wsState = 'disconnected';
- updateConnectionUI();
+ ws.onclose = (ev) => {
+ if (ev.code === 4001) {
+ // token invalid/revoked -> log out
+ forceLogout();
+ return;
+ }
+ if (State.wsState === 'connected' || State.wsState === 'connecting') {
+ scheduleReconnect();
}
};
- State.ws.onerror = () => {
+ ws.onerror = () => {
State.wsState = 'disconnected';
updateConnectionUI();
};
}
+ function scheduleReconnect() {
+ State.wsState = 'reconnecting';
+ updateConnectionUI();
+ State.reconnectAttempt += 1;
+ const base = Math.min(15000, 800 * Math.pow(1.7, State.reconnectAttempt - 1));
+ const delay = base * (0.6 + Math.random() * 0.8);
+ setTimeout(() => connectWebSocket(), delay);
+ }
+
+ // keepalive so proxies never kill an idle socket
+ setInterval(() => {
+ if (State.ws && State.ws.readyState === WebSocket.OPEN) {
+ State.ws.send(JSON.stringify({ type: 'ping', t: Date.now() }));
+ }
+ }, 20000);
+
+ function wsOpen() {
+ return State.ws && State.ws.readyState === WebSocket.OPEN;
+ }
+
+ function wsSend(obj) {
+ if (!wsOpen()) return false;
+ try { State.ws.send(JSON.stringify(obj)); return true; } catch (e) { return false; }
+ }
+
function updateConnectionUI() {
- DOM.connectionStatus.className = 'connection-status';
const map = {
connected: ['connected', 'Connected'],
- reconnecting: ['reconnecting', 'Reconnecting...'],
+ reconnecting: ['reconnecting', 'Reconnecting…'],
disconnected: ['disconnected', 'Disconnected'],
- connecting: ['reconnecting', 'Connecting...']
+ connecting: ['reconnecting', 'Connecting…']
};
const [cls, txt] = map[State.wsState] || ['disconnected', 'Disconnected'];
- DOM.connectionStatus.classList.add(cls);
+ DOM.connectionStatus.className = 'connection-status ' + cls;
DOM.connectionText.textContent = txt;
}
+ // ============================================================
+ // Conversations / sidebar
+ // ============================================================
+ function convById(id) { return State.conversations.get(id); }
+
+ function upsertConversation(summary) {
+ State.conversations.set(summary.id, summary);
+ if (!State.convCache.has(summary.id)) {
+ State.convCache.set(summary.id, { ids: [], cursor: null, hasMore: true, lastTs: 0 });
+ }
+ renderSidebar();
+ }
+
+ function deleteConversationLocally(id) {
+ // reserved: DM deletion UI may land in a later version
+ }
+
+ function convSortKey(c) {
+ if (c.id === GLOBAL_CONV) return [0, 0];
+ return [1, -(c.last_message_ts || 0)];
+ }
+
+ function renderSidebar() {
+ State.conversationOrder = [...State.conversations.values()].sort((a, b) => {
+ const ka = convSortKey(a), kb = convSortKey(b);
+ return ka[0] - kb[0] || ka[1] - kb[1];
+ });
+
+ let html = '';
+ let prevType = null;
+ let unreadTotal = 0;
+ State.conversationOrder.forEach((c) => {
+ if (c.type === 'dm' && prevType !== 'dm') {
+ html += '
Private Chats
';
+ }
+ prevType = c.type;
+ unreadTotal += c.unread_count || 0;
+ html += convItemHTML(c);
+ });
+ if (!State.conversationOrder.length) {
+ html = 'Loading chats…
';
+ }
+ DOM.convList.innerHTML = html;
+ DOM.convList.querySelectorAll('.conv-item').forEach((el) => {
+ el.addEventListener('click', () => {
+ switchConversation(parseInt(el.dataset.id, 10));
+ closeSidebar();
+ });
+ });
+ State.unreadTotal = unreadTotal;
+ updateTitleUnread();
+ }
+
+ function updateTitleUnread() {
+ const label = State.unreadTotal > 0 ? `(${State.unreadTotal > 99 ? '99+' : State.unreadTotal}) ` : '';
+ document.title = label + 'InfinityChat';
+ }
+
+ function convItemHTML(c) {
+ const isDm = c.type === 'dm';
+ const name = isDm ? dmLabel(c) : (c.title || 'Global Chat');
+ const active = c.id === State.activeConvId ? ' active' : '';
+ const time = c.last_message_ts ? formatTime(c.last_message_ts) : '';
+ const unread = Math.min(c.unread_count || 0, 99);
+ const unreadHtml = unread > 0
+ ? `${unread}`
+ : '';
+ const timeCls = unread > 0 ? ' unread' : '';
+ let avatar = avatarHTML(c, 44);
+ return ``;
+ }
+
+ function dmLabel(c) {
+ const base = displayNameOf(c.peer);
+ return c.dm_number && c.dm_number > 1 ? `${base} · ${c.dm_number}` : base;
+ }
+
+ function previewHTML(c) {
+ let txt = c.last_message_preview || (c.id === GLOBAL_CONV ? 'No messages yet' : 'No messages yet');
+ if (c.last_sender_id && !(c.last_sender_id === State.user?.id)) {
+ const n = c.last_sender_name;
+ if (c.type === 'global') txt = `${n}: ${txt}`;
+ }
+ return escapeHtml(truncate(txt, 60));
+ }
+
+ function avatarHTML(c, px) {
+ if (c.type === 'dm' && c.peer) {
+ const on = c.peer.online ? ' online' : '';
+ let inner = ``;
+ if (c.peer.avatar_path) {
+ inner = `
${inner}`;
+ } else {
+ inner = `${initialOf(displayNameOf(c.peer))}${inner}`;
+ }
+ return `${inner}
`;
+ }
+ return `
`;
+ }
+
+ function requestConversations() {
+ if (wsOpen()) wsSend({ type: 'request_conversations' });
+ }
+
+ function getConv(convId) {
+ if (!State.convCache.has(convId)) {
+ State.convCache.set(convId, { ids: [], cursor: null, hasMore: true, lastTs: 0 });
+ }
+ return State.convCache.get(convId);
+ }
+
+ function loadMessages(convId = State.activeConvId, opts = {}) {
+ if (!wsOpen()) return;
+ const cache = getConv(convId);
+ const msg = { type: 'load_messages', conversation_id: convId, limit: 50 };
+ if (opts.older && cache.cursor) msg.cursor = cache.cursor;
+ wsSend(msg);
+ }
+
+ // ============================================================
+ // Conversation switching
+ // ============================================================
+ function switchConversation(convId) {
+ convId = parseInt(convId, 10) || GLOBAL_CONV;
+ if (convId === State.activeConvId) return;
+ if (State.editingId) cancelInlineEdit();
+ clearReply(); // replies are conversation-scoped
+ hideLatestBar();
+ State.activeConvId = convId;
+ State.latestUnseen = null;
+ State.isAtBottom = true;
+
+ const cache = getConv(convId);
+ renderConversationHeader(convId);
+ renderSidebar();
+
+ if (cache.ids.length === 0) {
+ cache.loading = true;
+ loadMessages(convId);
+ renderEmptyLoading(convId);
+ } else {
+ renderAllMessages(convId, { scrollBottom: true });
+ }
+ markActiveRead(true);
+ DOM.messageInput.focus({ preventScroll: true });
+ }
+
+ function renderEmptyLoading(convId) {
+ DOM.messagesList.innerHTML = '';
+ // re-attach the empty-state element (innerHTML wipes it) before showing it
+ if (DOM.emptyState.parentNode !== DOM.messagesList) DOM.messagesList.appendChild(DOM.emptyState);
+ const conv = convById(convId);
+ const dm = conv && conv.type === 'dm' && conv.peer;
+ if (dm) {
+ DOM.emptyIcon.innerHTML = '';
+ DOM.emptyTitle.textContent = 'No messages yet';
+ DOM.emptySub.textContent = `This is the start of your private chat with ${displayNameOf(conv.peer)}`;
+ } else {
+ DOM.emptyIcon.innerHTML = '';
+ DOM.emptyTitle.textContent = 'No messages yet';
+ DOM.emptySub.textContent = 'Say hello to the Global Chat below';
+ }
+ DOM.emptyState.style.display = 'flex';
+ DOM.loadOlderBtn.classList.add('hidden');
+ }
+
+ function renderConversationHeader(convId) {
+ const conv = convById(convId);
+ if (!conv) return;
+ const dm = conv.type === 'dm';
+ const peer = conv.peer;
+
+ if (dm && peer) {
+ DOM.convTitle.textContent = dmLabel(conv);
+ if (peer.avatar_path) {
+ DOM.convAvatar.src = fileUrl(peer.avatar_path);
+ DOM.convAvatar.classList.remove('hidden');
+ DOM.convAvatarPh.classList.add('hidden');
+ } else {
+ DOM.convAvatar.classList.add('hidden');
+ DOM.convAvatarPh.classList.remove('hidden');
+ DOM.convAvatarPh.className = 'conv-avatar-ph ' + avatarColorClass(peer.username || peer.id);
+ DOM.convAvatarPh.textContent = initialOf(displayNameOf(peer));
+ }
+ const online = peer.online || State.onlineUsers.has(peer.id);
+ DOM.convPresenceDot.classList.remove('hidden');
+ DOM.convPresenceDot.classList.toggle('offline', !online);
+ DOM.convSubtitle.innerHTML = online
+ ? ' Online'
+ : escapeHtml(formatLastSeen(peer.last_seen));
+ DOM.onlineBtn.style.display = '';
+ } else {
+ DOM.convTitle.textContent = 'Global Chat';
+ DOM.convAvatar.classList.add('hidden');
+ DOM.convAvatarPh.classList.remove('hidden');
+ DOM.convAvatarPh.className = 'conv-avatar-ph';
+ DOM.convAvatarPh.innerHTML = '';
+ DOM.convPresenceDot.classList.add('hidden');
+ const meCount = State.onlineUsers.has(State.user?.id) ? State.onlineUsers.size - 1 : State.onlineUsers.size;
+ DOM.convSubtitle.innerHTML = ` ${Math.max(0, meCount)} online`;
+ DOM.onlineBtn.style.display = '';
+ }
+ }
+
+ function openSidebar() {
+ DOM.sidebar.classList.add('open');
+ DOM.sidebarBackdrop.classList.remove('hidden');
+ }
+ function closeSidebar() {
+ DOM.sidebar.classList.remove('open');
+ DOM.sidebarBackdrop.classList.add('hidden');
+ }
+ DOM.sidebarOpenBtn.addEventListener('click', openSidebar);
+ DOM.sidebarCloseBtn.addEventListener('click', closeSidebar);
+ DOM.sidebarBackdrop.addEventListener('click', closeSidebar);
+
+ // ============================================================
+ // Read receipts
+ // ============================================================
+ function latestMessageId(convId) {
+ const ids = getConv(convId).ids;
+ return ids.length ? ids[ids.length - 1] : null;
+ }
+
+ // coalesced, non-blocking "I have this chat open right now"
+ let markReadTimer = null;
+ function markActiveRead(force) {
+ if (!wsOpen()) return;
+ const convId = State.activeConvId;
+ if (document.visibilityState !== 'visible' && !force) return;
+ const upTo = latestMessageId(convId);
+ if (!upTo) return;
+ clearTimeout(markReadTimer);
+ markReadTimer = setTimeout(() => {
+ const summary = convById(convId);
+ if (summary) {
+ // optimistically zero the badge; server confirms via conversation_updated
+ if (summary.unread_count) { summary.unread_count = 0; renderSidebar(); }
+ }
+ wsSend({ type: 'mark_read', up_to_message_id: upTo, conversation_id: convId });
+ }, 120);
+ }
+
+ document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'visible') {
+ markActiveRead(true);
+ if (State.activeConvId) renderConversationHeader(State.activeConvId);
+ } else {
+ updateTitleUnread();
+ }
+ });
+
+ // clicking the tick = who read it, and when
+ function openReceipts(messageId) {
+ const msg = State.messages.get(messageId);
+ if (!msg) return;
+ DOM.receiptsList.innerHTML = '';
+ DOM.receiptsSummary.textContent = '';
+ openModal(DOM.receiptsModal);
+ fetch(`${API}/api/messages/${messageId}/read-receipts`, { headers: { 'X-Auth-Token': State.token } })
+ .then(async (res) => {
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.detail || 'Failed');
+ }
+ return res.json();
+ })
+ .then((data) => renderReceipts(data, messageId))
+ .catch((err) => {
+ DOM.receiptsList.innerHTML = ` ${escapeHtml(err.message)}
`;
+ });
+ }
+
+ function renderReceipts(data) {
+ const n = data.reader_count || 0;
+ if (data.is_dm && !data.not_read.length) {
+ DOM.receiptsSummary.innerHTML = ` Read`;
+ } else if (!data.is_dm && n > 0) {
+ DOM.receiptsSummary.textContent = `Read by ${n} ${n === 1 ? 'user' : 'users'}`;
+ } else if (data.not_read.length) {
+ DOM.receiptsSummary.textContent = 'Not read yet';
+ } else {
+ DOM.receiptsSummary.textContent = `No one has read this yet`;
+ }
+
+ let html = '';
+ if (!data.readers.length) {
+ html = `No read receipts yet${data.is_dm && data.not_read.length ? ` — ${displayNameOf(data.not_read[0])} hasn't opened this chat since you sent it` : ''}
`;
+ }
+ data.readers.forEach(r => {
+ const av = r.avatar_path
+ ? `
`
+ : `${initialOf(displayNameOf(r))}`;
+ html += `
+
${av}
+
+
${escapeHtml(displayNameOf(r))}${r.online ? ' ' : ''}
+
Read ${new Date(r.read_at).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
+
+
+
`;
+ });
+ // In a DM, explicitly show who hasn't read yet
+ data.not_read.forEach(r => {
+ const av = r.avatar_path
+ ? `
`
+ : `${initialOf(displayNameOf(r))}`;
+ html += `
+
${av}
+
+
${escapeHtml(displayNameOf(r))}
+
${r.online ? 'Online — has not read it yet' : 'Not read yet'}
+
+
+
`;
+ });
+ DOM.receiptsList.innerHTML = html;
+ }
+
+ // ============================================================
+ // WS message dispatch
+ // ============================================================
function handleWSMessage(msg) {
switch (msg.type) {
- case 'connection_established':
+
+ 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
+ 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));
- updateHeader();
+ (msg.online_users || []).forEach(u => State.onlineUsers.set(u.id, u));
+ updateSidebarMe();
updateOnlineBadge();
+
+ // fresh session: adopt server conversation list, drop old caches
+ State.conversations.clear();
+ State.conversationOrder = [];
+ State.convCache.clear();
+ State.messages.clear();
+ State.tombstoned.clear();
+ DOM.messagesList.innerHTML = '';
+ (msg.conversations || []).forEach(c => upsertConversation(c));
+ if (!State.conversations.has(GLOBAL_CONV)) {
+ upsertConversation({ id: GLOBAL_CONV, type: 'global', title: 'Global Chat', unread_count: 0 });
+ }
+ // jump back to where we were (default = Global Chat)
+ const target = State.activeConvId && State.conversations.has(State.activeConvId)
+ ? State.activeConvId
+ : GLOBAL_CONV;
+ State.activeConvId = null;
+ switchConversation(target);
break;
+ }
- case 'new_message': {
- const m = msg.message;
- if (State.messages.has(m.id)) {
- const ex = State.messages.get(m.id);
- ex.status = m.status;
- updateMessageBubble(m.id);
- return;
+ case 'conversations':
+ msg.conversations.forEach(c => upsertConversation(c));
+ if (State.activeConvId && State.conversations.has(State.activeConvId)) {
+ renderConversationHeader(State.activeConvId);
}
- State.messages.set(m.id, m);
- insertMessageIdSorted(m.id);
- renderMessage(m, true);
+ break;
- if (State.isAtBottom) {
- if (State.prefs.autoScroll) scrollToBottom();
- } else {
- // Show latest message bar if not at bottom
- State.latestUnseen = m;
- showLatestBar(m);
+ case 'conversation_updated':
+ upsertConversation(msg.conversation);
+ if (msg.conversation.id === State.activeConvId) {
+ renderConversationHeader(State.activeConvId);
}
+ break;
- if (document.visibilityState === 'visible' && State.isAtBottom) {
- sendMarkRead(m.id);
+ case 'dm_created': {
+ upsertConversation(msg.conversation);
+ const peer = msg.conversation.peer;
+ if (peer && peer.id !== State.user?.id) {
+ showToast(`Private chat with ${displayNameOf(peer)} created`, 'success');
+ switchConversation(msg.conversation.id);
}
- if (document.visibilityState !== 'visible' && Notification.permission === 'granted') {
- new Notification(m.sender_display_name || m.sender_username, {
- body: m.content || 'Sent a file',
- icon: '/static/icon.png'
- });
+ 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);
- insertMessageIdSorted(m.id);
+ } else {
+ const old = State.messages.get(m.id);
+ Object.assign(old, m);
}
+ if (!cache.ids.includes(m.id)) cache.ids.push(m.id);
});
- State.hasMore = msg.has_more;
- if (msg.next_cursor) State.cursor = msg.next_cursor;
- renderAllMessages();
- DOM.loadOlderBtn.classList.toggle('hidden', !msg.has_more);
+ 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 'user_status':
+ case 'typing_indicator': {
+ if (!msg.conversation_id) break;
+ if (msg.user_id === State.user?.id) break;
+ // key per conversation so a "typing" in one chat never leaks into another
+ const key = `${msg.conversation_id}:${msg.user_id}`;
+ if (msg.is_typing) {
+ const name = msg.display_name || msg.username;
+ const existing = State.typingUsers.get(key);
+ if (existing) clearTimeout(existing.timer);
+ const timer = setTimeout(() => {
+ State.typingUsers.delete(key);
+ if (msg.conversation_id === State.activeConvId) updateTypingIndicator();
+ }, 4000);
+ State.typingUsers.set(key, { name, timer });
+ } else {
+ const existing = State.typingUsers.get(key);
+ if (existing) clearTimeout(existing.timer);
+ State.typingUsers.delete(key);
+ }
+ if (msg.conversation_id === State.activeConvId) updateTypingIndicator();
+ break;
+ }
+
+ case 'user_status': {
if (msg.status === 'online') {
State.onlineUsers.set(msg.user_id, {
- id: msg.user_id,
- username: msg.username,
- display_name: msg.display_name,
- avatar_path: msg.avatar_path
+ id: msg.user_id, username: msg.username,
+ display_name: msg.display_name, avatar_path: msg.avatar_path
});
} else {
State.onlineUsers.delete(msg.user_id);
}
updateOnlineBadge();
+ updatePeerPresence(msg.user_id, msg.status === 'online');
+ if (State.activeConvId) renderConversationHeader(State.activeConvId);
break;
+ }
- case 'typing_indicator':
- if (msg.is_typing) State.typingUsers.set(msg.user_id, msg);
- else State.typingUsers.delete(msg.user_id);
- updateTypingIndicator();
+ case 'online_users': {
+ State.onlineUsers.clear();
+ msg.users.forEach(u => State.onlineUsers.set(u.id, u));
+ updateOnlineBadge();
+ updateAllPeerPresence();
+ if (!DOM.onlineModal.classList.contains('hidden')) renderOnlineUsers();
break;
+ }
- case 'message_edited':
- if (State.messages.has(msg.message_id)) {
- const m = State.messages.get(msg.message_id);
- m.content = msg.content;
+ 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;
- updateMessageBubble(msg.message_id);
- }
- break;
-
- case 'message_deleted':
- if (State.messages.has(msg.message_id)) {
- State.messages.get(msg.message_id).is_deleted = true;
- updateMessageBubble(msg.message_id);
+ if (m.conversation_id === State.activeConvId) {
+ updateMessageDom(mid);
+ }
}
break;
+ }
- case 'message_read':
- if (State.messages.has(msg.message_id)) {
- const m = State.messages.get(msg.message_id);
- if (m.sender_id === State.user.id) {
- m.status = 'read';
- updateMessageBubble(msg.message_id);
+ 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 'online_users':
- State.onlineUsers.clear();
- msg.users.forEach(u => State.onlineUsers.set(u.id, u));
- updateOnlineBadge();
- if (!DOM.onlineModal.classList.contains('hidden')) renderOnlineUsers();
+ 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':
- showToast(msg.message, 'error');
+ 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 insertMessageIdSorted(id) {
- let lo = 0, hi = State.messageOrder.length;
- while (lo < hi) {
- const mid = (lo + hi) >> 1;
- if (State.messageOrder[mid] < id) lo = mid + 1;
- else hi = mid;
- }
- if (State.messageOrder[lo] !== id) {
- State.messageOrder.splice(lo, 0, id);
+ 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);
}
- // =============================================
- // Latest message bar
- // =============================================
- function showLatestBar(msg) {
- if (!State.prefs.showLatestBar || !DOM.latestMessageBar) return;
- const name = msg.sender_display_name || msg.sender_username || 'Unknown';
- const text = msg.content || (msg.file_name ? '📎 ' + msg.file_name : '📎 File');
+ 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);
+ }
- // Avatar
- if (msg.sender_avatar_path) {
- DOM.lmbAvatar.innerHTML = `
`;
- } else {
- DOM.lmbAvatar.textContent = name.charAt(0).toUpperCase();
- DOM.lmbAvatar.style.background = 'var(--primary)';
+ 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);
}
-
- DOM.lmbName.textContent = name + ':';
- DOM.lmbText.textContent = truncate(text, 40);
- DOM.latestMessageBar.classList.remove('hidden');
}
- function hideLatestBar() {
- if (DOM.latestMessageBar) DOM.latestMessageBar.classList.add('hidden');
+ // ============================================================
+ // 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;
- }
- if (DOM.latestMessageBar) {
- DOM.latestMessageBar.addEventListener('click', () => {
- scrollToBottom();
- hideLatestBar();
- });
- }
+ // 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();
+ }
+ }
- // =============================================
- // Scroll UI
- // =============================================
- function updateScrollUI() {
- if (!State.isAtBottom) {
- if (State.prefs.showScrollBtn) {
- DOM.scrollBottomBtn.classList.remove('hidden');
+ const convOpen = convId === State.activeConvId;
+
+ if (convOpen) {
+ if (cache.ids.length === 1) {
+ renderAllMessages(convId, { scrollBottom: true });
} else {
- DOM.scrollBottomBtn.classList.add('hidden');
+ 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 {
- DOM.scrollBottomBtn.classList.add('hidden');
- hideLatestBar();
+ // 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);
}
}
- DOM.messagesScroll.addEventListener('scroll', () => {
- const { scrollTop, scrollHeight, clientHeight } = DOM.messagesScroll;
- State.isAtBottom = (scrollHeight - scrollTop - clientHeight) < 80;
- updateScrollUI();
- if (State.isAtBottom) {
- hideLatestBar();
- if (State.latestUnseen) sendMarkRead(State.latestUnseen.id);
+ 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');
+ }
- DOM.scrollBottomBtn.addEventListener('click', () => {
- scrollToBottom();
- hideLatestBar();
- });
+ 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 scrollToBottom() {
- DOM.messagesScroll.scrollTop = DOM.messagesScroll.scrollHeight;
- State.isAtBottom = true;
- DOM.scrollBottomBtn.classList.add('hidden');
+ 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 */ }
}
- // =============================================
- // Message Actions
- // =============================================
- function loadMessages() {
- if (State.ws?.readyState === WebSocket.OPEN) {
- State.ws.send(JSON.stringify({ type: 'load_messages', limit: 50 }));
+ // ============================================================
+ // 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();
}
- function requestOnlineUsers() {
- if (State.ws?.readyState === WebSocket.OPEN) {
- State.ws.send(JSON.stringify({ type: 'get_online_users' }));
- }
+ // 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 sendMarkRead(messageId) {
- if (State.ws?.readyState === WebSocket.OPEN) {
- State.ws.send(JSON.stringify({ type: 'mark_read', up_to_message_id: messageId }));
+ function buildMessageRow(msg, prevMsg) {
+ const isSelf = msg.sender_id === State.user?.id;
+ const row = document.createElement('div');
+ row.className = 'message-row ' + (isSelf ? 'self' : 'other');
+ row.dataset.id = msg.id;
+ row.dataset.conv = msg.conversation_id;
+ row.innerHTML = buildMessageHTML(msg, isSelf);
+ if (sameAuthor(msg, prevMsg)) row.classList.add('hug');
+ return row;
+ }
+
+ function buildMessageHTML(msg, isSelf) {
+ // tombstone for others' deleted messages
+ const tomb = (State.tombstoned.get(msg.conversation_id) || new Set()).has(msg.id);
+ if (tomb || msg.is_deleted) {
+ return ` This message was deleted
`;
+ }
+
+ const name = displayNameOf(msg);
+ const senderName = isSelf ? '' :
+ `${escapeHtml(name)}
`;
+ const avatar = isSelf
+ ? ''
+ : `${avatarSmallHTML(msg)}
`;
+
+ let content = '';
+ if (msg.content) {
+ content += `${renderInlineContent(msg.content)}
`;
}
+ if (msg.file_path) {
+ content += buildAttachmentHTML(msg);
+ }
+ if (msg.reply_to_id) {
+ content = buildReplyQuote(msg) + content;
+ }
+
+ const meta = buildMetaHTML(msg, isSelf);
+
+ // actions
+ let actions = '';
+ actions += actionBtn('reply', 'fas fa-reply', 'Reply');
+ if (!isSelf) {
+ actions += ``;
+ } else {
+ actions += actionBtn('edit', 'fas fa-pen', 'Edit');
+ actions += actionBtn('delete', 'fas fa-trash', 'Delete');
+ }
+ actions += actionBtn('copy', 'fas fa-copy', 'Copy');
+
+ return `
+ ${avatar}
+
+ ${senderName}
+
${content}${meta}
+
${actions}
+
`;
+ }
+
+ function actionBtn(action, icon, label) {
+ return ``;
}
- // =============================================
- // Rendering
- // =============================================
- function buildSenderAvatarHTML(msg) {
- const name = escapeHtml(msg.sender_display_name || msg.sender_username || '?');
- const initial = (msg.sender_display_name || msg.sender_username || '?').charAt(0).toUpperCase();
+ function avatarSmallHTML(msg) {
if (msg.sender_avatar_path) {
- return `
`;
+ return `
`;
}
- return `${initial}
`;
+ return `${initialOf(displayNameOf(msg))}
`;
}
- function renderMessage(msg, append = false) {
- if (DOM.messagesList.querySelector(`[data-id="${msg.id}"]`)) return;
- const wrapper = createMessageElement(msg);
- if (append) {
- DOM.messagesList.appendChild(wrapper);
- } else {
- const ids = State.messageOrder;
- const idx = ids.indexOf(msg.id);
- const nextId = ids[idx + 1];
- if (nextId) {
- const nextEl = DOM.messagesList.querySelector(`[data-id="${nextId}"]`);
- if (nextEl) { DOM.messagesList.insertBefore(wrapper, nextEl); return; }
- }
- DOM.messagesList.appendChild(wrapper);
+ 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 createMessageElement(msg) {
- const isSelf = msg.sender_id === State.user?.id;
- const wrapper = document.createElement('div');
- wrapper.className = `message-wrapper ${isSelf ? 'self' : 'other'}`;
- wrapper.dataset.id = msg.id;
- wrapper.innerHTML = buildMessageHTML(msg);
- return wrapper;
+ 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 buildMessageHTML(msg) {
- if (msg.is_deleted) {
- return ' This message was deleted
';
+ 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;
+ }
- const isSelf = msg.sender_id === State.user?.id;
- const displayName = escapeHtml(msg.sender_display_name || msg.sender_username || 'Unknown');
- let html = '';
+ 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)}"`;
- // Sender row: avatar + name, always shown
- if (!isSelf) {
- html += `
- ${buildSenderAvatarHTML(msg)}
-
${displayName}
+ if (ft.startsWith('image/')) {
+ return `
+
`;
- } else {
- html += `
-
${displayName}
- ${buildSenderAvatarHTML(msg)}
+ }
+ 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)}
+
+
+
`;
+ }
- // Reply indicator
- if (msg.reply_to_id && State.messages.has(msg.reply_to_id)) {
- const reply = State.messages.get(msg.reply_to_id);
- const rName = escapeHtml(reply.sender_display_name || reply.sender_username || 'Unknown');
- const rText = escapeHtml(truncate(reply.content || (reply.file_name ? '📎 ' + reply.file_name : ''), 60));
- html += `
-
- ${rName}: ${rText}
-
`;
+ // ---------------- 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');
}
+ }
- html += `
`;
+ 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);
+ }
+ }
- // Text content
- if (msg.content) {
- html += `
${escapeHtml(msg.content)}
`;
+ 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';
}
+ }
- // Attachment
- if (msg.file_path) {
- html += buildAttachmentHTML(msg);
+ 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;
}
+ });
- // Meta
- html += `
- ${formatTime(msg.timestamp_ms)}`;
- if (msg.is_edited) html += ` edited`;
- if (isSelf) {
- const isRead = msg.status === 'read';
- html += `
-
- `;
- }
- html += `
`;
+ // 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);
+ });
- // Actions - always visible
- html += `
- `;
- if (isSelf) {
- html += `
- `;
+ 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');
}
- html += `
`;
-
- html += `
`; // close message-bubble
+ });
- return html;
+ 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();
}
- function buildAttachmentHTML(msg) {
- const url = fileUrl(msg.file_path);
- const ft = (msg.file_type || '').toLowerCase();
- const fname = escapeHtml(msg.file_name || 'File');
+ // ============================================================
+ // 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();
+ });
- if (ft.startsWith('image/')) {
- return `
-

-
`;
- }
- if (ft.startsWith('video/')) {
- return `
-
-
`;
- }
- if (ft.startsWith('audio/')) {
- return `
`;
- }
- return `
-
-
-
${fname}
-
${formatFileSize(msg.file_size)}
+ // ============================================================
+ // 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 updateMessageBubble(id) {
- const el = DOM.messagesList.querySelector(`[data-id="${id}"]`);
- if (!el) return;
- const msg = State.messages.get(id);
- if (!msg) return;
- const isSelf = msg.sender_id === State.user?.id;
- el.className = `message-wrapper ${isSelf ? 'self' : 'other'}`;
- el.innerHTML = buildMessageHTML(msg);
+ function cancelInlineEdit() {
+ const id = State.editingId;
+ State.editingId = null;
+ if (id && State.messages.has(id)) updateMessageDom(id);
}
- function renderAllMessages() {
- const wasAtBottom = State.isAtBottom;
- const prevScrollHeight = DOM.messagesScroll.scrollHeight;
-
- DOM.messagesList.innerHTML = '';
-
- if (State.messageOrder.length === 0) {
- DOM.emptyState.style.display = 'flex';
- return;
+ 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');
}
- DOM.emptyState.style.display = 'none';
+ }
- State.messageOrder.forEach(id => {
- const msg = State.messages.get(id);
- if (msg) renderMessage(msg, true);
+ // ============================================================
+ // 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 (wasAtBottom && State.prefs.autoScroll) {
- scrollToBottom();
- } else if (!wasAtBottom) {
- DOM.messagesScroll.scrollTop += (DOM.messagesScroll.scrollHeight - prevScrollHeight);
- } else {
- scrollToBottom();
+ 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
- // =============================================
- DOM.composeForm.addEventListener('submit', (e) => {
- e.preventDefault();
- sendMessage();
+ // ============================================================
+ // 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) => {
- if (e.key === 'Enter' && !e.shiftKey) {
+ 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('input', () => {
- const hasContent = DOM.messageInput.value.trim().length > 0 || !!State.pendingFile;
- DOM.sendBtn.disabled = !hasContent;
- if (hasContent) sendTyping(true);
- autoResize();
- });
+ DOM.messageInput.addEventListener('blur', () => sendTyping(false));
function sendMessage() {
const content = DOM.messageInput.value.trim();
if (!content && !State.pendingFile) return;
- if (!State.ws || State.ws.readyState !== WebSocket.OPEN) {
- showToast('Not connected', 'warning');
- return;
+ if (!State.activeConvId) { showToast('No conversation selected', 'warning'); return; }
+ if (!wsOpen()) {
+ showToast('Not connected - message queued, sending when reconnected', 'warning');
}
+ const clientId = generateId();
const payload = {
type: 'send_message',
content: content,
+ conversation_id: State.activeConvId,
reply_to_id: State.replyTo ? State.replyTo.id : null,
- client_id: generateId()
+ client_id: clientId
};
if (State.pendingFile) {
payload.file_path = State.pendingFile.file_path;
@@ -780,90 +1892,208 @@
payload.file_name = State.pendingFile.file_name;
payload.file_size = State.pendingFile.file_size;
}
- State.ws.send(JSON.stringify(payload));
+ 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();
- autoResize();
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 (!State.ws || State.ws.readyState !== WebSocket.OPEN) return;
- clearTimeout(window.__typingTimer);
- State.ws.send(JSON.stringify({ type: 'typing', is_typing: 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) {
- window.__typingTimer = setTimeout(() => sendTyping(false), 2000);
+ clearTimeout(window.__typingStop);
+ window.__typingStop = setTimeout(() => sendTyping(false), 2200);
}
}
function updateTypingIndicator() {
- const users = Array.from(State.typingUsers.values()).filter(u => u.user_id !== State.user?.id);
- DOM.typingIndicator.classList.toggle('hidden', users.length === 0);
- if (users.length > 0) {
- DOM.typingText.textContent = users.map(u => u.display_name || u.username).join(', ') +
- (users.length === 1 ? ' is typing...' : ' are typing...');
+ 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…');
}
}
- // =============================================
- // Reply
- // =============================================
- function replyTo(messageId) {
- const msg = State.messages.get(messageId);
- if (!msg) return;
- State.replyTo = msg;
- DOM.replyUser.textContent = msg.sender_display_name || msg.sender_username;
- DOM.replyText.textContent = truncate(msg.content || (msg.file_name ? '📎 ' + msg.file_name : ''), 60);
- DOM.replyPreview.classList.remove('hidden');
- DOM.messageInput.focus();
+ function autoResize() {
+ DOM.messageInput.style.height = 'auto';
+ DOM.messageInput.style.height = Math.min(DOM.messageInput.scrollHeight, 120) + 'px';
}
- function clearReply() {
- State.replyTo = null;
- DOM.replyPreview.classList.add('hidden');
+ // ============================================================
+ // Start private chat modal
+ // ============================================================
+ function openConvModal(peerId, peerName) {
+ if (peerId === State.user?.id) { showToast('That is you!', 'info'); return; }
+ const existing = [...State.conversations.values()].filter(
+ c => c.type === 'dm' && c.peer && c.peer.id === peerId
+ );
+ DOM.convModalDesc.innerHTML = `Chat privately with
${escapeHtml(peerName)}` +
+ (existing.length ? ` (${existing.length} of 3 existing)` : '');
+ DOM.convModal.dataset.peerId = peerId;
+ DOM.convModal.dataset.peerName = peerName;
+ renderConvModal(existing);
+ openModal(DOM.convModal);
}
- document.addEventListener('click', (e) => {
- if (e.target.id === 'cancel-reply' || e.target.closest('#cancel-reply')) clearReply();
- });
-
- // =============================================
- // Edit / Delete
- // =============================================
- function editMessage(messageId) {
- const msg = State.messages.get(messageId);
- if (!msg) return;
- const newContent = prompt('Edit message:', msg.content);
- if (newContent !== null && newContent.trim()) {
- State.ws?.send(JSON.stringify({ type: 'edit_message', message_id: messageId, content: newContent.trim() }));
+ function renderConvModal(existing) {
+ let html = '';
+ existing.forEach(c => {
+ html += `
`;
+ });
+ if (!existing.length) {
+ html = '
No private chats with this person yet
';
+ }
+ DOM.convModalList.innerHTML = html;
+ const atLimit = existing.length >= 3;
+ DOM.newChatBtn.disabled = atLimit;
+ DOM.newChatBtn.querySelector('i').className = atLimit ? 'fas fa-ban' : 'fas fa-plus';
+ DOM.newChatBtn.lastChild.textContent = atLimit ? ' Limit Reached' : ' Start New Chat';
+ if (atLimit) {
+ const note = document.createElement('div');
+ note.className = 'conv-modal-note';
+ note.id = 'conv-limit-note';
+ note.innerHTML = '
You already have 3 private chats with this person. The limit is 3 per person.';
+ if (!DOM.convModalList.querySelector('#conv-limit-note')) DOM.convModalList.appendChild(note);
+ } else {
+ const note = DOM.convModalList.querySelector('#conv-limit-note');
+ if (note) note.remove();
}
+ DOM.convModalList.querySelectorAll('[data-open-conv]').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const cid = parseInt(btn.dataset.openConv, 10);
+ closeModal(DOM.convModal);
+ switchConversation(cid);
+ });
+ });
}
- function deleteMessage(messageId) {
- if (confirm('Permanently delete this message?')) {
- State.ws?.send(JSON.stringify({ type: 'delete_message', message_id: messageId }));
+ DOM.newChatBtn.addEventListener('click', async () => {
+ const peerId = parseInt(DOM.convModal.dataset.peerId, 10);
+ const peerName = DOM.convModal.dataset.peerName;
+ if (!peerId) return;
+ const btn = DOM.newChatBtn;
+ btn.disabled = true;
+ btn.innerHTML = '
Creating…';
+ const finish = () => { btn.disabled = false; btn.innerHTML = '
Start New Chat'; };
+ try {
+ if (wsOpen()) {
+ wsSend({ type: 'create_dm', user_id: peerId });
+ // The dm_created event switches conversation automatically.
+ // Close the modal: outcome arrives via ws in ms.
+ closeModal(DOM.convModal);
+ finish();
+ return;
+ }
+ const res = await fetch(`${API}/api/conversations/dm?user_id=${peerId}`, {
+ method: 'POST', headers: { 'X-Auth-Token': State.token }
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(data.detail || 'Could not start chat');
+ upsertConversation(data.conversation);
+ closeModal(DOM.convModal);
+ switchConversation(data.conversation.id);
+ showToast(`Private chat with ${peerName} started`, 'success');
+ } catch (err) {
+ showToast(err.message, 'error');
+ finish();
}
- }
+ });
- // =============================================
- // File Upload
- // =============================================
+ // ============================================================
+ // 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);
- e.target.value = '';
+ });
+
+ DOM.messageInput.addEventListener('paste', (e) => {
+ const items = e.clipboardData && e.clipboardData.items;
+ if (!items) return;
+ for (const item of items) {
+ if (item.type && item.type.startsWith('image/')) {
+ e.preventDefault();
+ const file = item.getAsFile();
+ if (file) uploadChunked(file);
+ break;
+ }
+ }
+ });
+
+ // drag & drop onto the chat window
+ let dragDepth = 0;
+ DOM.chatScreen.addEventListener('dragenter', (e) => {
+ if (!e.dataTransfer || ![...(e.dataTransfer.types || [])].includes('Files')) return;
+ dragDepth++;
+ DOM.dropOverlay.classList.remove('hidden');
+ });
+ DOM.chatScreen.addEventListener('dragover', (e) => { e.preventDefault(); });
+ DOM.chatScreen.addEventListener('dragleave', () => {
+ dragDepth = Math.max(0, dragDepth - 1);
+ if (!dragDepth) DOM.dropOverlay.classList.add('hidden');
+ });
+ DOM.chatScreen.addEventListener('drop', async (e) => {
+ e.preventDefault();
+ dragDepth = 0;
+ DOM.dropOverlay.classList.add('hidden');
+ const files = e.dataTransfer && e.dataTransfer.files;
+ if (!files || !files.length) return;
+ const file = files[0];
+ if (files.length > 1) showToast('Attaching the first file (one per message)', 'info');
+ await uploadChunked(file);
});
async function uploadChunked(file) {
+ if (file.size > 500 * 1024 * 1024) {
+ showToast('File too large (max 500MB)', 'error');
+ return;
+ }
const CHUNK_SIZE = 5 * 1024 * 1024;
- const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
+ const totalChunks = Math.max(1, Math.ceil(file.size / CHUNK_SIZE));
const uploadId = generateId();
DOM.uploadProgress.classList.remove('hidden');
- updateProgress(0, file.name);
+ updateProgress(0, file.name, file.size);
for (let i = 0; i < totalChunks; i++) {
const blob = file.slice(i * CHUNK_SIZE, Math.min((i + 1) * CHUNK_SIZE, file.size));
@@ -873,10 +2103,15 @@
`&file_name=${encodeURIComponent(file.name)}&file_type=${encodeURIComponent(file.type || 'application/octet-stream')}` +
`&file_size=${file.size}&upload_id=${uploadId}`;
try {
- const res = await fetch(url, { method: 'POST', body: formData, headers: { 'X-Auth-Token': State.token } });
- if (!res.ok) throw new Error('Upload failed');
+ const res = await fetch(url, {
+ method: 'POST', body: formData, headers: { 'X-Auth-Token': State.token }
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.detail || 'Upload failed');
+ }
const data = await res.json();
- updateProgress(((i + 1) / totalChunks) * 100, file.name);
+ updateProgress(((i + 1) / totalChunks) * 100, file.name, file.size);
if (data.status === 'complete') {
State.pendingFile = {
file_path: data.file_path,
@@ -884,9 +2119,10 @@
file_name: data.file_name,
file_size: data.file_size
};
- showUploadPreview(data.file_name);
+ showUploadPreview(State.pendingFile);
DOM.uploadProgress.classList.add('hidden');
DOM.sendBtn.disabled = false;
+ DOM.messageInput.focus();
return;
}
} catch (err) {
@@ -898,15 +2134,21 @@
DOM.uploadProgress.classList.add('hidden');
}
- function updateProgress(percent, filename) {
+ 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(filename) {
+ 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');
- DOM.uploadFilename.textContent = filename;
}
function clearPendingFile() {
@@ -919,15 +2161,14 @@
DOM.sendBtn.disabled = DOM.messageInput.value.trim().length === 0;
});
- // =============================================
- // Emoji Picker
- // =============================================
+ // ============================================================
+ // 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');
@@ -967,71 +2208,70 @@
});
}
- // =============================================
- // Load More
- // =============================================
+ // ============================================================
+ // Load older
+ // ============================================================
DOM.loadOlderBtn.addEventListener('click', () => {
- if (State.ws?.readyState === WebSocket.OPEN && State.hasMore && State.cursor) {
- State.isAtBottom = false;
- State.ws.send(JSON.stringify({ type: 'load_messages', cursor: State.cursor, limit: 50 }));
+ 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
- // =============================================
+ // ============================================================
+ // Online users
+ // ============================================================
DOM.onlineBtn.addEventListener('click', () => {
renderOnlineUsers();
- DOM.onlineModal.classList.remove('hidden');
+ openModal(DOM.onlineModal);
+ requestOnlineUsers();
});
- document.querySelectorAll('[data-close="online-modal"]').forEach(btn => {
- btn.addEventListener('click', () => DOM.onlineModal.classList.add('hidden'));
- });
+ function requestOnlineUsers() {
+ if (wsOpen()) wsSend({ type: 'get_online_users' });
+ }
function updateOnlineBadge() {
DOM.onlineBadge.textContent = State.onlineUsers.size;
+ DOM.onlineBadge.classList.toggle('hidden', State.onlineUsers.size === 0);
}
function renderOnlineUsers() {
DOM.onlineUsersList.innerHTML = '';
- if (State.onlineUsers.size === 0) {
+ if (!State.onlineUsers.size) {
DOM.onlineUsersList.innerHTML = '
No users online
';
DOM.onlineTotal.textContent = '0 users online';
return;
}
State.onlineUsers.forEach(u => {
+ if (u.id === State.user?.id) return; // don't list yourself
const div = document.createElement('div');
div.className = 'user-item';
- let avatarHtml;
- if (u.avatar_path) {
- avatarHtml = `
})
`;
- } else {
- avatarHtml = (u.display_name || u.username || '?').charAt(0).toUpperCase();
- }
+ const av = u.avatar_path
+ ? `
})
`
+ : `
${initialOf(displayNameOf(u))}`;
div.innerHTML = `
-
${avatarHtml}
+
${av}
-
${escapeHtml(u.display_name || u.username)}
-
● Online
+
${escapeHtml(displayNameOf(u))}
+
Online
`;
DOM.onlineUsersList.appendChild(div);
});
- DOM.onlineTotal.textContent = `${State.onlineUsers.size} user${State.onlineUsers.size !== 1 ? 's' : ''} online`;
+ DOM.onlineTotal.textContent = `${State.onlineUsers.size - (State.onlineUsers.has(State.user?.id) ? 1 : 0)} user${State.onlineUsers.size !== 1 ? 's' : ''} online`;
}
- // =============================================
+ // ============================================================
// Settings
- // =============================================
- document.getElementById('settings-btn').addEventListener('click', openSettings);
- document.querySelectorAll('[data-close="settings-modal"]').forEach(btn => {
- btn.addEventListener('click', () => DOM.settingsModal.classList.add('hidden'));
- });
- document.getElementById('save-settings').addEventListener('click', saveSettings);
-
+ // ============================================================
function openSettings() {
DOM.settingsDisplayName.value = State.user?.display_name || '';
DOM.settingsUsername.value = State.user?.username || '';
+ DOM.pwCurrent.value = '';
+ DOM.pwNew.value = '';
+ DOM.pwMsg.textContent = '';
if (State.user?.avatar_path) {
DOM.settingsAvatarImg.src = fileUrl(State.user.avatar_path);
@@ -1042,9 +2282,7 @@
DOM.settingsAvatarPreview.classList.remove('hidden');
}
- DOM.themeOptions.forEach(opt => {
- opt.classList.toggle('active', opt.dataset.theme === State.theme);
- });
+ 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;
@@ -1054,40 +2292,43 @@
DOM.otherColorHex.textContent = State.customColors.other;
DOM.textColorHex.textContent = State.customColors.text;
}
-
- // Load prefs into toggles
+ DOM.aboutVersion.textContent = APP_VERSION;
applyPrefs();
-
- DOM.settingsModal.classList.remove('hidden');
+ openModal(DOM.settingsModal);
}
+ DOM.settingsBtn.addEventListener('click', openSettings);
+ DOM.meSettingsBtn.addEventListener('click', openSettings);
+
async function saveSettings() {
const newName = DOM.settingsDisplayName.value.trim();
if (newName && newName !== State.user?.display_name) {
try {
const res = await fetch(`${API}/api/profile?display_name=${encodeURIComponent(newName)}`, {
- method: 'PATCH',
- headers: { 'X-Auth-Token': State.token }
+ method: 'PATCH', headers: { 'X-Auth-Token': State.token }
});
if (res.ok) {
State.user.display_name = newName;
- updateHeader();
+ updateSidebarMe();
+ renderSidebar();
+ renderConversationHeader(State.activeConvId);
showToast('Display name updated', 'success');
+ } else {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.detail || 'Failed');
}
- } catch (e) {
- showToast('Failed to update name', 'error');
- }
+ } catch (e) { showToast('Failed to update name', 'error'); }
}
const avatarFile = DOM.avatarInput.files[0];
if (avatarFile) {
const formData = new FormData();
formData.append('file', avatarFile);
+ DOM.avatarUploadBtn.disabled = true;
+ DOM.avatarUploadBtn.innerHTML = '
';
try {
const res = await fetch(`${API}/api/profile/avatar`, {
- method: 'POST',
- body: formData,
- headers: { 'X-Auth-Token': State.token }
+ method: 'POST', body: formData, headers: { 'X-Auth-Token': State.token }
});
if (res.ok) {
const data = await res.json();
@@ -1095,18 +2336,27 @@
DOM.settingsAvatarImg.src = fileUrl(data.avatar_path);
DOM.settingsAvatarImg.classList.remove('hidden');
DOM.settingsAvatarPreview.classList.add('hidden');
- updateHeader();
+ 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('Failed to update avatar', 'error');
+ showToast(e.message, 'error');
+ } finally {
+ DOM.avatarUploadBtn.disabled = false;
+ DOM.avatarUploadBtn.innerHTML = '
Change Photo';
}
}
- // Save prefs from toggles
- if (DOM.toggleAutoScroll) State.prefs.autoScroll = DOM.toggleAutoScroll.checked;
- if (DOM.toggleScrollBtn) State.prefs.showScrollBtn = DOM.toggleScrollBtn.checked;
- if (DOM.toggleLatestBar) State.prefs.showLatestBar = DOM.toggleLatestBar.checked;
+ 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';
@@ -1119,8 +2369,7 @@
localStorage.setItem('custom-text', State.customColors.text);
}
applyTheme(selectedTheme);
-
- DOM.settingsModal.classList.add('hidden');
+ closeModal(DOM.settingsModal);
}
DOM.customSelfColor.addEventListener('input', () => { DOM.selfColorHex.textContent = DOM.customSelfColor.value; });
@@ -1157,76 +2406,55 @@
}
});
- // =============================================
- // Utility
- // =============================================
- function generateId() {
- return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);
- }
-
- function truncate(str, n) {
- if (!str) return '';
- return str.length > n ? str.substr(0, n - 1) + '…' : str;
- }
-
- function escapeHtml(text) {
- if (!text) return '';
- const div = document.createElement('div');
- div.textContent = text;
- return div.innerHTML;
- }
+ document.getElementById('save-settings').addEventListener('click', saveSettings);
- function formatTime(ms) {
- if (!ms) return '';
- const date = new Date(ms);
- const now = new Date();
- const diff = now - date;
- if (diff < 60000) return 'just now';
- if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
- if (date.toDateString() === now.toDateString()) {
- return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ 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;
}
- 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' });
+ 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';
}
- return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' +
- date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
- }
-
- function formatFileSize(bytes) {
- if (!bytes) return '0 B';
- const k = 1024;
- const sizes = ['B', 'KB', 'MB', 'GB'];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
- return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
- }
-
- function autoResize() {
- DOM.messageInput.style.height = 'auto';
- DOM.messageInput.style.height = Math.min(DOM.messageInput.scrollHeight, 120) + 'px';
- }
+ });
- // =============================================
- // Init
- // =============================================
+ // ============================================================
+ // Auth lifecycle
+ // ============================================================
async function init() {
applyPrefs();
+ DOM.aboutVersion.textContent = APP_VERSION;
if (State.token) {
try {
- const res = await fetch(`${API}/api/auth/verify`, {
- headers: { 'X-Auth-Token': State.token }
- });
+ 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();
- startTimestampUpdater();
return;
}
- } catch (e) {}
+ } catch (e) { /* fall through to auth screen */ }
localStorage.removeItem('token');
State.token = null;
}
@@ -1234,6 +2462,7 @@
}
function showAuth() {
+ State.wsState = 'disconnected';
DOM.authScreen.classList.add('active');
DOM.chatScreen.classList.remove('active');
}
@@ -1241,62 +2470,116 @@
function showChat() {
DOM.authScreen.classList.remove('active');
DOM.chatScreen.classList.add('active');
- updateHeader();
- startTimestampUpdater();
+ updateSidebarMe();
}
- function updateHeader() {
+ function updateSidebarMe() {
if (!State.user) return;
- const displayEl = document.getElementById('header-display-name');
- if (displayEl) displayEl.textContent = State.user.display_name || State.user.username;
+ DOM.sbMeName.textContent = displayNameOf(State.user);
if (State.user.avatar_path) {
- const img = document.getElementById('header-avatar');
- const ph = document.getElementById('header-avatar-placeholder');
- if (img) { img.src = fileUrl(State.user.avatar_path); img.classList.remove('hidden'); }
- if (ph) ph.classList.add('hidden');
+ 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');
}
}
- 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';
- }
- });
- });
-
- document.getElementById('logout-btn').addEventListener('click', async () => {
- if (State.token) {
- try {
- await fetch(`${API}/api/auth/logout`, { method: 'POST', headers: { 'X-Auth-Token': State.token } });
- } catch (e) {}
- }
- if (State.ws) { State.ws.close(); State.ws = null; }
+ function forceLogout() {
+ showToast('Session expired - please sign in again', 'warning');
localStorage.removeItem('token');
State.token = null;
+ State.wsState = 'disconnected';
+ if (State.ws) { try { State.ws.close(); } catch (e) { /* noop */ } }
+ State.ws = null;
+ resetAllState();
+ showAuth();
+ }
+
+ function resetAllState() {
State.user = null;
+ State.conversations.clear();
+ State.conversationOrder = [];
+ State.convCache.clear();
State.messages.clear();
- State.messageOrder = [];
+ State.tombstoned.clear();
+ State.pendingSends.clear();
State.onlineUsers.clear();
State.typingUsers.clear();
+ State.activeConvId = null;
State.cursor = null;
- State.hasMore = true;
+ State.replyTo = null;
+ State.pendingFile = null;
State.latestUnseen = null;
+ State.editingId = null;
DOM.messagesList.innerHTML = '';
+ DOM.convList.innerHTML = '';
+ DOM.sidebar.classList.remove('open');
+ DOM.sidebarBackdrop.classList.add('hidden');
+ DOM.loadOlderBtn.classList.add('hidden');
DOM.emptyState.style.display = 'flex';
hideLatestBar();
- showAuth();
updateOnlineBadge();
- });
+ updateTitleUnread();
+ clearReply();
+ clearPendingFile();
+ DOM.messageInput.value = '';
+ DOM.sendBtn.disabled = true;
+ DOM.uploadProgress.classList.add('hidden');
+ DOM.onlineModal.classList.add('hidden');
+ DOM.settingsModal.classList.add('hidden');
+ DOM.convModal.classList.add('hidden');
+ DOM.receiptsModal.classList.add('hidden');
+ DOM.confirmModal.classList.add('hidden');
+ DOM.lightboxModal.classList.add('hidden');
+ DOM.emojiPicker.classList.add('hidden');
+ openModals.length = 0;
+ document.body.style.overflow = '';
+ }
- if (window.Notification && Notification.permission === 'default') {
- Notification.requestPermission();
+ function doLogout() {
+ if (State.token) {
+ fetch(`${API}/api/auth/logout`, { method: 'POST', headers: { 'X-Auth-Token': State.token } }).catch(() => { });
+ }
+ if (State.ws) { try { State.ws.close(); } catch (e) { /* noop */ } }
+ State.ws = null;
+ localStorage.removeItem('token');
+ State.token = null;
+ resetAllState();
+ showAuth();
}
- window.InfinityChat = { replyTo, editMessage, deleteMessage };
+ DOM.logoutBtn.addEventListener('click', () => {
+ showConfirm({
+ title: 'Log out',
+ message: 'Log out of InfinityChat on this device?',
+ okText: 'Log Out',
+ danger: false
+ }).then(ok => { if (ok) doLogout(); });
+ });
+ DOM.sidebarLogoutBtn.addEventListener('click', () => {
+ showConfirm({
+ title: 'Log out',
+ message: 'Log out of InfinityChat on this device?',
+ okText: 'Log Out',
+ danger: false
+ }).then(ok => { if (ok) doLogout(); });
+ });
+
+ document.querySelectorAll('.input-toggle').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const input = document.getElementById(btn.dataset.toggle);
+ if (input) {
+ input.type = input.type === 'password' ? 'text' : 'password';
+ const icon = btn.querySelector('i');
+ if (icon) icon.className = input.type === 'password' ? 'fas fa-eye' : 'fas fa-eye-slash';
+ }
+ });
+ });
+ // ============================================================
+ // Go
+ // ============================================================
init();
})();
\ No newline at end of file