TamperGram

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) => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" }[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();
})();