Reddit expanded community filter

Filter muted communities from /r/all

// ==UserScript==
// @name        Reddit expanded community filter
// @description Filter muted communities from /r/all
// @version     1.5.0
// @author      AJ Granowski
// @homepage    https://github.com/AJGranowski/reddit-expanded-community-filter-userscript/
// @namespace   github.com/AJGranowski/reddit-expanded-community-filter-userscript
// @connect     gql.reddit.com
// @connect     self
// @match       https://new.reddit.com/r/all/*
// @match       https://sh.reddit.com/r/all/*
// @match       https://www.reddit.com/r/all/*
// @noframes    
// @run-at      document-end
// @sandbox     JavaScript
// @license     MIT
// @grant       GM_addStyle
// @grant       GM_addValueChangeListener
// @grant       GM_getValue
// @grant       GM_registerMenuCommand
// @grant       GM_removeValueChangeListener
// @grant       GM_setValue
// @grant       GM_unregisterMenuCommand
// @grant       GM_xmlhttpRequest
// ==/UserScript==

function isObject(item) {
  return null != item && "object" == typeof item && !Array.isArray(item);
}

function mergeDeep(target, source) {
  for (const key in source)
    if (isObject(source[key])) {
      if (!(key in target))
        Object.assign(target, {
          [key]: {}
        });
      mergeDeep(target[key], source[key]);
    } else
      Object.assign(target, {
        [key]: source[key]
      });
  return target;
}

var zhRaw = {
  locale: "zh",
  translation: {
    debugMenu: {
      enableDebugMode: {
        text: "启用调试模式"
      },
      disableDebugMode: {
        text: "禁用调试模式"
      }
    },
    totalMutedPosts: {
      text: "总删除帖子: {#}"
    }
  }
};

function verifyTranslation(translation, expectedLocale) {
  if (translation.locale !== expectedLocale)
    throw new TypeError(`Invalid translation locale: expected "${expectedLocale}" but got "${translation.locale}"`);
  return translation;
}

const en = verifyTranslation(
    {
      locale: "en",
      translation: {
        debugMenu: {
          enableDebugMode: {
            text: "Enable Debug Mode"
          },
          disableDebugMode: {
            text: "Disable Debug Mode"
          }
        },
        totalMutedPosts: {
          text: "Total Muted Posts: {#}"
        }
      }
    },
    "en"
  ),
  zh = verifyTranslation(zhRaw, "zh");

class Localization {
  static get SINGLETON() {
    if (null == this.singleton) this.singleton = this.loadSingleton();
    return this.singleton;
  }
  static loadSingleton() {
    return new Localization(en).addTranslation(zh);
  }
  static singleton = null;
  fallbackTranslation;
  currentLocale;
  currentTranslation;
  preferredLocales;
  translations;
  constructor(defaultTranslation) {
    this.currentLocale = null;
    this.currentTranslation = null;
    this.fallbackTranslation = defaultTranslation.translation;
    this.preferredLocales = [];
    this.translations = {
      [defaultTranslation.locale]: this.fallbackTranslation
    };
  }
  addTranslation(translation) {
    const fallbackClone = JSON.parse(JSON.stringify(this.fallbackTranslation));
    this.translations[translation.locale] = mergeDeep(fallbackClone, translation.translation);
    this.populateCurrentTranslation();
    return this;
  }
  get() {
    return null == this.currentTranslation ? this.fallbackTranslation : this.currentTranslation;
  }
  setPreferredLanguages(preferredLanguages) {
    this.preferredLocales = preferredLanguages;
    this.populateCurrentTranslation();
  }
  populateCurrentTranslation() {
    const oldLocale = this.currentLocale;
    let newLocale = null;
    for (const locale of this.preferredLocales)
      if (locale in this.translations) {
        newLocale = locale;
        break;
      }
    if (newLocale !== oldLocale) {
      this.currentLocale = newLocale;
      if (null == newLocale) this.currentTranslation = null;
      else this.currentTranslation = this.translations[newLocale];
    }
  }
}

var STORAGE_KEY;

((STORAGE_KEY) => {
  STORAGE_KEY.DEBUG = "debug";
  STORAGE_KEY.TOTAL_MUTED_POSTS = "totalMutedPosts";
})(STORAGE_KEY || (STORAGE_KEY = {}));

const DEFAULT_VALUES = {
  [STORAGE_KEY.DEBUG]: false,
  [STORAGE_KEY.TOTAL_MUTED_POSTS]: 0
};

class Storage {
  get(key) {
    return this.getValue(key, DEFAULT_VALUES[key]);
  }
  set(key, value) {
    this.setValue(key, value);
  }
  getValue(name, defaultValue) {
    return GM_getValue(name, defaultValue);
  }
  setValue(name, value) {
    return GM_setValue(name, value);
  }
}

const i18n$1 = Localization.SINGLETON;

class DebugMenu {
  callback;
  storage;
  disableDebugId;
  enableDebugId;
  valueChangeListenerId;
  constructor(callback) {
    this.callback = callback;
    this.storage = new Storage();
    this.disableDebugId = null;
    this.enableDebugId = null;
    this.valueChangeListenerId = null;
  }
  draw() {
    if (null != this.valueChangeListenerId) return;
    const debugState = this.storage.get(STORAGE_KEY.DEBUG);
    this.setMenuCommand(debugState);
    this.valueChangeListenerId = GM_addValueChangeListener(STORAGE_KEY.DEBUG, this.valueChangeListener);
  }
  erase() {
    GM_removeValueChangeListener(this.valueChangeListenerId);
    this.valueChangeListenerId = null;
    GM_unregisterMenuCommand(this.disableDebugId);
    this.disableDebugId = null;
    GM_unregisterMenuCommand(this.enableDebugId);
    this.enableDebugId = null;
  }
  enableDebug() {
    if (null != this.disableDebugId) {
      GM_unregisterMenuCommand(this.disableDebugId);
      this.disableDebugId = null;
    }
    if (null == this.enableDebugId)
      this.enableDebugId = GM_registerMenuCommand(i18n$1.get().debugMenu.disableDebugMode.text, () => {
        this.storage.set(STORAGE_KEY.DEBUG, false);
      });
  }
  disableDebug() {
    if (null != this.enableDebugId) {
      GM_unregisterMenuCommand(this.enableDebugId);
      this.enableDebugId = null;
    }
    if (null == this.disableDebugId)
      this.disableDebugId = GM_registerMenuCommand(i18n$1.get().debugMenu.enableDebugMode.text, () => {
        this.storage.set(STORAGE_KEY.DEBUG, true);
      });
  }
  setMenuCommand(enableDebug) {
    if (enableDebug) this.enableDebug();
    else this.disableDebug();
    if (null != this.callback) this.callback(enableDebug);
  }
  valueChangeListener = (name, oldValue, newValue) => {
    this.setMenuCommand(newValue);
  };
}

class AccessToken {
  fromTokenV2(token_v2) {
    return JSON.parse(atob(token_v2.split(".")[1])).sub;
  }
  from___r(___rJSON) {
    return ___rJSON.user.session.accessToken;
  }
  fromWindow(window) {
    if (!("___r" in window)) throw new Error("Unable to retrieve ___r JSON from window.");
    return this.from___r(window.___r);
  }
  fromDocument(document) {
    const dataElement = document.getElementById("data");
    if (null == dataElement)
      throw new Error("Unable to retrieve ___r JSON from document: Could not find 'data' element.");
    const jsonExtractMatcher = dataElement.innerHTML.match(/({.*});$/);
    if (null == jsonExtractMatcher || null == jsonExtractMatcher[1])
      throw new Error("Unable to retrieve ___r JSON from document: Unable to extract text.");
    return this.from___r(JSON.parse(jsonExtractMatcher[1]));
  }
}

class AsyncMutationObserver {
  mutationObserver;
  promise;
  promiseResolve;
  promiseReject;
  constructor(callback) {
    this.mutationObserver = this.mutationObserverSupplier(async (mutationList) => {
      try {
        await callback(mutationList, this);
      } catch (e) {
        this.reject(e);
      }
    });
    this.promise = null;
    this.promiseResolve = null;
    this.promiseReject = null;
  }
  disconnect() {
    this.resolve();
  }
  observe(target, options) {
    if (null == this.promise)
      this.promise = new Promise((resolve, reject) => {
        this.promiseResolve = resolve;
        this.promiseReject = reject;
      });
    this.mutationObserver.observe(target, options);
    return this.promise;
  }
  takeRecords() {
    return this.mutationObserver.takeRecords();
  }
  mutationObserverSupplier(callback) {
    return new MutationObserver(callback);
  }
  resolve() {
    this.mutationObserver.disconnect();
    if (null != this.promise) {
      this.promise = null;
      if (null != this.promiseResolve) {
        this.promiseResolve();
        this.promiseResolve = null;
      }
    }
  }
  reject(reason) {
    this.mutationObserver.disconnect();
    if (null != this.promise) {
      this.promise = null;
      if (null != this.promiseReject) {
        this.promiseReject(reason);
        this.promiseReject = null;
      }
    }
  }
}

class AsyncXMLHttpRequest {
  asyncXMLHttpRequest(details, onLoadPredicate) {
    return new Promise((resolve, reject) => {
      this.xmlHttpRequest({
        timeout: 2e4,
        ...details,
        onabort: () => {
          reject(new Error("Request aborted."));
        },
        onerror: (response) => {
          reject(response);
        },
        onload: (response) => {
          if (onLoadPredicate(response)) resolve(response);
          else reject(response);
        },
        ontimeout: () => {
          reject(new Error("Request timed out."));
        }
      });
    });
  }
  xmlHttpRequest(details) {
    return GM_xmlhttpRequest(details);
  }
}

class Fetch {
  asyncXMLHttpRequest;
  domParser;
  constructor() {
    this.asyncXMLHttpRequest = this.asyncXMLHttpRequestSupplier();
    this.domParser = this.domParserSupplier();
  }
  fetchDocument(url) {
    const request = {
      method: "GET",
      url: url
    };
    return this.asyncXMLHttpRequest
      .asyncXMLHttpRequest(request, (response) => response.status >= 200 && response.status < 300)
      .then((response) => this.domParser.parseFromString(response.responseText, "text/html"));
  }
  fetchMutedSubreddits(accessToken) {
    const request = {
      data: JSON.stringify({
        id: "c09ff0d041c1"
      }),
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${accessToken}`
      },
      method: "POST",
      url: "https://gql.reddit.com/"
    };
    return this.asyncXMLHttpRequest
      .asyncXMLHttpRequest(request, (response) => response.status >= 200 && response.status < 300)
      .then((response) => {
        const responseJSON = JSON.parse(response.responseText);
        if (null == responseJSON.data.identity) throw new Error("User is logged out.");
        return responseJSON.data.identity.mutedSubreddits.edges.map((x) => x.node.name);
      });
  }
  asyncXMLHttpRequestSupplier() {
    return new AsyncXMLHttpRequest();
  }
  domParserSupplier() {
    return new DOMParser();
  }
}

const SUBREDDIT_NAME_ATTRIBUTE = "subreddit-prefixed-name";

class Shreddit {
  document;
  redditSession;
  constructor(document, redditSession) {
    if ("" !== document.body.className) throw new Error("Document is not using the Shreddit layout.");
    this.document = document;
    this.redditSession = redditSession;
  }
  getFeedContainer() {
    const shredditFeedElements = this.document.getElementsByTagName("shreddit-feed");
    if (shredditFeedElements.length < 1) throw new Error("Could not find shreddit-feed element.");
    else if (shredditFeedElements.length > 1) throw new Error("More than one shreddit-feed element.");
    const shredditFeedElement = shredditFeedElements.item(0);
    if (null == shredditFeedElement) throw new Error("shreddit-feed element is null.");
    return shredditFeedElement;
  }
  getMutedPosts(nodeList = [this.document]) {
    return this.redditSession.getMutedSubreddits().then((mutedSubreddits) => {
      const lowerCaseMutedSubreddits = new Set(mutedSubreddits.map((x) => x.toLowerCase())),
        result = [];
      for (const node of nodeList)
        this.getPosts(node)
          .filter((element) => {
            const subredditName = element.getAttribute(SUBREDDIT_NAME_ATTRIBUTE).substring(2);
            return lowerCaseMutedSubreddits.has(subredditName.toLowerCase());
          })
          .forEach((element) => {
            const postContainer = element.parentElement,
              hrElement = postContainer.nextElementSibling;
            result.push({
              elements: [postContainer, hrElement],
              subreddit: element.getAttribute(SUBREDDIT_NAME_ATTRIBUTE)
            });
          });
      return result;
    });
  }
  getPosts(rootNode) {
    return Array.from(rootNode.querySelectorAll("shreddit-post")).filter(
      (element) => null != element.parentElement && element.hasAttribute(SUBREDDIT_NAME_ATTRIBUTE)
    );
  }
}

class RedditFeedFactory {
  redditFeedSuppliers;
  constructor(redditSession) {
    this.redditFeedSuppliers = [(document) => new Shreddit(document, redditSession)];
  }
  getRedditFeed(document) {
    const throwList = [];
    for (const redditFeedSupplier of this.redditFeedSuppliers)
      try {
        return redditFeedSupplier(document);
      } catch (e) {
        throwList.push(e);
      }
    throwList.push(new Error("Could not construct a Reddit Feed from the set of available constructors."));
    throw throwList;
  }
}

function promisify(func) {
  return (...args) =>
    new Promise((resolve) => {
      resolve(func.call(this, ...args));
    });
}

class RedditSession {
  fetch;
  accessToken;
  sessionData;
  storage;
  updateAccessTokenPromise;
  updateMutedSubredditsPromise;
  constructor(accessToken, fetch) {
    this.accessToken = accessToken;
    this.fetch = fetch;
    this.sessionData = {};
    this.storage = this.storageSupplier();
    this.updateAccessTokenPromise = null;
    this.updateMutedSubredditsPromise = null;
  }
  getAccessToken() {
    if (null == this.sessionData.accessToken) return this.updateAccessToken();
    else return Promise.resolve(this.sessionData.accessToken);
  }
  getMutedSubreddits() {
    if (null == this.sessionData.mutedSubreddits) return this.updateMutedSubreddits();
    else return Promise.resolve(this.sessionData.mutedSubreddits);
  }
  updateAccessToken() {
    if (null != this.updateAccessTokenPromise) return this.updateAccessTokenPromise;
    const fromWindow = promisify(() => this.accessToken.fromWindow(this.windowSupplier()));
    this.updateAccessTokenPromise = fromWindow()
      .catch((e) => {
        if (this.storage.get(STORAGE_KEY.DEBUG)) {
          console.warn(e);
          console.warn("Failing back to scraping.");
        }
        return this.fetch
          .fetchDocument("https://new.reddit.com/coins")
          .then((document) => this.accessToken.fromDocument(document));
      })
      .then((accessToken) => {
        this.sessionData.accessToken = accessToken;
        return accessToken;
      })
      .finally(() => {
        this.updateAccessTokenPromise = null;
      });
    return this.updateAccessTokenPromise;
  }
  updateMutedSubreddits() {
    if (null != this.updateMutedSubredditsPromise) return this.updateMutedSubredditsPromise;
    this.updateMutedSubredditsPromise = this.getAccessToken()
      .then((accessToken) => this.fetch.fetchMutedSubreddits(accessToken))
      .then((mutedSubreddits) => {
        this.sessionData.mutedSubreddits = mutedSubreddits;
        return mutedSubreddits;
      })
      .finally(() => {
        this.updateMutedSubredditsPromise = null;
      });
    return this.updateMutedSubredditsPromise;
  }
  storageSupplier() {
    return new Storage();
  }
  windowSupplier() {
    return unsafeWindow;
  }
}

const DEBUG_CLASSNAME = "muted-subreddit-post";

class RedditExpandedCommunityFilter {
  asyncMutationObserver;
  reddit;
  redditSession;
  storage;
  startObservingPromise;
  startPromise;
  styleElement;
  constructor() {
    this.asyncMutationObserver = this.asyncMutationObserverSupplier(this.mutationCallback);
    this.redditSession = this.redditSessionSupplier();
    this.reddit = this.redditSupplier(this.redditSession);
    this.storage = this.storageSupplier();
    this.startObservingPromise = null;
    this.startPromise = null;
    this.styleElement = null;
  }
  start() {
    if (null != this.startPromise) return this.startPromise;
    if (null != this.styleElement) {
      this.styleElement.remove();
      this.styleElement = null;
    }
    this.asyncMutationObserver.disconnect();
    this.styleElement = this.addStyle(`.${DEBUG_CLASSNAME} {border: dashed red;}`);
    let resolveStartObservingPromise = null;
    this.startObservingPromise = new Promise((resolve) => {
      if (null == resolveStartObservingPromise) resolveStartObservingPromise = resolve;
      else resolve();
    });
    const startObserving = () =>
      Promise.resolve()
        .then(this.debugPrintCallback)
        .then(() => this.refresh())
        .then(() => {
          const feedContainerElement = this.reddit.getFeedContainer();
          if (this.storage.get(STORAGE_KEY.DEBUG)) console.log("Feed container", feedContainerElement);
          const options = {
              attributes: false,
              childList: true,
              subtree: true
            },
            observePromise = this.asyncMutationObserver.observe(feedContainerElement, options);
          if (null != resolveStartObservingPromise && true !== resolveStartObservingPromise)
            resolveStartObservingPromise();
          else resolveStartObservingPromise = true;
          return observePromise;
        });
    this.startPromise = Promise.all([
      this.redditSession.updateAccessToken(),
      this.redditSession.updateMutedSubreddits()
    ])
      .then(() => startObserving)
      .catch((e) => {
        if (this.storage.get(STORAGE_KEY.DEBUG)) console.warn(e);
        else if (e instanceof Error) console.log(`${e.name}:`, e.message);
        else console.warn(e);
      })
      .then((func) => {
        if (null != func) return func();
      })
      .finally(() => {
        this.startPromise = null;
        if (null != this.styleElement) this.styleElement.remove();
      });
    return this.startPromise;
  }
  stop() {
    if (null == this.startObservingPromise) return Promise.resolve();
    else
      return this.startObservingPromise.then(() => {
        if (null != this.startPromise) {
          this.asyncMutationObserver.disconnect();
          return this.startPromise;
        }
      });
  }
  refresh() {
    return this.reddit.getMutedPosts().then((redditPosts) => {
      for (const redditPost of redditPosts) this.mutePost(redditPost);
    });
  }
  containsText(node) {
    return 1 === node.childNodes.length && node.childNodes[0].nodeType === Node.TEXT_NODE;
  }
  debugPrintCallback = () => {
    if (this.storage.get(STORAGE_KEY.DEBUG))
      return this.redditSession.getMutedSubreddits().then((mutedSubreddits) => {
        console.log("Muted subreddits:", mutedSubreddits);
      });
  };
  filteredMutationCallback(addedNodes) {
    if (0 === addedNodes.length) return Promise.resolve();
    if (this.storage.get(STORAGE_KEY.DEBUG)) console.debug("Added nodes:", addedNodes);
    return this.reddit.getMutedPosts(addedNodes).then((redditPosts) => {
      for (const redditPost of redditPosts) this.mutePost(redditPost);
    });
  }
  isHTMLElement(node) {
    return (
      "offsetHeight" in node &&
      "offsetLeft" in node &&
      "offsetTop" in node &&
      "offsetWidth" in node &&
      "querySelectorAll" in node
    );
  }
  isVisible(element) {
    if ("checkVisibility" in element) return element.checkVisibility();
    else return true;
  }
  mutationCallback = (mutations) => {
    const addedElementNodes = mutations
      .filter((mutation) => "childList" === mutation.type && mutation.addedNodes.length > 0)
      .flatMap((mutation) => Array.from(mutation.addedNodes))
      .filter(
        (addedNode) =>
          null != addedNode.parentElement &&
          null != addedNode.parentNode &&
          this.isHTMLElement(addedNode) &&
          !this.containsText(addedNode) &&
          this.isVisible(addedNode)
      );
    return this.filteredMutationCallback(addedElementNodes);
  };
  mutePost(redditPost) {
    for (const element of redditPost.elements)
      if (this.storage.get(STORAGE_KEY.DEBUG)) {
        if (!element.classList.contains(DEBUG_CLASSNAME)) {
          element.classList.add(DEBUG_CLASSNAME);
          console.log(`Highlighted ${redditPost.subreddit} post (muted subreddit):`, redditPost.elements);
        }
      } else {
        element.remove();
        const newTotalMutedPosts = Math.max(0, this.storage.get(STORAGE_KEY.TOTAL_MUTED_POSTS)) + 1;
        this.storage.set(STORAGE_KEY.TOTAL_MUTED_POSTS, newTotalMutedPosts);
      }
  }
  addStyle(css) {
    return GM_addStyle(css);
  }
  asyncMutationObserverSupplier(callback) {
    return new AsyncMutationObserver(callback);
  }
  redditSupplier(redditSession) {
    return new RedditFeedFactory(redditSession).getRedditFeed(document);
  }
  redditSessionSupplier() {
    return new RedditSession(new AccessToken(), new Fetch());
  }
  storageSupplier() {
    return new Storage();
  }
}

const i18n = Localization.SINGLETON;

class TotalMutedPostsCounter {
  storage;
  counterId;
  valueChangeListenerId;
  constructor() {
    this.storage = new Storage();
    this.counterId = null;
    this.valueChangeListenerId = null;
  }
  draw() {
    if (null == this.valueChangeListenerId) {
      this.updateCounter(this.storage.get(STORAGE_KEY.TOTAL_MUTED_POSTS));
      this.valueChangeListenerId = GM_addValueChangeListener(STORAGE_KEY.TOTAL_MUTED_POSTS, this.valueChangeListener);
    }
  }
  erase() {
    GM_removeValueChangeListener(this.valueChangeListenerId);
    this.valueChangeListenerId = null;
    GM_unregisterMenuCommand(this.counterId);
    this.counterId = null;
  }
  emptyFunction = () => {};
  updateCounter(count) {
    if (null != this.counterId) {
      GM_unregisterMenuCommand(this.counterId);
      this.counterId = null;
    }
    const name = i18n.get().totalMutedPosts.text.replaceAll("{#}", count.toString());
    this.counterId = GM_registerMenuCommand(name, this.emptyFunction);
  }
  valueChangeListener = (name, oldValue, newValue) => {
    this.updateCounter(newValue);
  };
}

Localization.SINGLETON.setPreferredLanguages(navigator.languages);

window.addEventListener("languagechange", () => {
  Localization.SINGLETON.setPreferredLanguages(navigator.languages);
});

const redditExpandedCommunityFilter = new RedditExpandedCommunityFilter(),
  debugMenu = new DebugMenu((enableDebug) => {
    if (!enableDebug) redditExpandedCommunityFilter.refresh();
  }),
  storage = new Storage(),
  totalMutedPostsCounter = new TotalMutedPostsCounter();

debugMenu.draw();

totalMutedPostsCounter.draw();

redditExpandedCommunityFilter
  .start()
  .then(() => {
    if (storage.get(STORAGE_KEY.DEBUG)) console.log("Stopped script.");
  })
  .catch((e) => {
    console.error(e);
  });