if ('scrollRestoration' in history) history.scrollRestoration = 'manual'; const viewerState = { accountId: null, accounts: [], channels: [], channelId: null, oldestMessageId: null, newestMessageId: null, userId: null, search: '', loading: false, autoRefreshTimer: null, sentinelObserver: null, }; async function api(path) { const response = await fetch(path); const data = await response.json(); if (!response.ok) { throw new Error(data.error || 'Request failed'); } return data; } function showToast(message, type = 'info') { let root = document.getElementById('toast-root'); if (!root) { root = document.createElement('div'); root.id = 'toast-root'; root.className = 'toast-root'; document.body.appendChild(root); } const toast = document.createElement('div'); toast.className = `toast toast-${type}`; toast.textContent = message; root.appendChild(toast); window.setTimeout(() => { toast.classList.add('toast-hide'); window.setTimeout(() => toast.remove(), 250); }, 3600); } function formatDateHeader(dateStr) { if (!dateStr) return ''; const d = new Date(dateStr.replace(' ', 'T')); if (isNaN(d.getTime())) return ''; const now = new Date(); const diff = now - d; const oneDay = 86400000; if (diff < oneDay && d.getDate() === now.getDate()) return 'Today'; if (diff < 2 * oneDay && d.getDate() === now.getDate() - 1) return 'Yesterday'; return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined, }); } function formatTime(dateStr) { if (!dateStr) return ''; const d = new Date(dateStr.replace(' ', 'T')); if (isNaN(d.getTime())) return ''; return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); } function chatListTime(dateStr) { if (!dateStr) return ''; const d = new Date(dateStr.replace(' ', 'T')); if (isNaN(d.getTime())) return ''; const now = new Date(); if (d.toDateString() === now.toDateString()) { return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); } return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); } function initials(value) { const source = String(value || '?') .replace(/^@/, '') .trim(); const words = source.split(/\s+/).filter(Boolean); if (words.length > 1) return (words[0][0] + words[1][0]).toUpperCase(); return source.slice(0, 2).toUpperCase(); } function dateKey(dateStr) { if (!dateStr) return ''; return dateStr.slice(0, 10); } function textNodeWithBreaks(text) { const fragment = document.createDocumentFragment(); if (!text) return fragment; const parts = text.split('\n'); parts.forEach((part, index) => { if (index > 0) fragment.appendChild(document.createElement('br')); fragment.appendChild(document.createTextNode(part)); }); return fragment; } function scrollToBottom() { const list = document.getElementById('messages-list'); if (!list) return; const go = () => { list.scrollTop = list.scrollHeight; }; go(); requestAnimationFrame(go); setTimeout(go, 150); } function makeScrollSentinel() { const sentinel = document.createElement('div'); sentinel.id = 'scroll-sentinel'; sentinel.className = 'scroll-sentinel'; return sentinel; } function resetMessageView(title = 'Select a channel', subtitle = 'Reading messages from the local SQLite database.') { const root = document.getElementById('messages-list'); if (root) { root.innerHTML = ''; root.appendChild(makeScrollSentinel()); } document.getElementById('viewer-title').textContent = title; document.getElementById('viewer-subtitle').textContent = subtitle; viewerState.oldestMessageId = null; viewerState.newestMessageId = null; } function renderChannelList() { const root = document.getElementById('viewer-channel-list'); if (!root) return; root.innerHTML = ''; const template = document.getElementById('viewer-channel-template'); if (!template) return; viewerState.channels.forEach((channel) => { const node = template.content.firstElementChild.cloneNode(true); const preview = channel.last_message_preview || (channel.has_database ? 'No text in the last saved message' : 'No local database yet'); node.querySelector('.viewer-channel-avatar').textContent = initials(channel.name || channel.channel_id); node.querySelector('.viewer-channel-name').textContent = channel.name; node.querySelector('.viewer-channel-time').textContent = chatListTime(channel.last_date); node.querySelector('.viewer-channel-preview').textContent = preview; node.querySelector('.viewer-channel-count').textContent = String(channel.message_count || 0); if (channel.channel_id === viewerState.channelId) { node.classList.add('active'); } node.addEventListener('click', () => { document.querySelector('.viewer-sidebar')?.classList.remove('open'); viewerState.oldestMessageId = null; viewerState.newestMessageId = null; loadChannel(channel.channel_id); }); root.appendChild(node); }); } function messageEndpoint(channelId, before = '') { const search = viewerState.search ? `&search=${encodeURIComponent(viewerState.search)}` : ''; if (viewerState.accountId) { return `/api/accounts/${encodeURIComponent(viewerState.accountId)}/channels/${encodeURIComponent(channelId)}/messages?limit=80${before}${search}`; } return `/api/channels/${encodeURIComponent(channelId)}/messages?limit=80${before}${search}`; } function channelsEndpoint() { if (viewerState.accountId) { return `/api/accounts/${encodeURIComponent(viewerState.accountId)}/channels`; } return '/api/channels'; } function renderAccountSelector() { const sel = document.getElementById('viewer-account-select'); if (!sel) return; sel.innerHTML = ''; const legacy = viewerState.accounts.length === 0; if (legacy) { const opt = document.createElement('option'); opt.value = ''; opt.textContent = 'Legacy'; sel.appendChild(opt); } else { viewerState.accounts.forEach((acc) => { const opt = document.createElement('option'); opt.value = acc.id; opt.textContent = acc.label || acc.id; if (acc.id === viewerState.accountId) opt.selected = true; sel.appendChild(opt); }); } } async function switchViewerAccount(accountId) { viewerState.accountId = accountId || null; viewerState.search = ''; const searchInput = document.getElementById('viewer-search'); if (searchInput) searchInput.value = ''; viewerState.accounts.forEach((acc) => { if (acc.id === accountId) { const opts = document.getElementById('viewer-account-select')?.options; if (opts) { for (let i = 0; i < opts.length; i++) { opts[i].selected = opts[i].value === accountId; } } } }); try { const authData = viewerState.accountId ? await api(`/api/accounts/${encodeURIComponent(viewerState.accountId)}/auth`) : await api('/api/auth'); viewerState.userId = authData.user_id || null; } catch (e) { console.warn('Could not fetch user ID:', e); } const channels = await api(channelsEndpoint()); viewerState.channels = channels; viewerState.channelId = channels[0]?.channel_id || null; viewerState.oldestMessageId = null; viewerState.newestMessageId = null; renderChannelList(); if (viewerState.channelId) { await loadChannel(viewerState.channelId); } else { resetMessageView( 'No channels', viewerState.accountId ? 'This account has no tracked channels yet.' : 'No local legacy channels found.', ); } } async function loadViewerAccount(params) { const requested = params.get('account'); try { const payload = await api('/api/accounts'); viewerState.accounts = payload.accounts || []; } catch { viewerState.accounts = []; } const requestedExists = requested && viewerState.accounts.some((acc) => acc.id === requested); viewerState.accountId = requestedExists ? requested : viewerState.accounts[0]?.id || null; renderAccountSelector(); const sel = document.getElementById('viewer-account-select'); if (sel) { sel.addEventListener('change', () => switchViewerAccount(sel.value)); } try { const authData = viewerState.accountId ? await api(`/api/accounts/${encodeURIComponent(viewerState.accountId)}/auth`) : await api('/api/auth'); viewerState.userId = authData.user_id || null; } catch (e) { console.warn('Could not fetch user ID:', e); } } function buildMessageNode(message, root) { const template = document.getElementById('message-template'); const wrap = template.content.firstElementChild.cloneNode(true); const bubble = wrap.querySelector('.chat-bubble'); bubble.dataset.messageId = String(message.message_id); const isOwn = viewerState.userId && message.sender_id === viewerState.userId; if (isOwn) { wrap.classList.add('chat-bubble-wrap--own'); } const sender = bubble.querySelector('.chat-sender'); sender.textContent = message.sender_name || ''; const replyEl = bubble.querySelector('.chat-reply'); const replyMsg = message.reply_to_message; if (message.reply_to) { replyEl.classList.remove('hidden'); bubble.querySelector('.chat-reply-author').textContent = replyMsg?.sender_name || `#${message.reply_to}`; bubble.querySelector('.chat-reply-text').textContent = replyMsg?.text || '(message not available)'; replyEl.addEventListener('click', () => { const target = root.querySelector(`[data-message-id="${message.reply_to}"]`); if (target) { target.scrollIntoView({ behavior: 'smooth', block: 'center' }); target.classList.add('chat-bubble--highlight'); setTimeout(() => target.classList.remove('chat-bubble--highlight'), 2000); } }); } else { replyEl.classList.add('hidden'); } bubble.querySelector('.chat-text').appendChild(textNodeWithBreaks(message.text || '')); const media = bubble.querySelector('.chat-media'); if (message.media_url && message.media_kind === 'image') { const img = document.createElement('img'); img.src = message.media_url; img.loading = 'lazy'; media.appendChild(img); } else if (message.media_url && message.media_kind === 'video') { const video = document.createElement('video'); video.src = message.media_url; video.controls = true; video.preload = 'metadata'; media.appendChild(video); } else if (message.media_url) { const link = document.createElement('a'); link.href = message.media_url; link.target = '_blank'; link.rel = 'noreferrer'; link.textContent = 'Open file'; media.appendChild(link); } const footerParts = []; if (message.date) footerParts.push(formatTime(message.date)); if (message.views) footerParts.push(`views: ${message.views}`); if (message.forwards) footerParts.push(`forwards: ${message.forwards}`); if (message.reactions) footerParts.push(`reactions: ${message.reactions}`); bubble.querySelector('.chat-footer').textContent = footerParts.join(' | '); return { wrap, dateKey: dateKey(message.date) }; } function makeDateSep(text) { const el = document.createElement('div'); el.className = 'chat-date-sep'; el.textContent = text; return el; } function renderMessages(payload, append = false) { const root = document.getElementById('messages-list'); if (!root) return; let sentinel = document.getElementById('scroll-sentinel'); if (!sentinel) sentinel = makeScrollSentinel(); const channel = payload.channel; document.getElementById('viewer-title').textContent = channel?.name || payload.channel_id; document.getElementById('viewer-subtitle').textContent = channel ? `${payload.messages.length} shown${viewerState.search ? ` for "${viewerState.search}"` : ''}, ${channel.message_count} total, latest: ${channel.last_date || '-'}` : 'No local database found for this channel yet.'; if (!append) { root.innerHTML = ''; root.appendChild(sentinel); } let lastKey = null; if (append) { const firstExisting = root.querySelector('[data-message-id]'); if (firstExisting) { const candidate = firstExisting.closest('.chat-bubble-wrap'); if (candidate && candidate._dateKey) lastKey = candidate._dateKey; } } const items = payload.messages.map((m) => { const { wrap, dateKey: dk } = buildMessageNode(m, root); wrap._dateKey = dk; return { wrap, dk }; }); if (append) { for (let i = items.length - 1; i >= 0; i--) { const { wrap, dk } = items[i]; const target = sentinel.nextSibling; if (dk && dk !== lastKey) { root.insertBefore(makeDateSep(formatDateHeader(payload.messages[i].date)), target); lastKey = dk; } root.insertBefore(wrap, target); } } else { for (let i = 0; i < items.length; i++) { const { wrap, dk } = items[i]; if (dk && dk !== lastKey) { root.appendChild(makeDateSep(formatDateHeader(payload.messages[i].date))); lastKey = dk; } root.appendChild(wrap); } } if (payload.messages.length) { viewerState.oldestMessageId = payload.messages[0].message_id; viewerState.newestMessageId = payload.messages[payload.messages.length - 1].message_id; } else if (!append) { viewerState.oldestMessageId = null; viewerState.newestMessageId = null; const empty = document.createElement('div'); empty.className = 'viewer-empty-state'; empty.textContent = viewerState.search ? `No messages found for "${viewerState.search}".` : 'No saved messages in this channel yet.'; root.appendChild(empty); } if (!append) { requestAnimationFrame(() => scrollToBottom()); } } async function loadChannel(channelId, append = false) { if (viewerState.loading) return; viewerState.loading = true; viewerState.channelId = channelId; renderChannelList(); const before = append && viewerState.oldestMessageId ? `&before=${viewerState.oldestMessageId}` : ''; try { const payload = await api(messageEndpoint(channelId, before)); if (append) { const list = document.getElementById('messages-list'); const prevScrollHeight = list.scrollHeight; renderMessages(payload, true); requestAnimationFrame(() => { list.scrollTop = list.scrollHeight - prevScrollHeight; }); } else { renderMessages(payload, false); } } catch (err) { console.error('Failed to load messages:', err); } finally { viewerState.loading = false; } } async function refreshCurrentChannel() { if (!viewerState.channelId || viewerState.loading) return; viewerState.oldestMessageId = null; viewerState.newestMessageId = null; await loadChannel(viewerState.channelId); } function setupViewerTools() { const searchInput = document.getElementById('viewer-search'); const refreshBtn = document.getElementById('viewer-refresh-btn'); const autoRefresh = document.getElementById('viewer-auto-refresh'); let searchTimer = null; if (searchInput) { searchInput.addEventListener('input', () => { clearTimeout(searchTimer); searchTimer = setTimeout(() => { viewerState.search = searchInput.value.trim(); refreshCurrentChannel(); }, 250); }); } if (refreshBtn) { refreshBtn.addEventListener('click', () => refreshCurrentChannel()); } viewerState.autoRefreshTimer = window.setInterval(() => { if (!autoRefresh || autoRefresh.checked) { refreshCurrentChannel(); } }, 30000); } function setupInfiniteScroll() { const sentinel = document.getElementById('scroll-sentinel'); if (!sentinel) return; if (viewerState.sentinelObserver) { viewerState.sentinelObserver.disconnect(); } viewerState.sentinelObserver = new IntersectionObserver( (entries) => { for (const entry of entries) { if (entry.isIntersecting && viewerState.channelId && viewerState.oldestMessageId && !viewerState.loading) { loadChannel(viewerState.channelId, true); } } }, { root: document.getElementById('messages-list'), threshold: 0.1 }, ); viewerState.sentinelObserver.observe(sentinel); } async function main() { const params = new URLSearchParams(window.location.search); await loadViewerAccount(params); viewerState.channels = await api(channelsEndpoint()); const requestedChannel = params.get('channel'); const requestedChannelExists = requestedChannel && viewerState.channels.some((channel) => channel.channel_id === requestedChannel); viewerState.channelId = requestedChannelExists ? requestedChannel : viewerState.channels[0]?.channel_id || null; renderChannelList(); if (viewerState.channelId) { await loadChannel(viewerState.channelId); } else { resetMessageView( 'No channels', viewerState.accountId ? 'This account has no tracked channels yet.' : 'No local legacy channels found.', ); } setupInfiniteScroll(); setupViewerTools(); const toggle = document.getElementById('sidebar-toggle'); const sidebar = document.querySelector('.viewer-sidebar'); if (toggle && sidebar) { toggle.addEventListener('click', () => { sidebar.classList.toggle('open'); }); sidebar.addEventListener('click', (e) => { if (e.target === sidebar) { sidebar.classList.remove('open'); } }); } } main().catch((error) => { console.error(error); showToast(error.message, 'error'); });