Scratch Studio Realtime Comments

Shows new Scratch studio comments without a manual reload.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

Advertisement:

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

Advertisement:

// ==UserScript==
// @name            Scratch Studio Realtime Comments
// @name:ja         Scratch スタジオ リアルタイムコメント
// @namespace       https://github.com/mokuzyy/scratch-studio-realtime-comments
// @version         0.1.0
// @description     Shows new Scratch studio comments without a manual reload.
// @description:ja  Scratch のスタジオコメント欄で、新規コメント/返信を手動リロードなしにリアルタイム表示します。
// @author          mokuzyy
// @license         MIT
// @match           https://scratch.mit.edu/studios/*
// @run-at          document-idle
// @grant           GM_xmlhttpRequest
// @grant           GM_getValue
// @grant           GM_setValue
// @grant           GM_addStyle
// @grant           GM_registerMenuCommand
// @grant           GM_addValueChangeListener
// @connect         api.scratch.mit.edu
// ==/UserScript==

(function () {
  "use strict";

  // Inject the extension stylesheet (manifest "css" replacement).
  GM_addStyle(".srtc-managed-top {\n  display: block;\n  width: 100%;\n}\n\n.srtc-comment-container {\n  animation: srtc-fade-in 180ms ease-out;\n}\n\n.srtc-comment .comment-content a {\n  overflow-wrap: anywhere;\n}\n\n/* Native avatars get their size from .avatar-wrapper (--avatar-size: 3rem),\n   which our synthetic nodes don't have. Pin the size so it matches Scratch. */\n.srtc-comment-avatar {\n  flex: 0 0 auto;\n  display: block;\n  width: 3rem;\n  height: 3rem;\n}\n\n.srtc-comment-avatar .avatar {\n  width: 3rem;\n  height: 3rem;\n  object-fit: cover;\n  border-radius: 4px;\n  box-shadow: 0 0 0 1px rgba(77, 151, 255, 0.25);\n}\n\n.srtc-reply-button {\n  appearance: none;\n  border: 0;\n  background: transparent;\n  padding: 0;\n  margin-left: 0.5rem;\n  color: #4c97ff;\n  font: inherit;\n  cursor: pointer;\n}\n\n.srtc-reply-button:hover {\n  text-decoration: underline;\n}\n\n.srtc-reply-button:focus-visible {\n  outline: 3px solid rgba(76, 151, 255, 0.45);\n  outline-offset: 2px;\n  border-radius: 4px;\n}\n\n.srtc-reply-compose {\n  box-sizing: border-box;\n  margin-top: 0.5rem;\n}\n\n.srtc-reply-compose[hidden] {\n  display: none;\n}\n\n.srtc-reply-compose textarea {\n  box-sizing: border-box;\n  width: 100%;\n  min-height: 5.25rem;\n  resize: vertical;\n  border: 1px solid rgba(0, 0, 0, 0.15);\n  border-radius: 6px;\n  padding: 0.5rem;\n  font: inherit;\n}\n\n.srtc-reply-actions {\n  display: flex;\n  align-items: center;\n  gap: 0.5rem;\n  margin-top: 0.4rem;\n}\n\n.srtc-reply-post:disabled {\n  cursor: wait;\n  opacity: 0.6;\n}\n\n.srtc-reply-cancel {\n  appearance: none;\n  border: 0;\n  background: transparent;\n  padding: 0.25rem 0.4rem;\n  color: #575e75;\n  font: inherit;\n  cursor: pointer;\n}\n\n.srtc-reply-error {\n  margin-top: 0.35rem;\n  color: #e5395d;\n  font-size: 0.8rem;\n}\n\n.srtc-reply-error[hidden] {\n  display: none;\n}\n\n.srtc-new-replies-badge {\n  display: inline-flex;\n  align-items: center;\n  margin-left: 0.75rem;\n  border: 1px solid rgba(76, 151, 255, 0.25);\n  border-radius: 999px;\n  background: rgba(76, 151, 255, 0.08);\n  padding: 0.125rem 0.45rem;\n  color: #575e75;\n  font-size: 0.72rem;\n  line-height: 1.1rem;\n  white-space: nowrap;\n}\n\n.srtc-new-replies-badge {\n  border-color: rgba(15, 189, 140, 0.35);\n  background: rgba(15, 189, 140, 0.12);\n  color: #0f8f6c;\n  font-weight: 700;\n}\n\n.srtc-status-button {\n  position: fixed;\n  right: 12px;\n  bottom: 12px;\n  z-index: 2147483000;\n  margin: 0;\n  border: 1px solid rgba(0, 0, 0, 0.12);\n  border-radius: 999px;\n  background: #ffffff;\n  padding: 0.25rem 0.55rem;\n  color: #575e75;\n  font-size: 0.75rem;\n  font-weight: 700;\n  line-height: 1rem;\n  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);\n  cursor: pointer;\n}\n\n.srtc-status-button[data-state=\"on\"] {\n  border-color: rgba(15, 189, 140, 0.4);\n  background: rgba(15, 189, 140, 0.12);\n  color: #0f8f6c;\n}\n\n.srtc-status-button[data-state=\"off\"] {\n  background: rgba(0, 0, 0, 0.06);\n  color: #575e75;\n}\n\n.srtc-status-button[data-state=\"error\"] {\n  border-color: rgba(255, 140, 26, 0.4);\n  background: rgba(255, 140, 26, 0.12);\n  color: #a55800;\n}\n\n.srtc-status-button:focus-visible {\n  outline: 3px solid rgba(76, 151, 255, 0.45);\n  outline-offset: 2px;\n}\n\n@media only screen and (max-width: 479px) {\n  .srtc-new-replies-badge {\n    margin-left: 0.35rem;\n    font-size: 0.68rem;\n  }\n}\n\n@keyframes srtc-fade-in {\n  from {\n    opacity: 0;\n    transform: translateY(-4px);\n  }\n\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n");

  // ---- Ported from src/background.js (CORS-free GET via GM_xmlhttpRequest) ----
  const API_ORIGIN = "https://api.scratch.mit.edu";
  function buildApiUrl(p) {
    if (typeof p !== "string" || !p.startsWith("/studios/")) {
      throw new Error("Unsupported Scratch API path.");
    }
    if (p.includes("://") || p.startsWith("//")) {
      throw new Error("Absolute URLs are not accepted.");
    }
    return new URL(p, API_ORIGIN).toString();
  }
  function gmFetchScratchApi(reqPath) {
    return new Promise(function (resolve) {
      let url;
      try { url = buildApiUrl(reqPath); }
      catch (e) { resolve({ ok: false, status: 0, error: e && e.message ? e.message : String(e) }); return; }
      GM_xmlhttpRequest({
        method: "GET",
        url: url,
        headers: { "Accept": "application/json" },
        anonymous: true,
        onload: function (res) {
          const text = res.responseText || "";
          let data = null;
          if (text) { try { data = JSON.parse(text); } catch (_e) { data = { raw: text }; } }
          const rh = String(res.responseHeaders || "");
          const cc = (rh.match(/^\s*cache-control\s*:\s*(.+?)\s*$/im) || [])[1] || "";
          const age = (rh.match(/^\s*age\s*:\s*(.+?)\s*$/im) || [])[1] || "";
          resolve({ ok: res.status >= 200 && res.status < 300, status: res.status, data: data, cacheControl: cc, age: age });
        },
        onerror: function () { resolve({ ok: false, status: 0, error: "network error" }); },
        ontimeout: function () { resolve({ ok: false, status: 0, error: "timeout" }); }
      });
    });
  }

  // ---- Minimal chrome.* shim so the unchanged core runs under a userscript ----
  const storageListeners = [];
  function fireChange(changes) {
    for (const fn of storageListeners) { try { fn(changes, "sync"); } catch (_e) {} }
  }
  const chrome = {
    runtime: {
      get lastError() { return null; },
      sendMessage: function (message, cb) {
        if (message && message.type === "scratch-api-fetch") {
          gmFetchScratchApi(message.path).then(cb).catch(function (e) {
            cb({ ok: false, status: 0, error: e && e.message ? e.message : String(e) });
          });
        }
      },
      onMessage: { addListener: function () {} }
    },
    storage: {
      sync: {
        get: function (defaults, cb) {
          const out = {};
          for (const k in defaults) out[k] = GM_getValue(k, defaults[k]);
          cb(out);
        },
        set: function (obj, cb) {
          const changes = {};
          for (const k in obj) { GM_setValue(k, obj[k]); changes[k] = { newValue: obj[k] }; }
          fireChange(changes);
          if (cb) cb();
        }
      },
      onChanged: { addListener: function (fn) { storageListeners.push(fn); } }
    }
  };

  // Cross-tab settings sync (other tabs / the menu in another tab).
  if (typeof GM_addValueChangeListener === "function") {
    ["enabled", "lowLoadMode"].forEach(function (key) {
      GM_addValueChangeListener(key, function (name, _old, newValue, remote) {
        if (remote) fireChange({ [name]: { newValue: newValue } });
      });
    });
  }

  // ===================== embedded core (verbatim) =====================

(function attachShared(global) {
  "use strict";

  const DEFAULT_SETTINGS = Object.freeze({
    enabled: true,
    lowLoadMode: false
  });

  // Top comments are polled fast; replies for open/changed threads on a budget.
  const TOP_COMMENT_LIMIT = 20;
  const REPLY_LIMIT = 25;
  // How often we re-fetch top comments (page 0) from the API.
  const TOP_REFRESH_MS = 1000;
  const LOW_LOAD_TOP_REFRESH_MS = 5000;
  // How often the reply scheduler re-fetches a thread's replies.
  const REPLY_REFRESH_MS = 1000;
  const LOW_LOAD_REPLY_REFRESH_MS = 60000;
  // How often the relative-time labels are re-rendered locally (no API calls).
  const REPLY_TICK_MS = 1000;
  // Number of top-comment pages to walk when first taking ownership of the list.
  const MAX_INITIAL_PAGES = 10;
  // Max simultaneous in-flight API requests (top pages + reply fetches).
  const API_CONCURRENCY = 6;
  // Cap on top-comment pages scanned per cycle for reply_count coverage.
  const MAX_COVERAGE_PAGES = 6;
  // Cap on reply pages walked in a single thread fetch (burst protection).
  const REPLY_MAX_PAGES = 8;

  // How many top-comment pages are needed to cover `threadCount` threads,
  // clamped to [1, maxPages]. Pure helper (unit-tested).
  function coveragePageCount(threadCount, limit, maxPages) {
    const perPage = limit > 0 ? limit : 1;
    const needed = Math.ceil((Number(threadCount) || 0) / perPage);
    return Math.min(Math.max(needed, 1), maxPages);
  }

  // U+00A0 non-breaking space, built from its code point to avoid stray bytes.
  const NBSP = String.fromCharCode(0xa0);

  function getStudioIdFromPath(pathname) {
    const match = String(pathname || "").match(/^\/studios\/(\d+)\/comments\/?$/);
    return match ? match[1] : null;
  }

  function isStudioCommentsPath(pathname) {
    return Boolean(getStudioIdFromPath(pathname));
  }

  function decodeHtmlEntities(value) {
    const raw = String(value || "");
    if (!raw || !/[&<>]/.test(raw)) return raw;

    const named = {
      amp: "&",
      lt: "<",
      gt: ">",
      quot: "\"",
      apos: "'",
      nbsp: NBSP,
      "#39": "'"
    };

    return raw.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);/g, (full, entity) => {
      const key = entity.toLowerCase();
      if (Object.prototype.hasOwnProperty.call(named, key)) return named[key];
      if (key[0] === "#") {
        const isHex = key[1] === "x";
        const number = Number.parseInt(key.slice(isHex ? 2 : 1), isHex ? 16 : 10);
        if (Number.isFinite(number)) {
          try {
            return String.fromCodePoint(number);
          } catch (_error) {
            return full;
          }
        }
      }
      return full;
    });
  }

  function normalizeText(value) {
    return decodeHtmlEntities(value)
      .split(NBSP).join(" ")
      .replace(/\s+/g, " ")
      .trim();
  }

  function normalizeComment(raw) {
    if (!raw || typeof raw !== "object") return null;
    const id = Number(raw.id);
    if (!Number.isFinite(id)) return null;

    const author = raw.author && typeof raw.author === "object" ? raw.author : {};
    return {
      id,
      parent_id: raw.parent_id === null || raw.parent_id === undefined || raw.parent_id === ""
        ? null
        : Number(raw.parent_id),
      commentee_id: raw.commentee_id === null || raw.commentee_id === undefined || raw.commentee_id === ""
        ? null
        : Number(raw.commentee_id),
      content: decodeHtmlEntities(raw.content || ""),
      datetime_created: String(raw.datetime_created || ""),
      datetime_modified: String(raw.datetime_modified || ""),
      visibility: String(raw.visibility || "visible"),
      author: {
        id: Number(author.id) || 0,
        username: String(author.username || ""),
        scratchteam: Boolean(author.scratchteam),
        image: String(author.image || "")
      },
      reply_count: Number(raw.reply_count) || 0
    };
  }

  function compareNewestFirst(a, b) {
    const aTime = Date.parse(a.datetime_created || "");
    const bTime = Date.parse(b.datetime_created || "");
    if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) {
      return bTime - aTime;
    }
    return Number(b.id || 0) - Number(a.id || 0);
  }

  function compareOldestFirst(a, b) {
    return compareNewestFirst(b, a);
  }

  // Pure list reconciliation used by the renderer and unit tests.
  // `known` is a Map<id, comment>; `incoming` is an array of comments.
  // Returns the comments to add, the comments whose content changed, and the
  // ids that disappeared. `incoming` is assumed to cover the same window as
  // `known` (e.g. page 0), so callers decide whether a removal is a real
  // deletion or just an id that paged out of the polled window.
  function computeListDelta(known, incoming) {
    const added = [];
    const updated = [];
    const seen = new Set();

    for (const comment of incoming) {
      if (!comment) continue;
      seen.add(comment.id);
      const previous = known.get(comment.id);
      if (!previous) {
        added.push(comment);
      } else if (
        previous.content !== comment.content ||
        previous.visibility !== comment.visibility ||
        previous.reply_count !== comment.reply_count
      ) {
        updated.push(comment);
      }
    }

    const removed = [];
    for (const id of known.keys()) {
      if (!seen.has(id)) removed.push(id);
    }

    return { added, updated, removed };
  }

  function formatRelativeTime(datetime, now, locale) {
    const timestamp = Date.parse(datetime || "");
    if (!Number.isFinite(timestamp)) return "";

    const current = Number.isFinite(now) ? now : Date.now();
    const deltaSeconds = (timestamp - current) / 1000;
    const abs = Math.abs(deltaSeconds);
    const units = [
      ["year", 31536000],
      ["month", 2592000],
      ["week", 604800],
      ["day", 86400],
      ["hour", 3600],
      ["minute", 60],
      ["second", 1]
    ];
    const unit = units.find(([, seconds]) => abs >= seconds) || units[units.length - 1];
    const amount = Math.floor(abs / unit[1]);
    const signedAmount = timestamp <= current ? -amount : amount;

    try {
      return new Intl.RelativeTimeFormat(locale || "en", { numeric: "always" }).format(signedAmount, unit[0]);
    } catch (_error) {
      return new Date(timestamp).toLocaleString();
    }
  }

  function formatAbsoluteTime(datetime, locale) {
    const timestamp = Date.parse(datetime || "");
    if (!Number.isFinite(timestamp)) return "";
    try {
      return new Date(timestamp).toLocaleString(locale || "en");
    } catch (_error) {
      return new Date(timestamp).toISOString();
    }
  }

  function createBackoff(options) {
    const initial = options && options.initial ? options.initial : 1000;
    const max = options && options.max ? options.max : 30000;
    let current = 0;

    return {
      fail() {
        current = current ? Math.min(current * 2, max) : initial;
        return current;
      },
      reset() {
        current = 0;
      },
      get current() {
        return current;
      }
    };
  }

  const api = {
    DEFAULT_SETTINGS,
    TOP_COMMENT_LIMIT,
    REPLY_LIMIT,
    TOP_REFRESH_MS,
    LOW_LOAD_TOP_REFRESH_MS,
    REPLY_REFRESH_MS,
    LOW_LOAD_REPLY_REFRESH_MS,
    REPLY_TICK_MS,
    MAX_INITIAL_PAGES,
    API_CONCURRENCY,
    MAX_COVERAGE_PAGES,
    REPLY_MAX_PAGES,
    coveragePageCount,
    getStudioIdFromPath,
    isStudioCommentsPath,
    decodeHtmlEntities,
    normalizeText,
    normalizeComment,
    compareNewestFirst,
    compareOldestFirst,
    computeListDelta,
    formatRelativeTime,
    formatAbsoluteTime,
    createBackoff
  };

  if (typeof module !== "undefined" && module.exports) {
    module.exports = api;
  } else {
    global.SRTCShared = api;
  }
})(typeof globalThis !== "undefined" ? globalThis : this);

(function attachContentScript() {
  "use strict";

  const Shared = globalThis.SRTCShared;
  if (!Shared) return;

  // The script runs on every /studios/* page (so it survives SPA tab navigation
  // into the comments tab); the studio id is resolved per cycle from the URL and
  // work only happens on a `/studios/<id>/comments` path (requirement ①).
  const LOCALE = (navigator && navigator.language) || "en";

  // Lightweight i18n for our on-page UI so it matches the locale of the native
  // Scratch UI it sits next to (instead of mixing English controls with
  // Japanese messages). Falls back to English for unknown locales.
  const STRINGS = {
    en: {
      reply: "reply", post: "Post", cancel: "Cancel", placeholder: "Write a reply…",
      needContent: "Please enter a message.", sendFailed: "Failed to send",
      needLogin: "You must be logged in (couldn't get a session token).",
      noStudio: "Couldn't determine the studio.",
      live: "● live", retrying: "● retrying", off: "○ off"
    },
    ja: {
      reply: "返信", post: "投稿する", cancel: "キャンセル", placeholder: "返信を書く…",
      needContent: "本文を入力してください。", sendFailed: "送信に失敗しました",
      needLogin: "ログインが必要です(セッションを取得できません)。",
      noStudio: "スタジオを特定できません。",
      live: "● ライブ", retrying: "● 再試行", off: "○ 停止"
    }
  };
  const T = STRINGS[String(LOCALE).slice(0, 2).toLowerCase()] || STRINGS.en;
  // Scratch comments have a 500-character limit.
  const MAX_COMMENT_LENGTH = 500;

  const state = {
    settings: Object.assign({}, Shared.DEFAULT_SETTINGS),
    running: false,
    studioId: null,
    listEl: null,
    seededListEl: null,
    knownTopIds: new Set(),
    // id -> thread record (see ensureThread)
    threads: new Map(),
    statusEl: null,
    statusState: "off",
    pollTimer: null,
    tickTimer: null,
    inFlight: false,
    cdnCached: false,
    rateLimited: false,
    nonce: 0,
    backoff: Shared.createBackoff({ initial: 1000, max: 30000 })
  };

  // ---------------------------------------------------------------------------
  // API access (delegated to the background service worker to avoid CORS).
  // ---------------------------------------------------------------------------
  function apiFetch(path) {
    return new Promise(resolve => {
      try {
        chrome.runtime.sendMessage({ type: "scratch-api-fetch", path }, response => {
          if (chrome.runtime.lastError) {
            resolve({ ok: false, status: 0, error: chrome.runtime.lastError.message });
            return;
          }
          resolve(response || { ok: false, status: 0, error: "empty response" });
        });
      } catch (error) {
        resolve({ ok: false, status: 0, error: error && error.message ? error.message : String(error) });
      }
    });
  }

  // Once we detect the API is CDN-cached, append a unique param to bust it so
  // polling reflects fresh data (decision: auto-detect via Cache-Control).
  function withCacheBust(path) {
    if (!state.cdnCached) return path;
    const sep = path.includes("?") ? "&" : "?";
    return `${path}${sep}_=${Date.now()}-${state.nonce++}`;
  }

  function noteCacheHeaders(response) {
    if (state.cdnCached || !response) return;
    const cc = String(response.cacheControl || "").toLowerCase();
    const maxAge = cc.match(/(?:s-maxage|max-age)=(\d+)/);
    const cached = (maxAge && Number(maxAge[1]) > 0) || (response.age && Number(response.age) > 0);
    if (cached) state.cdnCached = true;
  }

  async function fetchTopPage(offset) {
    const response = await apiFetch(withCacheBust(
      `/studios/${state.studioId}/comments?offset=${offset}&limit=${Shared.TOP_COMMENT_LIMIT}`
    ));
    noteCacheHeaders(response);
    return response;
  }

  async function fetchReplyPage(commentId, offset) {
    const response = await apiFetch(withCacheBust(
      `/studios/${state.studioId}/comments/${commentId}/replies?offset=${offset}&limit=${Shared.REPLY_LIMIT}`
    ));
    noteCacheHeaders(response);
    return response;
  }

  // Run `worker` over `items` with at most `concurrency` in flight at once.
  // Results are returned in input order (callers rely on index alignment).
  async function runPool(items, worker, concurrency) {
    const results = new Array(items.length);
    let next = 0;
    async function drain() {
      while (true) {
        const i = next;
        next += 1;
        if (i >= items.length) break;
        results[i] = await worker(items[i]);
      }
    }
    const runnerCount = Math.min(Math.max(1, concurrency), items.length);
    const runners = [];
    for (let i = 0; i < runnerCount; i += 1) runners.push(drain());
    await Promise.all(runners);
    return results;
  }

  // ---------------------------------------------------------------------------
  // Posting (replies). Mirrors scratch-www: GET /session/ for the token, then
  // POST to the studio comments proxy with X-Token + X-CSRFToken, same as the
  // site does from its own page JS (so CORS/credentials behave identically).
  // ---------------------------------------------------------------------------
  let cachedToken = null;
  let tokenFetchedAt = 0;

  async function getSessionToken() {
    if (cachedToken && Date.now() - tokenFetchedAt < 5 * 60 * 1000) return cachedToken;
    try {
      const response = await fetch("/session/", {
        credentials: "include",
        headers: { "Accept": "application/json", "X-Requested-With": "XMLHttpRequest" }
      });
      if (!response.ok) return null;
      const data = await response.json();
      cachedToken = data && data.user && data.user.token ? data.user.token : null;
      tokenFetchedAt = Date.now();
      return cachedToken;
    } catch (_error) {
      return null;
    }
  }

  function getCsrfToken() {
    const match = document.cookie.match(/(?:^|;\s*)scratchcsrftoken=([^;]+)/);
    return match ? match[1] : "";
  }

  async function postReply(parentId, commenteeId, content) {
    if (!state.studioId) return { ok: false, error: T.noStudio };
    const token = await getSessionToken();
    if (!token) return { ok: false, error: T.needLogin };
    try {
      const response = await fetch(`https://api.scratch.mit.edu/proxy/comments/studio/${state.studioId}`, {
        method: "POST",
        credentials: "include",
        headers: {
          "Content-Type": "application/json",
          "X-Token": token,
          "X-CSRFToken": getCsrfToken()
        },
        body: JSON.stringify({
          content,
          parent_id: parentId || "",
          commentee_id: commenteeId || ""
        })
      });
      const text = await response.text();
      let data = null;
      if (text) {
        try { data = JSON.parse(text); } catch (_error) { data = { raw: text }; }
      }
      if (!response.ok) {
        // Token may be stale (re-login, expiry) — drop the cache so the next
        // attempt re-fetches a fresh one from /session/.
        if (response.status === 401 || response.status === 403) {
          cachedToken = null;
          tokenFetchedAt = 0;
        }
        const reason = (data && (data.message || data.error)) || `HTTP ${response.status}`;
        return { ok: false, status: response.status, error: reason, data };
      }
      return { ok: true, data };
    } catch (error) {
      return { ok: false, error: error && error.message ? error.message : String(error) };
    }
  }

  function normalizeList(data) {
    if (!Array.isArray(data)) return [];
    const out = [];
    for (const raw of data) {
      const comment = Shared.normalizeComment(raw);
      if (comment) out.push(comment);
    }
    return out;
  }

  // ---------------------------------------------------------------------------
  // DOM helpers.
  // ---------------------------------------------------------------------------
  function getScroller() {
    return document.scrollingElement || document.documentElement;
  }

  // The list element is the shared parent of the rendered .comment-container nodes.
  function acquireList() {
    const first = document.querySelector(".comment-container");
    const list = first ? first.parentElement : null;
    if (list && list !== state.seededListEl) {
      // A fresh list (first load or SPA navigation back to comments): re-baseline.
      resetForNewList(list);
    }
    state.listEl = list;
    return list;
  }

  function resetForNewList(list) {
    state.knownTopIds = new Set();
    state.threads = new Map();
    state.seededListEl = list;
    state.backoff.reset();
  }

  function nativeUsername(containerEl) {
    const link = containerEl.querySelector(".comment-body .username");
    return link ? Shared.normalizeText(link.textContent) : "";
  }

  function nativeContentText(containerEl) {
    const content = containerEl.querySelector(".comment-bubble .comment-content");
    return content ? Shared.normalizeText(content.textContent) : "";
  }

  function buildAnchorsFromText(text, target) {
    // Render plain text with safe http(s) links — never via innerHTML.
    const pattern = /(https?:\/\/[^\s<>"')]+)/g;
    let lastIndex = 0;
    let match;
    while ((match = pattern.exec(text)) !== null) {
      if (match.index > lastIndex) {
        target.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
      }
      const anchor = document.createElement("a");
      anchor.href = match[0];
      anchor.textContent = match[0];
      anchor.rel = "nofollow noopener";
      target.appendChild(anchor);
      lastIndex = match.index + match[0].length;
    }
    if (lastIndex < text.length) {
      target.appendChild(document.createTextNode(text.slice(lastIndex)));
    }
  }

  // Build a node that mirrors Scratch's native comment markup so the site CSS
  // styles it (requirement ②). `managedTop` marks a top-level synthetic node.
  function buildCommentNode(comment, opts) {
    opts = opts || {};
    const container = document.createElement("div");
    container.className = "comment-container srtc-comment-container";
    if (opts.managedTop) container.classList.add("srtc-managed-top");
    container.setAttribute("data-srtc-id", String(comment.id));

    const row = document.createElement("div");
    row.className = "flex-row comment srtc-comment";

    const username = comment.author.username || "";
    const avatarLink = document.createElement("a");
    avatarLink.href = username ? `/users/${encodeURIComponent(username)}/` : "#";
    avatarLink.className = "comment-avatar srtc-comment-avatar";
    if (comment.author.image) {
      const avatar = document.createElement("img");
      avatar.className = "avatar";
      avatar.src = comment.author.image;
      avatar.alt = username;
      avatarLink.appendChild(avatar);
    }
    row.appendChild(avatarLink);

    const body = document.createElement("div");
    body.className = "comment-body column";

    const userLink = document.createElement("a");
    userLink.className = "username";
    userLink.href = username ? `/users/${encodeURIComponent(username)}/` : "#";
    userLink.textContent = username;
    body.appendChild(userLink);

    const bubble = document.createElement("div");
    bubble.className = "comment-bubble";
    const content = document.createElement("div");
    content.className = "comment-content";
    buildAnchorsFromText(comment.content, content);
    bubble.appendChild(content);
    body.appendChild(bubble);

    const bottom = document.createElement("div");
    bottom.className = "flex-row comment-bottom-row";
    const time = document.createElement("span");
    time.className = "comment-time";
    time.setAttribute("data-srtc-created", comment.datetime_created);
    time.title = Shared.formatAbsoluteTime(comment.datetime_created, LOCALE);
    time.textContent = Shared.formatRelativeTime(comment.datetime_created, Date.now(), LOCALE);
    bottom.appendChild(time);
    body.appendChild(bottom);

    // Our synthetic nodes aren't in Scratch's React state, so the native reply
    // box can't open for them — attach our own. Studio replies are flat, so a
    // reply (even a reply-to-a-reply) always targets the TOP-level comment, with
    // commentee = the author being replied to.
    if (opts.reply) {
      attachReplyUi({
        body: body,
        bottom: bottom,
        topContainer: opts.reply.topContainer || container,
        parentId: opts.reply.parentId,
        commenteeId: comment.author.id
      });
    }

    row.appendChild(body);
    container.appendChild(row);
    return container;
  }

  function ensureSyntheticReplies(container) {
    let replies = container.querySelector(":scope > .replies");
    if (!replies) {
      replies = document.createElement("div");
      replies.className = "replies column srtc-synthetic-replies";
      container.appendChild(replies);
    }
    return replies;
  }

  // config: { body, bottom, topContainer, parentId, commenteeId }
  // parentId is always the TOP-level comment id; topContainer is that comment's
  // node (its `.replies` list is where the new reply is shown).
  function attachReplyUi(config) {
    const replyButton = document.createElement("button");
    replyButton.type = "button";
    replyButton.className = "comment-reply srtc-reply-button";
    replyButton.textContent = T.reply;
    config.bottom.appendChild(replyButton);

    const compose = document.createElement("div");
    compose.className = "srtc-reply-compose";
    compose.hidden = true;

    const textarea = document.createElement("textarea");
    textarea.placeholder = T.placeholder;
    textarea.maxLength = MAX_COMMENT_LENGTH;
    // No "@username" prefill — threading is handled by commentee_id in the POST.
    textarea.value = "";

    const errorRow = document.createElement("div");
    errorRow.className = "srtc-reply-error";
    errorRow.hidden = true;

    const actions = document.createElement("div");
    actions.className = "srtc-reply-actions";
    const postButton = document.createElement("button");
    postButton.type = "button";
    postButton.className = "button srtc-reply-post";
    postButton.textContent = T.post;
    const cancelButton = document.createElement("button");
    cancelButton.type = "button";
    cancelButton.className = "srtc-reply-cancel";
    cancelButton.textContent = T.cancel;
    actions.appendChild(postButton);
    actions.appendChild(cancelButton);

    compose.appendChild(textarea);
    compose.appendChild(errorRow);
    compose.appendChild(actions);
    config.body.appendChild(compose);

    replyButton.addEventListener("click", () => {
      compose.hidden = !compose.hidden;
      if (!compose.hidden) textarea.focus();
    });
    cancelButton.addEventListener("click", () => {
      compose.hidden = true;
      errorRow.hidden = true;
      textarea.value = "";
    });

    postButton.addEventListener("click", async () => {
      const content = textarea.value.trim();
      if (!content) {
        errorRow.hidden = false;
        errorRow.textContent = T.needContent;
        return;
      }
      postButton.disabled = true;
      errorRow.hidden = true;
      const result = await postReply(config.parentId, config.commenteeId, content);
      postButton.disabled = false;
      if (!result.ok) {
        errorRow.hidden = false;
        errorRow.textContent = `${T.sendFailed}: ${result.error}`;
        return;
      }
      // Show the new reply immediately under the top-level comment and record
      // its id so the poller won't re-insert it.
      const created = Shared.normalizeComment(result.data);
      const thread = state.threads.get(config.parentId);
      if (created) {
        ensureSyntheticReplies(config.topContainer).appendChild(
          buildCommentNode(created, {
            reply: { topContainer: config.topContainer, parentId: config.parentId }
          })
        );
        if (thread) {
          thread.knownReplyIds.add(created.id);
          thread.knownTotal += 1;
          thread.replyCount = Math.max(thread.replyCount, thread.knownTotal);
          clearBadge(thread);
        }
      } else if (thread) {
        // Couldn't parse the response; let the next cycle pick it up.
        thread.replyCountDirty = true;
      }
      compose.hidden = true;
      textarea.value = "";
    });
  }

  // ---------------------------------------------------------------------------
  // Thread bookkeeping (one record per visible top-level comment).
  // ---------------------------------------------------------------------------
  function ensureThread(id, el, synthetic) {
    let thread = state.threads.get(id);
    if (!thread) {
      thread = {
        id,
        el,
        synthetic: Boolean(synthetic),
        replyCount: 0,
        replyCountDirty: false,
        knownReplyIds: new Set(),
        // Number of replies already accounted for; the tail fetch starts here.
        knownTotal: 0,
        pendingBadge: 0
      };
      state.threads.set(id, thread);
    } else if (el) {
      thread.el = el;
    }
    return thread;
  }

  // Register a freshly-displayed thread and seed its reply baseline. Native
  // threads skip their existing replies (already rendered by Scratch); synthetic
  // threads start at 0 so we render all of their replies inline.
  function registerThread(id, el, synthetic, replyCount) {
    const fresh = !state.threads.has(id);
    const thread = ensureThread(id, el, synthetic);
    thread.replyCount = replyCount;
    if (fresh) thread.knownTotal = synthetic ? 0 : replyCount;
    return thread;
  }

  function repliesContainer(thread) {
    if (!thread.el || !thread.el.isConnected) return null;
    return thread.el.querySelector(":scope > .replies");
  }

  // True only when Scratch has hidden replies behind a "show more" toggle
  // (>3 replies). Small reply lists render inline, so they are NOT collapsed.
  function isThreadCollapsed(thread) {
    const replies = repliesContainer(thread);
    return Boolean(replies) && replies.classList.contains("collapsed");
  }

  function appendReplyNodes(thread, replies) {
    const container = repliesContainer(thread);
    if (!container) return false;
    for (const reply of replies) {
      // Give each synthetic reply its own reply form, targeting the top comment.
      container.appendChild(buildCommentNode(reply, {
        reply: { topContainer: thread.el, parentId: thread.id }
      }));
    }
    return true;
  }

  function bumpBadge(thread, increment) {
    thread.pendingBadge += increment;
    const bottom = thread.el && thread.el.querySelector(".comment-bottom-row");
    if (!bottom) return;
    let badge = bottom.querySelector(".srtc-new-replies-badge");
    if (!badge) {
      badge = document.createElement("span");
      badge.className = "srtc-new-replies-badge";
      bottom.appendChild(badge);
    }
    badge.textContent = `+${thread.pendingBadge} replies`;
  }

  function clearBadge(thread) {
    thread.pendingBadge = 0;
    const badge = thread.el && thread.el.querySelector(".srtc-new-replies-badge");
    if (badge) badge.remove();
  }

  // Map currently-displayed native top-level nodes by username+content so we can
  // attach a thread record to a comment the API returns.
  function buildNativeNodeIndex() {
    const index = new Map();
    const nodes = state.listEl.querySelectorAll(":scope > .comment-container:not(.srtc-managed-top)");
    for (const node of nodes) {
      const key = `${nativeUsername(node)} ${nativeContentText(node)}`;
      if (!index.has(key)) index.set(key, []);
      index.get(key).push(node);
    }
    return index;
  }

  function commentKey(comment) {
    return `${Shared.normalizeText(comment.author.username)} ${Shared.normalizeText(comment.content)}`;
  }

  // ---------------------------------------------------------------------------
  // Seeding: record what is already on the page without inserting anything.
  // ---------------------------------------------------------------------------
  async function seedFromApi() {
    const nativeIndex = buildNativeNodeIndex();
    let offset = 0;
    for (let page = 0; page < Shared.MAX_INITIAL_PAGES; page += 1) {
      const result = await fetchTopPage(offset);
      if (!result.ok) break;
      const comments = normalizeList(result.data);
      if (comments.length === 0) break;
      for (const comment of comments) {
        state.knownTopIds.add(comment.id);
        const bucket = nativeIndex.get(commentKey(comment));
        const node = bucket && bucket.length ? bucket.shift() : null;
        if (node) registerThread(comment.id, node, false, comment.reply_count);
      }
      if (comments.length < Shared.TOP_COMMENT_LIMIT) break;
      offset += Shared.TOP_COMMENT_LIMIT;
    }
  }

  // Process one fetched top page: (page 0 only) insert genuinely new comments,
  // and for every page diff reply_count and register newly-displayed natives.
  // Returns the list of brand-new comments to insert (page 0).
  function processTopPage(comments, isFirstPage, nativeIndex) {
    const fresh = [];
    for (const comment of comments) {
      const existing = state.threads.get(comment.id);
      if (existing) {
        // If React replaced this native node, re-bind to the current one so we
        // keep tracking its replies.
        if (!existing.synthetic && (!existing.el || !existing.el.isConnected)) {
          const bucket = nativeIndex.get(commentKey(comment));
          const node = bucket && bucket.length ? bucket.shift() : null;
          if (node) existing.el = node;
        }
        existing.replyCount = comment.reply_count;
        if (comment.reply_count > existing.knownTotal) {
          // Server has replies we haven't pulled yet.
          existing.replyCountDirty = true;
        } else if (comment.reply_count < existing.knownTotal) {
          // Replies were deleted — resync so detection keeps working.
          existing.knownTotal = comment.reply_count;
        }
        continue;
      }

      // Not yet tracked. If it's already shown as a native node (initial render
      // or "load more"), register it for reply tracking without inserting.
      const bucket = nativeIndex.get(commentKey(comment));
      const node = bucket && bucket.length ? bucket.shift() : null;
      if (node) {
        state.knownTopIds.add(comment.id);
        registerThread(comment.id, node, false, comment.reply_count);
        continue;
      }

      if (state.knownTopIds.has(comment.id)) continue;
      // Only page 0 can carry genuinely new comments (newest-first API).
      if (isFirstPage) fresh.push(comment);
    }
    return fresh;
  }

  // Find the top-level DOM node for a comment: our synthetic node (by id) or the
  // native node (by username+content). Only direct children of the list.
  function findTopNode(comment) {
    const idStr = String(comment.id);
    const key = commentKey(comment);
    let nativeMatch = null;
    for (const child of state.listEl.children) {
      if (!child.classList || !child.classList.contains("comment-container")) continue;
      if (child.classList.contains("srtc-managed-top")) {
        if (child.getAttribute("data-srtc-id") === idStr) return child;
      } else if (!nativeMatch && `${nativeUsername(child)} ${nativeContentText(child)}` === key) {
        nativeMatch = child;
      }
    }
    return nativeMatch;
  }

  function insertAtTop(node) {
    const anchor = state.listEl.querySelector(":scope > .comment-container");
    state.listEl.insertBefore(node, anchor);
  }

  // Reconcile the order of our synthetic top nodes to match the API (newest
  // first), inserting brand-new comments at the right spot. Native nodes are
  // React-owned, so we never move them — we only slot our own nodes between
  // them. This keeps order correct even when a native comment is posted after
  // we've inserted a (now older) synthetic one.
  function syncTopOrder(page0Comments, freshIds) {
    const scroller = getScroller();
    const beforeScroll = scroller.scrollTop;
    const atTop = beforeScroll <= 4;
    const beforeHeight = scroller.scrollHeight;
    let changed = false;

    let prevNode = null; // node of the nearest newer comment already placed
    for (const comment of page0Comments) { // newest-first
      let node = findTopNode(comment);
      if (!node && freshIds.has(comment.id)) {
        node = buildCommentNode(comment, { managedTop: true, reply: { parentId: comment.id } });
        state.knownTopIds.add(comment.id);
        // Synthetic node: knownTotal starts at 0 so all replies render inline.
        const thread = registerThread(comment.id, node, true, comment.reply_count);
        if (comment.reply_count > 0) thread.replyCountDirty = true;
        if (prevNode) prevNode.after(node);
        else insertAtTop(node);
        changed = true;
      } else if (node && node.classList.contains("srtc-managed-top")) {
        // Existing synthetic node: ensure it sits right after prevNode.
        const placed = prevNode
          ? node.previousElementSibling === prevNode
          : node === state.listEl.querySelector(":scope > .comment-container");
        if (!placed) {
          if (prevNode) prevNode.after(node);
          else insertAtTop(node);
          changed = true;
        }
      }
      if (node) prevNode = node;
    }

    // Requirement ②③: keep the reader's position fixed when content shifts above.
    if (changed && !atTop) {
      const added = scroller.scrollHeight - beforeHeight;
      if (added > 0) scroller.scrollTop = beforeScroll + added;
    }
  }

  // ---------------------------------------------------------------------------
  // Reply fetching: count-driven. Only called for threads whose reply_count
  // changed, so it just needs to pull the replies appended since knownTotal.
  // ---------------------------------------------------------------------------
  async function fetchThreadReplies(thread) {
    if (!thread.el || !thread.el.isConnected) return;
    thread.replyCountDirty = false;
    const limit = Shared.REPLY_LIMIT;
    const fresh = [];
    for (let page = 0; page < Shared.REPLY_MAX_PAGES; page += 1) {
      const result = await fetchReplyPage(thread.id, thread.knownTotal);
      if (!result.ok) {
        if (result.status === 429) state.rateLimited = true;
        return;
      }
      const replies = normalizeList(result.data);
      if (replies.length === 0) break;
      thread.knownTotal += replies.length;
      for (const reply of replies) {
        if (!thread.knownReplyIds.has(reply.id)) {
          thread.knownReplyIds.add(reply.id);
          fresh.push(reply);
        }
      }
      if (replies.length < limit) break;
    }
    if (!fresh.length) return;
    fresh.sort(Shared.compareOldestFirst);

    // Show inline unless the thread is explicitly collapsed (>3 replies behind a
    // "show more" toggle). Scratch renders small reply lists inline, so a new
    // reply there must appear without a reload. Only the collapsed case stays a
    // small "+N replies" hint so we never force-expand it (requirement ③).
    if (!isThreadCollapsed(thread)) {
      ensureSyntheticReplies(thread.el);
      if (appendReplyNodes(thread, fresh)) {
        clearBadge(thread);
        return;
      }
    }
    bumpBadge(thread, fresh.length);
  }

  // ---------------------------------------------------------------------------
  // Relative-time ticker: local-only, never touches the network.
  // ---------------------------------------------------------------------------
  function tickRelativeTimes() {
    const now = Date.now();
    const labels = document.querySelectorAll(".comment-time[data-srtc-created]");
    for (const label of labels) {
      const created = label.getAttribute("data-srtc-created");
      label.textContent = Shared.formatRelativeTime(created, now, LOCALE);
    }
  }

  // ---------------------------------------------------------------------------
  // Status pill (requirement ②: minimal, non-intrusive affordance).
  // ---------------------------------------------------------------------------
  function removeStatusButton() {
    if (state.statusEl) {
      state.statusEl.remove();
      state.statusEl = null;
    }
  }

  function ensureStatusButton() {
    if (state.statusEl && state.statusEl.isConnected) return;
    // Only show the pill on a comments page (the script also runs on other
    // studio tabs to survive SPA navigation).
    if (!Shared.getStudioIdFromPath(location.pathname)) return;
    // Keep this out of Scratch's React tree (especially the composer) so it can
    // never interfere with writing — a fixed pill on <body>.
    const button = document.createElement("button");
    button.type = "button";
    button.className = "srtc-status-button";
    button.title = "Realtime comments";
    button.addEventListener("click", () => {
      chrome.storage.sync.set({ enabled: !state.settings.enabled });
    });
    document.body.appendChild(button);
    state.statusEl = button;
    setStatus(state.statusState);
  }

  function setStatus(next) {
    // Only a successful cycle clears the backoff; errors must let it grow.
    if (next === "on") state.backoff.reset();
    state.statusState = next;
    if (!state.statusEl) return;
    state.statusEl.dataset.state = next;
    state.statusEl.textContent = next === "on" ? T.live : next === "error" ? T.retrying : T.off;
  }

  // ---------------------------------------------------------------------------
  // Scheduling: one cycle scans top pages (new comments + reply_count deltas),
  // then fetches replies only for the threads that changed.
  // ---------------------------------------------------------------------------
  // Minimum gap between cycles even if the work took ~a full interval, so we
  // never tight-loop the API.
  const MIN_POLL_GAP_MS = 100;

  function pollInterval() {
    return state.settings.lowLoadMode ? Shared.LOW_LOAD_TOP_REFRESH_MS : Shared.TOP_REFRESH_MS;
  }

  // Fixed-rate: measure the next delay from the cycle START, not from when the
  // awaited fetches finished, so the real cadence stays near the interval
  // instead of (interval + work time).
  function nextPollDelay(startedAt) {
    return Math.max(MIN_POLL_GAP_MS, pollInterval() - (Date.now() - startedAt));
  }

  function schedulePoll(delay) {
    clearTimeout(state.pollTimer);
    if (!state.running) return;
    state.pollTimer = setTimeout(pollCycle, delay);
  }

  async function scanTopPages() {
    // Cover every displayed top-level comment (native + synthetic), so threads
    // revealed by "load more" get tracked and their reply_count watched.
    const displayed = state.listEl.querySelectorAll(":scope > .comment-container").length;
    const pageCount = Shared.coveragePageCount(
      displayed, Shared.TOP_COMMENT_LIMIT, Shared.MAX_COVERAGE_PAGES
    );
    const offsets = [];
    for (let p = 0; p < pageCount; p += 1) offsets.push(p * Shared.TOP_COMMENT_LIMIT);

    const results = await runPool(offsets, fetchTopPage, Shared.API_CONCURRENCY);
    if (!results.length || !results[0].ok) return false;

    const nativeIndex = buildNativeNodeIndex();
    let page0Comments = [];
    const freshIds = new Set();
    for (let i = 0; i < results.length; i += 1) {
      const result = results[i];
      if (!result.ok) {
        if (result.status === 429) state.rateLimited = true;
        continue;
      }
      const comments = normalizeList(result.data);
      const isFirstPage = offsets[i] === 0;
      if (isFirstPage) page0Comments = comments;
      const fresh = processTopPage(comments, isFirstPage, nativeIndex);
      for (const comment of fresh) freshIds.add(comment.id);
    }
    // Place/insert synthetic nodes in API (newest-first) order.
    if (page0Comments.length) syncTopOrder(page0Comments, freshIds);
    return true;
  }

  async function pollCycle() {
    if (!state.running) return;
    const sid = Shared.getStudioIdFromPath(location.pathname);
    if (!sid) {
      // On a non-comments studio tab (or navigated away): idle but keep polling
      // the URL so we re-activate when the user opens the comments tab.
      removeStatusButton();
      schedulePoll(pollInterval());
      return;
    }
    if (document.hidden || state.inFlight) {
      schedulePoll(pollInterval());
      return;
    }
    if (sid !== state.studioId) {
      // First activation or switched to a different studio — start fresh.
      state.studioId = sid;
      state.threads = new Map();
      state.knownTopIds = new Set();
      state.seededListEl = null;
      state.listEl = null;
      state.backoff.reset();
    }
    if (!acquireList()) {
      schedulePoll(pollInterval());
      return;
    }
    ensureStatusButton();
    state.inFlight = true;
    state.rateLimited = false;
    const startedAt = Date.now();
    try {
      if (state.seededListEl === state.listEl && state.knownTopIds.size === 0) {
        await seedFromApi();
      }
      const ok = await scanTopPages();
      if (!ok) {
        setStatus("error");
        schedulePoll(state.backoff.fail());
        return;
      }
      // Count-driven: only fetch replies for threads whose reply_count grew.
      const due = [];
      for (const thread of state.threads.values()) {
        if (thread.replyCountDirty && thread.el && thread.el.isConnected) due.push(thread);
      }
      if (due.length) await runPool(due, fetchThreadReplies, Shared.API_CONCURRENCY);

      if (state.rateLimited) {
        setStatus("error");
        schedulePoll(state.backoff.fail());
      } else {
        setStatus("on");
        schedulePoll(nextPollDelay(startedAt));
      }
    } catch (_error) {
      setStatus("error");
      schedulePoll(state.backoff.fail());
    } finally {
      state.inFlight = false;
    }
  }

  function start() {
    if (state.running) return;
    state.running = true;
    ensureStatusButton();
    setStatus("on");
    if (!state.tickTimer) {
      state.tickTimer = setInterval(tickRelativeTimes, Shared.REPLY_TICK_MS);
    }
    schedulePoll(0);
  }

  function stop() {
    state.running = false;
    clearTimeout(state.pollTimer);
    setStatus("off");
  }

  // Catch up immediately when the tab becomes visible again.
  document.addEventListener("visibilitychange", () => {
    if (!document.hidden && state.running) schedulePoll(0);
  });

  // ---------------------------------------------------------------------------
  // Settings wiring.
  // ---------------------------------------------------------------------------
  function applySettings(settings) {
    state.settings = Object.assign({}, Shared.DEFAULT_SETTINGS, settings);
    ensureStatusButton();
    if (state.settings.enabled) start();
    else stop();
  }

  chrome.storage.sync.get(Shared.DEFAULT_SETTINGS, applySettings);

  chrome.storage.onChanged.addListener((changes, area) => {
    if (area !== "sync") return;
    const next = Object.assign({}, state.settings);
    if (changes.enabled) next.enabled = Boolean(changes.enabled.newValue);
    if (changes.lowLoadMode) next.lowLoadMode = Boolean(changes.lowLoadMode.newValue);
    applySettings(next);
  });
})();


  // ============== settings menu (replaces the extension popup) ==============
  function toggleSetting(key) {
    const defaults = (globalThis.SRTCShared && globalThis.SRTCShared.DEFAULT_SETTINGS) ||
      { enabled: true, lowLoadMode: false };
    chrome.storage.sync.get(defaults, function (s) {
      chrome.storage.sync.set({ [key]: !s[key] });
    });
  }
  if (typeof GM_registerMenuCommand === "function") {
    GM_registerMenuCommand("リアルタイム更新: ON/OFF を切替 (Toggle realtime)", function () { toggleSetting("enabled"); });
    GM_registerMenuCommand("低負荷モード: ON/OFF を切替 (Toggle low-load)", function () { toggleSetting("lowLoadMode"); });
  }
})();