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;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-shell,
|
.app-shell {
|
||||||
.viewer-shell {
|
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: grid;
|
display: grid;
|
||||||
}
|
|
||||||
|
|
||||||
.app-shell,
|
|
||||||
.viewer-shell {
|
|
||||||
grid-template-columns: 300px minmax(0, 1fr);
|
grid-template-columns: 300px minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar,
|
.sidebar {
|
||||||
.viewer-sidebar {
|
|
||||||
background: var(--panel-alt);
|
background: var(--panel-alt);
|
||||||
border-right: 1px dashed var(--line);
|
border-right: 1px dashed var(--line);
|
||||||
padding: 28px 24px;
|
padding: 28px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content,
|
.content {
|
||||||
.viewer-main {
|
|
||||||
padding: 28px;
|
padding: 28px;
|
||||||
max-width: 1320px;
|
max-width: 1320px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-block h1,
|
.brand-block h1,
|
||||||
.viewer-sidebar-head h1,
|
|
||||||
.viewer-header h2,
|
|
||||||
.panel-header h2,
|
|
||||||
.dialog-header h2 {
|
.dialog-header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
@@ -105,8 +95,7 @@ button {
|
|||||||
|
|
||||||
.nav-links,
|
.nav-links,
|
||||||
.jobs-list,
|
.jobs-list,
|
||||||
.continuous-form,
|
.continuous-form {
|
||||||
.viewer-channel-list {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
@@ -227,10 +216,7 @@ input,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel-header,
|
.panel-header,
|
||||||
.viewer-sidebar-head,
|
|
||||||
.viewer-header,
|
|
||||||
.dialog-header,
|
.dialog-header,
|
||||||
.message-meta,
|
|
||||||
.job-head {
|
.job-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -489,6 +475,45 @@ tbody tr:hover {
|
|||||||
display: none !important;
|
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 {
|
.viewer-channel-item {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -496,6 +521,9 @@ tbody tr:hover {
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewer-channel-item.active {
|
.viewer-channel-item.active {
|
||||||
@@ -503,52 +531,232 @@ tbody tr:hover {
|
|||||||
box-shadow: inset 3px 0 0 var(--accent);
|
box-shadow: inset 3px 0 0 var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewer-channel-name,
|
.viewer-channel-name {
|
||||||
.message-author {
|
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewer-channel-meta,
|
.viewer-channel-meta {
|
||||||
.message-date {
|
|
||||||
color: var(--dim);
|
color: var(--dim);
|
||||||
font-size: 0.82rem;
|
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 {
|
.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;
|
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 {
|
.chat-date-sep::before,
|
||||||
max-width: 820px;
|
.chat-date-sep::after {
|
||||||
|
content: "";
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-reply:empty,
|
.chat-date-sep:empty {
|
||||||
.message-text:empty,
|
|
||||||
.message-footer:empty,
|
|
||||||
.message-media:empty {
|
|
||||||
display: none;
|
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;
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-media {
|
.chat-text:empty {
|
||||||
margin-top: 12px;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-media img,
|
/* ---------- Media ---------- */
|
||||||
.message-media video {
|
|
||||||
|
.chat-media {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-media:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-media img,
|
||||||
|
.chat-media video {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-footer {
|
/* ---------- Footer ---------- */
|
||||||
margin-top: 10px;
|
|
||||||
|
.chat-footer {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--dim);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-footer:empty {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.api-docs {
|
.api-docs {
|
||||||
|
|||||||
+18
-14
@@ -6,7 +6,7 @@
|
|||||||
<title>Telegram Scraper Viewer</title>
|
<title>Telegram Scraper Viewer</title>
|
||||||
<link rel="stylesheet" href="/static/style.css" />
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
</head>
|
</head>
|
||||||
<body class="viewer-body">
|
<body>
|
||||||
<div class="viewer-shell">
|
<div class="viewer-shell">
|
||||||
<aside class="viewer-sidebar">
|
<aside class="viewer-sidebar">
|
||||||
<div class="viewer-sidebar-head">
|
<div class="viewer-sidebar-head">
|
||||||
@@ -25,12 +25,11 @@
|
|||||||
<h2 id="viewer-title">Select a channel</h2>
|
<h2 id="viewer-title">Select a channel</h2>
|
||||||
<p id="viewer-subtitle" class="muted">Reading messages from the local SQLite database.</p>
|
<p id="viewer-subtitle" class="muted">Reading messages from the local SQLite database.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="viewer-actions">
|
|
||||||
<button class="button" id="load-older-btn" disabled>Load older</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
</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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -42,16 +41,21 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template id="message-template">
|
<template id="message-template">
|
||||||
<article class="message-card">
|
<div class="chat-bubble-wrap">
|
||||||
<div class="message-meta">
|
<div class="chat-bubble">
|
||||||
<span class="message-author"></span>
|
<div class="chat-sender"></div>
|
||||||
<span class="message-date"></span>
|
<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>
|
||||||
<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>
|
</template>
|
||||||
|
|
||||||
<script src="/static/viewer.js"></script>
|
<script src="/static/viewer.js"></script>
|
||||||
|
|||||||
+209
-34
@@ -1,7 +1,13 @@
|
|||||||
|
if ("scrollRestoration" in history) history.scrollRestoration = "manual";
|
||||||
|
|
||||||
const viewerState = {
|
const viewerState = {
|
||||||
channels: [],
|
channels: [],
|
||||||
channelId: null,
|
channelId: null,
|
||||||
oldestMessageId: null,
|
oldestMessageId: null,
|
||||||
|
newestMessageId: null,
|
||||||
|
userId: null,
|
||||||
|
loading: false,
|
||||||
|
sentinelObserver: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
async function api(path) {
|
async function api(path) {
|
||||||
@@ -13,9 +19,38 @@ async function api(path) {
|
|||||||
return data;
|
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) {
|
function textNodeWithBreaks(text) {
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
const parts = (text || "").split("\n");
|
if (!text) return fragment;
|
||||||
|
const parts = text.split("\n");
|
||||||
parts.forEach((part, index) => {
|
parts.forEach((part, index) => {
|
||||||
if (index > 0) fragment.appendChild(document.createElement("br"));
|
if (index > 0) fragment.appendChild(document.createElement("br"));
|
||||||
fragment.appendChild(document.createTextNode(part));
|
fragment.appendChild(document.createTextNode(part));
|
||||||
@@ -23,10 +58,21 @@ function textNodeWithBreaks(text) {
|
|||||||
return fragment;
|
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() {
|
function renderChannelList() {
|
||||||
const root = document.getElementById("viewer-channel-list");
|
const root = document.getElementById("viewer-channel-list");
|
||||||
|
if (!root) return;
|
||||||
root.innerHTML = "";
|
root.innerHTML = "";
|
||||||
const template = document.getElementById("viewer-channel-template");
|
const template = document.getElementById("viewer-channel-template");
|
||||||
|
if (!template) return;
|
||||||
viewerState.channels.forEach((channel) => {
|
viewerState.channels.forEach((channel) => {
|
||||||
const node = template.content.firstElementChild.cloneNode(true);
|
const node = template.content.firstElementChild.cloneNode(true);
|
||||||
node.querySelector(".viewer-channel-name").textContent = channel.name;
|
node.querySelector(".viewer-channel-name").textContent = channel.name;
|
||||||
@@ -36,37 +82,51 @@ function renderChannelList() {
|
|||||||
}
|
}
|
||||||
node.addEventListener("click", () => {
|
node.addEventListener("click", () => {
|
||||||
viewerState.oldestMessageId = null;
|
viewerState.oldestMessageId = null;
|
||||||
|
viewerState.newestMessageId = null;
|
||||||
loadChannel(channel.channel_id);
|
loadChannel(channel.channel_id);
|
||||||
});
|
});
|
||||||
root.appendChild(node);
|
root.appendChild(node);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMessages(payload, append = false) {
|
function buildMessageNode(message, root) {
|
||||||
const root = document.getElementById("messages-list");
|
|
||||||
const template = document.getElementById("message-template");
|
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;
|
bubble.dataset.messageId = String(message.message_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 isOwn = viewerState.userId && message.sender_id === viewerState.userId;
|
||||||
const node = template.content.firstElementChild.cloneNode(true);
|
if (isOwn) {
|
||||||
node.dataset.messageId = String(message.message_id);
|
wrap.classList.add("chat-bubble-wrap--own");
|
||||||
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}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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") {
|
if (message.media_url && message.media_kind === "image") {
|
||||||
const img = document.createElement("img");
|
const img = document.createElement("img");
|
||||||
img.src = message.media_url;
|
img.src = message.media_url;
|
||||||
@@ -88,47 +148,162 @@ function renderMessages(payload, append = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const footerParts = [];
|
const footerParts = [];
|
||||||
|
if (message.date) footerParts.push(formatTime(message.date));
|
||||||
if (message.views) footerParts.push(`views: ${message.views}`);
|
if (message.views) footerParts.push(`views: ${message.views}`);
|
||||||
if (message.forwards) footerParts.push(`forwards: ${message.forwards}`);
|
if (message.forwards) footerParts.push(`forwards: ${message.forwards}`);
|
||||||
if (message.reactions) footerParts.push(`reactions: ${message.reactions}`);
|
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) {
|
if (append) {
|
||||||
root.prepend(node);
|
for (let i = items.length - 1; i >= 0; i--) {
|
||||||
} else {
|
const { wrap, dk } = items[i];
|
||||||
root.appendChild(node);
|
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) {
|
if (payload.messages.length) {
|
||||||
viewerState.oldestMessageId = payload.messages[0].message_id;
|
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) {
|
async function loadChannel(channelId, append = false) {
|
||||||
|
if (viewerState.loading) return;
|
||||||
|
viewerState.loading = true;
|
||||||
viewerState.channelId = channelId;
|
viewerState.channelId = channelId;
|
||||||
renderChannelList();
|
renderChannelList();
|
||||||
const before = append && viewerState.oldestMessageId ? `&before=${viewerState.oldestMessageId}` : "";
|
|
||||||
const payload = await api(`/api/channels/${encodeURIComponent(channelId)}/messages?limit=80${before}`);
|
const before =
|
||||||
renderMessages(payload, append);
|
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() {
|
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");
|
viewerState.channels = await api("/api/channels");
|
||||||
const params = new URLSearchParams(window.location.search);
|
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();
|
renderChannelList();
|
||||||
|
|
||||||
if (viewerState.channelId) {
|
if (viewerState.channelId) {
|
||||||
await loadChannel(viewerState.channelId);
|
await loadChannel(viewerState.channelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("load-older-btn").addEventListener("click", async () => {
|
setupInfiniteScroll();
|
||||||
if (!viewerState.channelId || !viewerState.oldestMessageId) return;
|
|
||||||
await loadChannel(viewerState.channelId, true);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
|
|||||||
@@ -205,6 +205,37 @@ def load_messages(
|
|||||||
"reactions": row["reactions"],
|
"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
|
return messages
|
||||||
|
|
||||||
|
|
||||||
@@ -328,6 +359,7 @@ class TelegramAuthManager:
|
|||||||
self.thread = threading.Thread(target=self._run_loop, daemon=True)
|
self.thread = threading.Thread(target=self._run_loop, daemon=True)
|
||||||
self.thread.start()
|
self.thread.start()
|
||||||
self.client: Optional[TelegramClient] = None
|
self.client: Optional[TelegramClient] = None
|
||||||
|
self.user_id: Optional[int] = None
|
||||||
self.qr_login = None
|
self.qr_login = None
|
||||||
self.qr_wait_task = None
|
self.qr_wait_task = None
|
||||||
self.phone = None
|
self.phone = None
|
||||||
@@ -359,6 +391,7 @@ class TelegramAuthManager:
|
|||||||
auth = auth_status()
|
auth = auth_status()
|
||||||
with self.lock:
|
with self.lock:
|
||||||
snapshot = dict(self.state)
|
snapshot = dict(self.state)
|
||||||
|
snapshot["user_id"] = self.user_id
|
||||||
snapshot["saved_credentials"] = {
|
snapshot["saved_credentials"] = {
|
||||||
"api_id": load_state().get("api_id"),
|
"api_id": load_state().get("api_id"),
|
||||||
"api_hash_present": bool(load_state().get("api_hash")),
|
"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)
|
self.client = TelegramClient(str(SESSION_DIR / "session"), api_id, api_hash)
|
||||||
if not self.client.is_connected():
|
if not self.client.is_connected():
|
||||||
await self.client.connect()
|
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
|
return self.client
|
||||||
|
|
||||||
async def _is_authorized(self) -> bool:
|
async def _is_authorized(self) -> bool:
|
||||||
|
|||||||
Reference in New Issue
Block a user