if ("scrollRestoration" in history) history.scrollRestoration = "manual"; const viewerState = { accountId: null, accounts: [], channels: [], channelId: null, oldestMessageId: null, newestMessageId: null, userId: null, loading: false, 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 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 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 = "") { if (viewerState.accountId) { return `/api/accounts/${encodeURIComponent(viewerState.accountId)}/channels/${encodeURIComponent(channelId)}/messages?limit=80${before}`; } return `/api/channels/${encodeURIComponent(channelId)}/messages?limit=80${before}`; } function channelsEndpoint() { if (viewerState.accountId) { return `/api/accounts/${encodeURIComponent(viewerState.accountId)}/channels`; } return "/api/channels"; } async function loadViewerAccount(params) { const requested = params.get("account"); try { const payload = await api("/api/accounts"); viewerState.accounts = payload.accounts || []; } catch { viewerState.accounts = []; } viewerState.accountId = requested || viewerState.accounts[0]?.id || null; const accountName = viewerState.accounts.find((item) => item.id === viewerState.accountId)?.label || viewerState.accountId || "Legacy"; const accountEl = document.getElementById("viewer-account-name"); if (accountEl) accountEl.textContent = accountName; 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"); const sentinel = document.getElementById("scroll-sentinel"); if (!root) return; const channel = payload.channel; document.getElementById("viewer-title").textContent = channel?.name || payload.channel_id; document.getElementById("viewer-subtitle").textContent = channel ? `${channel.message_count} messages, 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; } 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; } } 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()); viewerState.channelId = params.get("channel") || viewerState.channels[0]?.channel_id || null; renderChannelList(); if (viewerState.channelId) { await loadChannel(viewerState.channelId); } setupInfiniteScroll(); 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); alert(error.message); });