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