feat(telemirror): enhance media handling and UI improvements

This commit is contained in:
Sarto
2026-05-05 13:09:47 +03:30
parent 4e10870bdd
commit b26bf2a1ee
7 changed files with 674 additions and 69 deletions
@@ -289,7 +289,7 @@ class MainActivity : ComponentActivity() {
with(webView.settings) {
javaScriptEnabled = true
domStorageEnabled = true
cacheMode = WebSettings.LOAD_NO_CACHE
cacheMode = WebSettings.LOAD_DEFAULT
allowFileAccess = false
allowContentAccess = false
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
+1 -2
View File
@@ -117,7 +117,6 @@ var userAgents = []string{
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1",
}
// Client mirrors HttpClient from teleMirror's http-client.js.
type Client struct {
rateMu sync.Mutex
@@ -445,7 +444,7 @@ func (c *Client) FetchURL(ctx context.Context, rawURL string) ([]byte, string, e
select {
case <-ctx.Done():
return nil, "", ctx.Err()
case <-time.After(300 * time.Millisecond):
case <-time.After(200 * time.Millisecond):
}
}
body, ctype, status, err := c.fetchOnce(ctx, ap, rawURL, hostHeader)
+71
View File
@@ -94,10 +94,81 @@ func parseSinglePost(wrap *html.Node) *Post {
m.Duration = textOf(d)
}
p.Media = append(p.Media, m)
case hasClass(n, "tgme_widget_message_voice"), hasClass(n, "tgme_widget_message_voice_player"):
m := Media{Type: "voice"}
if d := findFirstByClass(n, "tgme_widget_message_voice_duration"); d != nil {
m.Duration = textOf(d)
}
p.Media = append(p.Media, m)
case hasClass(n, "tgme_widget_message_audio"), hasClass(n, "tgme_widget_message_audio_player"):
m := Media{Type: "audio"}
if d := findFirstByClass(n, "tgme_widget_message_audio_duration"); d != nil {
m.Duration = textOf(d)
}
if t := findFirstByClass(n, "tgme_widget_message_audio_title"); t != nil {
m.Title = textOf(t)
}
if a := findFirstByClass(n, "tgme_widget_message_audio_performer"); a != nil {
m.Subtitle = textOf(a)
}
p.Media = append(p.Media, m)
case hasClass(n, "tgme_widget_message_document_wrap"), hasClass(n, "tgme_widget_message_document"):
m := Media{Type: "document"}
if t := findFirstByClass(n, "tgme_widget_message_document_title"); t != nil {
m.Title = textOf(t)
}
if e := findFirstByClass(n, "tgme_widget_message_document_extra"); e != nil {
m.Subtitle = textOf(e)
}
p.Media = append(p.Media, m)
case hasClass(n, "tgme_widget_message_sticker_wrap"), hasClass(n, "tgme_widget_message_sticker"):
m := Media{Type: "sticker"}
if img := findFirstByTag(n, "img"); img != nil {
m.Thumb = attrOf(img, "src")
}
if m.Thumb == "" {
m.Thumb = extractBgImage(attrOf(n, "style"))
}
p.Media = append(p.Media, m)
case hasClass(n, "tgme_widget_message_poll"):
m := Media{Type: "poll"}
if q := findFirstByClass(n, "tgme_widget_message_poll_question"); q != nil {
m.Title = textOf(q)
}
if t := findFirstByClass(n, "tgme_widget_message_poll_type"); t != nil {
m.Subtitle = textOf(t)
}
p.Media = append(p.Media, m)
}
return true
})
// Reactions: each .tgme_reaction holds an emoji + a count.
visit(msg, func(n *html.Node) bool {
if !hasClass(n, "tgme_reaction") {
return true
}
emoji := ""
if e := findFirstByClass(n, "emoji"); e != nil {
if b := findFirstByTag(e, "b"); b != nil {
emoji = textOf(b)
} else {
emoji = textOf(e)
}
}
// Fallback: take any <b> directly inside.
if emoji == "" {
if b := findFirstByTag(n, "b"); b != nil {
emoji = textOf(b)
}
}
count := strings.TrimSpace(strings.TrimPrefix(textOf(n), emoji))
if emoji != "" || count != "" {
p.Reactions = append(p.Reactions, Reaction{Emoji: emoji, Count: count})
}
return false // don't recurse inside a reaction
})
if dateEl := findFirstByClass(msg, "tgme_widget_message_date"); dateEl != nil {
if tag := findFirstByTag(dateEl, "time"); tag != nil {
if dt := attrOf(tag, "datetime"); dt != "" {
+17 -8
View File
@@ -26,21 +26,30 @@ type Channel struct {
// Media is one attachment on a post.
type Media struct {
Type string `json:"type"` // "photo" | "video"
Type string `json:"type"` // "photo" | "video" | "voice" | "audio" | "document" | "sticker" | "poll"
URL string `json:"url,omitempty"`
Thumb string `json:"thumb,omitempty"`
Duration string `json:"duration,omitempty"`
Title string `json:"title,omitempty"` // file name / poll question / audio title
Subtitle string `json:"subtitle,omitempty"`
}
// Reaction is one emoji + count on a post.
type Reaction struct {
Emoji string `json:"emoji"`
Count string `json:"count,omitempty"`
}
// Post is a single message from the channel feed.
type Post struct {
ID string `json:"id"` // "<channel>/<msgid>"
Author string `json:"author,omitempty"`
Text string `json:"text,omitempty"` // sanitised inner HTML
Media []Media `json:"media,omitempty"`
Time time.Time `json:"time,omitempty"`
Views string `json:"views,omitempty"`
Edited bool `json:"edited,omitempty"`
ID string `json:"id"` // "<channel>/<msgid>"
Author string `json:"author,omitempty"`
Text string `json:"text,omitempty"` // sanitised inner HTML
Media []Media `json:"media,omitempty"`
Reactions []Reaction `json:"reactions,omitempty"`
Time time.Time `json:"time,omitempty"`
Views string `json:"views,omitempty"`
Edited bool `json:"edited,omitempty"`
}
// FetchResult is what we cache per channel.
+226 -19
View File
@@ -931,6 +931,17 @@
}
/* BEGIN telemirror */
/* Sidebar trigger — small "↗" icon signals leaving the main flow.
inline-flex would override .stb's text-align:center, so use
inline-block and inline-block the icon alongside the text. */
.tm-sidebar-btn-ext {
display: inline-block;
margin-inline-start: 4px;
font-size: 11px; opacity: .65; line-height: 1;
vertical-align: baseline
}
.tm-sidebar-btn:hover .tm-sidebar-btn-ext { opacity: 1 }
.tm-no-scroll { overflow: hidden }
.tm-modal {
position: fixed; inset: 0; z-index: 9000;
@@ -955,8 +966,8 @@
transition: background .12s, border-color .12s
}
.tm-back:hover, .tm-menu:hover { background: var(--border); border-color: var(--border) }
/* .tm-menu visibility handled inside the same @media block as the sidebar drawer (below). */
.tm-menu { display: none }
@media (max-width: 768px) { .tm-menu { display: inline-flex } }
.tm-topbar-info { display: flex; align-items: center; gap: 12px; flex: 1; min-width: 0 }
.tm-topbar-meta { min-width: 0; flex: 1 }
@@ -989,13 +1000,41 @@
background: var(--bg-elevated, var(--bg));
display: flex; flex-direction: column; min-height: 0
}
@media (max-width: 768px) {
/* Backdrop sits between the sidebar (z:2) and the content. In mobile
drawer mode it lights up when .tm-sidebar.open is set so tapping
outside the drawer dismisses it. */
.tm-backdrop { display: none }
/* Mobile drawer. The hamburger menu also shows up here, plus on
touch devices up to 1024px so phones in landscape and tablets
in portrait still get the drawer treatment. */
@media (max-width: 768px), (pointer: coarse) and (max-width: 1024px) {
.tm-menu { display: inline-flex }
.tm-sidebar {
position: absolute; top: 0; bottom: 0; left: 0; width: 86%; max-width: 340px;
z-index: 2; transform: translateX(-100%); transition: transform .22s ease;
box-shadow: 4px 0 24px rgba(0,0,0,.0)
position: absolute; top: 0; bottom: 0;
width: 86%; max-width: 340px;
z-index: 2; transition: transform .22s ease;
box-shadow: 0 0 0 rgba(0,0,0,.0)
}
/* LTR: slide in from the left. */
html[dir="ltr"] .tm-sidebar { left: 0; right: auto; transform: translateX(-100%) }
html[dir="ltr"] .tm-sidebar.open { transform: translateX(0); box-shadow: 4px 0 24px rgba(0,0,0,.32) }
/* RTL: slide in from the right (where the channel list belongs). */
html[dir="rtl"] .tm-sidebar { right: 0; left: auto; transform: translateX(100%) }
html[dir="rtl"] .tm-sidebar.open { transform: translateX(0); box-shadow: -4px 0 24px rgba(0,0,0,.32) }
.tm-backdrop {
display: block;
position: absolute; inset: 0;
background: rgba(0,0,0,0);
pointer-events: none;
transition: background .22s ease;
z-index: 1
}
.tm-sidebar.open ~ .tm-backdrop {
background: rgba(0,0,0,.42);
pointer-events: auto
}
.tm-sidebar.open { transform: translateX(0); box-shadow: 4px 0 24px rgba(0,0,0,.32) }
}
.tm-disclaimer {
padding: 12px 14px; margin: 10px;
@@ -1060,15 +1099,29 @@
text-align: center; color: var(--text-dim); font-size: 13px
}
.tm-channel-desc {
max-width: 720px; width: 100%; margin: 0 auto 4px;
padding: 12px 16px; font-size: 13px; color: var(--text-dim);
line-height: 1.7;
background: var(--bg-elevated, var(--bg));
border: 1px solid var(--border); border-radius: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,.04)
/* Channel bio — clearly distinct from posts: dashed border, label header, accent left edge. */
.tm-channel-bio {
max-width: 720px; width: 100%; margin: 0 auto 8px;
background: color-mix(in oklab, var(--accent) 5%, transparent);
border: 1px dashed color-mix(in oklab, var(--accent) 50%, var(--border));
border-radius: 12px;
overflow: hidden
}
.tm-channel-desc a { color: var(--accent) }
.tm-channel-bio-label {
padding: 8px 14px;
font-size: 11px; font-weight: 700;
text-transform: uppercase; letter-spacing: .04em;
color: var(--accent);
background: color-mix(in oklab, var(--accent) 10%, transparent);
border-bottom: 1px dashed color-mix(in oklab, var(--accent) 35%, var(--border))
}
.tm-channel-bio-body {
padding: 12px 14px;
font-size: 13px; line-height: 1.75;
color: var(--text-dim);
overflow-wrap: anywhere; word-break: break-word
}
.tm-channel-bio-body a { color: var(--accent) }
.tm-post {
max-width: 720px; width: 100%; margin: 0 auto;
@@ -1093,8 +1146,20 @@
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; word-break: break-word }
.tm-post-text a { color: var(--accent); text-decoration: underline; text-decoration-color: color-mix(in oklab, var(--accent) 50%, transparent) }
.tm-post-text {
font-size: 14px; line-height: 1.7;
overflow-wrap: anywhere; /* break long URLs / unbroken strings */
word-break: break-word
}
.tm-post-text pre, .tm-post-text code {
white-space: pre-wrap;
overflow-wrap: anywhere
}
.tm-post-text a {
color: var(--accent); text-decoration: underline;
text-decoration-color: color-mix(in oklab, var(--accent) 50%, transparent);
overflow-wrap: anywhere
}
.tm-post-text a:hover { text-decoration-color: currentColor }
.tm-post-media {
margin-top: 10px;
@@ -1111,6 +1176,7 @@
.tm-album-3 .tm-photo img { aspect-ratio: 1 / 1; object-fit: cover }
.tm-photo {
position: relative;
display: block; line-height: 0;
border-radius: 10px; overflow: hidden;
background: #000;
@@ -1119,6 +1185,22 @@
.tm-photo img {
width: 100%; height: auto; display: block
}
/* Download badge — fades in on hover; always visible on touch screens. */
.tm-photo-dl {
position: absolute; bottom: 8px; inset-inline-end: 8px;
width: 32px; height: 32px;
display: inline-flex; align-items: center; justify-content: center;
background: rgba(0,0,0,.65); color: #fff;
border-radius: 50%;
font-size: 16px; text-decoration: none;
opacity: 0; transition: opacity .15s, background .15s;
backdrop-filter: blur(2px)
}
.tm-photo:hover .tm-photo-dl { opacity: 1 }
.tm-photo-dl:hover { background: rgba(0,0,0,.85) }
@media (hover: none) {
.tm-photo-dl { opacity: .85 }
}
.tm-vid {
position: relative; display: flex;
align-items: center; justify-content: center;
@@ -1144,11 +1226,110 @@
font-size: 11px
}
.tm-post-foot {
display: flex; gap: 14px;
margin-top: 8px;
display: flex; align-items: center; gap: 14px;
margin-top: 10px;
padding-top: 6px;
border-top: 1px dashed var(--border);
font-size: 11px; color: var(--text-dim)
}
.tm-views { display: inline-flex; align-items: center; gap: 4px }
/* Time at the end of the foot (i18n-friendly with start-end). */
.tm-post-foot .tm-post-time { margin-inline-start: auto }
/* Topbar refresh button */
.tm-refresh {
background: none; border: 1px solid transparent; color: var(--text);
font-size: 18px; cursor: pointer; padding: 6px 10px; border-radius: 8px;
transition: background .12s, border-color .12s, transform .15s
}
.tm-refresh:hover { background: var(--border); border-color: var(--border) }
.tm-refresh:active { transform: rotate(45deg) }
/* Copy button — text label, sits at the end of post-head. */
.tm-post-copy {
margin-inline-start: auto;
background: none; border: 1px solid var(--border);
color: var(--text-dim);
cursor: pointer;
font-size: 11px;
padding: 3px 10px;
border-radius: 6px;
transition: background .12s, color .12s, border-color .12s
}
.tm-post-copy:hover { background: var(--border); color: var(--text); border-color: var(--accent) }
.tm-post-copy.tm-copied { color: var(--accent); border-color: var(--accent); background: color-mix(in oklab, var(--accent) 10%, transparent) }
/* Generic media tile (voice, audio, document, poll). */
.tm-media-tile {
display: flex; align-items: center; gap: 12px;
padding: 10px 12px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 10px;
min-width: 0
}
.tm-media-icon {
flex-shrink: 0;
width: 40px; height: 40px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 50%;
background: color-mix(in oklab, var(--accent) 18%, transparent);
font-size: 18px
}
.tm-media-meta { flex: 1; min-width: 0 }
.tm-media-title {
font-size: 13px; font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis
}
.tm-media-sub { font-size: 11px; color: var(--text-dim); margin-top: 2px }
/* Sticker — small inline image, no border. */
.tm-sticker {
max-width: 200px; line-height: 0
}
.tm-sticker img {
max-width: 100%; max-height: 200px; display: block; background: transparent
}
/* Reactions row — pills of emoji + count. */
.tm-post-reactions {
display: flex; flex-wrap: wrap; gap: 6px;
margin-top: 10px
}
.tm-reaction {
display: inline-flex; align-items: center; gap: 4px;
padding: 3px 9px;
background: color-mix(in oklab, var(--accent) 10%, transparent);
border: 1px solid var(--border);
border-radius: 999px;
font-size: 12px
}
.tm-reaction-emoji { font-size: 14px; line-height: 1 }
.tm-reaction-count { color: var(--text-dim); font-weight: 600 }
/* If our image proxy fails, collapse the wrapper so we don't show
a permanent broken-image icon. */
.tm-photo-failed { display: none }
/* First-visit hint — points the user at the channel list. */
.tm-first-hint {
margin: auto;
padding: 28px 24px;
max-width: 420px;
text-align: center;
color: var(--text-dim);
font-size: 14px;
line-height: 1.7;
background: var(--bg-elevated, var(--bg));
border: 1px dashed var(--border);
border-radius: 14px
}
.tm-first-hint-arrow {
font-size: 32px; line-height: 1;
color: var(--accent);
margin-bottom: 10px
}
.tm-first-hint-text { white-space: pre-line }
/* END telemirror */
.media-action {
@@ -2687,7 +2868,7 @@
<button class="stb" id="scannerIconBtn" onclick="openScanner()" data-i18n="scanner_find_resolvers">Find Resolvers</button>
<button class="stb" id="resolversSidebarBtn" onclick="openResolversModal()"><span data-i18n="sidebar_resolvers">Resolvers</span> <span class="stb-badge" id="resolversBadge" style="color:var(--error)">0</span></button>
<!-- BEGIN telemirror -->
<button class="stb" id="telemirrorSidebarBtn" onclick="openTelemirror()" data-i18n-title="telemirror_btn_title"><span data-i18n="telemirror_btn">Browse channels</span></button>
<button class="stb tm-sidebar-btn" id="telemirrorSidebarBtn" onclick="openTelemirror()" data-i18n-title="telemirror_btn_title"><span data-i18n="telemirror_btn">Browse channels</span><span class="tm-sidebar-btn-ext" aria-hidden="true">&#8599;</span></button>
<!-- END telemirror -->
</div>
<input class="sidebar-search" id="channelSearch" type="text" data-i18n-ph="search" placeholder="Search..."
@@ -2775,6 +2956,7 @@
<div class="tm-topbar-sub" id="tmTopbarSub"></div>
</div>
</div>
<button class="tm-refresh" onclick="tmRefreshActive()" title="Refresh" data-i18n-title="telemirror_refresh">&#8635;</button>
</header>
<div class="tm-body">
<aside id="tmSidebar" class="tm-sidebar">
@@ -2785,6 +2967,7 @@
</div>
<div id="tmChannelsList" class="tm-channels-list"></div>
</aside>
<div class="tm-backdrop" onclick="toggleTmSidebar()" aria-hidden="true"></div>
<main id="tmContent" class="tm-content">
<div class="tm-empty" data-i18n="telemirror_pick_channel">Pick a channel</div>
</main>
@@ -3290,6 +3473,18 @@
telemirror_remove_pinned: 'این کانال پیش‌فرض قابل حذف نیست',
telemirror_views: 'بازدید',
telemirror_edited: 'ویرایش شده',
telemirror_first_hint: 'یک کانال از لیست انتخاب کن، یا با ورودی بالا کانال جدیدی اضافه کن.',
telemirror_first_hint_mobile: 'برای دیدن لیست کانال‌ها، دکمه‌ی منو در بالای صفحه را بزن. سپس یک کانال انتخاب یا اضافه کن.',
telemirror_refresh: 'به‌روز کردن',
telemirror_refresh_warn: 'این کانال {n} ثانیه پیش به‌روز شد. به‌روزرسانی مکرر ممکنه به محدودیت تعداد درخواست بخوره و چند دقیقه قابل استفاده نباشه. ادامه می‌دی؟',
telemirror_refresh_yes: 'به‌روز کن',
telemirror_voice: 'پیام صوتی',
telemirror_audio: 'فایل صوتی',
telemirror_file: 'فایل',
telemirror_poll: 'نظرسنجی',
telemirror_about: 'درباره‌ی این کانال',
download: 'دانلود',
copy: 'کپی',
// END telemirror
font_size: 'اندازه قلم', debug_mode: 'حالت دیباگ', language: 'زبان',
next_fetch_info: 'زمان باقی‌مانده تا دریافت بعدی محتوا توسط سرور',
@@ -3515,6 +3710,18 @@
telemirror_remove_pinned: 'Pinned channels cannot be removed',
telemirror_views: 'views',
telemirror_edited: 'edited',
telemirror_first_hint: 'Pick a channel from the list, or add a new one with the input above.',
telemirror_first_hint_mobile: 'Tap the menu button at the top to open the channel list, then pick or add a channel.',
telemirror_refresh: 'Refresh',
telemirror_refresh_warn: 'This channel was refreshed {n} sec ago. Refreshing too often can hit a rate limit and stop working for a few minutes. Refresh anyway?',
telemirror_refresh_yes: 'Refresh',
telemirror_voice: 'Voice message',
telemirror_audio: 'Audio',
telemirror_file: 'File',
telemirror_poll: 'Poll',
telemirror_about: 'About this channel',
download: 'Download',
copy: 'Copy',
// END telemirror
font_size: 'Font Size', debug_mode: 'Debug mode', language: 'Language',
next_fetch_info: 'Time until the server next fetches fresh channel content',
+302 -27
View File
@@ -5,6 +5,26 @@
var tmChannels = [];
var tmActive = '';
var tmAvatarCache = {}; // username (lower) -> photo URL once we've fetched it
var tmPostText = {}; // pid -> plaintext (avoids huge data-text attrs)
var tmLastFetchedAt = {}; // username (lower) -> last successful fetch ms
// ===== persisted state =====
// Restore the previously-active channel and any avatar URLs we've
// resolved before, so reopening the app lands the user back on the
// same channel (with sidebar avatars already populated) instead of
// the first-hint and blank initial-letter circles.
try {
tmActive = localStorage.getItem('tm_active') || '';
var avRaw = localStorage.getItem('tm_avatars');
if (avRaw) tmAvatarCache = JSON.parse(avRaw) || {};
} catch (e) { /* localStorage may be disabled / quota; that's fine */ }
function tmSaveActive() {
try { localStorage.setItem('tm_active', tmActive || ''); } catch (e) { }
}
function tmSaveAvatars() {
try { localStorage.setItem('tm_avatars', JSON.stringify(tmAvatarCache)); } catch (e) { }
}
function tmI18n(key, fallback) {
try {
@@ -60,15 +80,53 @@
}
// ===== open / close =====
// Track whether we pushed a history entry on open, so close() can
// pop it without leaving phantom states behind. Without this the
// Android hardware back button does nothing inside this modal.
var tmHistoryPushed = false;
window.openTelemirror = function () {
document.getElementById('telemirrorModal').classList.add('active');
document.body.classList.add('tm-no-scroll');
if (!tmHistoryPushed) {
try { history.pushState({ view: 'telemirror' }, ''); tmHistoryPushed = true; } catch (e) { }
}
// Defer the layout-mode check until after the modal is rendered, so
// getComputedStyle on .tm-menu reflects the actual CSS state.
requestAnimationFrame(function () {
var sb = document.getElementById('tmSidebar');
if (sb && tmIsMobileLayout()) sb.classList.add('open');
});
tmLoadChannels();
};
window.closeTelemirror = function () {
document.getElementById('telemirrorModal').classList.remove('active');
var modal = document.getElementById('telemirrorModal');
if (!modal || !modal.classList.contains('active')) return;
modal.classList.remove('active');
document.body.classList.remove('tm-no-scroll');
var sb = document.getElementById('tmSidebar');
if (sb) sb.classList.remove('open');
if (tmHistoryPushed) {
tmHistoryPushed = false;
try { history.back(); } catch (e) { }
}
};
// Hardware / browser back: if our modal is the top of the history
// stack, intercept and close without re-popping (history already
// popped us).
window.addEventListener('popstate', function () {
var modal = document.getElementById('telemirrorModal');
if (modal && modal.classList.contains('active')) {
modal.classList.remove('active');
document.body.classList.remove('tm-no-scroll');
var sb = document.getElementById('tmSidebar');
if (sb) sb.classList.remove('open');
tmHistoryPushed = false;
}
});
window.toggleTmSidebar = function () {
var sb = document.getElementById('tmSidebar');
if (sb) sb.classList.toggle('open');
@@ -82,16 +140,44 @@
tmChannels = (d.channels || []).slice();
} catch (e) { tmChannels = []; }
tmRenderChannels();
if (!tmActive && tmChannels.length > 0) {
tmSelect(tmChannels[0].username);
} else if (tmActive) {
if (tmActive) {
// Already viewing a channel from a previous session — keep it.
tmSelect(tmActive);
} else {
document.getElementById('tmContent').innerHTML =
'<div class="tm-empty">' + tmEsc(tmI18n('telemirror_pick_channel', 'Pick a channel')) + '</div>';
// First open: don't auto-select. Show a hint pointing the user
// to the channel list (which is the open drawer on mobile).
tmShowFirstHint();
}
}
// Detect "mobile layout" by checking whether the hamburger button is
// actually visible — it's display:none on >768px via the CSS rule.
// Way more reliable than guessing from window.matchMedia, which can
// be off on tablets / odd viewport widths / DPR changes.
function tmIsMobileLayout() {
var btn = document.querySelector('.tm-menu');
return !!(btn && getComputedStyle(btn).display !== 'none');
}
function tmShowFirstHint() {
var content = document.getElementById('tmContent');
var msg, icon;
if (tmIsMobileLayout()) {
icon = '☰';
msg = tmI18n('telemirror_first_hint_mobile',
'Tap the menu button at the top to open the channel list, then pick or add a channel.');
} else {
icon = document.documentElement.dir === 'rtl' ? '→' : '←';
msg = tmI18n('telemirror_first_hint',
'Pick a channel from the list, or add a new one with the input above.');
}
content.innerHTML =
'<div class="tm-first-hint">'
+ '<div class="tm-first-hint-arrow">' + tmEsc(icon) + '</div>'
+ '<div class="tm-first-hint-text">' + tmEsc(msg) + '</div>'
+ '</div>';
}
function tmRenderChannels() {
var box = document.getElementById('tmChannelsList');
var html = '';
@@ -127,14 +213,17 @@
+ '</pre></div>';
}
async function tmSelect(username) {
async function tmSelect(username, opts) {
opts = opts || {};
tmActive = username;
tmRenderChannels();
tmRenderTopbar(null, username);
var content = document.getElementById('tmContent');
content.innerHTML = '<div class="tm-empty">' + tmEsc(tmI18n('telemirror_loading', 'Loading...')) + '</div>';
try {
var r = await fetch('/api/telemirror/channel/' + encodeURIComponent(username));
var url = '/api/telemirror/channel/' + encodeURIComponent(username);
if (opts.refresh) url += '?refresh=1';
var r = await fetch(url);
if (!r.ok) {
var errBody = '';
try { errBody = await r.text(); } catch (e2) { }
@@ -142,10 +231,13 @@
return;
}
var d = await r.json();
tmLastFetchedAt[username.toLowerCase()] = Date.now();
if (d && d.channel && d.channel.photo) {
tmAvatarCache[username.toLowerCase()] = d.channel.photo;
tmSaveAvatars();
tmRenderChannels();
}
tmSaveActive();
tmRenderTopbar(d && d.channel, username);
tmRenderPosts(d);
} catch (e) {
@@ -154,6 +246,31 @@
}
window.tmSelect = tmSelect;
// 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 () {
if (!tmActive) return;
var key = tmActive.toLowerCase();
var last = tmLastFetchedAt[key] || 0;
var ageSec = Math.floor((Date.now() - last) / 1000);
if (last && ageSec < 600) {
var msg = (tmI18n('telemirror_refresh_warn',
'You refreshed this channel {n} sec ago. Refreshing too often can hit a rate limit and stop working for a while. Refresh anyway?')
).replace('{n}', ageSec);
// Reuse the main app's themed confirm dialog if available.
var ok;
if (typeof showConfirmDialog === 'function') {
ok = await showConfirmDialog(msg,
tmI18n('telemirror_refresh_yes', 'Refresh'),
tmI18n('cancel', 'Cancel'));
} else {
ok = window.confirm(msg);
}
if (!ok) return;
}
tmSelect(tmActive, { refresh: true });
};
function tmRenderTopbar(channel, username) {
var name = (channel && channel.title) || username;
var sub = '';
@@ -181,44 +298,73 @@
var html = '';
if (ch.description) {
html += '<div class="tm-channel-desc">' + ch.description + '</div>';
// Clearly mark as channel info, not a post.
html += '<div class="tm-channel-bio">'
+ '<div class="tm-channel-bio-label">'
+ tmEsc(tmI18n('telemirror_about', 'About this channel'))
+ '</div>'
+ '<div class="tm-channel-bio-body">' + ch.description + '</div>'
+ '</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).
tmPostText = {};
for (var i = 0; i < posts.length; i++) {
var p = posts[i];
var when = p.time ? new Date(p.time).toLocaleString() : '';
html += '<div class="tm-post">';
var when = tmFormatTime(p.time);
var plain = tmPostPlainText(p);
var pid = 'tmp_' + i;
if (plain) tmPostText[pid] = plain;
html += '<div class="tm-post" data-pid="' + pid + '">';
// Head: author + edited + copy button. Time moved to the bottom.
html += '<div class="tm-post-head">';
if (ch.title) html += '<span class="tm-post-author">' + tmEsc(ch.title) + '</span>';
html += '<span class="tm-post-time">' + tmEsc(when) + '</span>';
if (p.edited) html += '<span class="tm-post-edited">' + tmEsc(tmI18n('telemirror_edited', 'edited')) + '</span>';
if (plain) {
html += '<button class="tm-post-copy"'
+ ' onclick="tmCopyPost(this)">'
+ tmEsc(tmI18n('copy', 'Copy'))
+ '</button>';
}
html += '</div>';
if (p.text) html += '<div class="tm-post-text">' + p.text + '</div>';
if (p.media && p.media.length) {
// Album-aware grid: 1 photo → fullwidth, 2 → 2 cols, 3+ → 3 cols.
var gridClass = 'tm-post-media tm-album-' + Math.min(p.media.length, 3);
var photoCount = 0;
for (var k = 0; k < p.media.length; k++) {
if (p.media[k].type === 'photo' && p.media[k].thumb) photoCount++;
}
var gridClass = 'tm-post-media tm-album-' + Math.min(Math.max(photoCount, 1), 3);
html += '<div class="' + gridClass + '">';
for (var j = 0; j < p.media.length; j++) {
var m = p.media[j];
if (m.type === 'photo' && m.thumb) {
// No link wrapping — clicking a Translate-proxied permalink
// just returns useless bytes via /api/telemirror/img.
html += '<div class="tm-photo">'
+ '<img src="' + tmEscAttr(m.thumb) + '" loading="lazy" alt=""></div>';
} else if (m.type === 'video') {
var bg = m.thumb ? 'background-image:url(\'' + tmEscAttr(m.thumb) + '\')' : '';
var dur = m.duration ? '<span class="tm-vid-dur">' + tmEsc(m.duration) + '</span>' : '';
html += '<div class="tm-vid" style="' + bg + '">'
+ '<span class="tm-vid-play">&#9654;</span>' + dur + '</div>';
}
html += tmRenderMedia(p.media[j], i, j);
}
html += '</div>';
}
if (p.reactions && p.reactions.length) {
html += '<div class="tm-post-reactions">';
for (var r = 0; r < p.reactions.length; r++) {
var rx = p.reactions[r];
html += '<span class="tm-reaction">'
+ '<span class="tm-reaction-emoji">' + tmEsc(rx.emoji || '?') + '</span>'
+ (rx.count ? '<span class="tm-reaction-count">' + tmEsc(rx.count) + '</span>' : '')
+ '</span>';
}
html += '</div>';
}
// Footer: timestamp + view count.
html += '<div class="tm-post-foot">';
if (p.views) html += '<span class="tm-views">👁 ' + tmEsc(p.views) + '</span>';
if (when) html += '<span class="tm-post-time">' + tmEsc(when) + '</span>';
html += '</div>';
html += '</div>';
}
content.innerHTML = html;
@@ -228,6 +374,129 @@
});
}
// Format a timestamp for display. Persian users see Jalali calendar
// (Intl handles the conversion natively in modern browsers); other
// languages get the system locale.
function tmFormatTime(iso) {
if (!iso) return '';
var d = new Date(iso);
if (isNaN(d.getTime())) return '';
var lang = (typeof window !== 'undefined' && window.lang) ||
localStorage.getItem('thefeed_lang') || 'en';
var locale = (lang === 'fa') ? 'fa-IR-u-ca-persian' : undefined;
try {
return d.toLocaleString(locale, {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit'
});
} catch (e) {
return d.toLocaleString();
}
}
// Strip HTML and decode common entities for the copy button.
function tmPostPlainText(p) {
if (!p.text) return '';
var s = String(p.text)
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|li)>/gi, '\n')
.replace(/<[^>]+>/g, '');
return s
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.trim();
}
// Render one media tile based on its type.
// postIdx/mediaIdx are used to build a sane filename for downloads.
function tmRenderMedia(m, postIdx, mediaIdx) {
if (m.type === 'photo' && m.thumb) {
var fname = 'photo-' + (postIdx + 1) + '-' + (mediaIdx + 1) + '.jpg';
return '<div class="tm-photo">'
+ '<img src="' + tmEscAttr(m.thumb) + '" loading="lazy" alt=""'
+ ' referrerpolicy="no-referrer"'
+ ' onerror="this.parentNode.classList.add(\'tm-photo-failed\')">'
+ '<a class="tm-photo-dl" href="' + tmEscAttr(m.thumb) + '"'
+ ' download="' + tmEscAttr(fname) + '"'
+ ' title="' + tmEscAttr(tmI18n('download', 'Download')) + '"'
+ ' onclick="event.stopPropagation()">⬇</a>'
+ '</div>';
}
if (m.type === 'video') {
var bg = m.thumb ? 'background-image:url(\'' + tmEscAttr(m.thumb) + '\')' : '';
var dur = m.duration ? '<span class="tm-vid-dur">' + tmEsc(m.duration) + '</span>' : '';
return '<div class="tm-vid" style="' + bg + '">'
+ '<span class="tm-vid-play">&#9654;</span>' + dur + '</div>';
}
if (m.type === 'voice') {
return '<div class="tm-media-tile"><span class="tm-media-icon">🎙️</span>'
+ '<div class="tm-media-meta"><div class="tm-media-title">'
+ tmEsc(tmI18n('telemirror_voice', 'Voice message'))
+ '</div><div class="tm-media-sub">' + tmEsc(m.duration || '') + '</div></div></div>';
}
if (m.type === 'audio') {
return '<div class="tm-media-tile"><span class="tm-media-icon">🎵</span>'
+ '<div class="tm-media-meta"><div class="tm-media-title">'
+ tmEsc(m.title || tmI18n('telemirror_audio', 'Audio'))
+ '</div><div class="tm-media-sub">' + tmEsc(m.subtitle || m.duration || '') + '</div></div></div>';
}
if (m.type === 'document') {
return '<div class="tm-media-tile"><span class="tm-media-icon">📄</span>'
+ '<div class="tm-media-meta"><div class="tm-media-title">'
+ tmEsc(m.title || tmI18n('telemirror_file', 'File'))
+ '</div><div class="tm-media-sub">' + tmEsc(m.subtitle || '') + '</div></div></div>';
}
if (m.type === 'sticker' && m.thumb) {
return '<div class="tm-sticker">'
+ '<img src="' + tmEscAttr(m.thumb) + '" loading="lazy" alt=""'
+ ' referrerpolicy="no-referrer"'
+ ' onerror="this.parentNode.classList.add(\'tm-photo-failed\')">'
+ '</div>';
}
if (m.type === 'poll') {
return '<div class="tm-media-tile"><span class="tm-media-icon">📊</span>'
+ '<div class="tm-media-meta"><div class="tm-media-title">'
+ tmEsc(m.title || tmI18n('telemirror_poll', 'Poll'))
+ '</div><div class="tm-media-sub">' + tmEsc(m.subtitle || '') + '</div></div></div>';
}
return '';
}
window.tmCopyPost = function (btn) {
var post = btn.closest ? btn.closest('.tm-post') : null;
var pid = post ? post.getAttribute('data-pid') : '';
var text = (pid && tmPostText[pid]) || '';
if (!text) return;
var done = function () {
var prev = btn.textContent;
btn.textContent = '✓';
btn.classList.add('tm-copied');
setTimeout(function () { btn.textContent = prev; btn.classList.remove('tm-copied'); }, 1200);
tmToast(tmI18n('copied', 'Copied'));
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done).catch(function () {
// Fallback: hidden textarea + execCommand.
tmCopyFallback(text, done);
});
} else {
tmCopyFallback(text, done);
}
};
function tmCopyFallback(text, done) {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed'; ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); done(); } catch (e) { }
document.body.removeChild(ta);
}
// ===== add / remove =====
window.telemirrorAdd = async function () {
var input = document.getElementById('tmAddInput');
@@ -253,7 +522,13 @@
body: JSON.stringify({ action: 'remove', username: username })
});
if (!r.ok) { tmToast(tmI18n('telemirror_remove_pinned', 'Cannot remove pinned')); return; }
if (tmActive.toLowerCase() === username.toLowerCase()) tmActive = '';
if (tmActive.toLowerCase() === username.toLowerCase()) {
tmActive = '';
tmSaveActive();
}
// Drop the avatar cache entry too so a re-add fetches fresh.
delete tmAvatarCache[username.toLowerCase()];
tmSaveAvatars();
await tmLoadChannels();
} catch (e) { tmToast((e && e.message) || 'failed'); }
};
+56 -12
View File
@@ -142,21 +142,58 @@ func rewriteImageURLs(in *telemirror.FetchResult) *telemirror.FetchResult {
return &cp
}
// proxyImgURL routes an image URL through our /api/telemirror/img
// endpoint, applying the translate.goog hostname rewrite when the
// source is a Telegram CDN. Google's Translate proxy doesn't rewrite
// inline background-image URLs, so the parser hands us the original
// cdn4.telegram.org / cdn4.telesco.pe URLs and we have to translate
// them ourselves before fetching through fronting.
func proxyImgURL(raw string) string {
if raw == "" || !strings.HasPrefix(raw, "https://") {
rewritten := translateGoogify(raw)
if rewritten == "" {
return raw
}
// Only rewrite hosts we'll actually proxy — anything else passes
// through so the browser can load it directly.
if !isProxiableHost(raw) {
return raw
}
return "/api/telemirror/img?u=" + url.QueryEscape(raw)
return "/api/telemirror/img?u=" + url.QueryEscape(rewritten)
}
// isProxiableHost — only translate.goog. Other CDNs (cdn*.telesco.pe,
// cdn*.telegram.org) can't be proxied because Google's edge only routes
// to its own backends, not arbitrary external hosts.
// translateGoogify maps a CDN URL to its Google-Translate-proxied form.
// Returns "" if the URL is something we shouldn't proxy at all.
//
// Examples:
//
// https://cdn4.telegram.org/file/abc.jpg
// → https://cdn4-telegram-org.translate.goog/file/abc.jpg
// https://cdn1.telesco.pe/file/x.jpg
// → https://cdn1-telesco-pe.translate.goog/file/x.jpg
// https://cdn4-telegram-org.translate.goog/file/abc.jpg
// → unchanged (already on translate.goog)
func translateGoogify(raw string) string {
if raw == "" || !strings.HasPrefix(raw, "https://") {
return ""
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return ""
}
host := strings.ToLower(u.Host)
switch {
case strings.HasSuffix(host, ".translate.goog"):
// Already on translate.goog — proxy directly.
return raw
case strings.HasSuffix(host, ".telegram.org"),
strings.HasSuffix(host, ".telesco.pe"),
host == "t.me":
// Replace every "." with "-" and append ".translate.goog".
// Same scheme Google's proxy uses.
u.Host = strings.ReplaceAll(host, ".", "-") + ".translate.goog"
return u.String()
}
return ""
}
// isProxiableHost — only the translate.goog form. Used by the /api/telemirror/img
// handler to validate the `u` query parameter, after the JSON
// rewrite has converted Telegram CDN URLs to their .translate.goog equivalent.
func isProxiableHost(rawURL string) bool {
u, err := url.Parse(rawURL)
if err != nil {
@@ -189,12 +226,19 @@ func (h *telemirrorHub) handleImg(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), 502)
return
}
// If the upstream returned HTML or some non-image content (a Google
// error page, captcha, etc.), surface a 502 instead of relaying it.
// The browser fires onerror reliably on a 502, so the avatar / image
// fallback in the JS kicks in cleanly.
low := strings.ToLower(ctype)
if !strings.HasPrefix(low, "image/") && !strings.HasPrefix(low, "video/") && !strings.HasPrefix(low, "audio/") && low != "" {
http.Error(w, "upstream not an image: "+ctype, 502)
return
}
if ctype == "" {
ctype = "application/octet-stream"
}
w.Header().Set("Content-Type", ctype)
// Browser cache for an hour — these URLs are content-addressed so
// any change shows up as a different URL anyway.
w.Header().Set("Cache-Control", "private, max-age=3600")
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
_, _ = w.Write(body)