Shows new Scratch studio comments without a manual reload.
// ==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"); });
}
})();