Helps navigate to MangaDex Forums faster
// ==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);
});
})();