Files
telegram-scraper/webui/viewer.js
T
2026-06-11 18:19:09 +02:00

327 lines
9.9 KiB
JavaScript

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) {
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 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);
node.querySelector(".viewer-channel-name").textContent = channel.name;
node.querySelector(".viewer-channel-meta").textContent = `${channel.message_count} messages`;
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 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(
`/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;
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);
});