529 lines
17 KiB
JavaScript
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');
|
|
});
|