-
-
+
+
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:
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+