Files
telegram-scraper/webui/viewer.js
T

529 lines
17 KiB
JavaScript

if ('scrollRestoration' in history) history.scrollRestoration = 'manual';
const viewerState = {
accountId: null,
accounts: [],
channels: [],
channelId: null,
oldestMessageId: null,
newestMessageId: null,
userId: null,
search: '',
loading: false,
autoRefreshTimer: null,
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 showToast(message, type = 'info') {
let root = document.getElementById('toast-root');
if (!root) {
root = document.createElement('div');
root.id = 'toast-root';
root.className = 'toast-root';
document.body.appendChild(root);
}
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
root.appendChild(toast);
window.setTimeout(() => {
toast.classList.add('toast-hide');
window.setTimeout(() => toast.remove(), 250);
}, 3600);
}
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 chatListTime(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr.replace(' ', 'T'));
if (isNaN(d.getTime())) return '';
const now = new Date();
if (d.toDateString() === now.toDateString()) {
return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
}
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
function initials(value) {
const source = String(value || '?')
.replace(/^@/, '')
.trim();
const words = source.split(/\s+/).filter(Boolean);
if (words.length > 1) return (words[0][0] + words[1][0]).toUpperCase();
return source.slice(0, 2).toUpperCase();
}
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 makeScrollSentinel() {
const sentinel = document.createElement('div');
sentinel.id = 'scroll-sentinel';
sentinel.className = 'scroll-sentinel';
return sentinel;
}
function resetMessageView(title = 'Select a channel', subtitle = 'Reading messages from the local SQLite database.') {
const root = document.getElementById('messages-list');
if (root) {
root.innerHTML = '';
root.appendChild(makeScrollSentinel());
}
document.getElementById('viewer-title').textContent = title;
document.getElementById('viewer-subtitle').textContent = subtitle;
viewerState.oldestMessageId = null;
viewerState.newestMessageId = null;
}
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);
const preview =
channel.last_message_preview ||
(channel.has_database ? 'No text in the last saved message' : 'No local database yet');
node.querySelector('.viewer-channel-avatar').textContent = initials(channel.name || channel.channel_id);
node.querySelector('.viewer-channel-name').textContent = channel.name;
node.querySelector('.viewer-channel-time').textContent = chatListTime(channel.last_date);
node.querySelector('.viewer-channel-preview').textContent = preview;
node.querySelector('.viewer-channel-count').textContent = String(channel.message_count || 0);
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 messageEndpoint(channelId, before = '') {
const search = viewerState.search ? `&search=${encodeURIComponent(viewerState.search)}` : '';
if (viewerState.accountId) {
return `/api/accounts/${encodeURIComponent(viewerState.accountId)}/channels/${encodeURIComponent(channelId)}/messages?limit=80${before}${search}`;
}
return `/api/channels/${encodeURIComponent(channelId)}/messages?limit=80${before}${search}`;
}
function channelsEndpoint() {
if (viewerState.accountId) {
return `/api/accounts/${encodeURIComponent(viewerState.accountId)}/channels`;
}
return '/api/channels';
}
function renderAccountSelector() {
const sel = document.getElementById('viewer-account-select');
if (!sel) return;
sel.innerHTML = '';
const legacy = viewerState.accounts.length === 0;
if (legacy) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = 'Legacy';
sel.appendChild(opt);
} else {
viewerState.accounts.forEach((acc) => {
const opt = document.createElement('option');
opt.value = acc.id;
opt.textContent = acc.label || acc.id;
if (acc.id === viewerState.accountId) opt.selected = true;
sel.appendChild(opt);
});
}
}
async function switchViewerAccount(accountId) {
viewerState.accountId = accountId || null;
viewerState.search = '';
const searchInput = document.getElementById('viewer-search');
if (searchInput) searchInput.value = '';
viewerState.accounts.forEach((acc) => {
if (acc.id === accountId) {
const opts = document.getElementById('viewer-account-select')?.options;
if (opts) {
for (let i = 0; i < opts.length; i++) {
opts[i].selected = opts[i].value === accountId;
}
}
}
});
try {
const authData = viewerState.accountId
? await api(`/api/accounts/${encodeURIComponent(viewerState.accountId)}/auth`)
: await api('/api/auth');
viewerState.userId = authData.user_id || null;
} catch (e) {
console.warn('Could not fetch user ID:', e);
}
const channels = await api(channelsEndpoint());
viewerState.channels = channels;
viewerState.channelId = channels[0]?.channel_id || null;
viewerState.oldestMessageId = null;
viewerState.newestMessageId = null;
renderChannelList();
if (viewerState.channelId) {
await loadChannel(viewerState.channelId);
} else {
resetMessageView(
'No channels',
viewerState.accountId ? 'This account has no tracked channels yet.' : 'No local legacy channels found.',
);
}
}
async function loadViewerAccount(params) {
const requested = params.get('account');
try {
const payload = await api('/api/accounts');
viewerState.accounts = payload.accounts || [];
} catch {
viewerState.accounts = [];
}
const requestedExists = requested && viewerState.accounts.some((acc) => acc.id === requested);
viewerState.accountId = requestedExists ? requested : viewerState.accounts[0]?.id || null;
renderAccountSelector();
const sel = document.getElementById('viewer-account-select');
if (sel) {
sel.addEventListener('change', () => switchViewerAccount(sel.value));
}
try {
const authData = viewerState.accountId
? await api(`/api/accounts/${encodeURIComponent(viewerState.accountId)}/auth`)
: await api('/api/auth');
viewerState.userId = authData.user_id || null;
} catch (e) {
console.warn('Could not fetch user ID:', e);
}
}
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');
if (!root) return;
let sentinel = document.getElementById('scroll-sentinel');
if (!sentinel) sentinel = makeScrollSentinel();
const channel = payload.channel;
document.getElementById('viewer-title').textContent = channel?.name || payload.channel_id;
document.getElementById('viewer-subtitle').textContent = channel
? `${payload.messages.length} shown${viewerState.search ? ` for "${viewerState.search}"` : ''}, ${channel.message_count} total, 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;
} else if (!append) {
viewerState.oldestMessageId = null;
viewerState.newestMessageId = null;
const empty = document.createElement('div');
empty.className = 'viewer-empty-state';
empty.textContent = viewerState.search
? `No messages found for "${viewerState.search}".`
: 'No saved messages in this channel yet.';
root.appendChild(empty);
}
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(messageEndpoint(channelId, 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;
}
}
async function refreshCurrentChannel() {
if (!viewerState.channelId || viewerState.loading) return;
viewerState.oldestMessageId = null;
viewerState.newestMessageId = null;
await loadChannel(viewerState.channelId);
}
function setupViewerTools() {
const searchInput = document.getElementById('viewer-search');
const refreshBtn = document.getElementById('viewer-refresh-btn');
const autoRefresh = document.getElementById('viewer-auto-refresh');
let searchTimer = null;
if (searchInput) {
searchInput.addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
viewerState.search = searchInput.value.trim();
refreshCurrentChannel();
}, 250);
});
}
if (refreshBtn) {
refreshBtn.addEventListener('click', () => refreshCurrentChannel());
}
viewerState.autoRefreshTimer = window.setInterval(() => {
if (!autoRefresh || autoRefresh.checked) {
refreshCurrentChannel();
}
}, 30000);
}
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() {
const params = new URLSearchParams(window.location.search);
await loadViewerAccount(params);
viewerState.channels = await api(channelsEndpoint());
const requestedChannel = params.get('channel');
const requestedChannelExists =
requestedChannel && viewerState.channels.some((channel) => channel.channel_id === requestedChannel);
viewerState.channelId = requestedChannelExists ? requestedChannel : viewerState.channels[0]?.channel_id || null;
renderChannelList();
if (viewerState.channelId) {
await loadChannel(viewerState.channelId);
} else {
resetMessageView(
'No channels',
viewerState.accountId ? 'This account has no tracked channels yet.' : 'No local legacy channels found.',
);
}
setupInfiniteScroll();
setupViewerTools();
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);
showToast(error.message, 'error');
});