Reddit: Use System Theme

Sync Reddit night mode to OS theme; persists via GraphQL; updates all tabs + new tabs without reload.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

You will need to install an extension such as Tampermonkey to install this script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Necesitará instalar una extensión como Tampermonkey para instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         Reddit: Use System Theme
// @namespace    https://example.com/
// @version      1.6.1
// @description  Sync Reddit night mode to OS theme; persists via GraphQL; updates all tabs + new tabs without reload.
// @license MIT
// @match        https://*.reddit.com/*
// @icon         https://www.google.com/s2/favicons?domain=reddit.com
// @run-at       document-start
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addValueChangeListener
// @grant        unsafeWindow
// ==/UserScript==

(() => {
  "use strict";

  const GRAPHQL_URL = "https://www.reddit.com/svc/shreddit/graphql";
  const OPERATION = "UpdateAccountPreferences";
  const MIN_MS_BETWEEN_SERVER_UPDATES = 10_000;

  const SYNC_KEY = "reddit_theme_sync_v1";
  const CHANNEL_NAME = "reddit-theme-sync-v1";
  const GM_SYNC_KEY = "reddit_theme_sync_gm_v1";

  const media = window.matchMedia("(prefers-color-scheme: dark)");
  let inFlight = false;

  const bc = ("BroadcastChannel" in window) ? new BroadcastChannel(CHANNEL_NAME) : null;

  // Helps avoid infinite fights with the page
  let lastApplied = null; // boolean|null
  let lastSeenTs = 0;

  function getCookie(name) {
    const cookies = document.cookie ? document.cookie.split("; ") : [];
    for (const part of cookies) {
      const eq = part.indexOf("=");
      const key = eq >= 0 ? part.slice(0, eq) : part;
      if (key === name) return eq >= 0 ? decodeURIComponent(part.slice(eq + 1)) : "";
    }
    return null;
  }

  function setCookie(name, value, { domain, maxAgeSeconds = 31536000 } = {}) {
    const base = `${encodeURIComponent(name)}=${encodeURIComponent(value)}; Path=/; Max-Age=${maxAgeSeconds}; SameSite=Lax`;
    const secure = location.protocol === "https:" ? "; Secure" : "";
    const domainPart = domain ? `; Domain=${domain}` : "";
    document.cookie = base + domainPart + secure;
  }

  function findCsrfInInlineScripts() {
    const scripts = Array.from(document.scripts || []);
    for (const s of scripts) {
      if (s.src) continue;
      const t = s.textContent || "";
      if (!t) continue;

      const m =
        t.match(/"csrfToken"\s*:\s*"([a-f0-9]{16,})"/i) ||
        t.match(/csrfToken\s*:\s*"([a-f0-9]{16,})"/i) ||
        t.match(/"csrf_token"\s*:\s*"([a-f0-9]{16,})"/i) ||
        t.match(/csrf_token\s*:\s*"([a-f0-9]{16,})"/i);

      if (m?.[1]) return m[1];
    }
    return null;
  }

  function getCsrfToken() {
    const fromCookie = getCookie("csrf_token");
    if (fromCookie) return fromCookie;

    const meta = document.querySelector('meta[name="csrf-token"], meta[name="csrf_token"]');
    if (meta?.content) return meta.content;

    try {
      const r = unsafeWindow?.__r;
      const t = r?.config?.csrfToken || r?.config?.csrf_token;
      if (t) return t;
    } catch {}

    return findCsrfInInlineScripts();
  }

  function desiredNightModeEnabled() {
    return !!media.matches;
  }

  function applyClientTheme(isDark) {
    isDark = !!isDark;
    if (lastApplied === isDark) return;
    lastApplied = isDark;

    const mode = isDark ? "dark" : "light";
    const night = isDark ? "1" : "0";

    // Common immediate switches
    document.documentElement.style.colorScheme = mode;

    // These attributes/classes are “best effort”; harmless if ignored.
    document.documentElement.setAttribute("data-theme", mode);
    document.documentElement.setAttribute("data-color-mode", mode);
    document.documentElement.setAttribute("data-nightmode", night);

    document.documentElement.classList.toggle("theme-dark", isDark);
    document.documentElement.classList.toggle("theme-light", !isDark);

    // Best-effort hints Reddit variants commonly respect:
    setCookie("nightmode", night, { domain: location.hostname });
    setCookie("nightmode", night, { domain: ".reddit.com" });

    try {
      localStorage.setItem("nightmode", isDark ? "true" : "false");
      localStorage.setItem("theme", mode);
      localStorage.setItem("reddit_theme", mode);
    } catch {}
  }

  function broadcastTheme(isDark) {
    const msg = { isDark: !!isDark, ts: Date.now() };
    lastSeenTs = Math.max(lastSeenTs, msg.ts);

    try { localStorage.setItem(SYNC_KEY, JSON.stringify(msg)); } catch {}
    try { bc?.postMessage(msg); } catch {}
    try { GM_setValue(GM_SYNC_KEY, msg); } catch {}
  }

  function readPersistedTheme() {
    try {
      const raw = localStorage.getItem(SYNC_KEY);
      if (!raw) return null;
      const msg = JSON.parse(raw);
      if (!msg || typeof msg.isDark !== "boolean") return null;
      return msg;
    } catch {
      return null;
    }
  }

  async function updatePreferenceServer(isNightModeEnabled) {
    const csrf = getCsrfToken();
    if (!csrf) return { ok: false, reason: "missing_csrf" };

    const payload = {
      operation: OPERATION,
      variables: { input: { isNightModeEnabled } },
      csrf_token: csrf,
    };

    const res = await fetch(GRAPHQL_URL, {
      method: "POST",
      credentials: "include",
      headers: { "accept": "application/json", "content-type": "application/json" },
      body: JSON.stringify(payload),
    });

    return { ok: res.ok, reason: res.ok ? null : `http_${res.status}` };
  }

  async function syncFromThisTab() {
    if (inFlight) return;

    const desired = desiredNightModeEnabled();
    const lastDesired = GM_getValue("lastDesired", null);
    const lastUpdateAt = GM_getValue("lastUpdateAt", 0);
    const now = Date.now();

    inFlight = true;
    try {
      applyClientTheme(desired);
      broadcastTheme(desired);

      if (!(lastDesired === desired && (now - lastUpdateAt) < MIN_MS_BETWEEN_SERVER_UPDATES)) {
        const result = await updatePreferenceServer(desired);
        if (result.ok) {
          GM_setValue("lastDesired", desired);
          GM_setValue("lastUpdateAt", now);
        }
      }
    } finally {
      inFlight = false;
    }
  }

  function catchUpLocally() {
    const persisted = readPersistedTheme();
    if (persisted) {
      if (typeof persisted.ts === "number") lastSeenTs = Math.max(lastSeenTs, persisted.ts);
      applyClientTheme(persisted.isDark);
      return;
    }
    applyClientTheme(desiredNightModeEnabled());
  }

  function handleIncoming(msg) {
    if (!msg || typeof msg.isDark !== "boolean") return;
    const ts = (typeof msg.ts === "number") ? msg.ts : 0;
    if (ts && ts <= lastSeenTs) return;
    if (ts) lastSeenTs = ts;
    applyClientTheme(msg.isDark);
  }

  // --- NEW: re-apply on SPA route changes (fixes /r/popular/ + other feeds) ---
  function reapplySoon() {
    // Let Reddit finish its own routing/hydration, then “win back” theme.
    catchUpLocally();
    setTimeout(catchUpLocally, 50);
    setTimeout(catchUpLocally, 250);
    setTimeout(catchUpLocally, 1200);
  }

  function installSpaHook() {
    const origPush = history.pushState;
    const origReplace = history.replaceState;

    history.pushState = function (...args) {
      origPush.apply(this, args);
      reapplySoon();
    };
    history.replaceState = function (...args) {
      origReplace.apply(this, args);
      reapplySoon();
    };

    window.addEventListener("popstate", () => reapplySoon(), true);
  }

  // --- NEW: watch for Reddit overriding html attrs/classes after hydration ---
  function installAntiOverrideObserver() {
    const target = document.documentElement;
    if (!target || typeof MutationObserver === "undefined") return;

    const obs = new MutationObserver(() => {
      // If Reddit flips theme attributes/classes, re-assert ours.
      // (Debounced by lastApplied de-dupe.)
      catchUpLocally();
    });

    obs.observe(target, { attributes: true, attributeFilter: ["class", "data-theme", "data-color-mode", "style"] });
  }

  // Startup behavior
  catchUpLocally();
  setTimeout(catchUpLocally, 250);
  setTimeout(catchUpLocally, 1500);

  installSpaHook();
  installAntiOverrideObserver();

  // OS theme changes while this tab is open
  media.addEventListener?.("change", () => syncFromThisTab());

  // Receive from other tabs
  bc?.addEventListener?.("message", (e) => handleIncoming(e.data));
  window.addEventListener("storage", (e) => {
    if (e.key !== SYNC_KEY || !e.newValue) return;
    try { handleIncoming(JSON.parse(e.newValue)); } catch {}
  });
  if (typeof GM_addValueChangeListener === "function") {
    GM_addValueChangeListener(GM_SYNC_KEY, (_name, _oldValue, newValue, _remote) => {
      handleIncoming(newValue);
    });
  }

  // When you “re-open” an existing tab / BFCache restore
  window.addEventListener("focus", catchUpLocally);
  document.addEventListener("visibilitychange", () => { if (!document.hidden) catchUpLocally(); });
  window.addEventListener("pageshow", () => catchUpLocally(), true);

  // Server sync once csrf appears
  (function serverRetry() {
    let attempts = 0;
    const maxAttempts = 10;
    const tick = () => {
      attempts++;
      if (getCsrfToken()) syncFromThisTab();
      if (attempts < maxAttempts && !getCsrfToken()) setTimeout(tick, 750);
    };
    setTimeout(tick, 750);
  })();
})();