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
This commit is contained in:
+245
-37
@@ -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 {
|
||||
|
||||
+18
-14
@@ -6,7 +6,7 @@
|
||||
<title>Telegram Scraper Viewer</title>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
<body class="viewer-body">
|
||||
<body>
|
||||
<div class="viewer-shell">
|
||||
<aside class="viewer-sidebar">
|
||||
<div class="viewer-sidebar-head">
|
||||
@@ -25,12 +25,11 @@
|
||||
<h2 id="viewer-title">Select a channel</h2>
|
||||
<p id="viewer-subtitle" class="muted">Reading messages from the local SQLite database.</p>
|
||||
</div>
|
||||
<div class="viewer-actions">
|
||||
<button class="button" id="load-older-btn" disabled>Load older</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="messages-list" class="messages-list"></section>
|
||||
<section id="messages-list" class="messages-list">
|
||||
<div id="scroll-sentinel" class="scroll-sentinel"></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -42,16 +41,21 @@
|
||||
</template>
|
||||
|
||||
<template id="message-template">
|
||||
<article class="message-card">
|
||||
<div class="message-meta">
|
||||
<span class="message-author"></span>
|
||||
<span class="message-date"></span>
|
||||
<div class="chat-bubble-wrap">
|
||||
<div class="chat-bubble">
|
||||
<div class="chat-sender"></div>
|
||||
<div class="chat-reply">
|
||||
<div class="chat-reply-border"></div>
|
||||
<div class="chat-reply-body">
|
||||
<div class="chat-reply-author"></div>
|
||||
<div class="chat-reply-text"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-text"></div>
|
||||
<div class="chat-media"></div>
|
||||
<div class="chat-footer"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-reply muted small"></div>
|
||||
<div class="message-text"></div>
|
||||
<div class="message-media"></div>
|
||||
<div class="message-footer muted small"></div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script src="/static/viewer.js"></script>
|
||||
|
||||
+209
-34
@@ -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,37 +82,51 @@ function renderChannelList() {
|
||||
}
|
||||
node.addEventListener("click", () => {
|
||||
viewerState.oldestMessageId = null;
|
||||
viewerState.newestMessageId = null;
|
||||
loadChannel(channel.channel_id);
|
||||
});
|
||||
root.appendChild(node);
|
||||
});
|
||||
}
|
||||
|
||||
function renderMessages(payload, append = false) {
|
||||
const root = document.getElementById("messages-list");
|
||||
function buildMessageNode(message, root) {
|
||||
const template = document.getElementById("message-template");
|
||||
if (!append) root.innerHTML = "";
|
||||
const wrap = template.content.firstElementChild.cloneNode(true);
|
||||
const bubble = wrap.querySelector(".chat-bubble");
|
||||
|
||||
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.";
|
||||
bubble.dataset.messageId = String(message.message_id);
|
||||
|
||||
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 || "";
|
||||
|
||||
const reply = node.querySelector(".message-reply");
|
||||
if (message.reply_to) {
|
||||
reply.textContent = `Reply to message #${message.reply_to}`;
|
||||
const isOwn = viewerState.userId && message.sender_id === viewerState.userId;
|
||||
if (isOwn) {
|
||||
wrap.classList.add("chat-bubble-wrap--own");
|
||||
}
|
||||
|
||||
node.querySelector(".message-text").appendChild(textNodeWithBreaks(message.text || ""));
|
||||
const sender = bubble.querySelector(".chat-sender");
|
||||
sender.textContent = message.sender_name || "";
|
||||
|
||||
const media = node.querySelector(".message-media");
|
||||
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;
|
||||
@@ -88,47 +148,162 @@ function renderMessages(payload, append = false) {
|
||||
}
|
||||
|
||||
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}`);
|
||||
node.querySelector(".message-footer").textContent = footerParts.join(" | ");
|
||||
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) {
|
||||
root.prepend(node);
|
||||
} else {
|
||||
root.appendChild(node);
|
||||
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) => {
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user