Reddit: Use System Theme

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

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==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);
  })();
})();