Reddit community list Join buttons

Add Reddit's native Join/Joined button with 2-column grid layout, using /subreddits/mine for accurate state

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

ستحتاج إلى تثبيت إضافة مثل Stylus لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتتمكن من تثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

(لدي بالفعل مثبت أنماط للمستخدم، دعني أقم بتثبيته!)

// ==UserScript==
// @name         Reddit community list Join buttons
// @namespace    https://github.com/quantavil/reddit-join
// @version      0.8
// @description  Add Reddit's native Join/Joined button with 2-column grid layout, using /subreddits/mine for accurate state
// @author       you
// @match        https://www.reddit.com/*
// @run-at       document-idle
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  // ---------- CONFIG ----------
  const CACHE_STORAGE_KEY = 'redditJoinSubStateCache_v2';
  const FULL_LOAD_TIMESTAMP_KEY = 'redditJoinFullLoadTS_v2'; 
  const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes

  // ---------- CSS INJECTION ----------
  function injectStyles() {
    const style = document.createElement('style');
    style.textContent = `
      /* Force 2-column grid layout for community list */
      .community-list {
        display: grid !important;
        grid-template-columns: repeat(2, 1fr) !important;
        gap: 1rem !important;
        width: 100% !important;
      }

      /* Row layout */
      .community-list > div[data-community-id] {
        display: flex !important;
        flex-direction: row !important;
        align-items: center !important;
        gap: 0.75rem !important;
        flex-wrap: nowrap !important;
        padding: 0.75rem !important;
        box-sizing: border-box !important;
        width: 100% !important;
      }

      /* Title and description section - allow growth */
      .community-list > div[data-community-id] > div:not([style*="margin-left"]) {
        flex-grow: 1 !important;
        min-width: 0 !important;
        display: flex !important;
        flex-direction: column !important;
      }

      /* Position join button container inline */
      .community-list > div[data-community-id] > div[style*="margin-left"] {
        margin-left: auto !important;
        order: 3 !important;
        flex-shrink: 0 !important;
      }

      /* Ensure icon stays at start */
      .community-list > div[data-community-id] > span {
        order: 1 !important;
        flex-shrink: 0 !important;
      }

      /* Ensure rank number stays first */
      .community-list > div[data-community-id] > h6:first-child {
        order: 0 !important;
        flex-shrink: 0 !important;
        width: 2rem !important;
      }

      /* Text content container */
      .community-list > div[data-community-id] > div.flex-col {
        order: 2 !important;
      }
    `;
    document.head.appendChild(style);
  }

  // ---------- CACHING LAYER ----------
  const subStateCache = new Map();
  const pendingButtons = new Map(); 

  function now() {
    return Date.now();
  }

  function loadCacheFromStorage() {
    try {
      const raw = localStorage.getItem(CACHE_STORAGE_KEY);
      if (!raw) return;
      const obj = JSON.parse(raw);
      const cutoff = now() - CACHE_TTL_MS;
      Object.entries(obj).forEach(([name, entry]) => {
        if (!entry || typeof entry !== 'object') return;
        if (typeof entry.ts !== 'number') return;
        if (entry.ts < cutoff) return;
        subStateCache.set(name, { isSub: !!entry.isSub, ts: entry.ts });
      });
    } catch (e) {
      console.warn('[Reddit Join] Failed to load cache:', e);
    }
  }

  function saveCacheToStorage() {
    try {
      const cutoff = now() - CACHE_TTL_MS;
      const obj = {};
      for (const [name, entry] of subStateCache.entries()) {
        if (!entry || entry.ts < cutoff) continue;
        obj[name] = { isSub: !!entry.isSub, ts: entry.ts };
      }
      localStorage.setItem(CACHE_STORAGE_KEY, JSON.stringify(obj));
    } catch (e) {
      console.warn('[Reddit Join] Failed to save cache:', e);
    }
  }

  function getCachedSubState(normName) {
    const entry = subStateCache.get(normName);
    if (!entry) return null;
    if (entry.ts < now() - CACHE_TTL_MS) {
      subStateCache.delete(normName);
      return null;
    }
    return entry.isSub;
  }

  function setCachedSubState(normName, isSub) {
    subStateCache.set(normName, { isSub: !!isSub, ts: now() });
    // Save asynchronously so we don't block UI
    queueMicrotask(saveCacheToStorage);
  }

  // ---------- UTILS ----------
  function normalizeSubName(text) {
    if (!text) return null;
    return text
      .trim()
      .replace(/^\/?r\//i, '')
      .replace(/\/$/, '');
  }

  // Apply state to shreddit-join-button
  function applySubscribedState(joinEl, isSub) {
    if (!joinEl) return;

    if (isSub) {
      joinEl.setAttribute('subscribed', '');
    } else {
      joinEl.removeAttribute('subscribed');
    }

    // Also try setting as property (Lit element)
    try {
      joinEl.subscribed = isSub;
    } catch (e) {
      // property might be read-only; ignore
    }

    // If Lit has already initialized, request re-render
    if (typeof joinEl.updateComplete !== 'undefined') {
      joinEl.requestUpdate?.();
    }
  }

  // ---------- LOAD ALL SUBSCRIPTIONS ONCE ----------
  let subsLoadingPromise = null;

  async function loadAllSubscribedOnce() {
    if (subsLoadingPromise) return subsLoadingPromise;

    subsLoadingPromise = (async () => {
      try {
        const lastFullLoad = parseInt(localStorage.getItem(FULL_LOAD_TIMESTAMP_KEY)) || 0;
        const cutoff = now() - CACHE_TTL_MS;

        if (lastFullLoad >= cutoff) {
          console.log('[Reddit Join] Using recent full load from cache');
          return;
        }

        console.log('[Reddit Join] Fetching fresh subscription list...');
        
        // ✅ Clear stale cache before fresh load
        subStateCache.clear();

        let after = null;
        let pageCount = 0;
        let totalLoaded = 0;

        while (pageCount < 10) { // safety limit: max 1000 subs
          const url = new URL('https://www.reddit.com/subreddits/mine/subscriber.json');
          url.searchParams.set('limit', '100');
          if (after) url.searchParams.set('after', after);

          const resp = await fetch(url.toString(), {
            credentials: 'include',
            headers: { 'Accept': 'application/json' },
          });

          if (!resp.ok) {
            console.warn('[Reddit Join] /subreddits/mine/subscriber HTTP', resp.status);
            break;
          }

          const json = await resp.json();
          const children = json?.data?.children || [];

          for (const child of children) {
            const name = child?.data?.display_name;
            const norm = normalizeSubName(name);
            if (norm) {
              setCachedSubState(norm, true);
              totalLoaded++;
            }
          }

          after = json?.data?.after || null;
          pageCount += 1;

          if (!after) break;
        }

        localStorage.setItem(FULL_LOAD_TIMESTAMP_KEY, now().toString());
        saveCacheToStorage();

        console.log(`[Reddit Join] ✓ Loaded ${totalLoaded} subscribed subreddits`);
      } catch (e) {
        console.error('[Reddit Join] Failed to load subscribed list:', e);
      }
    })();

    return subsLoadingPromise;
  }

  function updateAllPendingButtons() {
    if (pendingButtons.size === 0) return;
    
    console.log(`[Reddit Join] Updating ${pendingButtons.size} pending buttons...`);
    
    for (const [normName, joinEl] of pendingButtons.entries()) {
      // Check if element still in DOM
      if (!joinEl.isConnected) {
        pendingButtons.delete(normName);
        continue;
      }
      
      const state = getCachedSubState(normName);
      // Subscribed = true, not in list = false
      applySubscribedState(joinEl, !!state);
    }
    
    pendingButtons.clear();
  }

  // ---------- COMMUNITY LIST PROCESSING ----------
  function createJoinButton(normName, communityId, initialState = null) {
    const join = document.createElement('shreddit-join-button');

    // IMPORTANT: name is just the subreddit name, no "r/" prefix
    join.setAttribute('name', normName);
    join.setAttribute('subreddit-id', communityId);
    join.setAttribute('buttonsize', 'medium');
    join.setAttribute('button-classes', 'px-sm py-xs');
    join.setAttribute('subscribe-label', 'Join');
    join.setAttribute('unsubscribe-label', 'Joined');
    join.setAttribute('unsubscribe-button-type-override', 'bordered');
    join.dataset.normName = normName;

    // If cache already knows, set state
    if (initialState !== null) {
      applySubscribedState(join, initialState);
    }
    const observer = new MutationObserver(() => {
      const isSubbed = join.hasAttribute('subscribed');
      const currentCache = getCachedSubState(normName);
      
      // Only update if state actually changed
      if (currentCache !== isSubbed) {
        setCachedSubState(normName, isSubbed);
        console.log(`[Cache Updated] r/${normName} → ${isSubbed ? 'Joined ✓' : 'Left ✗'}`);
        
        // Invalidate full load timestamp to force refresh on next page load
        // This ensures we catch any subscription changes made outside this page
        localStorage.removeItem(FULL_LOAD_TIMESTAMP_KEY);
      }
    });

    observer.observe(join, {
      attributes: true,
      attributeFilter: ['subscribed']
    });

    // Store observer reference for potential cleanup
    join.dataset.stateObserver = 'active';

    return join;
  }

  function processCommunityList(root = document) {
    const rows = root.querySelectorAll('.community-list > div[data-community-id]');
    let newPendingCount = 0;

    rows.forEach((row) => {
      if (row.dataset.joinButtonInjected === '1') return;
      row.dataset.joinButtonInjected = '1';

      const communityId = row.dataset.communityId;
      let prefixed = row.dataset.prefixedName;

      if (!prefixed) {
        const link = row.querySelector('a[id^="/r/"], a[href^="/r/"]');
        if (link) {
          prefixed = (link.textContent || link.getAttribute('href') || '').trim();
        }
      }

      const normName = normalizeSubName(prefixed);
      if (!normName || !communityId) return;
      const cachedState = getCachedSubState(normName);
      const join = createJoinButton(normName, communityId, cachedState);

      const container = document.createElement('div');
      container.style.display = 'flex';
      container.style.alignItems = 'center';
      container.style.marginLeft = 'auto';
      container.appendChild(join);
      row.appendChild(container);
      if (cachedState === null) {
        pendingButtons.set(normName, join);
        newPendingCount++;
      }
    });
    if (pendingButtons.size > 0) {
      loadAllSubscribedOnce().then(updateAllPendingButtons);
    }
  }

  // ---------- INIT ----------
  function initObserver() {
    injectStyles();
    loadCacheFromStorage();

    // Kick off global subscribed list load in background
    loadAllSubscribedOnce().then(() => {
      processCommunityList();
    });

    const observer = new MutationObserver((mutations) => {
      for (const m of mutations) {
        for (const node of m.addedNodes) {
          if (!(node instanceof HTMLElement)) continue;

          if (node.matches?.('.community-list') || node.querySelector?.('.community-list')) {
            processCommunityList(node);
          } else if (
            node.matches?.('[data-community-id]') &&
            node.parentElement?.classList.contains('community-list')
          ) {
            processCommunityList(node.parentElement);
          }
        }
      }
    });

    observer.observe(document.body, {
      childList: true,
      subtree: true,
    });

    console.log('[Reddit Join] ✓ Initialized, cache entries:', subStateCache.size);
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', initObserver);
  } else {
    initObserver();
  }
})();