mirror of
https://github.com/sartoopjj/thefeed.git
synced 2026-05-18 06:34:36 +03:00
feat: implement pagination for older posts in telemirror and add floating date display
This commit is contained in:
@@ -2,9 +2,7 @@ name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- '!v*-ios*' # iOS-only tags are handled by ios-release.yml
|
||||
tags: ['v*']
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -224,8 +222,60 @@ jobs:
|
||||
artifacts/thefeed-android-*-arm64-v8a.apk
|
||||
artifacts/thefeed-android-*-armeabi-v7a.apk
|
||||
|
||||
ios-ipa:
|
||||
needs: test
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.26'
|
||||
cache: true
|
||||
|
||||
- name: Select Xcode
|
||||
run: sudo xcode-select -s /Applications/Xcode.app
|
||||
|
||||
- name: Install gomobile + gobind
|
||||
run: |
|
||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||
go install golang.org/x/mobile/cmd/gobind@latest
|
||||
gomobile init
|
||||
go get golang.org/x/mobile/bind golang.org/x/mobile/bind/objc
|
||||
go mod tidy
|
||||
|
||||
- name: Build Mobile.xcframework
|
||||
run: gomobile bind -iosversion=14.0 -target=ios,iossimulator -o ios/Mobile.xcframework ./mobile
|
||||
|
||||
- name: Archive (unsigned)
|
||||
run: |
|
||||
xcodebuild \
|
||||
-project ios/Thefeed.xcodeproj \
|
||||
-scheme Thefeed \
|
||||
-configuration Release \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-archivePath build/Thefeed.xcarchive \
|
||||
archive \
|
||||
CODE_SIGN_IDENTITY="" \
|
||||
CODE_SIGNING_REQUIRED=NO \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
DEVELOPMENT_TEAM=""
|
||||
|
||||
- name: Pack unsigned IPA
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME:-dev}
|
||||
mkdir -p build/Payload
|
||||
cp -r build/Thefeed.xcarchive/Products/Applications/Thefeed.app build/Payload/
|
||||
(cd build && zip -qry "thefeed-ios-${VERSION}-unsigned.ipa" Payload)
|
||||
ls -lh build/*.ipa
|
||||
|
||||
- name: Upload iOS IPA artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: thefeed-ios-ipa
|
||||
path: build/thefeed-ios-*-unsigned.ipa
|
||||
|
||||
release:
|
||||
needs: [build, android-apk]
|
||||
needs: [build, android-apk, ios-ipa]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -256,3 +306,6 @@ jobs:
|
||||
| Windows | amd64 | [server-سرور](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/thefeed-server-windows-amd64.exe) / [client-کلاینت](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/thefeed-client-${{ github.ref_name }}-windows-amd64.exe) |
|
||||
| Android | arm64-v8a (most modern phones) | [thefeed-android-${{ github.ref_name }}-arm64-v8a.apk - اندروید (گوشیهای جدید)](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/thefeed-android-${{ github.ref_name }}-arm64-v8a.apk) |
|
||||
| Android | armeabi-v7a (older 32-bit phones) | [thefeed-android-${{ github.ref_name }}-armeabi-v7a.apk - اندروید (دستگاههای قدیمیتر)](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/thefeed-android-${{ github.ref_name }}-armeabi-v7a.apk) |
|
||||
| iOS (unsigned) | universal | [thefeed-ios-${{ github.ref_name }}-unsigned.ipa](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/thefeed-ios-${{ github.ref_name }}-unsigned.ipa) |
|
||||
|
||||
**iOS / iPadOS (preview, iOS 14+):** the `.ipa` is unsigned. Re-sign it with your own Apple ID and provisioning profile (AltStore, Sideloadly, or `xcrun altool` upload to your own TestFlight). It will not install directly from a download.
|
||||
|
||||
@@ -9,9 +9,11 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Cache TTLs.
|
||||
// Cache TTLs. FreshTTL is intentionally short so opening a channel a
|
||||
// second time triggers a background refresh — without it, a "live"
|
||||
// feed feels frozen for the first 10 minutes after every fetch.
|
||||
const (
|
||||
FreshTTL = 10 * time.Minute
|
||||
FreshTTL = 1 * time.Minute
|
||||
StaleTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
|
||||
@@ -161,6 +161,19 @@ func (c *Client) markSuccess(idx int) {
|
||||
|
||||
// FetchHTML returns the rendered widget HTML for the username.
|
||||
func (c *Client) FetchHTML(ctx context.Context, username string) (string, error) {
|
||||
return c.fetchHTMLWithBefore(ctx, username, 0)
|
||||
}
|
||||
|
||||
// FetchHTMLBefore fetches the widget filtered to posts strictly older
|
||||
// than beforeID. Used for "Load older" pagination.
|
||||
func (c *Client) FetchHTMLBefore(ctx context.Context, username string, beforeID int) (string, error) {
|
||||
if beforeID <= 0 {
|
||||
return c.FetchHTML(ctx, username)
|
||||
}
|
||||
return c.fetchHTMLWithBefore(ctx, username, beforeID)
|
||||
}
|
||||
|
||||
func (c *Client) fetchHTMLWithBefore(ctx context.Context, username string, beforeID int) (string, error) {
|
||||
username = SanitizeUsername(username)
|
||||
if username == "" {
|
||||
return "", ErrEmptyUsername
|
||||
@@ -178,7 +191,7 @@ func (c *Client) FetchHTML(ctx context.Context, username string) (string, error)
|
||||
return "", err
|
||||
}
|
||||
ua := userAgents[mrand.IntN(len(userAgents))]
|
||||
body, status, err := c.do(ctx, ap, username, ua)
|
||||
body, status, err := c.do(ctx, ap, username, ua, beforeID)
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("attempt %d (%s): %w", i+1, ap.label(), err)
|
||||
continue
|
||||
@@ -391,11 +404,14 @@ func setBrowserHeaders(req *http.Request, ua string, fronted bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) do(ctx context.Context, ap proxyAttempt, username, ua string) (string, int, error) {
|
||||
func (c *Client) do(ctx context.Context, ap proxyAttempt, username, ua string, beforeID int) (string, int, error) {
|
||||
url := fmt.Sprintf(
|
||||
"https://%s/s/%s?_x_tr_sl=%s&_x_tr_tl=%s&_x_tr_hl=en&_x_tr_pto=wapp",
|
||||
proxyHost, username, ap.sl, ap.tl,
|
||||
)
|
||||
if beforeID > 0 {
|
||||
url += fmt.Sprintf("&before=%d", beforeID)
|
||||
}
|
||||
|
||||
transport := transportFor(ap)
|
||||
defer transport.CloseIdleConnections()
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -25,17 +26,22 @@ type telemirrorHub struct {
|
||||
store *telemirror.Store
|
||||
imgs *telemirror.ImageCache
|
||||
|
||||
// onUpdate fires after a successful refresh so the SSE channel can
|
||||
// nudge the frontend to re-fetch. nil-safe.
|
||||
onUpdate func(username string)
|
||||
|
||||
mu sync.Mutex
|
||||
refreshing map[string]chan struct{}
|
||||
}
|
||||
|
||||
func newTelemirrorHub(dataDir string) *telemirrorHub {
|
||||
func newTelemirrorHub(dataDir string, onUpdate func(string)) *telemirrorHub {
|
||||
tmDir := filepath.Join(dataDir, "telemirror")
|
||||
return &telemirrorHub{
|
||||
client: telemirror.NewClient(),
|
||||
cache: telemirror.NewCache(tmDir),
|
||||
store: telemirror.NewStore(dataDir),
|
||||
imgs: telemirror.NewImageCache(filepath.Join(tmDir, "images")),
|
||||
onUpdate: onUpdate,
|
||||
refreshing: make(map[string]chan struct{}),
|
||||
}
|
||||
}
|
||||
@@ -110,12 +116,37 @@ func (h *telemirrorHub) handleChannel(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, rewriteImageURLs(cached))
|
||||
return
|
||||
}
|
||||
|
||||
// forceRefresh + cache hit: trigger refresh in background but cap
|
||||
// how long we make the user wait. On flaky networks fronting can
|
||||
// take minutes — we'd rather return cached than time out. The
|
||||
// background fetch keeps running (h.refresh coalesces concurrent
|
||||
// callers), so the next request sees fresh data.
|
||||
if forceRefresh && cached != nil {
|
||||
ch := make(chan *telemirror.FetchResult, 1)
|
||||
go func() {
|
||||
res, err := h.refresh(username)
|
||||
if err != nil || res == nil {
|
||||
ch <- nil
|
||||
return
|
||||
}
|
||||
ch <- res
|
||||
}()
|
||||
select {
|
||||
case res := <-ch:
|
||||
if res != nil {
|
||||
writeJSON(w, rewriteImageURLs(res))
|
||||
return
|
||||
}
|
||||
case <-time.After(15 * time.Second):
|
||||
}
|
||||
writeJSON(w, rewriteImageURLs(cached))
|
||||
return
|
||||
}
|
||||
|
||||
// No cache yet — must wait for the first fetch.
|
||||
res, err := h.refresh(username)
|
||||
if err != nil {
|
||||
if cached != nil {
|
||||
writeJSON(w, rewriteImageURLs(cached))
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), 502)
|
||||
return
|
||||
}
|
||||
@@ -306,6 +337,9 @@ func (h *telemirrorHub) refresh(username string) (*telemirror.FetchResult, error
|
||||
}
|
||||
res := &telemirror.FetchResult{Channel: *chInfo, Posts: posts}
|
||||
_ = h.cache.Put(username, res)
|
||||
if h.onUpdate != nil {
|
||||
h.onUpdate(username)
|
||||
}
|
||||
go h.ensureAvatarCached(chInfo.Username, chInfo.Photo)
|
||||
return res, nil
|
||||
}
|
||||
@@ -391,3 +425,42 @@ func (h *telemirrorHub) ClearCache() {
|
||||
h.cache.Clear()
|
||||
h.imgs.Clear()
|
||||
}
|
||||
|
||||
// handleOlder serves /api/telemirror/older/<username>?before=<id>.
|
||||
// Fetches a fresh widget filtered to posts older than the given id.
|
||||
// Not cached — every call hits upstream because pagination data
|
||||
// otherwise grows unbounded per channel.
|
||||
func (h *telemirrorHub) handleOlder(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
username := strings.ToLower(telemirror.SanitizeUsername(
|
||||
strings.TrimPrefix(r.URL.Path, "/api/telemirror/older/")))
|
||||
if username == "" {
|
||||
http.Error(w, "missing username", 400)
|
||||
return
|
||||
}
|
||||
beforeID, _ := strconv.Atoi(r.URL.Query().Get("before"))
|
||||
if beforeID <= 0 {
|
||||
http.Error(w, "missing before", 400)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
body, err := h.client.FetchHTMLBefore(ctx, username, beforeID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 502)
|
||||
return
|
||||
}
|
||||
chInfo, posts, err := telemirror.ParseHTML(body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 502)
|
||||
return
|
||||
}
|
||||
if chInfo.Username == "" {
|
||||
chInfo.Username = username
|
||||
}
|
||||
res := &telemirror.FetchResult{Channel: *chInfo, Posts: posts}
|
||||
writeJSON(w, rewriteImageURLs(res))
|
||||
}
|
||||
|
||||
+8
-2
@@ -260,9 +260,14 @@ func New(dataDir string, port int, host string, password string) (*Server, error
|
||||
mediaCache: mediaCache,
|
||||
dlProgress: make(map[string]*mediaDLProgress),
|
||||
relayInfo: newRelayCache(),
|
||||
telemirror: newTelemirrorHub(dataDir),
|
||||
profilePics: newProfilePicsHub(dataDir),
|
||||
profilePics: newProfilePicsHub(dataDir),
|
||||
}
|
||||
// Set up telemirror with an onUpdate hook so background refreshes
|
||||
// push an SSE event; the frontend re-fetches the active channel
|
||||
// when it sees the matching event.
|
||||
s.telemirror = newTelemirrorHub(dataDir, func(username string) {
|
||||
s.broadcast("event: update\ndata: \"telemirror:" + username + "\"\n\n")
|
||||
})
|
||||
|
||||
if mediaCache != nil {
|
||||
go mediaCache.Cleanup()
|
||||
@@ -362,6 +367,7 @@ func (s *Server) serve(ln net.Listener) error {
|
||||
mux.HandleFunc("/api/telemirror/channel/", s.telemirror.handleChannel)
|
||||
mux.HandleFunc("/api/telemirror/img", s.telemirror.handleImg)
|
||||
mux.HandleFunc("/api/telemirror/avatar/", s.telemirror.handleAvatar)
|
||||
mux.HandleFunc("/api/telemirror/older/", s.telemirror.handleOlder)
|
||||
// Profile-pics cache + control endpoints.
|
||||
mux.HandleFunc("/api/profile-pics/", s.profilePics.handleProfilePic)
|
||||
mux.HandleFunc("/api/profile-pics", s.handleProfilePicsList)
|
||||
|
||||
Reference in New Issue
Block a user