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
+57 -4
View File
@@ -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.
+4 -2
View File
@@ -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
)
+18 -2
View File
@@ -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()
+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') {
+78 -5
View File
@@ -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
View File
@@ -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)