Facebook All Comments Helper

Easy way to show all comments.

// ==UserScript==
// @name         Facebook All Comments Helper
// @name:zh-TW   FB全部留言小幫手
// @name:zh-CN   FB全部留言小帮手
// @namespace    https://github.com/Xuitty/FBallComments
// @version      2.4
// @description  Easy way to show all comments.
// @description:zh-tw  讓您更快打開全部留言
// @description:zh-cn  让您更快打开全部留言
// @author       Xuitty
// @match        https://www.facebook.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=facebook.com
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM.registerMenuCommand
// @grant        GM.setValue
// @grant        GM.getValue
// @run-at       document-end
// @license      MIT
// ==/UserScript==

/**
 * string array for detecting the menu button
 */

const langs = {
	de: ["Relevanteste", "Top-Kommentare", "Am zutreffendsten", "Neueste zuerst", "Neueste", "Alle Kommentare"],
	en: ["Top comments", "Most relevant", "Most applicable", "Most recent", "Newest", "All comments"],
	es: ["Comentarios destacados", "Más relevantes", "Más pertinentes", "Más recientes", "Más recientes", "Todos los comentarios"],
	hu: ["A legfontosabb hozzászólások", "A legrelevánsabbak", "A témához leginkább illők", "A legújabbak", "A legutóbbiak", "Az összes hozzászólás"],
	ja: ["トップコメント", "関連度の高い順", "最も適切", "新しい順", "新しい順", "すべてのコメント"],
	ko: ["관련성 높은 댓글", "참여도 높은 댓글", "적합성 높은 순", "최신순", "날짜 내림차순", "모든 댓글"],
	fr: ["Plus pertinents", "Les meilleurs commentaires", "Les plus pertinents", "Plus récents", "Les plus récents", "Tous les commentaires"],
	sk: ["Top komentáre", "Najrelevantnejšie", "Najvhodnejšie", "Najnovšie", "Najnovšie", "Všetky komentáre"],
	sl: ["Najbolj priljubljeni komentarji", "Najustreznejši", "Najustreznejše", "Najnovejši", "Najnovejši", "Vsi komentarji"],
	"zh-Hans": ["热门评论", "最相关", "最合适", "从新到旧", "最新", "所有评论"],
	"zh-Hant": ["最熱門留言", "最相關", "最相關", "最新", "由新到舊", "所有留言"],
};

/**
 * string array for notification
 */

const notificationStr = {
	de: ["Wechseln zu allen Kommentaren!", "Wechseln zu den neuesten Kommentaren!"],
	en: ["Switch to All Comments!", "Switch to Latest Comments!"],
	es: ["Cambiar a todos los comentarios!", "Cambiar a los comentarios más recientes!"],
	hu: ["Váltás az összes hozzászólásra!", "Váltás a legújabb hozzászólásokra!"],
	ja: ["すべてのコメントに切り替え!", "最新のコメントに切り替え!"],
	ko: ["모든 댓글로 전환!", "최신 댓글로 전환!"],
	fr: ["Passer à tous les commentaires!", "Passer aux commentaires les plus récents!"],
	sk: ["Prepnúť na všetky komentáre!", "Prepnúť na najnovšie komentáre!"],
	sl: ["Preklopite na vse komentarje!", "Preklopite na najnovejše komentarje!"],
	"zh-Hant": ["切換到所有留言!", "切換到最新留言!"],
	"zh-Hans": ["切换到所有评论!", "切换到最新评论!"],
};

/**
 * string array for settings
 */

const settingsStr = {
	"zh-Hant": {
		settings: "設定",
		autoDetect: "自動切換為全部/最新留言",
		notifyEnabled: "操作通知",
		isAll: "全部留言/最新留言",
		isScroll: "是否開啟捲動",
		scrollBehavior: "捲動特效",
		hideSettings: "隱藏選單",
		smooth: "平滑",
		auto: "無",
		openMenu: "開啟選單",
		needRefresh: "需要重新整理頁面應用自動切換",
	},
	"zh-Hans": {
		settings: "设置",
		autoDetect: "自动切换为全部/最新评论",
		notifyEnabled: "操作通知",
		isAll: "全部评论/最新评论",
		isScroll: "是否开启滚动",
		scrollBehavior: "滚动特效",
		hideSettings: "隐藏菜单",
		smooth: "平滑",
		auto: "无",
		openMenu: "打开菜单",
		needRefresh: "需要重新刷新页面应用自动切换",
	},
	en: {
		settings: "Settings",
		autoDetect: "Auto detect all/latest comments",
		notifyEnabled: "Notify after action",
		isAll: "All comments/Latest comments",
		isScroll: "Enable scroll effect",
		scrollBehavior: "Scroll behavior",
		hideSettings: "Hide settings",
		smooth: "Smooth",
		auto: "None",
		openMenu: "Open menu",
		needRefresh: "Need to refresh the page to apply auto detection",
	},
	de: {
		settings: "Einstellungen",
		autoDetect: "Automatisch alle/neuesten Kommentare erkennen",
		notifyEnabled: "Nach Aktion benachrichtigen",
		isAll: "Alle Kommentare/Neueste Kommentare",
		isScroll: "Bildlauf aktivieren",
		scrollBehavior: "Bildlaufverhalten",
		hideSettings: "Einstellungen ausblenden",
		smooth: "Sanft",
		auto: "Keine",
		openMenu: "Menü öffnen",
		needRefresh: "Die Seite muss aktualisiert werden, um die automatische Erkennung anzuwenden",
	},
	es: {
		settings: "Ajustes",
		autoDetect: "Detectar automáticamente todos/los comentarios más recientes",
		notifyEnabled: "Notificar después de la acción",
		isAll: "Todos los comentarios/Comentarios más recientes",
		isScroll: "Activar efecto de desplazamiento",
		scrollBehavior: "Comportamiento de desplazamiento",
		hideSettings: "Ocultar ajustes",
		smooth: "Suave",
		auto: "Ninguno",
		openMenu: "Abrir menú",
		needRefresh: "Necesita actualizar la página para aplicar la detección automática",
	},
	fr: {
		settings: "Paramètres",
		autoDetect: "Détecter automatiquement tous/les derniers commentaires",
		notifyEnabled: "Notifier après l'action",
		isAll: "Tous les commentaires/Derniers commentaires",
		isScroll: "Activer l'effet de défilement",
		scrollBehavior: "Comportement de défilement",
		hideSettings: "Masquer les paramètres",
		smooth: "Doux",
		auto: "Aucun",
		openMenu: "Ouvrir le menu",
		needRefresh: "Besoin de rafraîchir la page pour appliquer la détection automatique",
	},
	hu: {
		settings: "Beállítások",
		autoDetect: "Az összes/legújabb hozzászólás automatikus észlelése",
		notifyEnabled: "Értesítés az akció után",
		isAll: "Minden megjegyzés/Legfrissebb megjegyzések",
		isScroll: "Gördítési hatás engedélyezése",
		scrollBehavior: "Gördülési viselkedés",
		hideSettings: "Beállítások elrejtése",
		smooth: "Simít",
		auto: "Nincs",
		openMenu: "Menü megnyitása",
		needRefresh: "Az automatikus észlelés alkalmazásához frissíteni kell az oldalt",
	},
	ja: {
		settings: "設定",
		autoDetect: "すべて/最新のコメントを自動検出",
		notifyEnabled: "アクション後に通知",
		isAll: "すべてのコメント/最新のコメント",
		isScroll: "スクロール効果を有効にする",
		scrollBehavior: "スクロール動作",
		hideSettings: "設定を非表示",
		smooth: "スムーズ",
		auto: "なし",
		openMenu: "メニューを開く",
		needRefresh: "自動検出を適用するにはページを更新する必要があります",
	},
	ko: {
		settings: "설정",
		autoDetect: "모든/최신 댓글 자동 감지",
		notifyEnabled: "작업 후 알림",
		isAll: "모든 댓글/최신 댓글",
		isScroll: "스크롤 효과 사용",
		scrollBehavior: "스크롤 동작",
		hideSettings: "설정 숨기기",
		smooth: "부드러운",
		auto: "없음",
		openMenu: "메뉴 열기",
		needRefresh: "자동 감지를 적용하려면 페이지를 새로 고쳐야 합니다",
	},
	sk: {
		settings: "Nastavenia",
		autoDetect: "Automaticky zistiť všetky/najnovšie komentáre",
		notifyEnabled: "Upozorniť po akcii",
		isAll: "Všetky komentáre/Najnovšie komentáre",
		isScroll: "Povoliť posuvný efekt",
		scrollBehavior: "Správanie posuvu",
		hideSettings: "Skryť nastavenia",
		smooth: "Hladký",
		auto: "Žiadny",
		openMenu: "Otvoriť menu",
		needRefresh: "Na použitie automatického zistenia je potrebné obnoviť stránku",
	},
	sl: {
		settings: "Nastavitve",
		autoDetect: "Samodejno zaznani vsi/najnovejši komentarji",
		notifyEnabled: "Obvesti po dejanju",
		isAll: "Vsi komentarji/Najnovejši komentarji",
		isScroll: "Omogoči učinek drsenja",
		scrollBehavior: "Vedenje drsenja",
		hideSettings: "Skrij nastavitve",
		smooth: "Gladko",
		auto: "Brez",
		openMenu: "Odpri meni",
		needRefresh: "Za uporabo samodejnega zaznavanja je treba osvežiti stran",
	},
};

/**
 * get the language of the fb
 * @returns the language of the fb
 */
function detectLang() {
	return document.getElementById("facebook")?.getAttribute("lang") || "en";
}

/**
 * get the settings string array
 * @returns the settings string array
 */

function getSettingsStr() {
	return settingsStr[detectLang()] || settingsStr.en;
}

/**
 * get the xpath for menu
 * @returns the xpath for menu
 */

function getMenuButtonXPath() {
	const lang = langs[detectLang()] || langs.en;
	return `//span[not(@style) and (text()='${lang[0]}' or text()='${lang[1]}' or text()='${lang[2]}' or text()='${lang[3]}' or text()='${lang[4]}' or text()='${lang[5]}')]`;
}

/**
 * handle the click the comment button or the right bottom comment count
 */

function handleClickOutside() {
	if (settings.isAll) showAllComment();
	else showLatestComment();
}

/**
 * wait for the element to appear in the DOM
 * @param {string} xpath
 * @param {Function} callback
 * @param {number} timeout
 * @param {number} interval
 */

function waitForElement(xpath, callback, timeout = 3000, intervalTime = 100) {
	const startTime = Date.now();
	const interval = setInterval(() => {
		const element = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
		if (element) {
			clearInterval(interval);
			callback(element);
		} else if (Date.now() - startTime > timeout) {
			clearInterval(interval);
			console.warn("Timeout: Element not found for XPath", xpath);
		}
	}, intervalTime);
}

/**
 *
 * @param {Element} element
 */

function safeClick(element) {
	try {
		element.click();
	} catch (err) {
		console.error("Error clicking element:", err);
	}
}

/**
 * show the notification to user after the action
 * override is for mode in settings is allcomments and user press ctrl+dblclick/ctrl+insert
 * @param {boolean} reverse
 */

async function notifyUser(reverse = false) {
	const notification = document.createElement("div");
	notification.setAttribute("id", "FBAllCommentsHelperNotification");
	notification.style.position = "fixed";
	notification.style.bottom = "20px";
	notification.style.left = "20px";
	notification.style.backgroundColor = "rgba(0,0,0,1)";
	notification.style.color = "white";
	notification.style.padding = "10px";
	notification.style.borderRadius = "5px";
	notification.style.zIndex = "9999";
	const notifyingTimeout = Number(await GM.getValue("notifyingTimeout"));
	if (reverse) {
		notification.textContent = notificationStr[detectLang()][settings.isAll ? 1 : 0] || notificationStr.en[settings.isAll ? 1 : 0];
	} else {
		notification.textContent = notificationStr[detectLang()][settings.isAll ? 0 : 1] || notificationStr.en[settings.isAll ? 0 : 1];
	}
	document.body.appendChild(notification);
	if (notifyingTimeout) {
		document.getElementById("FBAllCommentsHelperNotification").remove();
		clearTimeout(notifyingTimeout);
		await GM.setValue("notifyingTimeout", undefined);
	}
	const id = setTimeout(async () => {
		notification.remove();
		await GM.setValue("notifyingTimeout", undefined);
	}, 3000);
	await GM.setValue("notifyingTimeout", id);
}

/**
 * parse the user action
 * @param {*} e
 * @returns
 */

function actionParser(e) {
	if (e.type === "dblclick" && e.ctrlKey) {
		!settings.isAll ? showAllComment(true) : showLatestComment(true);
		return;
	}
	if (e.type === "dblclick") {
		settings.isAll ? showAllComment() : showLatestComment();
		return;
	}
	if (e.code === "Insert" && e.ctrlKey) {
		!settings.isAll ? showAllComment(true) : showLatestComment(true);
		return;
	}
	if (e.code === "Insert") {
		settings.isAll ? showAllComment() : showLatestComment();
		return;
	}
}

/**
 * detecting the changes in the DOM
 * @param {Function} callback
 */

function observeDOM(callback) {
	const observer = new MutationObserver((mutations) => {
		mutations.forEach(() => callback());
	});
	observer.observe(document.body, { childList: true, subtree: true });
}

/**
 * show all comments
 */

function showAllComment(reverse = false) {
	waitForElement(
		//getting the menu
		getMenuButtonXPath(),
		(element) => {
			if (settings.isScroll) {
				element.scrollIntoView({ behavior: settings.scrollBehavior, block: "center" });
			}
			setTimeout(() => {
				safeClick(element);
			}, 100);
			waitForElement(
				//getting the items in menu
				"//*[@role='menuitem']",
				(element) => {
					const menuItems = document.querySelectorAll('*[role="menuitem"]');
					if (menuItems.length > 1) {
						safeClick(menuItems[menuItems.length - 1]);
						if (settings.notifyEnabled) {
							notifyUser(reverse);
						}
					}
				}
			);
		}
	);
}

/**
 * show latest comment
 * override is for mode in settings is allcomments and user press ctrl+dblclick/ctrl+insert
 * @param {boolean} reverse
 */
function showLatestComment(reverse = false) {
	waitForElement(
		//getting the menu
		getMenuButtonXPath(),
		(element) => {
			if (settings.isScroll) {
				element.scrollIntoView({ behavior: settings.scrollBehavior, block: "center" });
			}
			setTimeout(() => {
				safeClick(element);
			}, 100);
			waitForElement(
				//getting the items in menu
				"//*[@role='menuitem']",
				(element) => {
					const menuItems = document.querySelectorAll('*[role="menuitem"]');
					if (menuItems.length > 1) {
						safeClick(menuItems[menuItems.length - 2]);
						if (settings.notifyEnabled) {
							notifyUser(reverse);
						}
					}
				}
			);
		}
	);
}

/**
 * bind the event for detected object after the DOM changes
 */

function bindForDetected(action = "bind") {
	let commentRightBottomBtn = document.querySelectorAll("div[role='button'][tabindex='0'][id^=':']");
	let commentBtn = document.querySelectorAll("span[data-ad-rendering-role='comment_button']");
	commentRightBottomBtn.forEach((btn) => {
		if (action === "remove") {
			btn.removeEventListener("click", handleClickOutside);
		} else {
			btn.addEventListener("click", handleClickOutside);
		}
	});
	commentBtn.forEach((btn) => {
		if (action === "remove") {
			btn.parentElement.parentElement.parentElement.removeEventListener("click", handleClickOutside);
		} else {
			btn.parentElement.parentElement.parentElement.addEventListener("click", handleClickOutside);
		}
	});
}

/**
 * default settings
 */

const settings = {
	autoDetect: true, // auto detect all/latest comments
	scrollBehavior: "smooth", // scroll behavior
	notifyEnabled: true, // notify after action
	isAll: true, // all comments/latest comments
	isHidden: false, // settings panel hidden
	isScroll: false, // enable scroll effect
};

/**
 * save settings to gm storage
 */

async function saveSettings() {
	await GM.setValue("fbAllCommentsHelperSettings", JSON.stringify(settings));
}

/**
 * load settings from gm storage
 */

async function loadSettings() {
	const storedSettings = await GM.getValue("fbAllCommentsHelperSettings");
	if (storedSettings) {
		Object.assign(settings, JSON.parse(storedSettings));
	}
}

/**
 * create settings panel
 */

function createSettingsPanel() {
	const panel = document.createElement("div");
	panel.id = "settingsPanel";
	panel.style.position = "fixed";
	panel.style.top = "10px";
	panel.style.right = "10px";
	panel.style.backgroundColor = "rgba(0, 0, 0, 1)";
	panel.style.padding = "10px";
	panel.style.borderRadius = "5px";
	panel.style.zIndex = "9999";
	panel.style.fontSize = "14px";

	panel.innerHTML = `
        <h4 style="margin: 0 0 10px;color: white;">${getSettingsStr().settings}</h4>
        <label style="color: white;">
            <input type="checkbox" id="autoDetect" ${settings.autoDetect ? "checked" : ""}>
            ${getSettingsStr().autoDetect}
        </label><br>
        <label style="color: white;">
            <input type="checkbox" id="notifyEnabled" ${settings.notifyEnabled ? "checked" : ""}>
            ${getSettingsStr().notifyEnabled}
        </label><br>
		<label style="color: white;">
            <input type="checkbox" id="isAll" ${settings.isAll ? "checked" : ""}>
            ${getSettingsStr().isAll}
        </label><br>
		<label style="color: white;">
            <input type="checkbox" id="isScroll" ${settings.isScroll ? "checked" : ""}>
            ${getSettingsStr().isScroll}
        </label><br>
        <label style="color: white;">
		${getSettingsStr().scrollBehavior}
            <select id="scrollBehavior">
                <option value="smooth" ${settings.scrollBehavior === "smooth" ? "selected" : ""}>${getSettingsStr().smooth}</option>
                <option value="auto" ${settings.scrollBehavior === "auto" ? "selected" : ""}>${getSettingsStr().auto}</option>
            </select>
        </label><br>
        <button id="hideSettings" style="margin-top: 10px;">${getSettingsStr().hideSettings}</button>
    `;

	document.body.appendChild(panel);

	// add panel buttons event listeners
	document.getElementById("autoDetect").addEventListener("change", (e) => {
		settings.autoDetect = e.target.checked;
		saveSettings();
		alert(getSettingsStr().needRefresh);
	});
	document.getElementById("notifyEnabled").addEventListener("change", (e) => {
		settings.notifyEnabled = e.target.checked;
		saveSettings();
	});
	document.getElementById("isAll").addEventListener("change", (e) => {
		settings.isAll = e.target.checked;
		saveSettings();
	});
	document.getElementById("isScroll").addEventListener("change", (e) => {
		settings.isScroll = e.target.checked;
		saveSettings();
	});
	document.getElementById("scrollBehavior").addEventListener("change", (e) => {
		settings.scrollBehavior = e.target.value;
		saveSettings();
	});
	document.getElementById("hideSettings").addEventListener("click", () => {
		settings.isHidden = true;
		document.getElementById("settingsPanel").remove();
		saveSettings();
	});
}

(async function () {
	"use strict";
	window.addEventListener("load", async () => {
		await loadSettings();
		await saveSettings();
		await GM_registerMenuCommand(getSettingsStr().openMenu, () => {
			// gm menu command
			createSettingsPanel();
			settings.isHidden = false;
		});
		if (!settings.isHidden) {
			// create settings panel when isHidden is false
			createSettingsPanel();
		}
		document.addEventListener("dblclick", actionParser); //handle dblclick event
		document.addEventListener("keydown", actionParser); //handle keydown event
		bindForDetected("remove");
		if (settings.autoDetect) {
			bindForDetected();
			// for auto detect
			observeDOM(bindForDetected);
		}
	});
})();