From c5361f227197b6688f0cf216db360dac12581ac6 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Wed, 27 May 2026 01:01:18 +0200 Subject: [PATCH] Redesign message viewer as Telegram-style chat with bubbles, replies, infinite scroll, and orientation - Chat bubbles with 1px border-radius, angular tail arrows - Own messages right (blue), others left (dark) via sender_id vs user_id - Reply preview with left border accent, clickable scroll to original - Infinite scroll up via IntersectionObserver on scroll-sentinel - Date separators (Today/Yesterday/date) between messages - Full-height messages panel with sidebar (channels) + header - Backend: reply_to_message data in load_messages(), user_id exposed in auth snapshot - Auto-scroll to bottom on channel load (triple scrollTop fallback) - history.scrollRestoration = manual to prevent mid-page jumps --- webui/style.css | 282 +++++++++++++++++++++++++++++++++++++++------ webui/viewer.html | 32 +++--- webui/viewer.js | 285 +++++++++++++++++++++++++++++++++++++--------- webui_server.py | 39 +++++++ 4 files changed, 532 insertions(+), 106 deletions(-) diff --git a/webui/style.css b/webui/style.css index ac1dd6d..492b881 100644 --- a/webui/style.css +++ b/webui/style.css @@ -46,35 +46,25 @@ button { font: inherit; } -.app-shell, -.viewer-shell { +.app-shell { min-height: 100vh; display: grid; -} - -.app-shell, -.viewer-shell { grid-template-columns: 300px minmax(0, 1fr); } -.sidebar, -.viewer-sidebar { +.sidebar { background: var(--panel-alt); border-right: 1px dashed var(--line); padding: 28px 24px; } -.content, -.viewer-main { +.content { padding: 28px; max-width: 1320px; width: 100%; } .brand-block h1, -.viewer-sidebar-head h1, -.viewer-header h2, -.panel-header h2, .dialog-header h2 { margin: 0; color: var(--accent); @@ -105,8 +95,7 @@ button { .nav-links, .jobs-list, -.continuous-form, -.viewer-channel-list { +.continuous-form { display: grid; gap: 10px; } @@ -227,10 +216,7 @@ input, } .panel-header, -.viewer-sidebar-head, -.viewer-header, .dialog-header, -.message-meta, .job-head { display: flex; align-items: flex-start; @@ -489,6 +475,45 @@ tbody tr:hover { display: none !important; } +/* ---------- Viewer layout: sidebar + chat ---------- */ + +.viewer-shell { + display: grid; + grid-template-columns: 300px minmax(0, 1fr); + height: 100vh; + overflow: hidden; +} + +.viewer-sidebar { + background: var(--panel-alt); + border-right: 1px dashed var(--line); + padding: 28px 24px; + display: flex; + flex-direction: column; + gap: 20px; + overflow-y: auto; +} + +.viewer-sidebar-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.viewer-sidebar-head h1 { + margin: 0; + color: var(--accent); + font-size: 1.45rem; + text-transform: uppercase; + letter-spacing: 0; +} + +.viewer-channel-list { + display: grid; + gap: 10px; +} + .viewer-channel-item { display: grid; gap: 4px; @@ -496,6 +521,9 @@ tbody tr:hover { padding: 12px; text-align: left; cursor: pointer; + border: 1px solid var(--line); + background: var(--panel); + color: var(--text-color); } .viewer-channel-item.active { @@ -503,52 +531,232 @@ tbody tr:hover { box-shadow: inset 3px 0 0 var(--accent); } -.viewer-channel-name, -.message-author { +.viewer-channel-name { color: var(--accent); font-weight: 700; } -.viewer-channel-meta, -.message-date { +.viewer-channel-meta { color: var(--dim); font-size: 0.82rem; } +/* ---------- Main panel ---------- */ + +.viewer-main { + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; +} + +.viewer-header { + flex-shrink: 0; + padding: 20px 28px; + border-bottom: 1px dashed var(--line); + background: var(--panel-alt); +} + +.viewer-header h2 { + margin: 0; + color: var(--accent); + font-size: 1.45rem; + text-transform: uppercase; + letter-spacing: 0; +} + .messages-list { - display: grid; + flex: 1; + overflow-y: auto; + padding: 12px 28px 20px; + display: flex; + flex-direction: column; +} + +.scroll-sentinel { + flex-shrink: 0; + height: 1px; + width: 100%; +} + +/* ---------- Date separator ---------- */ + +.chat-date-sep { + display: flex; + align-items: center; + justify-content: center; gap: 12px; - align-content: start; + margin: 12px 0; + font-size: 0.78rem; + color: var(--dim); + text-transform: uppercase; + letter-spacing: 0.03em; + flex-shrink: 0; } -.message-card { - max-width: 820px; +.chat-date-sep::before, +.chat-date-sep::after { + content: ""; + flex: 1; + height: 1px; + background: var(--line); } -.message-reply:empty, -.message-text:empty, -.message-footer:empty, -.message-media:empty { +.chat-date-sep:empty { display: none; } -.message-text { +/* ---------- Bubble wrap ---------- */ + +.chat-bubble-wrap { + display: flex; + justify-content: flex-start; + flex-shrink: 0; +} + +.chat-bubble-wrap--own { + justify-content: flex-end; +} + +/* ---------- Chat bubble ---------- */ + +.chat-bubble { + max-width: 75%; + padding: 7px 11px; + background: #1c1c1c; + border: 1px solid var(--line); + border-radius: 1px; + margin-bottom: 4px; + overflow-wrap: anywhere; + word-break: break-word; + position: relative; +} + +.chat-bubble::before { + content: ""; + position: absolute; + top: 8px; + border: 6px solid transparent; +} + +.chat-bubble-wrap:not(.chat-bubble-wrap--own) .chat-bubble::before { + left: -12px; + border-right-color: #1c1c1c; + border-left: 0; +} + +.chat-bubble-wrap--own .chat-bubble::before { + right: -12px; + border-left-color: #1e3a5f; + border-right: 0; +} + +.chat-bubble-wrap--own .chat-bubble { + background: #1e3a5f; + border-color: #2b5278; +} + +.chat-bubble--highlight { + animation: chat-highlight 0.5s ease-in-out 3; +} + +@keyframes chat-highlight { + 0%, 100% { box-shadow: 0 0 0 0 rgba(139, 180, 248, 0); } + 50% { box-shadow: 0 0 0 3px rgba(139, 180, 248, 0.4); } +} + +/* ---------- Sender ---------- */ + +.chat-sender { + font-size: 0.82rem; + color: var(--info); + font-weight: 700; + margin-bottom: 2px; +} + +.chat-sender:empty { + display: none; +} + +/* ---------- Reply preview ---------- */ + +.chat-reply { + display: flex; + gap: 6px; + margin-bottom: 4px; + padding: 4px 0; + cursor: pointer; +} + +.chat-reply.hidden { + display: none; +} + +.chat-reply-border { + flex-shrink: 0; + width: 2px; + background: var(--accent); + border-radius: 1px; + opacity: 0.5; +} + +.chat-reply-body { + min-width: 0; +} + +.chat-reply-author { + font-size: 0.78rem; + color: var(--info); + font-weight: 700; +} + +.chat-reply-text { + font-size: 0.82rem; + color: var(--dim); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ---------- Message text ---------- */ + +.chat-text { white-space: pre-wrap; + line-height: 1.45; } -.message-media { - margin-top: 12px; +.chat-text:empty { + display: none; } -.message-media img, -.message-media video { +/* ---------- Media ---------- */ + +.chat-media { + margin-top: 6px; +} + +.chat-media:empty { + display: none; +} + +.chat-media img, +.chat-media video { display: block; max-width: 100%; border: 1px solid var(--line); } -.message-footer { - margin-top: 10px; +/* ---------- Footer ---------- */ + +.chat-footer { + margin-top: 4px; + font-size: 0.72rem; + color: var(--dim); + text-align: right; +} + +.chat-footer:empty { + display: none; } .api-docs { diff --git a/webui/viewer.html b/webui/viewer.html index 0789e86..718e9cf 100644 --- a/webui/viewer.html +++ b/webui/viewer.html @@ -6,7 +6,7 @@ Telegram Scraper Viewer - +
@@ -42,16 +41,21 @@ diff --git a/webui/viewer.js b/webui/viewer.js index 7db2adb..deb7f83 100644 --- a/webui/viewer.js +++ b/webui/viewer.js @@ -1,7 +1,13 @@ +if ("scrollRestoration" in history) history.scrollRestoration = "manual"; + const viewerState = { channels: [], channelId: null, oldestMessageId: null, + newestMessageId: null, + userId: null, + loading: false, + sentinelObserver: null, }; async function api(path) { @@ -13,9 +19,38 @@ async function api(path) { 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 dateKey(dateStr) { + if (!dateStr) return ""; + return dateStr.slice(0, 10); +} + function textNodeWithBreaks(text) { const fragment = document.createDocumentFragment(); - const parts = (text || "").split("\n"); + 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)); @@ -23,10 +58,21 @@ function textNodeWithBreaks(text) { 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); node.querySelector(".viewer-channel-name").textContent = channel.name; @@ -36,99 +82,228 @@ function renderChannelList() { } node.addEventListener("click", () => { viewerState.oldestMessageId = null; + viewerState.newestMessageId = null; loadChannel(channel.channel_id); }); root.appendChild(node); }); } +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 template = document.getElementById("message-template"); - if (!append) root.innerHTML = ""; + 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-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."; - payload.messages.forEach((message) => { - const node = template.content.firstElementChild.cloneNode(true); - node.dataset.messageId = String(message.message_id); - node.querySelector(".message-author").textContent = message.sender_name || "Unknown"; - node.querySelector(".message-date").textContent = message.date || ""; + if (!append) { + root.innerHTML = ""; + root.appendChild(sentinel); + } - const reply = node.querySelector(".message-reply"); - if (message.reply_to) { - reply.textContent = `Reply to message #${message.reply_to}`; + 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; } + } - node.querySelector(".message-text").appendChild(textNodeWithBreaks(message.text || "")); - - const media = node.querySelector(".message-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.views) footerParts.push(`views: ${message.views}`); - if (message.forwards) footerParts.push(`forwards: ${message.forwards}`); - if (message.reactions) footerParts.push(`reactions: ${message.reactions}`); - node.querySelector(".message-footer").textContent = footerParts.join(" | "); - - if (append) { - root.prepend(node); - } else { - root.appendChild(node); - } + 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; } - document.getElementById("load-older-btn").disabled = !payload.messages.length; + 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}` : ""; - const payload = await api(`/api/channels/${encodeURIComponent(channelId)}/messages?limit=80${before}`); - renderMessages(payload, append); + + const before = + append && viewerState.oldestMessageId + ? `&before=${viewerState.oldestMessageId}` + : ""; + try { + const payload = await api( + `/api/channels/${encodeURIComponent(channelId)}/messages?limit=80${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() { + try { + const authData = await api("/api/auth"); + viewerState.userId = authData.user_id || null; + } catch (e) { + console.warn("Could not fetch user ID:", e); + } + viewerState.channels = await api("/api/channels"); const params = new URLSearchParams(window.location.search); - viewerState.channelId = params.get("channel") || viewerState.channels[0]?.channel_id || null; + viewerState.channelId = + params.get("channel") || + viewerState.channels[0]?.channel_id || + null; renderChannelList(); if (viewerState.channelId) { await loadChannel(viewerState.channelId); } - document.getElementById("load-older-btn").addEventListener("click", async () => { - if (!viewerState.channelId || !viewerState.oldestMessageId) return; - await loadChannel(viewerState.channelId, true); - }); + setupInfiniteScroll(); } main().catch((error) => { diff --git a/webui_server.py b/webui_server.py index bd1af75..c886d7a 100644 --- a/webui_server.py +++ b/webui_server.py @@ -205,6 +205,37 @@ def load_messages( "reactions": row["reactions"], } ) + + reply_ids = list({m["reply_to"] for m in messages if m["reply_to"]}) + if reply_ids: + placeholders = ",".join("?" for _ in reply_ids) + try: + conn2 = sqlite3.connect(str(db_path)) + conn2.row_factory = sqlite3.Row + try: + rows2 = conn2.execute( + f"SELECT message_id, message, first_name, last_name, username " + f"FROM messages WHERE message_id IN ({placeholders})", + reply_ids, + ).fetchall() + reply_map: Dict[int, Dict[str, str]] = {} + for r in rows2: + r_sender = " ".join( + part for part in [r["first_name"], r["last_name"]] if part + ).strip() or (r["username"] or "") + reply_map[r["message_id"]] = { + "text": (r["message"] or "")[:300], + "sender_name": r_sender, + } + for m in messages: + rt = m.get("reply_to") + if rt and rt in reply_map: + m["reply_to_message"] = reply_map[rt] + finally: + conn2.close() + except sqlite3.Error: + pass + return messages @@ -328,6 +359,7 @@ class TelegramAuthManager: self.thread = threading.Thread(target=self._run_loop, daemon=True) self.thread.start() self.client: Optional[TelegramClient] = None + self.user_id: Optional[int] = None self.qr_login = None self.qr_wait_task = None self.phone = None @@ -359,6 +391,7 @@ class TelegramAuthManager: auth = auth_status() with self.lock: snapshot = dict(self.state) + snapshot["user_id"] = self.user_id snapshot["saved_credentials"] = { "api_id": load_state().get("api_id"), "api_hash_present": bool(load_state().get("api_hash")), @@ -376,6 +409,12 @@ class TelegramAuthManager: self.client = TelegramClient(str(SESSION_DIR / "session"), api_id, api_hash) if not self.client.is_connected(): await self.client.connect() + if self.user_id is None and await self.client.is_user_authorized(): + try: + me = await self.client.get_me() + self.user_id = me.id + except Exception: + pass return self.client async def _is_authorized(self) -> bool: