feat: implement pagination for older posts in telemirror and add floating date display

This commit is contained in:
Sarto
2026-05-08 00:03:58 +03:30
parent fa583d9a12
commit f09949a604
7 changed files with 462 additions and 22 deletions
+150 -3
View File
@@ -586,6 +586,29 @@
direction: ltr
}
.floating-date {
/* Sit just below the chat-header (~54px tall). */
position: absolute;
top: 64px;
left: 50%;
transform: translateX(-50%);
z-index: 50;
padding: 4px 12px;
border-radius: 12px;
background: color-mix(in oklab, var(--bg-elevated, var(--bg)) 75%, transparent);
color: var(--text);
border: 1px solid var(--border);
font-size: 11px;
font-weight: 500;
backdrop-filter: blur(8px);
box-shadow: 0 1px 3px rgba(0,0,0,.08);
pointer-events: none;
opacity: 0;
transition: opacity 0.25s ease;
white-space: nowrap;
}
.floating-date.visible { opacity: 0.92; }
.msg-date-sep {
text-align: center;
padding: 8px 0;
@@ -1156,13 +1179,90 @@
border-bottom: 1px dashed var(--border)
}
.tm-post-author { font-weight: 700; color: var(--accent) }
#tmLightbox {
position: fixed; inset: 0; z-index: 99999;
background: rgba(0,0,0,.92);
display: flex; align-items: center; justify-content: center;
padding: 20px;
cursor: zoom-out;
}
#tmLightbox img {
max-width: 100%; max-height: 100%;
object-fit: contain;
box-shadow: 0 4px 30px rgba(0,0,0,.5);
cursor: default;
}
.tm-lightbox-close {
position: absolute;
top: max(12px, env(safe-area-inset-top));
inset-inline-end: 16px;
width: 40px; height: 40px;
border-radius: 50%;
border: none;
background: rgba(255,255,255,.15);
color: #fff;
font-size: 26px; line-height: 1;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(6px);
}
.tm-lightbox-close:hover { background: rgba(255,255,255,.28); }
.tm-scroll-down {
position: absolute;
bottom: max(20px, env(safe-area-inset-bottom));
inset-inline-end: 16px;
width: 40px; height: 40px;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--surface2, var(--bg-elevated, var(--bg)));
color: var(--text);
font-size: 18px;
display: none;
align-items: center; justify-content: center;
box-shadow: 0 2px 10px rgba(0,0,0,.4);
z-index: 10;
cursor: pointer;
}
.tm-scroll-down.visible { display: flex; }
.tm-scroll-down:hover {
background: color-mix(in oklab, var(--accent) 12%, var(--surface2, var(--bg)));
}
.tm-load-older-row {
display: flex; justify-content: center;
margin: 8px 0 14px;
}
.tm-load-older {
background: transparent;
border: 1px solid var(--border);
color: var(--text);
padding: 6px 16px;
border-radius: 14px;
font-size: 13px;
cursor: pointer;
transition: background .15s, border-color .15s;
}
.tm-load-older:hover {
background: color-mix(in oklab, var(--accent) 10%, transparent);
border-color: color-mix(in oklab, var(--accent) 50%, var(--border));
}
.tm-load-older:disabled { opacity: .6; cursor: default; }
.tm-post-msgid {
font-size: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
color: var(--text-dim); padding: 1px 5px; border-radius: 4px;
background: color-mix(in oklab, var(--text-dim) 12%, transparent);
user-select: text
}
.tm-post-time { opacity: .85 }
.tm-post-edited {
font-size: 10px; padding: 1px 6px; border-radius: 4px;
background: var(--border); color: var(--text-dim)
}
.tm-post-text {
font-size: 14px; line-height: 1.7;
/* Inherit from --font-size (set by the global Settings slider) and
add a small bump so telemirror posts read at the same scale as
main-feed messages. */
font-size: calc(var(--font-size) + 1px);
line-height: 1.7;
overflow-wrap: anywhere; /* break long URLs / unbroken strings */
word-break: break-word
}
@@ -3065,6 +3165,7 @@
<button class="btn btn-primary" onclick="openProfiles()" data-i18n="set_up">Set Up</button>
</div>
</div>
<div class="floating-date" id="floatingDate" dir="auto" hidden></div>
<div class="send-panel" id="sendPanel">
<input class="send-input" id="sendInput" data-i18n-ph="write_message" placeholder="Write a message..."
maxlength="4000">
@@ -3108,6 +3209,7 @@
<main id="tmContent" class="tm-content">
<div class="tm-empty" data-i18n="telemirror_pick_channel">Pick a channel</div>
</main>
<button class="tm-scroll-down" id="tmScrollDownBtn" onclick="tmScrollToBottom()" aria-label="Scroll to latest" title="Scroll to latest">&#8595;</button>
</div>
</div>
<!-- END telemirror -->
@@ -3763,6 +3865,9 @@
scanner_load_presets: 'بارگذاری لیست پیش‌فرض',
scanner_preset_active: 'ریزالورهای پیش‌فرض بارگذاری شد',
privacy_policy: 'حریم خصوصی',
telemirror_load_older: 'بارگذاری پیام‌های قدیمی‌تر',
telemirror_no_older: 'پیام قدیمی‌تری وجود ندارد',
telemirror_load_older_failed: 'خطا در بارگذاری پیام‌های قدیمی',
scanner_from_input: 'از ورودی',
scanner_from_preset: 'از پیش‌فرض',
scanner_new_scan: 'اسکن جدید',
@@ -4009,6 +4114,9 @@
scanner_load_presets: 'Load Presets',
scanner_preset_active: 'Default resolvers loaded',
privacy_policy: 'Privacy',
telemirror_load_older: 'Load older posts',
telemirror_no_older: 'No older posts',
telemirror_load_older_failed: 'Failed to load older posts',
scanner_from_input: 'from input',
scanner_from_preset: 'from preset',
scanner_new_scan: 'New Scan',
@@ -5284,6 +5392,14 @@
// populated from a bank scan, or user-triggered rescan
// overwrote them). Refresh the tab strip so the count
// badge stops showing 0 while resolvers are actually live.
// Server finished a background telemirror refresh for this
// channel — let telemirror.js re-fetch if it's the active one.
if (typeof data === 'string' && data.indexOf('telemirror:') === 0) {
if (typeof window.tmOnServerUpdate === 'function') {
try { window.tmOnServerUpdate(data.slice('telemirror:'.length)); } catch (e2) { }
}
return;
}
if (data === 'resolver-lists') {
if (document.getElementById('resolversModal').classList.contains('active')) {
try { loadResolverLists(); } catch (e2) { }
@@ -7925,15 +8041,46 @@
navigator.clipboard.writeText(text).then(function () { showToast(t('msg_copied')) }).catch(function () { });
}
// ===== SCROLL TO BOTTOM =====
// ===== SCROLL TO BOTTOM + FLOATING DATE =====
(function () {
var messagesEl = null;
var messagesEl = null, dateEl = null, hideTimer = null;
function topMostDate() {
if (!messagesEl) return '';
var seps = messagesEl.querySelectorAll('.msg-date-sep');
if (!seps.length) return '';
var top = messagesEl.getBoundingClientRect().top;
var current = '';
for (var i = 0; i < seps.length; i++) {
var r = seps[i].getBoundingClientRect();
// The most recent separator at or above the viewport's top
// edge is the date the user is currently reading under.
if (r.top - top <= 8) current = seps[i].textContent;
else break;
}
return current.trim();
}
function showFloatingDate() {
if (!dateEl) return;
var s = topMostDate();
if (!s) { dateEl.classList.remove('visible'); dateEl.hidden = true; return; }
dateEl.textContent = s;
// Force RTL for Persian/Arabic content so the day-month-year
// sequence reads right-to-left as expected (16 اردیبهشت 1405,
// not 1405 اردیبهشت 16).
dateEl.dir = /[؀-ۿ]/.test(s) ? 'rtl' : 'ltr';
dateEl.hidden = false;
dateEl.classList.add('visible');
if (hideTimer) clearTimeout(hideTimer);
hideTimer = setTimeout(function () { dateEl.classList.remove('visible'); }, 1200);
}
function initScrollBtn() {
messagesEl = document.getElementById('messages');
dateEl = document.getElementById('floatingDate');
if (!messagesEl) return;
messagesEl.addEventListener('scroll', function () {
var atBottom = messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 150;
document.getElementById('scrollDownBtn').classList.toggle('visible', !atBottom);
showFloatingDate();
});
}
// Init after DOM is ready
+147 -4
View File
@@ -40,6 +40,112 @@
return ch ? ch.toUpperCase() : '?';
}
// Scroll the active channel view to the latest (bottom) post.
window.tmScrollToBottom = function () {
var el = document.getElementById('tmContent');
if (el) el.scrollTop = el.scrollHeight;
};
// Bind once on first open: toggle the scroll-down button when the
// user is more than ~150px away from the bottom of the post list.
function tmInitScrollBtn() {
var sc = document.getElementById('tmContent');
var btn = document.getElementById('tmScrollDownBtn');
if (!sc || !btn || sc._tmScrollBtnBound) return;
sc._tmScrollBtnBound = true;
sc.addEventListener('scroll', function () {
var atBottom = sc.scrollHeight - sc.scrollTop - sc.clientHeight < 150;
btn.classList.toggle('visible', !atBottom);
});
}
document.addEventListener('DOMContentLoaded', tmInitScrollBtn, { once: true });
// Fetch a page of older posts and prepend them above the current
// ones. Calls /api/telemirror/older/<user>?before=<id> which always
// hits upstream (not cached — pagination data otherwise grows
// unbounded per channel).
window.tmLoadOlder = function (beforeId, btn) {
if (!tmActive || !beforeId) return;
var origLabel = btn ? btn.textContent : '';
if (btn) { btn.disabled = true; btn.textContent = tmI18n('loading', 'Loading...'); }
// Anchor by scrollHeight delta. Prepending content makes the
// scrollHeight grow by P; we keep the same viewport content visible
// by setting scrollTop = oldScrollTop + (newScrollHeight - oldScrollHeight).
var scroller = document.getElementById('tmContent');
var oldHeight = scroller ? scroller.scrollHeight : 0;
var oldTop = scroller ? scroller.scrollTop : 0;
fetch('/api/telemirror/older/' + encodeURIComponent(tmActive) + '?before=' + encodeURIComponent(beforeId))
.then(function (r) { return r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status)); })
.then(function (older) {
if (!older || !older.posts || !older.posts.length) {
if (btn) { btn.textContent = tmI18n('telemirror_no_older', 'No older posts'); }
return;
}
var merged = older.posts.concat(window._tmCurrentPosts || []);
var seen = {}, out = [];
for (var i = 0; i < merged.length; i++) {
var id = merged[i].id;
if (id && seen[id]) continue;
if (id) seen[id] = true;
out.push(merged[i]);
}
tmRenderPosts({ channel: window._tmCurrentChannel, posts: out });
if (!scroller) return;
// Restore on next frame so layout has flushed. Then re-correct
// once more after images load — they grow the prepended block
// and would otherwise push the anchor down.
var fix = function () {
scroller.scrollTop = oldTop + (scroller.scrollHeight - oldHeight);
};
requestAnimationFrame(function () {
fix();
var imgs = scroller.querySelectorAll('img');
var pending = 0;
for (var k = 0; k < imgs.length; k++) {
if (!imgs[k].complete) {
pending++;
imgs[k].addEventListener('load', function () { fix(); }, { once: true });
imgs[k].addEventListener('error', function () { fix(); }, { once: true });
}
}
});
})
.catch(function () {
if (btn) { btn.disabled = false; btn.textContent = origLabel; }
tmToast(tmI18n('telemirror_load_older_failed', 'Failed to load older posts'));
});
};
// Fullscreen image overlay. Tap anywhere or the X button to close.
// Tapping the image itself doesn't close (avoids accidents on iOS
// double-tap zoom).
window.tmOpenLightbox = function (src) {
var existing = document.getElementById('tmLightbox');
if (existing) existing.remove();
var d = document.createElement('div');
d.id = 'tmLightbox';
d.innerHTML =
'<button class="tm-lightbox-close" type="button" aria-label="Close">×</button>' +
'<img src="' + tmEscAttr(src) + '" referrerpolicy="no-referrer" alt="">';
var close = function () { d.remove(); };
d.addEventListener('click', function (e) {
if (e.target === d || e.target.classList.contains('tm-lightbox-close')) close();
});
document.body.appendChild(d);
};
// Telegram wraps every emoji in <i class="emoji" style="background-image:url(...)"><b>X</b></i>
// so it can render its own sprite. Outside Telegram's CSS the sprite
// never loads but the inline-styled box stays — leaving a visible
// background patch around each glyph. Strip the wrapper; the device
// renders the inner character natively.
function tmStripEmojiSprites(html) {
if (!html) return html;
return String(html).replace(/<i\s+class="emoji"[^>]*>([\s\S]*?)<\/i>/g, '$1');
}
// Deterministic colour-from-name so the placeholder avatars don't all
// look identical. Mirrors what Telegram's web client does.
function tmAvatarColor(name) {
@@ -239,6 +345,24 @@
}
window.tmSelect = tmSelect;
// Server-driven update — called from the main SSE handler when the
// backend finishes a background telemirror refresh. If the updated
// channel is the one currently open, silently re-fetch so the user
// sees the new posts without manually refreshing.
window.tmOnServerUpdate = function (username) {
if (!tmActive || !username) return;
if (tmActive.toLowerCase() !== username.toLowerCase()) return;
fetch('/api/telemirror/channel/' + encodeURIComponent(username))
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d) return;
tmLastFetchedAt[username.toLowerCase()] = Date.now();
tmRenderTopbar(d && d.channel, username);
tmRenderPosts(d);
})
.catch(function () { });
};
// Refresh: warns if the last successful fetch was within 10 min,
// since hammering Translate trips Google's per-IP rate limit.
window.tmRefreshActive = async function () {
@@ -310,6 +434,10 @@
var content = document.getElementById('tmContent');
var posts = (data && data.posts) || [];
var ch = (data && data.channel) || {};
// Stash for tmLoadOlder so it can merge older posts into the same
// view without a full re-fetch of the active channel.
window._tmCurrentPosts = posts;
window._tmCurrentChannel = ch;
if (!posts.length) {
content.innerHTML = '<div class="tm-empty">' + tmEsc(tmI18n('telemirror_no_posts', 'No posts')) + '</div>';
return;
@@ -327,6 +455,16 @@
+ '<div class="tm-channel-bio-body">' + ch.description + '</div>'
+ '</div>';
}
// Load-older button if we have at least one post — anchored to the
// smallest message id so each click steps back through history.
var oldestId = (posts[0] && posts[0].id || '').split('/').pop();
if (oldestId) {
html += '<div class="tm-load-older-row">'
+ '<button class="tm-load-older" onclick="tmLoadOlder(\'' + tmEscAttr(oldestId) + '\', this)">'
+ tmEsc(tmI18n('telemirror_load_older', 'Load older posts'))
+ '</button>'
+ '</div>';
}
// Stash plain text for each post in a JS map keyed by id, so the
// copy button doesn't have to embed huge multiline strings into a
// data-text attribute (which broke rendering on long Persian posts).
@@ -341,9 +479,12 @@
html += '<div class="tm-post" data-pid="' + pid + '">';
// Head: author + edited + copy button. Time moved to the bottom.
// Head: author + msg id + edited + copy button. Time at the bottom.
html += '<div class="tm-post-head">';
if (ch.title) html += '<span class="tm-post-author">' + tmEsc(ch.title) + '</span>';
// p.id looks like "channel/12345" — show only the numeric part.
var msgNum = (p.id || '').split('/').pop();
if (msgNum) html += '<span class="tm-post-msgid">#' + tmEsc(msgNum) + '</span>';
if (p.edited) html += '<span class="tm-post-edited">' + tmEsc(tmI18n('telemirror_edited', 'edited')) + '</span>';
if (plain) {
html += '<button class="tm-post-copy"'
@@ -371,11 +512,11 @@
+ (p.reply.url ? ' style="cursor:pointer"' : '')
+ '>';
if (rAuth) html += '<div class="tm-post-reply-author">' + rAuth + '</div>';
if (rText) html += '<div class="tm-post-reply-text">' + rText + '</div>';
if (rText) html += '<div class="tm-post-reply-text">' + tmStripEmojiSprites(rText) + '</div>';
html += '</div>';
}
if (p.text) html += '<div class="tm-post-text">' + p.text + '</div>';
if (p.text) html += '<div class="tm-post-text">' + tmStripEmojiSprites(p.text) + '</div>';
if (p.media && p.media.length) {
var photoCount = 0;
@@ -462,12 +603,14 @@
return '<div class="tm-photo">'
+ '<img src="' + tmEscAttr(m.thumb) + '" loading="lazy" alt=""'
+ ' referrerpolicy="no-referrer"'
+ ' onclick="tmOpenLightbox(\'' + tmEscAttr(m.thumb) + '\')"'
+ ' style="cursor:zoom-in"'
+ ' onerror="this.parentNode.classList.add(\'tm-photo-failed\')">'
+ '<a class="tm-photo-dl" href="' + tmEscAttr(m.thumb) + '"'
+ ' download="' + tmEscAttr(fname) + '"'
+ ' data-fname="' + tmEscAttr(fname) + '"'
+ ' title="' + tmEscAttr(tmI18n('download', 'Download')) + '"'
+ ' onclick="return tmDownloadPhoto(this, event)">⬇</a>'
+ ' onclick="event.stopPropagation();return tmDownloadPhoto(this, event)">⬇</a>'
+ '</div>';
}
if (m.type === 'video') {