mirror of
https://github.com/sartoopjj/thefeed.git
synced 2026-05-19 07:54:36 +03:00
feat: implement pagination for older posts in telemirror and add floating date display
This commit is contained in:
@@ -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">↓</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
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user