您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Telegram multi-channel reader with folders, unread counters and discussions
// ==UserScript== // @name TamperGram // @namespace https://greasyfork.org/ru/scripts/551187-tampergram // @version 0.1 // @description Telegram multi-channel reader with folders, unread counters and discussions // @author TesterTV // @homepageURL https://github.com/testertv/TamperGram // @license GPL v.3 or any later version. // @match file:///*TamperGram.html // @run-at document-end // @grant GM_xmlhttpRequest // @grant GM_setClipboard // @grant GM.setValue // @grant GM.getValue // @grant GM.deleteValue // @connect t.me // ==/UserScript== (async function () { "use strict"; // ===== Bootstrap, Constants & Defaults ===== const ALL = "All"; const EXISTS_TTL = 5 * 60 * 1000; const PROBE_START = 200; const BISECT_DELAY_MS = 60; const EXP_DELAY_MS = 100; const LOAD_UP_TRIGGER_PX = 350; const NEAR_BOTTOM_PX = 150; const META_TTL_MS = 60 * 60 * 1000; const POST_WIDTH_PX = 560; const CFG_KEY = "tg_cfg"; // One concurrency knob for all network pools const NET_CONCURRENCY = 3; document.body.replaceChildren(); const DEF_SETTINGS = { initialCount: 12, olderBatch: 5, darkTheme: true, refreshSec: 60, titleBadge: true, loadDelayInitial: 300, loadDelayScroll: 150, pinStartAtBottom: true, }; function normalizeSettings(s) { const st = { ...DEF_SETTINGS, ...(s || {}) }; if (!("refreshSec" in st)) st.refreshSec = st.autoRefreshSec ?? st.bgPollSec ?? 60; delete st.autoRefreshSec; delete st.bgPollSec; return st; } let savedTabs, activeTab, allChannels, tabMap, mainTab, sidebarWidth, lastIndexMap, scrollState, lastSeenMap, settings, channelMeta, discussionWidth, activeChannelSlug; // ===== Config Normalize, Load, Snapshot & Save ===== function normalizeCfg(c) { const cfg = { ...c }; if (!Array.isArray(cfg.tabs)) cfg.tabs = [ALL]; if (!cfg.tabs.includes(ALL)) cfg.tabs.unshift(ALL); cfg.tabs = [ALL, ...cfg.tabs.filter((t) => t !== ALL)]; if (!Array.isArray(cfg.channels) || cfg.channels.length === 0) { cfg.channels = ["durov", "telegram", "bloomberg", "notcoin"]; } if (!cfg.tabMap || typeof cfg.tabMap !== "object") cfg.tabMap = {}; if (!cfg.tabs.includes(cfg.mainTab)) cfg.mainTab = ALL; cfg.activeTab = cfg.mainTab; cfg.sidebarWidth = Number.isFinite(+cfg.sidebarWidth) ? +cfg.sidebarWidth : 280; cfg.discussionWidth = Number.isFinite(+cfg.discussionWidth) ? +cfg.discussionWidth : 420; cfg.lastIndexMap = cfg.lastIndexMap || {}; cfg.scrollState = cfg.scrollState || {}; cfg.lastSeenMap = cfg.lastSeenMap || {}; cfg.channelMeta = cfg.channelMeta || {}; cfg.settings = normalizeSettings(cfg.settings); return cfg; } async function loadCfg() { let cfg = await GM.getValue(CFG_KEY, null); if (!cfg) { cfg = { tabs: [ALL], activeTab: ALL, channels: ["durov", "telegram", "bloomberg", "notcoin"], tabMap: {}, mainTab: ALL, sidebarWidth: 280, discussionWidth: 420, lastIndexMap: {}, scrollState: {}, lastSeenMap: {}, settings: { ...DEF_SETTINGS }, activeChannel: null, channelMeta: {}, }; await GM.setValue(CFG_KEY, cfg); } cfg = normalizeCfg(cfg); savedTabs = cfg.tabs.slice(); activeTab = cfg.activeTab; allChannels = cfg.channels.slice(); tabMap = JSON.parse(JSON.stringify(cfg.tabMap || {})); mainTab = cfg.mainTab; sidebarWidth = cfg.sidebarWidth; discussionWidth = cfg.discussionWidth; lastIndexMap = { ...cfg.lastIndexMap }; scrollState = { ...cfg.scrollState }; lastSeenMap = { ...cfg.lastSeenMap }; settings = { ...cfg.settings }; channelMeta = { ...(cfg.channelMeta || {}) }; activeChannelSlug = cfg.activeChannel || null; } function snapshotCfg() { return { tabs: savedTabs.slice(), activeTab, channels: allChannels.slice(), tabMap: JSON.parse(JSON.stringify(tabMap || {})), mainTab, sidebarWidth, discussionWidth, lastIndexMap: { ...lastIndexMap }, scrollState: { ...scrollState }, lastSeenMap: { ...lastSeenMap }, settings: { ...settings }, activeChannel: activeChannelSlug || null, channelMeta: { ...channelMeta }, }; } let saveTimer = null; function save(immediate = false) { const doSave = () => GM.setValue(CFG_KEY, snapshotCfg()); if (immediate) return doSave(); if (saveTimer) clearTimeout(saveTimer); saveTimer = setTimeout(doSave, 150); } await loadCfg(); // ===== Telegram Page Parsing & HTTP Utilities ===== const NOT_FOUND_SELECTORS = [ ".tgme_widget_message_error", ".tgme_widget_error", ".tgme_page_error", ".tgme_page_wrap .tgme_page_content .tgme_page_description", ]; const NOT_FOUND_REGEX = new RegExp( [ "Post not found", "Пост не найден", "Запись не найдена", "Публікацію не знайдено", "Posta bulunamadı", "Post nicht gefunden", "Publicación no encontrada", "投稿が見つかりません", "文章未找到", "Channel not found", "Канал не найден", ].join("|"), "i" ); const PRIVATE_REGEX = new RegExp( [ "This channel is private", "Этот канал приватный", "Этот канал закрыт", "Bu kanal özeldir", "Dieser Kanal ist privat", "Этот канал недоступен", ].join("|"), "i" ); function normalizeSlug(input) { if (!input) return null; let s = String(input).trim(); s = s.replace(/^https?:\/\/(www\.)?t\.me\//i, ""); s = s.replace(/^@/, ""); s = s.replace(/^s\//i, ""); s = s.split(/[/?#]/)[0]; if (!/^[A-Za-z0-9_]{3,64}$/.test(s)) return null; return s; } let backoffMs = 0; function handleBackoff(status) { if (status === 429 || status === 0 || status === -1) { backoffMs = Math.min((backoffMs || 500) * 2, 8000) + Math.floor(Math.random() * 250); } else { backoffMs = 0; } } function gmFetch(url, timeout = 15000) { return new Promise((resolve) => { GM_xmlhttpRequest({ method: "GET", url, headers: { Accept: "text/html" }, timeout, onload: (res) => resolve({ status: res.status, text: res.responseText || "" }), onerror: () => resolve({ status: 0, text: "" }), ontimeout: () => resolve({ status: -1, text: "" }), }); }); } async function fetchHtmlWithBackoff(url, timeout = 15000) { if (backoffMs > 0) await sleep(backoffMs); const res = await gmFetch(url, timeout); handleBackoff(res.status); return res; } async function gmFetchEmbed(slug, n, timeout = 15000) { const { status, text } = await fetchHtmlWithBackoff(`https://t.me/${slug}/${n}?embed=1`, timeout); if (status !== 200) return { ok: false, reason: `HTTP ${status}` }; if (PRIVATE_REGEX.test(text)) return { ok: false, reason: "private" }; if (NOT_FOUND_REGEX.test(text)) return { ok: false, reason: "not_found" }; try { const doc = new DOMParser().parseFromString(text, "text/html"); const isErrorDom = NOT_FOUND_SELECTORS.some((sel) => doc.querySelector(sel)); if (isErrorDom) return { ok: false, reason: "not_found" }; } catch {} return { ok: true }; } // ===== Existence Cache (post availability) ===== const existsCache = new Map(); function pruneExistsCache(limit = 3000) { if (existsCache.size < limit) return; const now = Date.now(); for (const [k, v] of existsCache) { if (now - v.t > EXISTS_TTL) existsCache.delete(k); } if (existsCache.size > limit) { const arr = [...existsCache.entries()].sort((a, b) => a[1].t - b[1].t); const cut = Math.floor(arr.length / 2); for (let i = 0; i < cut; i++) existsCache.delete(arr[i][0]); } } async function checkExistsWithCache(slug, n) { pruneExistsCache(); const key = `${slug}#${n}`; const now = Date.now(); const cached = existsCache.get(key); if (cached && now - cached.t < EXISTS_TTL) return { exists: cached.v, reason: cached.reason }; const res = await gmFetchEmbed(slug, n); const ok = !!res.ok; const reason = res.reason || null; existsCache.set(key, { v: ok, reason, t: now }); return { exists: ok, reason }; } // ===== Channel Last-ID & Metadata Basics ===== async function fetchLastIdViaS(slug, timeout = 15000) { const { status, text } = await fetchHtmlWithBackoff(`https://t.me/s/${slug}`, timeout); if (status !== 200) { return { last: null, status }; } if (PRIVATE_REGEX.test(text)) return { last: -1, status: 200 }; if (NOT_FOUND_REGEX.test(text)) return { last: 0, status: 200 }; let max = 0, m; const re = new RegExp(`data-post="${slug}\\/(\\d+)"`, "g"); while ((m = re.exec(text))) max = Math.max(max, +m[1]); const reHref = new RegExp(`href="/${slug}\\/(\\d+)"`, "g"); while ((m = reHref.exec(text))) max = Math.max(max, +m[1]); return { last: max || null, status: 200 }; } function needMeta(slug) { const m = channelMeta[slug]; if (!m) return true; if (!m.title && !m.avatar) return true; if (!m.t || Date.now() - m.t > META_TTL_MS) return true; return false; } function setMeta(slug, data) { channelMeta[slug] = { ...(channelMeta[slug] || {}), ...data, t: Date.now() }; save(true); } function getMeta(slug) { return channelMeta[slug] || null; } function extractBgUrlFromStyle(styleStr = "") { // Properly extract url("...") from style string const m = /urlKATEX_INLINE_OPEN(['"]?)(.*?)\1KATEX_INLINE_CLOSE/i.exec(styleStr); return m ? m[2] : null; } function textOrNull(el) { if (!el) return null; const t = (el.textContent || "").replace(/\s+/g, " ").trim(); return t || null; } function urlOrNull(el) { if (!el) return null; const src = el.getAttribute("src"); if (src) return src; let style = el.getAttribute("style") || ""; let bg = extractBgUrlFromStyle(style); if (!bg && el.style && el.style.backgroundImage) { bg = extractBgUrlFromStyle(el.style.backgroundImage); } if (!bg) return null; if (bg.startsWith("//")) bg = "https:" + bg; return bg; } // ===== Channel Metadata Fetching & Entity Type Detection ===== async function fetchChannelMetaFromS(slug) { const { status, text } = await fetchHtmlWithBackoff(`https://t.me/s/${slug}`, 15000); if (status !== 200) return { title: null, avatar: null }; try { const doc = new DOMParser().parseFromString(text, "text/html"); let title = textOrNull(doc.querySelector(".tgme_channel_info_header_title")) || textOrNull(doc.querySelector(".tgme_page_title")) || textOrNull(doc.querySelector(".tgme_widget_message_owner_name")) || null; let avatar = null; const og = doc.querySelector('meta[property="og:image"]'); if (og && og.getAttribute("content")) { avatar = og.getAttribute("content"); } if (!avatar) { const el1 = doc.querySelector(".tgme_page_photo_image"); const el2 = doc.querySelector(".tgme_widget_message_user_photo"); avatar = urlOrNull(el1) || urlOrNull(el2) || null; } if (avatar && avatar.startsWith("//")) avatar = "https:" + avatar; return { title, avatar }; } catch { return { title: null, avatar: null }; } } const metaInFlight = new Map(); function ensureChannelMeta(slug) { if (!slug) return Promise.resolve(getMeta(slug)); if (!needMeta(slug)) return Promise.resolve(getMeta(slug)); if (metaInFlight.has(slug)) return metaInFlight.get(slug); const p = (async () => { try { const m = await fetchChannelMetaFromS(slug); if (m.title || m.avatar) { setMeta(slug, m); updateChannelHeader(slug); renderChats(); } } finally { metaInFlight.delete(slug); } return getMeta(slug); })(); metaInFlight.set(slug, p); return p; } function getType(slug) { const m = channelMeta[slug]; return m && m.type ? m.type : null; } function setType(slug, type) { if (!slug || !type) return; channelMeta[slug] = { ...(channelMeta[slug] || {}), type, t: Date.now() }; save(true); } function parseTypeFromExtraText(extraText) { const t = (extraText || "").toLowerCase(); if (t.includes("subscribers")) return "channel"; if (t.includes("members") && t.includes("online")) return "chat"; return null; } async function fetchEntityTypeFromPage(slug) { const { status, text } = await fetchHtmlWithBackoff(`https://t.me/${slug}`, 15000); if (status !== 200) return null; try { const doc = new DOMParser().parseFromString(text, "text/html"); const extra = textOrNull(doc.querySelector(".tgme_page_extra")) || ""; return parseTypeFromExtraText(extra); } catch { return null; } } const typeInFlight = new Map(); function ensureEntityType(slug) { if (!slug) return Promise.resolve(getType(slug)); if (getType(slug)) return Promise.resolve(getType(slug)); if (typeInFlight.has(slug)) return typeInFlight.get(slug); const p = (async () => { try { const t = await fetchEntityTypeFromPage(slug); if (t) setType(slug, t); } finally { typeInFlight.delete(slug); } return getType(slug); })(); typeInFlight.set(slug, p); return p; } // ===== Root Container & Static Layout ===== const container = document.createElement("div"); container.id = "tg-container"; document.body.appendChild(container); document.documentElement.style.setProperty('--post-width', POST_WIDTH_PX ? POST_WIDTH_PX + 'px' : 'auto'); container.innerHTML = ` <div id="sidebar" style="width:${sidebarWidth}px"> <div class="sidebar-header"> <div class="settings" title="Settings">⚙️</div> <div class="search-wrapper"> <input type="text" class="search" placeholder="Search channels (@handle or t.me)..."> <button class="clear-search hidden" title="Clear">❌</button> </div> </div> <div class="tabs-wrapper"> <div class="tabs"></div> <button class="add-tab" title="Add folder">+</button> </div> <div class="chat-list"></div> <button class="add-channel" title="Add channel/chat">📢</button> <div class="sidebar-resizer"></div> </div> <div id="chat-area"> <div class="empty-hint">Pick a channel/chat on the left 👈</div> </div> <ul id="context-menu" class="hidden" role="menu"></ul> <div id="settings-modal" class="hidden" aria-hidden="true"> <div class="settings-backdrop"></div> <div class="settings-panel"> <div class="settings-header"> <div class="settings-title">Settings</div> <button class="settings-close" title="Close">✖</button> </div> <div class="settings-content"> <div class="settings-row"> <label>Initial posts to load (initialCount):</label> <input type="number" id="st-initialCount" min="1" max="1000" value="${settings.initialCount}"> </div> <div class="settings-row"> <label>Batch size when loading older (olderBatch):</label> <input type="number" id="st-olderBatch" min="1" max="100" value="${settings.olderBatch}"> </div> <div class="settings-row"> <label>Dark theme (widget):</label> <input type="checkbox" id="st-darkTheme" ${settings.darkTheme ? "checked" : ""}> </div> <div class="settings-row"> <label>Refresh interval (sec, 0=off):</label> <input type="number" id="st-refresh" min="5" value="${settings.refreshSec}"> </div> <div class="settings-row"> <label>Unread counter in browser tab title:</label> <input type="checkbox" id="st-titleBadge" ${settings.titleBadge ? "checked" : ""}> </div> <div class="settings-row"> <label>Initial load delay (ms):</label> <input type="number" id="st-loadDelayInitial" min="0" max="2000" value="${settings.loadDelayInitial}"> </div> <div class="settings-row"> <label>Scroll load delay (ms):</label> <input type="number" id="st-loadDelayScroll" min="0" max="2000" value="${settings.loadDelayScroll}"> </div> <div class="settings-row"> <label>Start at bottom:</label> <input type="checkbox" id="st-pinBottom" ${settings.pinStartAtBottom ? "checked" : ""}> </div> <div class="settings-actions"> <button id="st-save">Save</button> <button id="st-cancel">Cancel</button> </div> <hr> <div class="settings-row two-col"> <button id="st-export">Export profile</button> <label class="import-label"> Import profile <input id="st-import" type="file" accept="application/json" style="display:none;"> </label> <button id="st-reset">Reset all</button> </div> </div> </div> </div> `; // ===== Element References ===== const sidebar = container.querySelector("#sidebar"); const tabsContainer = container.querySelector(".tabs"); const chatList = container.querySelector(".chat-list"); const searchInput = container.querySelector(".search"); const clearSearchBtn = container.querySelector(".clear-search"); const contextMenu = container.querySelector("#context-menu"); const addTabBtn = container.querySelector(".add-tab"); const addChannelBtn = container.querySelector(".add-channel"); const resizer = container.querySelector(".sidebar-resizer"); const chatArea = container.querySelector("#chat-area"); const settingsBtn = container.querySelector(".settings"); const modal = container.querySelector("#settings-modal"); const modalClose = container.querySelector(".settings-close"); const modalBackdrop = container.querySelector(".settings-backdrop"); // ===== Generic Helpers & Utilities ===== function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } function setStatus(el, msg) { if (el) el.textContent = msg; } function clamp(n, min, max) { return Math.min(max, Math.max(min, n)); } function highlightMatch(text, query) { if (!query) return text; const idx = text.toLowerCase().indexOf(query.toLowerCase()); if (idx < 0) return text; const before = text.slice(0, idx); const match = text.slice(idx, idx + query.length); const after = text.slice(idx + query.length); return `${escapeHtml(before)}<mark>${escapeHtml(match)}</mark>${escapeHtml(after)}`; } function escapeHtml(s) { return s.replace(/[&<>"']/g, (c) => ({ "&":"&","<":"<",">":">",'"':""","'":"'" }[c])); } function hashStr(s) { let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return Math.abs(h); } function colorIndexFor(slug, modulo = 8) { return (hashStr(slug) % modulo) + 1; // 1..8 } function extractIdFromUrl(url) { if (!url) return null; const match = url.match(/\/(\d+)\/?$/); return match && match[1] ? parseInt(match[1], 10) : null; } function setupHorizontalResizer(handleEl, getWidth, setWidth, onEnd = () => {}, invert = false) { let resizing = false, startX = 0, startW = 0; handleEl.addEventListener("mousedown", (e) => { e.preventDefault(); resizing = true; startX = e.clientX; startW = getWidth(); document.body.style.cursor = "col-resize"; }); document.addEventListener("mousemove", (e) => { if (!resizing) return; const delta = e.clientX - startX; const newW = invert ? (startW - delta) : (startW + delta); setWidth(newW); }); document.addEventListener("mouseup", () => { if (!resizing) return; resizing = false; document.body.style.cursor = ""; onEnd(); }); } // ===== Concurrency & Probing Helpers ===== async function runPool(items, concurrency, worker) { let idx = 0; let any = false; const workers = Array.from({ length: Math.max(1, concurrency) }, async () => { while (idx < items.length) { const i = idx++; try { any = (await worker(items[i], i)) || any; } catch {} } }); await Promise.all(workers); return any; } async function binarySearchLastTrue(lowTrue, highFalseExclusive, exists, delayMs = 0) { let L = lowTrue, H = highFalseExclusive; while (H - L > 1) { const mid = (L + H) >> 1; if (await exists(mid)) L = mid; else H = mid; if (delayMs) await sleep(delayMs); } return L; } async function expandUpperBound(low, step, exists, delayMs = 0) { let curLow = low; let curStep = Math.max(1, step); let probe = curLow + curStep; while (await exists(probe)) { curLow = probe; curStep *= 2; probe = curLow + curStep; if (delayMs) await sleep(delayMs); } return { low: curLow, high: probe }; } // ===== Title Badge & Unread Counters ===== const BASE_TITLE = document.title || "TamperGram"; const link = document.querySelector('link[rel~="icon"]') || document.head.appendChild(document.createElement('link')); link.rel = 'icon'; link.href = 'https://telegram.org/favicon.ico'; function getUnreadCountForSlug(slug) { const lastKnown = +lastIndexMap[slug] || 0; const lastSeen = +lastSeenMap[slug] || 0; return Math.max(0, lastKnown - lastSeen); } function totalUnreadCount() { return allChannels.reduce((acc, slug) => acc + getUnreadCountForSlug(slug), 0); } function updateTitleUnread() { if (!settings.titleBadge) { document.title = BASE_TITLE; return; } const total = totalUnreadCount(); document.title = total > 0 ? `(${total > 999 ? "999+" : total}) ${BASE_TITLE}` : BASE_TITLE; } // ===== Sidebar Resizer ===== setupHorizontalResizer( resizer, () => sidebar.getBoundingClientRect().width, (w) => { sidebarWidth = clamp(w, 200, window.innerWidth - 100); sidebar.style.width = sidebarWidth + "px"; }, () => save() ); // ===== Tabs: State & Rendering ===== let firstRenderTabs = true; let draggingTabName = null; function unreadForTab(tab) { const list = tab === ALL ? allChannels : (tabMap[tab] || []); return list.reduce((sum, slug) => sum + getUnreadCountForSlug(slug), 0); } function renderTabs() { tabsContainer.innerHTML = ""; savedTabs.forEach((tab) => { const tabEl = document.createElement("div"); tabEl.className = "tab" + (tab === activeTab ? " active" : ""); const labelText = tab === mainTab ? `🏠 ${tab}` : tab; const unread = unreadForTab(tab); const badge = unread > 0 ? `<span class="tbadge">${unread > 999 ? "999+" : unread}</span>` : ""; tabEl.innerHTML = `<span class="tab-label">${escapeHtml(labelText)}</span>${badge}`; if (tab !== ALL) { tabEl.draggable = true; tabEl.addEventListener("dragstart", () => { draggingTabName = tab; tabEl.classList.add("dragging"); }); tabEl.addEventListener("dragend", () => { draggingTabName = null; tabEl.classList.remove("dragging"); }); tabEl.addEventListener("dragover", (e) => e.preventDefault()); tabEl.addEventListener("drop", () => { if (!draggingTabName || draggingTabName === tab) return; const arr = savedTabs.filter((t) => t !== ALL); const fromIdx = arr.indexOf(draggingTabName); const toIdx = arr.indexOf(tab); if (fromIdx < 0 || toIdx < 0) return; arr.splice(toIdx, 0, arr.splice(fromIdx, 1)[0]); savedTabs = [ALL, ...arr]; save(); renderTabs(); }); } tabEl.addEventListener("click", () => { activeTab = tab; save(); renderTabs(); renderChats(); pollSidebarOnce(); setTimeout(() => tabEl.scrollIntoView({ behavior: "smooth", inline: "center" }), 0); }); tabEl.addEventListener("contextmenu", (e) => { e.preventDefault(); if (tab === ALL) { contextMenu.innerHTML = ` <li data-action="make-main" data-tab="${tab}">🏠 Set "${tab}" as main</li> <li data-action="mark-all-read-tab" data-tab="${tab}">✅ Mark all as read in "${tab}"</li> `; } else { contextMenu.innerHTML = ` <li data-action="make-main" data-tab="${tab}">🏠 Set "${tab}" as main</li> <li data-action="rename-tab" data-tab="${tab}">✏️ Rename folder</li> <li data-action="del-tab" data-tab="${tab}">❌ Delete folder "${tab}"</li> <li data-action="mark-all-read-tab" data-tab="${tab}">✅ Mark all as read in "${tab}"</li> `; } openContextMenu(e.pageX, e.pageY); }); tabsContainer.appendChild(tabEl); if (firstRenderTabs && tab === mainTab) { setTimeout(() => tabEl.scrollIntoView({ behavior: "auto", inline: "center" }), 0); } }); firstRenderTabs = false; updateTitleUnread(); } // ===== Channels List Rendering ===== function getVisibleChannels() { return activeTab === ALL ? allChannels : (tabMap[activeTab] || []).slice(); } function displayName(slug) { const m = getMeta(slug); return (m && m.title) ? m.title : slug; } function renderChats() { const prevTop = chatList.scrollTop; chatList.innerHTML = ""; const filter = (searchInput.value || "").toLowerCase(); const visibleChannels = getVisibleChannels(); const baseIndex = new Map(visibleChannels.map((s, i) => [s, i])); const filtered = visibleChannels .filter((c) => c.toLowerCase().includes(filter) || (displayName(c).toLowerCase().includes(filter))); filtered.sort((a, b) => { const ua = getUnreadCountForSlug(a); const ub = getUnreadCountForSlug(b); if (ua !== ub) return ub - ua; const la = +lastIndexMap[a] || 0; const lb = +lastIndexMap[b] || 0; if (la !== lb) return lb - la; return (baseIndex.get(a) ?? 0) - (baseIndex.get(b) ?? 0); }); filtered.forEach((slug) => { const m = getMeta(slug) || {}; const item = document.createElement("div"); item.className = "chat-item" + (slug === activeChannelSlug ? " active" : ""); item.draggable = true; item.dataset.slug = slug; item.title = "Open channel/chat"; const lastKnown = +lastIndexMap[slug] || 0; const lastSeen = +lastSeenMap[slug] || 0; const diff = Math.max(0, lastKnown - lastSeen); const titleText = (m.title || slug); const titleHtml = highlightMatch(titleText, filter); const slugHtml = highlightMatch("@" + slug, filter); let avatarHtml = ""; if (m.avatar) { avatarHtml = `<img class="ci-ava-img" referrerpolicy="no-referrer" loading="lazy" src="${escapeHtml(m.avatar)}" alt="@${escapeHtml(slug)}">`; } else { const ch = (titleText || slug).trim(); const letter = (ch[0] || slug[0] || "?").toUpperCase(); const idx = colorIndexFor(slug); avatarHtml = `<div class="ci-ava-fallback bgc${idx}">${escapeHtml(letter)}</div>`; } item.innerHTML = ` <div class="ci-ava">${avatarHtml}</div> <div class="ci-main"> <div class="ci-top"> <span class="ci-title">${titleHtml}</span> <span class="ci-badges">${diff > 0 ? `<span class="badge">${diff > 999 ? "999+" : diff}</span>` : ""}</span> </div> <div class="ci-sub">${slugHtml}</div> </div> `; chatList.appendChild(item); }); chatList.scrollTop = prevTop; updateTitleUnread(); warmupVisibleMeta(); } // ===== Channels List: Interactions (click, context menu, DnD) ===== chatList.addEventListener("click", (e) => { const item = e.target.closest(".chat-item"); if (!item) return; openChannel(item.dataset.slug); }); chatList.addEventListener("contextmenu", (e) => { const item = e.target.closest(".chat-item"); if (!item) return; e.preventDefault(); const ch = item.dataset.slug; let html = ` <li class="submenu">📂 Add to folder ▸ <ul class="submenu-list"> <li data-action="create-tab" data-channel="${ch}"><strong>✚ Create a new folder and add</strong></li>`; const otherTabs = savedTabs.filter((t) => t !== ALL); otherTabs.forEach((tab) => { html += `<li data-action="add-to" data-tab="${tab}" data-channel="${ch}">📁 ${tab}</li>`; }); html += `</ul></li>`; if (activeTab !== ALL) { html += `<li data-action="remove-from-tab" data-tab="${activeTab}" data-channel="${ch}">🗑 Remove from "${activeTab}"</li>`; } html += ` <li data-action="mark-read" data-channel="${ch}">✅ Mark as read</li> <li data-action="rename-channel" data-channel="${ch}">✏️ Rename (@handle)</li> <li data-action="delete-channel" data-channel="${ch}">❌ Delete channel</li>`; contextMenu.innerHTML = html; openContextMenu(e.pageX, e.pageY); }); let draggingSlug = null; chatList.addEventListener("dragstart", (e) => { const item = e.target.closest(".chat-item"); if (!item) return; draggingSlug = item.dataset.slug; item.classList.add("dragging"); }); chatList.addEventListener("dragend", (e) => { const item = e.target.closest(".chat-item"); if (item) item.classList.remove("dragging"); draggingSlug = null; }); chatList.addEventListener("dragover", (e) => { const over = e.target.closest(".chat-item"); if (!over) return; e.preventDefault(); }); chatList.addEventListener("drop", (e) => { const over = e.target.closest(".chat-item"); if (!over || !draggingSlug) return; const toSlug = over.dataset.slug; if (toSlug === draggingSlug) return; if (activeTab === ALL) { const arr = allChannels.slice(); const fromIdx = arr.indexOf(draggingSlug); const toIdx = arr.indexOf(toSlug); if (fromIdx < 0 || toIdx < 0) return; arr.splice(toIdx, 0, arr.splice(fromIdx, 1)[0]); allChannels = arr; } else { const arr = (tabMap[activeTab] || []).slice(); const fromIdx = arr.indexOf(draggingSlug); const toIdx = arr.indexOf(toSlug); if (fromIdx < 0 || toIdx < 0) return; arr.splice(toIdx, 0, arr.splice(fromIdx, 1)[0]); tabMap[activeTab] = arr; } save(); renderChats(); }); // ===== Context Menu: Open/Close & Actions ===== function openContextMenu(x, y) { contextMenu.style.left = x + "px"; contextMenu.style.top = y + "px"; contextMenu.classList.remove("hidden"); requestAnimationFrame(() => { const rect = contextMenu.getBoundingClientRect(); let nx = rect.left, ny = rect.top; if (rect.right > window.innerWidth) nx = window.innerWidth - rect.width - 8; if (rect.bottom > window.innerHeight) ny = window.innerHeight - rect.height - 8; contextMenu.style.left = Math.max(8, nx) + "px"; contextMenu.style.top = Math.max(8, ny) + "px"; }); } function closeContextMenu() { contextMenu.classList.add("hidden"); } document.addEventListener("click", () => closeContextMenu()); document.addEventListener("keydown", (e) => { if (e.key === "Escape") { closeContextMenu(); if (document.activeElement === searchInput && searchInput.value) { searchInput.value = ""; renderChats(); } } }); contextMenu.addEventListener("click", (e) => { const li = e.target.closest("li[data-action]"); if (!li) return; const action = li.dataset.action; const tab = li.dataset.tab; const ch = li.dataset.channel; switch (action) { case "make-main": mainTab = tab; activeTab = tab; save(); renderTabs(); renderChats(); break; case "rename-tab": { const oldName = tab; const next = prompt("New folder name:", oldName); if (next == null) break; const newName = next.trim(); if (!newName) return alert("Folder name cannot be empty."); if (newName === ALL) return alert(`The name "${ALL}" is reserved.`); if (newName === oldName) break; if (savedTabs.includes(newName)) return alert("A folder with this name already exists."); savedTabs = savedTabs.map((t) => (t === oldName ? newName : t)); tabMap[newName] = (tabMap[oldName] || []).slice(); delete tabMap[oldName]; if (activeTab === oldName) activeTab = newName; if (mainTab === oldName) mainTab = newName; save(); renderTabs(); renderChats(); break; } case "del-tab": savedTabs = savedTabs.filter((t) => t !== tab); delete tabMap[tab]; if (activeTab === tab) activeTab = ALL; if (mainTab === tab) mainTab = ALL; save(); renderTabs(); renderChats(); break; case "add-to": if (!tabMap[tab]) tabMap[tab] = []; if (!tabMap[tab].includes(ch)) tabMap[tab].push(ch); save(); break; case "create-tab": { const name = prompt("New folder name:"); if (!name) break; const trimmed = name.trim(); if (!trimmed || savedTabs.includes(trimmed) || trimmed === ALL) return alert("Error: the name is invalid, reserved, or already exists."); savedTabs.push(trimmed); tabMap[trimmed] = [ch]; activeTab = trimmed; save(); renderTabs(); renderChats(); break; } case "remove-from-tab": tabMap[tab] = (tabMap[tab] || []).filter((x) => x !== ch); save(); renderChats(); break; case "delete-channel": { allChannels = allChannels.filter((c) => c !== ch); for (const t in tabMap) tabMap[t] = (tabMap[t] || []).filter((x) => x !== ch); delete lastIndexMap[ch]; delete lastSeenMap[ch]; delete scrollState[ch]; delete channelMeta[ch]; const view = channelViews.get(ch); if (view) { try { view.loader?.destroy?.(); } catch {} try { view.wrap.remove(); } catch {} channelViews.delete(ch); } if (activeChannelSlug === ch) { activeChannelSlug = null; const hint = chatArea.querySelector(".empty-hint"); if (hint) hint.style.display = ""; chatArea.querySelectorAll(".channel-wrap").forEach(w => w.style.display = "none"); } save(); renderTabs(); renderChats(); break; } case "rename-channel": { const current = ch; const next = prompt("Enter @handle or a t.me link:", current.startsWith("@") ? current : "@" + current); if (!next) break; const slug = normalizeSlug(next); if (!slug) return alert("Invalid @handle. Example: durov or https://t.me/durov"); if (allChannels.includes(slug) && slug !== current) return alert("A channel with this @handle already exists."); allChannels = allChannels.map((c) => (c === current ? slug : c)); for (const t in tabMap) tabMap[t] = (tabMap[t] || []).map((x) => (x === current ? slug : x)); if (lastIndexMap[current] != null) { lastIndexMap[slug] = lastIndexMap[current]; delete lastIndexMap[current]; } if (lastSeenMap[current] != null) { lastSeenMap[slug] = lastSeenMap[current]; delete lastSeenMap[current]; } if (scrollState[current]) { scrollState[slug] = scrollState[current]; delete scrollState[current]; } if (channelMeta[current]) { channelMeta[slug] = channelMeta[current]; delete channelMeta[current]; } const view = channelViews.get(current); if (view) { try { view.loader?.destroy?.(); } catch {} try { view.wrap.remove(); } catch {} channelViews.delete(current); } if (activeChannelSlug === current) activeChannelSlug = slug; save(); renderTabs(); renderChats(); if (activeChannelSlug === slug) openChannel(slug); break; } case "mark-read": lastSeenMap[ch] = +lastIndexMap[ch] || 0; save(); renderTabs(); renderChats(); if (activeChannelSlug === ch) { refreshUnreadUI(ch); } break; case "mark-all-read-tab": { const list = (tab === ALL) ? allChannels : (tabMap[tab] || []); list.forEach((slug) => { lastSeenMap[slug] = +lastIndexMap[slug] || 0; }); save(); renderTabs(); renderChats(); if (list.includes(activeChannelSlug)) { refreshUnreadUI(activeChannelSlug); } break; } case "open-post": window.open(li.dataset.url, "_blank"); break; case "copy-link": { const u = li.dataset.url; if (typeof GM_setClipboard === "function") GM_setClipboard(u); else if (navigator.clipboard) navigator.clipboard.writeText(u); break; } } closeContextMenu(); }); // ===== Add Tab & Add Channel Actions ===== addTabBtn.addEventListener("click", () => { const name = prompt("New folder name:"); if (!name) return; const trimmed = name.trim(); if (!trimmed || savedTabs.includes(trimmed) || trimmed === ALL) return alert("Error: the name is invalid, reserved, or already exists."); savedTabs.push(trimmed); tabMap[trimmed] = []; activeTab = trimmed; save(); renderTabs(); renderChats(); }); addChannelBtn.addEventListener("click", () => { const v = prompt( "You can enter:\n\n\ @handle\n handle\n https://t.me/handle\n\nℹ️ Technical limitations of Telegram ℹ️\nIn some cases, the number of the last post/comment may be incorrect or not determined at all. In this case, enter it manually \ne.g. https://t.me/handle/42\n", "https://t.me/" ); if (!v) return; const slug = normalizeSlug(v); const initialPostId = extractIdFromUrl(v); if (!slug) return alert("Invalid @handle. Example: durov or https://t.me/durov"); if (allChannels.includes(slug)) return alert("Error: a channel with this @handle already exists."); allChannels.push(slug); if (initialPostId) { lastIndexMap[slug] = initialPostId; } if (activeTab !== ALL) { if (!tabMap[activeTab]) tabMap[activeTab] = []; tabMap[activeTab].push(slug); } save(); renderTabs(); renderChats(); ensureChannelMeta(slug); ensureEntityType(slug); openChannel(slug); }); // ===== Search (filter channels) ===== let searchDebounce = null; function applySearch() { renderChats(); if (searchInput.value.trim() !== "") clearSearchBtn.classList.remove("hidden"); else clearSearchBtn.classList.add("hidden"); } searchInput.addEventListener("input", () => { if (searchDebounce) clearTimeout(searchDebounce); searchDebounce = setTimeout(applySearch, 120); }); clearSearchBtn.addEventListener("click", () => { searchInput.value = ""; clearSearchBtn.classList.add("hidden"); renderChats(); searchInput.focus(); }); searchInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { const first = chatList.querySelector(".chat-item"); if (first) first.click(); } }); // ===== Tabs Horizontal Scroll (wheel) ===== tabsContainer.addEventListener("wheel", (e) => { if (e.deltaY !== 0) { e.preventDefault(); tabsContainer.scrollLeft += e.deltaY; } }, { passive: false }); // ===== Post Placeholder & Embed Loader ===== function createPostPlaceholder(n) { const wrap = document.createElement("div"); wrap.className = "tg-post"; wrap.dataset.n = String(n); wrap.dataset.loaded = "0"; wrap.innerHTML = ` <div class="post-inner"> <div class="post-skel">Post #${n}…</div> </div> `; return wrap; } function loadPostInto(el, slug, n) { if (!el || el.dataset.loaded === "1") return; el.dataset.loaded = "1"; const inner = el.querySelector(".post-inner") || el; (async () => { let type = getType(slug); if (!type) { try { await ensureEntityType(slug); type = getType(slug); } catch {} } const isChannel = type === "channel"; const isChat = type === "chat"; if (isChannel) { if (!inner.querySelector(".post-discuss-btn")) { const btn = document.createElement("button"); btn.className = "post-discuss-btn"; btn.type = "button"; btn.title = "Open discussion"; btn.setAttribute("aria-label", "Open discussion"); btn.textContent = "💬"; inner.appendChild(btn); } } else { inner.querySelector(".post-discuss-btn")?.remove(); } const sc = document.createElement("script"); sc.async = true; sc.src = "https://telegram.org/js/telegram-widget.js?22"; sc.setAttribute("data-telegram-post", `${slug}/${n}`); sc.setAttribute("data-width", "100%"); sc.setAttribute("data-userpic", isChat ? "true" : "false"); sc.setAttribute("data-dark", settings.darkTheme ? "1" : "0"); inner.querySelector(".post-skel")?.remove?.(); inner.appendChild(sc); })(); } // ===== Lazy Loader (IntersectionObserver) ===== function setupLazyLoader(rootEl, slug) { function makeIO() { return new IntersectionObserver((entries) => { for (const en of entries) { if (!en.isIntersecting) continue; const el = en.target; const n = +el.dataset.n; if (el.dataset.loaded !== "1") { loadPostInto(el, slug, n); } } }, { root: rootEl, rootMargin: "600px 0px" }); } let io = makeIO(); return { observe(el) { io && io.observe(el); }, disconnect() { if (io) io.disconnect(); }, reconnect() { if (io) { try { io.disconnect(); } catch {} } io = makeIO(); rootEl.querySelectorAll('.tg-post[data-loaded="0"]').forEach((el) => io.observe(el)); }, }; } // ===== Finding Last Post Number ===== async function findLastPostForChannel(slug, statusEl, stillActive) { async function exists(n, label = "") { if (!stillActive()) throw new Error("aborted"); if (label) setStatus(statusEl, `Checking post #${n} (${label})…`); const r = await checkExistsWithCache(slug, n); if (!stillActive()) throw new Error("aborted"); if (r.reason === "private") throw new Error("private"); return r.exists; } try { const quick = await fetchLastIdViaS(slug); if (!stillActive()) throw new Error("aborted"); if (quick && typeof quick.last === "number") { const last = quick.last; if (last === -1) { setStatus(statusEl, "The channel is private or restricted. Posts are unavailable."); return -1; } if (last === 0) return 0; if (last > 0) { const r = await checkExistsWithCache(slug, last); if (!stillActive()) throw new Error("aborted"); if (r.exists) return last; } } } catch (e) { if (e.message === "aborted") throw e; } const saved = Number.isFinite(+lastIndexMap[slug]) ? +lastIndexMap[slug] : null; try { if (saved && saved > 0) { if (await exists(saved + 1, "ahead")) { const { low, high } = await expandUpperBound(saved + 1, PROBE_START, (n) => exists(n, "exp"), EXP_DELAY_MS); return await binarySearchLastTrue(low, high, (n) => exists(n, "bisect"), BISECT_DELAY_MS); } else if (await exists(saved, "saved")) { return saved; } else { if (!(await exists(1, "base"))) return 0; return await binarySearchLastTrue(1, saved + 1, (n) => exists(n, "bisect"), BISECT_DELAY_MS); } } } catch (e) { if (e.message === "aborted") throw e; if (e.message === "private") { setStatus(statusEl, "The channel is private or restricted. Posts are unavailable."); return -1; } } try { if (!(await exists(1, "base"))) return 0; if (await exists(PROBE_START, "probe")) { const { low, high } = await expandUpperBound(PROBE_START, PROBE_START, (n) => exists(n, "exp"), EXP_DELAY_MS); return await binarySearchLastTrue(low, high, (n) => exists(n, "bisect"), BISECT_DELAY_MS); } return await binarySearchLastTrue(1, PROBE_START + 1, (n) => exists(n, "bisect"), BISECT_DELAY_MS); } catch (e) { if (e.message === "aborted") throw e; setStatus(statusEl, "Error while searching for the last post."); return -2; } } // ===== Channel View: Build & Register ===== const channelViews = new Map(); function buildChannelView(slug) { const m = getMeta(slug); const nice = (m && m.title) ? m.title : null; const titleText = nice || `Channel`; const subText = `@${slug}`; let avatarHtml = ""; if (m?.avatar) { avatarHtml = `<img class="ch-ava-img" src="${escapeHtml(m.avatar)}" alt="@${escapeHtml(slug)}">`; } else { const letter = (titleText[0] || slug[0] || "?").toUpperCase(); const idx = colorIndexFor(slug); avatarHtml = `<div class="ch-ava-fallback bgc${idx}">${escapeHtml(letter)}</div>`; } const wrap = document.createElement("div"); wrap.className = "channel-wrap"; wrap.dataset.slug = slug; wrap.style.display = "none"; wrap.innerHTML = ` <div class="channel-header"> <div class="ch-wrap"> <div class="ch-ava">${avatarHtml}</div> <div class="ch-main"> <div class="ch-title">${escapeHtml(titleText)}</div> <div class="ch-sub">${escapeHtml(subText)}</div> </div> </div> </div> <div class="status-line"> <span class="status-text">Ready.</span> </div> <div class="content-row"> <div class="posts-scroll"> <div class="posts"></div> </div> <div class="discussion-resizer hidden" title="Resize"></div> <div class="discussion-panel hidden" style="width:${discussionWidth}px"> <div class="discussion-header"> <div class="discussion-title">Discussion</div> <button class="discussion-close" title="Close">✖</button> </div> <div class="discussion-body"></div> </div> </div> <button class="new-bubble hidden" type="button" title="Show new">↓ <span class="cnt">0</span> new</button> `; chatArea.appendChild(wrap); const statusEl = wrap.querySelector(".status-text"); const scrollEl = wrap.querySelector(".posts-scroll"); const postsEl = wrap.querySelector(".posts"); const newBtn = wrap.querySelector(".new-bubble"); const discussionPanel = wrap.querySelector(".discussion-panel"); const discussionResizer = wrap.querySelector(".discussion-resizer"); const discussionBody = wrap.querySelector(".discussion-body"); const discussionTitle = wrap.querySelector(".discussion-title"); const discussionClose = wrap.querySelector(".discussion-close"); const st = scrollState[slug]; setTimeout(() => { if (st && Number.isFinite(st.top)) scrollEl.scrollTop = st.top; }, 0); const onScrollPersist = () => { scrollState[slug] = { top: scrollEl.scrollTop }; save(); }; scrollEl.addEventListener("scroll", onScrollPersist); newBtn.addEventListener("click", () => { const rec = channelViews.get(slug); if (!rec || !rec.loader) return; rec.scrollEl.scrollTo({ top: rec.scrollEl.scrollHeight, behavior: "smooth" }); lastSeenMap[slug] = rec.loader.newest; save(); renderTabs(); renderChats(); refreshUnreadUI(slug, rec); }); discussionClose.addEventListener("click", () => closeDiscussion(slug)); setupHorizontalResizer( discussionResizer, () => discussionPanel.getBoundingClientRect().width, (w) => { const nw = clamp(w, 280, Math.min(window.innerWidth * 0.7, 900)); discussionPanel.style.width = nw + "px"; }, () => { discussionWidth = parseInt(discussionPanel.getBoundingClientRect().width, 10) || discussionWidth; save(); }, true ); return { wrap, statusEl, scrollEl, postsEl, onScrollPersist, newBtn, discussionPanel, discussionBody, discussionTitle, discussionClose, discussionResizer, currentDiscussion: null, }; } // ===== Channel Header Update & Empty Hint ===== function updateChannelHeader(slug) { const rec = channelViews.get(slug); if (!rec) return; const m = getMeta(slug); const titleEl = rec.wrap.querySelector(".ch-title"); const subEl = rec.wrap.querySelector(".ch-sub"); const avaEl = rec.wrap.querySelector(".ch-ava"); if (titleEl) titleEl.textContent = (m?.title || "Channel"); if (subEl) subEl.textContent = `@${slug}`; if (avaEl) { if (m?.avatar) { avaEl.innerHTML = `<img class="ch-ava-img" referrerpolicy="no-referrer" loading="lazy" src="${escapeHtml(m.avatar)}" alt="@${escapeHtml(slug)}">`; } else { const letter = ((m?.title || slug)[0] || "?").toUpperCase(); const idx = colorIndexFor(slug); avaEl.innerHTML = `<div class="ch-ava-fallback bgc${idx}">${escapeHtml(letter)}</div>`; } } } function showEmptyHintIfNoActive() { const hint = chatArea.querySelector(".empty-hint"); const anyVisible = [...chatArea.querySelectorAll(".channel-wrap")].some(el => el.style.display !== "none"); if (hint) hint.style.display = anyVisible ? "none" : ""; } // ===== Discussion Panel: Open/Close & Button Handler ===== function openDiscussion(slug, n) { if (!slug || !Number.isFinite(+n)) return; const rec = channelViews.get(slug); if (!rec) return; rec.discussionPanel.classList.remove("hidden"); rec.discussionResizer.classList.remove("hidden"); rec.discussionPanel.style.width = clamp(discussionWidth, 280, Math.min(window.innerWidth * 0.7, 900)) + "px"; rec.discussionTitle.textContent = `Discussion of post #${n}`; rec.discussionBody.innerHTML = ""; const sc = document.createElement("script"); sc.async = true; sc.src = "https://telegram.org/js/telegram-widget.js?22"; sc.setAttribute("data-telegram-discussion", `${slug}/${n}`); sc.setAttribute("data-comments-limit", "50"); sc.setAttribute("data-dark", settings.darkTheme ? "1" : "0"); rec.discussionBody.appendChild(sc); rec.currentDiscussion = { slug, n: +n }; } function closeDiscussion(slug) { const rec = channelViews.get(slug); if (!rec) return; rec.discussionBody.innerHTML = ""; rec.discussionPanel.classList.add("hidden"); rec.discussionResizer.classList.add("hidden"); rec.currentDiscussion = null; } chatArea.addEventListener("click", (e) => { const btn = e.target.closest(".post-discuss-btn"); if (!btn) return; const postEl = btn.closest(".tg-post"); if (!postEl) return; const n = +postEl.dataset.n; const slug = activeChannelSlug; openDiscussion(slug, n); }); // ===== Unread & Bottom Detection ===== function isNearBottom(scrollEl) { return scrollEl.scrollHeight - (scrollEl.scrollTop + scrollEl.clientHeight) < NEAR_BOTTOM_PX; } function updateNewBubble(slug, rec) { if (!rec?.newBtn) return; const loaderNewest = rec.loader?.newest || 0; const lastSeen = +lastSeenMap[slug] || 0; const unread = Math.max(0, loaderNewest - lastSeen); const show = unread > 0 && !isNearBottom(rec.scrollEl); const cntEl = rec.newBtn.querySelector(".cnt"); if (cntEl) cntEl.textContent = unread > 999 ? "999+" : String(unread); rec.newBtn.classList.toggle("hidden", !show); } function findFirstUnreadElement(postsEl, afterN) { for (const el of postsEl.children) { if (el.classList.contains("tg-post")) { const n = +el.dataset.n; if (n > afterN) return el; } } return null; } function updateUnreadSeparator(slug, rec) { const postsEl = rec?.postsEl; if (!postsEl) return; const lastSeen = +lastSeenMap[slug] || 0; const newest = rec.loader?.newest || (+lastIndexMap[slug] || 0); const hasUnread = newest > lastSeen; const existing = postsEl.querySelector(".unread-sep"); if (!hasUnread) { if (existing) existing.remove(); return; } const anchor = findFirstUnreadElement(postsEl, lastSeen); if (!anchor) { if (existing) existing.remove(); return; } let sep = existing; if (!sep) { sep = document.createElement("div"); sep.className = "unread-sep"; sep.innerHTML = "<span>Unread messages</span>"; } postsEl.insertBefore(sep, anchor); } function refreshUnreadUI(slug, rec = channelViews.get(slug)) { if (!rec) return; updateNewBubble(slug, rec); updateUnreadSeparator(slug, rec); } function maybeMarkSeen(slug, scrollEl, newest) { const nearBottom = isNearBottom(scrollEl); let changed = false; if (nearBottom && newest > 0) { if ((+lastSeenMap[slug] || 0) !== newest) { lastSeenMap[slug] = newest; changed = true; save(); renderTabs(); renderChats(); } } refreshUnreadUI(slug); return changed; } // ===== Open Channel (activate view) ===== function openChannel(inputName) { const slug = normalizeSlug(inputName); if (!slug) { alert("This doesn't look like an @handle.\nRight‑click → 'Rename (@handle)' — enter an @username or a t.me link."); return; } const prev = activeChannelSlug; activeChannelSlug = slug; save(); renderChats(); const item = chatList.querySelector(`.chat-item[data-slug="${slug}"]`); if (item) item.scrollIntoView({ block: "nearest", behavior: "smooth" }); if (prev && prev !== slug) { const prevView = channelViews.get(prev); if (prevView && prevView.loader && typeof prevView.loader.pause === "function") { prevView.loader.pause(); } } chatArea.querySelectorAll(".channel-wrap").forEach((w) => w.style.display = "none"); const hint = chatArea.querySelector(".empty-hint"); if (hint) hint.style.display = "none"; let rec = channelViews.get(slug); const firstTime = !rec; if (!rec) { const view = buildChannelView(slug); rec = { ...view, loader: null, token: 0 }; channelViews.set(slug, rec); } rec.wrap.style.display = "flex"; if (rec.loader && typeof rec.loader.resume === "function") { rec.loader.resume(); } ensureChannelMeta(slug).then(() => updateChannelHeader(slug)).catch(()=>{}); ensureEntityType(slug).catch(()=>{}); refreshUnreadUI(slug, rec); refreshChannelFor(slug, firstTime); } // ===== Sequential Loader (bottom-up posts) ===== function setupSequentialBottomUp(scrollEl, postsEl, slug, lastN, stillActive) { const lazy = setupLazyLoader(scrollEl, slug); let newestLoaded = lastN; let oldestLoaded = lastN; let destroyed = false; let loadingOlder = false; let loadingNewer = false; let paused = false; function isAlive() { return !destroyed && stillActive() && document.body.contains(scrollEl) && document.body.contains(postsEl); } const hasPost = (n) => !!postsEl.querySelector(`.tg-post[data-n="${n}"]`); const touchBounds = (n) => { if (n > newestLoaded) newestLoaded = n; if (n < oldestLoaded) oldestLoaded = n; }; const callUi = () => refreshUnreadUI(slug); function createAtBottom(n, pinBottom = true, forceLoad = false) { if (!isAlive()) return; if (hasPost(n)) { touchBounds(n); return; } const prevNearBottom = isNearBottom(scrollEl); const ph = createPostPlaceholder(n); postsEl.appendChild(ph); lazy.observe(ph); touchBounds(n); if (forceLoad) loadPostInto(ph, slug, n); if (pinBottom || prevNearBottom) scrollEl.scrollTop = scrollEl.scrollHeight; } function prependOlderOne(n, keepViewStable) { if (!isAlive()) return; if (hasPost(n)) { touchBounds(n); return; } const prevH = scrollEl.scrollHeight; const prevTop = scrollEl.scrollTop; const ph = createPostPlaceholder(n); postsEl.insertBefore(ph, postsEl.firstChild); lazy.observe(ph); touchBounds(n); if (keepViewStable) { const newH = scrollEl.scrollHeight; scrollEl.scrollTop = prevTop + (newH - prevH); } } postsEl.innerHTML = ""; createAtBottom(lastN, settings.pinStartAtBottom, true); (async () => { const initialCount = Math.max(1, settings.initialCount); const targetOldest = Math.max(1, lastN - initialCount + 1); for (let n = lastN - 1; n >= targetOldest; n--) { if (!isAlive()) break; prependOlderOne(n, false); if (settings.pinStartAtBottom) scrollEl.scrollTop = scrollEl.scrollHeight; await sleep(settings.loadDelayInitial); } callUi(); })(); const onScroll = async () => { if (!isAlive()) return; let loadedOlder = false; if (scrollEl.scrollTop < LOAD_UP_TRIGGER_PX && !loadingOlder && oldestLoaded > 1) { loadingOlder = true; const batchSize = Math.max(1, settings.olderBatch); const to = Math.max(1, oldestLoaded - batchSize); for (let n = oldestLoaded - 1; n >= to; n--) { if (!isAlive()) break; prependOlderOne(n, true); loadedOlder = true; await sleep(settings.loadDelayScroll); } loadingOlder = false; } if (loadedOlder) callUi(); maybeMarkSeen(slug, scrollEl, newestLoaded); }; scrollEl.addEventListener("scroll", onScroll); async function appendNewer(newLast) { if (loadingNewer || newLast <= newestLoaded) return; loadingNewer = true; const nearBottom = isNearBottom(scrollEl); for (let n = newestLoaded + 1; n <= newLast; n++) { if (!isAlive()) break; createAtBottom(n, nearBottom, nearBottom); await sleep(settings.loadDelayScroll); } loadingNewer = false; callUi(); maybeMarkSeen(slug, scrollEl, newestLoaded); } function pause() { if (paused) return; paused = true; lazy.disconnect(); scrollEl.removeEventListener("scroll", onScroll); } function resume() { if (!paused) return; paused = false; scrollEl.addEventListener("scroll", onScroll); lazy.reconnect(); } function destroy() { destroyed = true; try { scrollEl.removeEventListener("scroll", onScroll); } catch {} try { lazy.disconnect(); } catch {} } return { get oldest() { return oldestLoaded; }, get newest() { return newestLoaded; }, get scrollEl() { return scrollEl; }, appendNewer, pause, resume, destroy, }; } // ===== Refresh Channel (initial + updates) ===== async function refreshChannelFor(slug, initial = false) { const rec = channelViews.get(slug); if (!rec) return; const { statusEl, scrollEl, postsEl } = rec; rec.token = (rec.token || 0) + 1; const myToken = rec.token; const stillActive = () => rec.token === myToken && activeChannelSlug === slug; setStatus(statusEl, initial ? "Looking for the last post…" : "Checking for new posts…"); let last = -2; try { last = await findLastPostForChannel(slug, statusEl, stillActive); } catch (e) { if (e.message === "aborted") return; } if (!stillActive()) return; if (last <= 0 && getType(slug) === 'chat') { const userUrl = prompt( `Could not automatically determine the last message in the chat "${slug}".\n\n` + `This is normal for chats. To continue, paste a link to any (preferably the latest) message from this chat.\n\n` + `Example: https://t.me/${slug}/174`, `https://t.me/${slug}/` ); if (userUrl) { const extractedId = extractIdFromUrl(userUrl); if (extractedId && Number.isFinite(extractedId)) { last = extractedId; setStatus(statusEl, `Using ID #${last} from the provided link.`); await sleep(500); } else { alert("Invalid link. Message ID not found. Loading canceled."); } } } if (last <= 0) { if (last === -1) setStatus(statusEl, "The channel is private or restricted. Posts are unavailable."); else if (last === 0) { lastIndexMap[slug] = 0; save(); postsEl.innerHTML = ""; setStatus(statusEl, "There are no posts in the channel."); renderTabs(); renderChats(); } else setStatus(statusEl, "Search error. A link may be required for chats."); return; } lastIndexMap[slug] = last; save(); renderTabs(); renderChats(); if (!rec.loader) { setStatus(statusEl, `Latest post: #${last}. Rendering…`); const loader = setupSequentialBottomUp(scrollEl, postsEl, slug, last, stillActive); rec.loader = loader; setStatus(statusEl, `Done. Latest: #${last}. Scroll up for older.`); maybeMarkSeen(slug, loader.scrollEl, loader.newest); refreshUnreadUI(slug, rec); } else { if (last > rec.loader.newest) { await rec.loader.appendNewer(last); setStatus(statusEl, `Updated. Latest: #${last}.`); maybeMarkSeen(slug, rec.loader.scrollEl, rec.loader.newest); refreshUnreadUI(slug, rec); } else { setStatus(statusEl, `No new posts. Latest: #${last}.`); } } } // ===== Settings Modal: Open/Close & Save ===== function openSettings() { modal.classList.remove("hidden"); modal.setAttribute("aria-hidden", "false"); } function closeSettings() { modal.classList.add("hidden"); modal.setAttribute("aria-hidden", "true"); } settingsBtn.addEventListener("click", openSettings); modalClose.addEventListener("click", closeSettings); modalBackdrop.addEventListener("click", closeSettings); container.querySelector("#st-save").addEventListener("click", () => { const getNum = (id, min, max, def) => { const v = parseInt(container.querySelector(id).value, 10); return Number.isFinite(v) ? clamp(v, min, max) : def; }; settings.initialCount = getNum("#st-initialCount", 1, 1000, settings.initialCount); settings.olderBatch = getNum("#st-olderBatch", 1, 100, settings.olderBatch); settings.darkTheme = container.querySelector("#st-darkTheme").checked; settings.refreshSec = getNum("#st-refresh", 0, Number.POSITIVE_INFINITY, settings.refreshSec); settings.titleBadge = container.querySelector("#st-titleBadge").checked; settings.loadDelayInitial = getNum("#st-loadDelayInitial", 0, 2000, settings.loadDelayInitial); settings.loadDelayScroll = getNum("#st-loadDelayScroll", 0, 2000, settings.loadDelayScroll); settings.pinStartAtBottom = container.querySelector("#st-pinBottom").checked; save(); closeSettings(); startSidebarPolling(); setTimeout(pollSidebarOnce, 200); if (activeChannelSlug) openChannel(activeChannelSlug); }); container.querySelector("#st-cancel").addEventListener("click", closeSettings); // ===== Export / Import / Reset ===== container.querySelector("#st-export").addEventListener("click", () => { const data = { tabs: savedTabs, activeTab, channels: allChannels, tabMap, mainTab, sidebarWidth, discussionWidth, lastIndexMap, scrollState, lastSeenMap, settings, activeChannel: activeChannelSlug, channelMeta, }; const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "tampergram-config.json"; a.click(); URL.revokeObjectURL(a.href); }); container.querySelector("#st-import").addEventListener("change", async (e) => { const file = e.target.files && e.target.files[0]; if (!file) return; try { const text = await file.text(); const data = JSON.parse(text); if (!Array.isArray(data.channels)) return alert("Invalid import file"); savedTabs = data.tabs || [ALL]; if (!savedTabs.includes(ALL)) savedTabs.unshift(ALL); activeTab = data.activeTab || ALL; allChannels = data.channels || []; tabMap = data.tabMap || {}; mainTab = data.mainTab || ALL; sidebarWidth = data.sidebarWidth || sidebarWidth; discussionWidth = data.discussionWidth || discussionWidth; lastIndexMap = data.lastIndexMap || {}; scrollState = data.scrollState || {}; lastSeenMap = data.lastSeenMap || {}; settings = normalizeSettings(data.settings || settings); activeChannelSlug = data.activeChannel || null; channelMeta = data.channelMeta || {}; for (const [s, v] of channelViews) { try { v.loader?.destroy?.(); } catch {} try { v.wrap.remove(); } catch {} } channelViews.clear(); save(); renderTabs(); renderChats(); closeSettings(); startSidebarPolling(); setTimeout(pollSidebarOnce, 200); if (activeChannelSlug) openChannel(activeChannelSlug); else showEmptyHintIfNoActive(); } catch (err) { alert("Import error: " + err.message); } finally { e.target.value = ""; } }); container.querySelector("#st-reset").addEventListener("click", async () => { if (!confirm("Reset all settings and data?")) return; try { stopSidebarPolling(); if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; } if (typeof GM.listValues === "function") { const keys = await GM.listValues(); await Promise.all(keys.map(k => GM.deleteValue(k))); } else { await GM.deleteValue(CFG_KEY); } } catch (e) { console.error("Failed to reset config:", e); } finally { location.reload(); } }); // ===== Post Context Menu (open/copy link) ===== chatArea.addEventListener("contextmenu", (e) => { const post = e.target.closest(".tg-post"); if (!post) return; e.preventDefault(); const n = post.dataset.n; const slug = activeChannelSlug; if (!slug) return; const url = `https://t.me/${slug}/${n}`; contextMenu.innerHTML = ` <li data-action="open-post" data-url="${url}">🔗 Open original</li> <li data-action="copy-link" data-url="${url}">📋 Copy link</li> `; openContextMenu(e.pageX, e.pageY); }); // ===== Sidebar Polling: Refresh Last IDs & Update UI ===== let sidebarPollTimer = null; let sidebarPolling = false; async function pollSingleChannelLast(slug) { if (!slug) return false; const { last } = await fetchLastIdViaS(slug); if (typeof last === "number" && last >= 0) { const prev = +lastIndexMap[slug]; if (prev !== last) { lastIndexMap[slug] = last; return true; } } return false; } async function pollSingleChatLast(slug) { const base = +lastIndexMap[slug] || 0; if (base <= 0) return false; const exists = (n) => checkExistsWithCache(slug, n).then(r => r.exists); if (!(await exists(base + 1))) return false; const { low, high } = await expandUpperBound(base + 1, PROBE_START, exists, EXP_DELAY_MS); const last = await binarySearchLastTrue(low, high, exists, BISECT_DELAY_MS); if (last > base) { lastIndexMap[slug] = last; return true; } return false; } async function pollSingleEntityLast(slug) { if (!slug) return false; let type = getType(slug); if (!type) { try { type = await ensureEntityType(slug); } catch {} } if (type === "chat") { return await pollSingleChatLast(slug); } return await pollSingleChannelLast(slug); } async function pollSidebarOnce() { if (sidebarPolling || settings.refreshSec <= 0) return; if (document.visibilityState !== "visible") return; if (typeof navigator !== "undefined" && "onLine" in navigator && !navigator.onLine) return; sidebarPolling = true; try { const chans = allChannels.slice().sort(() => Math.random() - 0.5); if (activeChannelSlug) { const i = chans.indexOf(activeChannelSlug); if (i > 0) { chans.splice(i, 1); chans.unshift(activeChannelSlug); } } const changed = await runPool(chans, NET_CONCURRENCY, async (slug) => { try { if (backoffMs > 0) await sleep(backoffMs); const updated = await pollSingleEntityLast(slug); await sleep(220 + Math.floor(Math.random() * 160)); return updated; } catch { return false; } }); if (changed) { save(); renderTabs(); renderChats(); } if (activeChannelSlug) { const rec = channelViews.get(activeChannelSlug); const newLast = +lastIndexMap[activeChannelSlug] || 0; if (rec && rec.loader && newLast > 0 && newLast > rec.loader.newest) { await rec.loader.appendNewer(newLast); if (rec.loader.scrollEl) { maybeMarkSeen(activeChannelSlug, rec.loader.scrollEl, rec.loader.newest); } refreshUnreadUI(activeChannelSlug, rec); } } warmupVisibleMeta(); } finally { sidebarPolling = false; } } function startSidebarPolling() { stopSidebarPolling(); if (settings.refreshSec > 0) { const jitter = Math.floor(Math.random() * 400); const period = Math.max(5, settings.refreshSec) * 1000 + jitter; sidebarPollTimer = setInterval(pollSidebarOnce, period); } } function stopSidebarPolling() { if (sidebarPollTimer) { clearInterval(sidebarPollTimer); sidebarPollTimer = null; } } document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") { startSidebarPolling(); setTimeout(pollSidebarOnce, 500); } else { stopSidebarPolling(); } }); window.addEventListener("online", () => { startSidebarPolling(); setTimeout(pollSidebarOnce, 300); }); window.addEventListener("offline", () => { stopSidebarPolling(); }); // ===== Warmup Visible Meta/Types (uses shared pool) ===== async function warmupVisibleMeta() { const visible = getVisibleChannels(); const targets = visible.filter((s) => needMeta(s) || !getType(s)); if (targets.length === 0) return; await runPool(targets, NET_CONCURRENCY, async (slug) => { try { await Promise.allSettled([ensureChannelMeta(slug), ensureEntityType(slug)]); } catch {} await sleep(120); return false; }); } // ===== Styles Injection (CSS) ===== const style = document.createElement("style"); style.textContent = ` * { box-sizing: border-box; } html, body { margin:0; padding:0; height:100%; overflow:hidden; font-family:"Segoe UI", Arial, sans-serif; background:#181a1f; color:#e1e1e1; } #tg-container { display:flex; height:100vh; width:100vw; } #sidebar { background:#202329; border-right:1px solid #2c2f36; display:flex; flex-direction:column; position:relative; } .sidebar-header { display:flex; align-items:center; gap:8px; padding:10px; border-bottom:1px solid #2c2f36; } .settings { cursor:pointer; font-size:18px; } .settings:hover { opacity:.85; } .search-wrapper { flex:1; position:relative; } .search { width:100%; padding:6px 32px 6px 10px; border:none; border-radius:6px; background:#2c2f36; color:#e1e1e1; } .search::placeholder { color:#888; } .clear-search { position:absolute; right:6px; top:50%; transform:translateY(-50%); background:none; border:none; color:#aaa; font-size:14px; cursor:pointer; padding:2px; } .clear-search:hover { color:#fff; } .clear-search.hidden { display:none; } .tabs-wrapper { display:flex; align-items:center; border-bottom:1px solid #2c2f36; padding:6px; } .tabs { flex:1; display:flex; gap:6px; overflow-x:auto; scrollbar-color:#3a3d44 #202329; scrollbar-width:thin; } .tabs::-webkit-scrollbar { height:6px; } .tabs::-webkit-scrollbar-thumb { background:#3a3d44; border-radius:3px; } .tabs::-webkit-scrollbar-track { background:#202329; } .tab { background:#2a2d34; padding:6px 12px; border-radius:6px; cursor:pointer; transition:background .2s; white-space:nowrap; flex-shrink:0; display:flex; align-items:center; gap:6px; } .tab.active { background:#3390ec; color:white; font-weight:bold; } .tab:hover:not(.active) { background:#3a3d44; } .tab.dragging { opacity:.6; } .tbadge { background:#b8ff99; color:#000; border-radius:10px; padding:0 6px; font-size:12px; line-height:18px; height:18px; display:inline-flex; align-items:center; } .add-tab { margin-left:6px; padding:6px 12px; border:none; background:#3b8ef3; color:white; font-size:16px; cursor:pointer; border-radius:6px; transition:background .2s; flex-shrink:0; } .add-tab:hover { background:#2f7bd6; } .chat-list { flex:1; overflow-y:auto; } .chat-item { position:relative; padding:10px 12px; border-bottom:1px solid #2c2f36; cursor:pointer; transition:background .2s; display:flex; align-items:center; gap:10px; } .chat-item:hover { background:#2d3139; } .chat-item.dragging { opacity:.6; } .chat-item.active { background:#2f3340; } .chat-item.active::before { content: ""; position:absolute; left:0; top:0; bottom:0; width:3px; background:#3390ec; } .ci-ava { width:40px; height:40px; border-radius:50%; overflow:hidden; flex-shrink:0; display:flex; align-items:center; justify-content:center; } .ci-ava-img { width:100%; height:100%; object-fit:cover; display:block; } .ci-ava-fallback { width:40px; height:40px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-weight:700; color:#fff; } .bgc1 { background:#5865F2; } .bgc2 { background:#F26522; } .bgc3 { background:#2AA876; } .bgc4 { background:#FF5A5F; } .bgc5 { background:#3B82F6; } .bgc6 { background:#8B5CF6; } .bgc7 { background:#EAB308; } .bgc8 { background:#10B981; } .ci-main { min-width:0; flex:1; display:flex; flex-direction:column; gap:2px; } .ci-top { display:flex; align-items:center; gap:10px; } .ci-title { font-weight:600; color:#eaeaea; flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .ci-sub { color:#9aa3b2; font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .badge { background:#b8ff99; color:#000; border-radius:10px; padding:0 6px; font-size:12px; margin-left:auto; } mark { background:#3b8ef3; color:#fff; padding:0 3px; border-radius:3px; } #chat-area { flex:1; background:#1c1f26; display:flex; align-items:stretch; justify-content:center; color:#bbb; font-size:14px; position:relative; } #chat-area .empty-hint { margin:auto; color:#666; } #context-menu { position:absolute; background:#2a2d34; border:1px solid #2c2f36; list-style:none; padding:4px 0; margin:0; z-index:1000; min-width:260px; border-radius:6px; box-shadow:0 4px 12px rgba(0,0,0,0.4); } #context-menu li { padding:8px 14px; cursor:pointer; white-space:nowrap; transition:background .2s; } #context-menu li:hover { background:#3a3d44; } #context-menu.hidden { display:none; } #context-menu li.submenu { position:relative; } .submenu-list { display:none; position:absolute; top:0; left:100%; background:#2a2d34; border:1px solid #2c2f36; border-radius:6px; list-style:none; padding:4px 0; margin:0; min-width:180px; z-index:2000; } #context-menu li.submenu:hover > .submenu-list { display:block; } .submenu-list li { padding:8px 14px; } .submenu-list li:hover { background:#3a3d44; } .add-channel { position:absolute; bottom:16px; right:16px; width:44px; height:44px; border-radius:50%; border:none; background:#3b8ef3; color:white; font-size:20px; cursor:pointer; box-shadow:0 2px 6px rgba(0,0,0,0.4); transition:background .2s, transform .1s; display:inline-flex; align-items:center; justify-content:center; } .add-channel:hover { background:#2f7bd6; transform:scale(1.05); } .sidebar-resizer { position:absolute; top:0; right:0; width:6px; height:100%; cursor:col-resize; background:transparent; } .sidebar-resizer:hover { background:rgba(255,255,255,0.05); } .channel-wrap { display:flex; flex-direction:column; height:100%; width:100%; position:relative; } .channel-header { display:flex; align-items:center; justify-content:center; padding:10px 14px; border-bottom:1px solid #2c2f36; background:#1f222a; } .ch-wrap { display:flex; align-items:center; gap:12px; } .ch-ava { width:36px; height:36px; border-radius:50%; overflow:hidden; display:flex; align-items:center; justify-content:center; } .ch-ava-img { width:100%; height:100%; object-fit:cover; display:block; } .ch-ava-fallback { width:36px; height:36px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-weight:700; color:#fff; } .ch-main { display:flex; flex-direction:column; line-height:1.2; } .ch-title { font-weight:600; color:#e1e1e1; } .ch-sub { font-size:12px; color:#9aa3b2; } .status-line { padding:8px 14px; color:#aeb4be; border-bottom:1px solid #2c2f36; background:#1d2027; font-size:13px; } .content-row { flex:1; display:flex; min-height:0; } .posts-scroll { flex:1; overflow-y:auto; padding:0; } .posts { display:flex; flex-direction:column; gap:8px; } .discussion-resizer { width:6px; cursor:col-resize; background:transparent; } .discussion-resizer:hover { background:rgba(255,255,255,0.05); } .discussion-resizer.hidden { display:none; } .discussion-panel { width:420px; max-width:70vw; min-width:280px; display:flex; flex-direction:column; border-left:1px solid #2c2f36; background:#1f222a; } .discussion-panel.hidden { display:none; } .discussion-header { display:flex; align-items:center; justify-content:space-between; padding:8px 12px; border-bottom:1px solid #2c2f36; } .discussion-title { font-weight:600; color:#e1e1e1; } .discussion-close { background:none; border:none; color:#e1e1e1; cursor:pointer; font-size:16px; } .discussion-close:hover { color:#fff; } .discussion-body { flex:1; overflow-y:auto; padding:8px; } .tg-post { display:flex; align-items:flex-start; gap:8px; } .tg-post iframe { display:block; width:100% !important; min-width:0 !important; border:0 !important; } .post-inner { flex:1 1 auto; min-width:0; position:relative; } .post-skel { color:#8a93a3; font-size:13px; padding:0; } .post-aside { width:52px; flex:0 0 52px; display:flex; align-items:flex-start; justify-content:flex-end; padding-right:6px; padding-top:4px; } .post-discuss-btn { width:44px; height:44px; border-radius:50%; border:none; background:#3b8ef3; color:white; font-size:18px; cursor:pointer; box-shadow:0 2px 6px rgba(0,0,0,0.4); transition:background .2s, transform .1s, opacity .15s; display:inline-flex; align-items:center; justify-content:center; } .post-discuss-btn:hover { background:#2f7bd6; transform:scale(1.05); } .tg-post { position: relative; display: block; text-align: center; } .post-inner { position: relative; display: inline-block; text-align: left; width: var(--post-width, auto); max-width: 100%; } .post-discuss-btn { position: absolute; bottom: 8px; left: calc(100% - 24px) !important; z-index: 5; } .tg-post iframe { width: 100% !important; max-width: 100% !important; min-width: 0 !important; border: 0 !important; } #settings-modal.hidden { display:none; } #settings-modal { position:fixed; inset:0; z-index:2000; } .settings-backdrop { position:absolute; inset:0; background:rgba(0,0,0,0.5); } .settings-panel { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:520px; max-width:90vw; background:#23262e; border:1px solid #2c2f36; border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,.5); } .settings-header { display:flex; align-items:center; justify-content:space-between; padding:12px; border-bottom:1px solid #2c2f36; } .settings-title { font-weight:600; font-size:16px; } .settings-close { background:none; border:none; color:#e1e1e1; font-size:18px; cursor:pointer; } .settings-content { padding:12px; } .settings-row { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:8px 0; } .settings-row.two-col { display:grid; grid-template-columns: repeat(3, 1fr); gap:12px; } .settings-row label { color:#fff; } .settings-actions { display:flex; gap:8px; padding:8px 0; } #st-save, #st-cancel, #st-export, #st-reset, .import-label { background:#2f7bd6; color:#fff; border:none; border-radius:6px; padding:6px 12px; cursor:pointer; display:inline-flex; align-items:center; justify-content:center; font-family:inherit; font-size:14px; transition: background-color 0.2s, opacity 0.2s; flex:1; } #st-save:hover, #st-export:hover, .import-label:hover { background-color:#2566b0; } #st-cancel { background:#525a6b; } #st-cancel:hover { background-color:#414856; } #st-reset { background:#b8ff99; color:#000; } #st-reset:hover { opacity:.9; } .new-bubble { position:absolute; right:18px; bottom:18px; background:#3390ec; color:#fff; border:none; padding:8px 12px; border-radius:18px; box-shadow:0 6px 16px rgba(0,0,0,0.35); font-weight:600; cursor:pointer; z-index:20; transition: transform .08s ease, opacity .12s ease, background-color .2s; } .new-bubble:hover { background:#2f7bd6; transform: translateY(-1px); } .new-bubble.hidden { display:none; } .unread-sep { position:relative; display:flex; align-items:center; justify-content:center; gap:10px; margin:6px 0; color:#b8ff99; font-weight:600; font-size:12px; user-select:none; } .unread-sep::before, .unread-sep::after { content:""; flex:1; height:1px; background:#2c2f36; } .unread-sep > span { border:1px solid #2c2f36; background:#1c1f26; border-radius:999px; padding:2px 8px; } `; document.head.appendChild(style); // ===== Init: State Reset, Render & Start Polling ===== activeChannelSlug = null; save(true); renderTabs(); renderChats(); startSidebarPolling(); pollSidebarOnce(); showEmptyHintIfNoActive(); })();