MangaDex: forums button

Helps navigate to MangaDex Forums faster

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 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.

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

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

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

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

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

// ==UserScript==
// @name         MangaDex: forums button
// @namespace    https://andrybak.dev
// @version      5
// @description  Helps navigate to MangaDex Forums faster
// @author       Andrei Rybak
// @license      MIT
// @match        https://mangadex.org/chapter/*
// @icon         https://mangadex.org/pwa/icons/icon-180.png
// @grant        none
// @require      https://cdn.jsdelivr.net/gh/rybak/userscript-libs@dc32d5897dcfa40a01c371c8ee0e211162dfd24c/waitForElement.js
// ==/UserScript==

/*
 * Copyright (c) 2026 Andrei Rybak
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

/* globals waitForElement */

(function() {
	'use strict';

	const LOG_PREFIX = '[MangaDex: forums button]';
	function error(...toLog) {
		console.error(LOG_PREFIX, ...toLog);
	}
	function warn(...toLog) {
		console.warn(LOG_PREFIX, ...toLog);
	}
	function info(...toLog) {
		console.info(LOG_PREFIX, ...toLog);
	}
	function debug(...toLog) {
		console.debug(LOG_PREFIX, ...toLog);
	}

	/*
	 * Selector for the builtin button that opens the new comment section.
	 */
	const THE_BUTTON_SELECTOR = '.md--reader-menu hr + button.accent';
	/*
	 * Selector for the link to the forums in the hidden slide-out with the new comment section.
	 * Desktop, mobile.
	 */
	const FORUMS_LINK_SELECTOR = '.md-content .fixed a[href^="https://forums"], .drawer-mount-area.isolate .fixed a[href^="https://forums"]';
	const FORUMS_BUTTON_ID = 'andrybakForumsButton';
	const FORUMS_BUTTON_SPAN_ID = 'andrybakForumsButtonText';
	const cache = new Map();

	function getChapterId(maybeUrl) {
		const CHAPTER_ID_REGEX = /https:\/\/mangadex\.org\/chapter\/([a-f0-9-]+)(\/[0-9]+)?/g;
		const s = maybeUrl ? maybeUrl : document.location.href;
		const m = CHAPTER_ID_REGEX.exec(s);
		if (!m || !m[1]) {
			info('Not a chapter page:', s);
			info('Got regex result:', m);
			return null;
		}
		return m[1];
	}

	function getStats(chapterId) {
		if (cache[chapterId]) {
			debug('Cache hit for', chapterId);
			return Promise.resolve(cache[chapterId]);
		}
		const apiUrlStats = `https://api.mangadex.org/statistics/chapter/?chapter%5B%5D=${chapterId}`;
		info('Fetching stats API endpoint:', apiUrlStats, ' ...');
		return new Promise((resolve, reject) => {
			fetch(apiUrlStats).then(
				response => {
					if (!response.ok) {
						error('API returned error:', response.status, response.body);
						reject('API error');
						return;
					}
					info('Got something. Parsing JSON...');
					response.json().then(json => {
						const forumThreadId = json?.statistics[chapterId]?.comments?.threadId;
						if (!forumThreadId) {
							error('Cannot find threadId in JSON:', json);
							reject('JSON parsing error');
							return;
						}
						info('New threadId =', forumThreadId);
						const newNumber = json?.statistics[chapterId]?.comments?.repliesCount;
						cache[chapterId] = {
							'threadId': forumThreadId,
							'repliesCount' : newNumber
						};
						resolve(cache[chapterId]);
					}, rejection => {
						error('Cannot get JSON from response.', rejection);
						reject(rejection);
					});
				},
				rejection => {
					error(`Cannot load '${apiUrlStats}'.`, rejection);
					reject(rejection);
				}
			);
		});
	}

	function updateCounter(newNumber) {
		const mySpan = document.getElementById(FORUMS_BUTTON_SPAN_ID);
		mySpan.replaceChildren(document.createTextNode(`Forums: ${newNumber}`));
		info('Updated the button text.');
	}

	function getButton(newNumber) {
		const maybeAlreadyCreated = document.getElementById(FORUMS_BUTTON_ID);
		if (maybeAlreadyCreated != null && maybeAlreadyCreated.parentNode != null) {
			updateCounter(newNumber);
			return maybeAlreadyCreated;
		}
		const newButton = document.createElement('a');
		newButton.id = FORUMS_BUTTON_ID;
		const newSpan = document.createElement('span');
		newSpan.id = FORUMS_BUTTON_SPAN_ID;
		// reproducing styling of `theButton`
		newButton.classList.add('rounded', 'custom-opacity', 'relative', 'md-btn', 'flex', 'items-center', 'px-3', 'overflow-hidden', 'accent', 'px-4');
		newButton.setAttribute('data-v-0082f4a3', '');
		newButton.style.minHeight = '2.5rem';
		newButton.style.minWidth = '100%';
		newSpan.classList.add('flex', 'relative', 'items-center', 'justify-center', 'font-medium', 'select-none', 'w-full');
		newSpan.append(`Forums: ${newNumber}`);
		newButton.target = '_blank'; // to make it open in a new tab
		newButton.append(newSpan);
		waitForElement(`${THE_BUTTON_SELECTOR} > span > svg.icon`).then(ignored => {
			const theButton = document.querySelector(THE_BUTTON_SELECTOR);
			theButton.parentNode.insertBefore(newButton, theButton);
			info('Inserted new button');
		});
		info('Created new button.');
		return newButton;
	}

	let chapterId = getChapterId();
	function chapterChanged() {
		if (!chapterId) {
			error('Cannot find chapterId. Aborting');
			return;
		}
		getStats(chapterId).then(stats => {
			const forumUrl = `https://forums.mangadex.org/threads/${stats.threadId}`;
			const myButton = getButton(stats.repliesCount);
			myButton.href = forumUrl;
		}, rejection => {
			error(`Cannot load stats for chapterId=${chapterId}. Got error:`, rejection);
		});
	}
	chapterChanged(); // fire the "listener" immediately

	function checkChapterId(logMessage, newUrl) {
		const maybeNewChapterId = getChapterId(newUrl);
		info(`${logMessage} triggered. maybeNewChapterId = ${maybeNewChapterId}`);
		if (chapterId != maybeNewChapterId) {
			chapterId = maybeNewChapterId;
			info('CHAPTER ID CHANGED:', chapterId);
			chapterChanged();
		}
	}

	function observe(selector) {
		const observer = new MutationObserver(mutations => {
			checkChapterId(`Observer for "${selector}"`);
		});
		waitForElement(selector).then(e => {
			observer.observe(e, {
				childList: true,
				subtree: true
			});
			info('Connected the observer to', e);
		});
	}
	observe(THE_BUTTON_SELECTOR);
	window.navigation.addEventListener('navigate', (event) => {
		// info(event);
		checkChapterId('window.navigation "navigate" event', event.destination.url);
	});
})();