mirror of
https://github.com/sartoopjj/thefeed.git
synced 2026-05-19 11:04:35 +03:00
feat(telemirror): enhance media handling and UI improvements
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
@@ -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">↗</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">↻</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',
|
||||
|
||||
@@ -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">▶</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(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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">▶</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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user