AO3: [Wrangling] View and Post Comments from the Bin

Loads a preview of top-level comments (such as translations) and lets you comment on the tag

As of 2025-04-13. See the latest version.

// ==UserScript==
// @name         AO3: [Wrangling] View and Post Comments from the Bin
// @namespace    https://greasyfork.org/en/users/906106-escctrl
// @description  Loads a preview of top-level comments (such as translations) and lets you comment on the tag
// @author       escctrl
// @version      0.4
// @match        *://*.archiveofourown.org/tags/*/wrangle?*
// @grant        none
// @require      https://ajax.googleapis.com/ajax/libs/jquery/3.7.0/jquery.min.js
// @require      https://ajax.googleapis.com/ajax/libs/jqueryui/1.13.2/jquery-ui.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/jqueryui-touch-punch/0.2.3/jquery.ui.touch-punch.min.js
// @require      https://update.greasyfork.org/scripts/491888/1355841/Light%20or%20Dark.js
// @license      MIT
// ==/UserScript==

/* eslint-disable no-multi-spaces */
/* global jQuery, lightOrDark */

(function($) {
    'use strict';

    if ($('#wrangulator').length === 0) return; // bow out in an empty bin

    /***** INITIALIZE THE COMMENTS DIALOG *****/

    let dlg = "#peekTopLevelCmt";
    // prepare the HTML framework within the new dialog including the textarea
    $("#main").append(`<div id="peekTopLevelCmt">
            <div id="toplvlcmt-placeholder"></div>
            <div id="add-toplvlcmt">
                <div id="pseud-toplvlcmt"></div>
                <textarea data-tagname="" id="txt-toplvlcmt"></textarea>
                <p class="submit"><button type="button" class="submitComment">Comment<span class="spin"/></button></p>
            </div>
        </div>`);

    // adding the jQuery stylesheet to style the dialog, and fixing the interference of AO3's styling
    if(document.head.querySelector('link[href$="/jquery-ui.css"]') === null) {
        // if the background is dark, use the dark UI theme to match
        let dialogtheme = lightOrDark($('body').css('background-color')) == "dark" ? "dark-hive" : "base";
        $("head").append(`<link rel="stylesheet" href="https://code.jquery.com/ui/1.13.2/themes/${dialogtheme}/jquery-ui.css">`);
    }
    $("head").append(`<style type="text/css">.ui-widget, ${dlg}, .ui-dialog .ui-dialog-buttonpane button { font-size: revert; line-height: 1.286; }
        ${dlg} .toplvlcmt { margin-top: 1em; clear: right; }
        ${dlg} .toplvlcmt .toplvlby { font-size: 0.8em; margin: 0; float: right; }
        ${dlg} .toplvlcmt .userstuff { border-left: 2px dotted ${$(dlg).css('color')}; padding-left: 0.5em; }
        ${dlg} .fontawesomeicon { display: inline-block; width: 1em; height: 1em; vertical-align: -0.125em; color: ${$(dlg).css('color')} }
        ${dlg} textarea { width: 100%; resize: vertical; height: 10em; }
        ${dlg} #add-toplvlcmt { margin-top: 1em; }

        #load-toplvlcmt, ${dlg} .submitComment { white-space: nowrap; }
        #load-toplvlcmt .spin, ${dlg} .submitComment .spin { display: none; margin-left: 0.5em; }
        #load-toplvlcmt .spin::after {
            content: "\\2312";
            display: inline-block;
            animation: loading 3s linear infinite;
        }
        @keyframes loading {
          0% { transform: rotate(0deg); }
          100% { transform: rotate(360deg); }
        }
        </style>`);

    // optimizing the size of the GUI in case it's a mobile device
    let dialogwidth = parseInt($("body").css("width")); // parseInt ignores letters (px)
    dialogwidth = dialogwidth > 700 ? 700 : dialogwidth * 0.9;

    $(dlg).dialog({
        appendTo: "#main",
        modal: false,
        autoOpen: false,
        resizable: false,
        width: dialogwidth,
        title: "Tag Comment Threads"
    });

    // we add a button the Manage header to load all comments on this page at once
    $('#wrangulator').find('thead th').filter(function(ix, el) { return $(this).text() === "Manage"; })
                                      .append(`<br /><button type="button" id="load-toplvlcmt">Load All Comments<span class="spin"/></button>`);

    /***** BUTTON EVENTS *****/

    $('#wrangulator').on('click','#load-toplvlcmt', function(e) {
        e.preventDefault();
        loadAllTopLevelComments();
    });

    // we co-opt the /comments link and instead of opening the page, we give a little additional dialog
    $('#wrangulator').on('click','a[href$="/comments"]', async function(e) {
        e.preventDefault();
        let row = $(e.target).parents('tr')[0];

        if (row.dataset.cmttoptevel === "unknown") {
            let resolved = await loadTopLevelComments(row); // if we have no data for this tag yet, we load it
            if (resolved === "failed") { // if XHR failed, we have probably encountered a 403 or 500 error. don't open the dialog
                alert('Top Level Comments could be loaded, we encountered errors trying to load the Comment page.');
                return;
            }
        }
        $(dlg).find('textarea').prop('value', ''); // empty the textbox content from whatever was there before
        viewTopLevelComments(e.target); // then we write out the dialog
    });

    // not a button event, but if the window resizes the dialog would move off of the screen
    $(window).on('resize', function(e) {
        if ($(dlg).dialog("isOpen")) { // don't need to worry about this if the dialog wasn't opened before

            // optimizing the size of the GUI in case it's a mobile device
            let dialogwidth = parseInt($("body").css("width")); // parseInt ignores letters (px)
            dialogwidth = dialogwidth > 700 ? 700 : dialogwidth * 0.9;

            let target = $(dlg).dialog("option", "position").of; // find the current target button

            $(dlg).dialog("option", "position", { my: "left top", at: "left bottom", of: target } ) // reposition the dialog
                  .dialog("option", "width", dialogwidth); // resize the dialog
        }
    });

    /***** RETRIEVE TOP-LEVEL COMMENTS *****/

    // on pageload, silently loop over all tags on the page
    // check if we already have the tag stored

    // grab the user's pseuds from storage
    let pseuds = JSON.parse(sessionStorage.getItem("cmt_pseud") || null);
    $(dlg).find('#pseud-toplvlcmt').html(pseuds);

    let rows = $('#wrangulator').find('tbody tr').toArray();
    for (let row of rows) {
        let tagname = $(row).find('th label').text();
        let cmtTopLevel = JSON.parse(sessionStorage.getItem("cmt_" + tagname) || null);

        if (cmtTopLevel !== null) { // if we found something in storage
            updateCommentButton($(row).find('a[href$="/comments"]'), cmtTopLevel.length, (cmtTopLevel[cmtTopLevel-1] === "+"));
            // and then we should put it somewhere so we know not to ask again on click
            row.dataset.cmttoptevel = "stored";
        }
        else row.dataset.cmttoptevel = "unknown";
    }

    function updateCommentButton(target, cmtCount, morePages) {
        // how many comment threads are there?
        if (morePages) cmtCount = cmtCount-1 + "+"; // if the last item in the array is a + turn this from 21 to "20+" threads

        // we add the comment count to the icon ... not on the button, that changes width, but to the title
        if (cmtCount === 0) {
            $(target).prop('title', `There are no comments on this tag`);
            if ($(target).css('font-family').includes("FontAwesome")) $(target).html("&#xf0e6;"); // change the icon to "comments-o" f0e6 if there's no comment
        }
        else {
            if (cmtCount === 1) $(target).prop('title', `There is 1 comment thread on this tag`);
            else $(target).prop('title', `There are ${cmtCount} comment threads on this tag`);
            if ($(target).css('font-family').includes("FontAwesome")) $(target).html("&#xf086;"); // change the icon to "comments" f086 if there's (now) a comment
        }
    }

    async function loadAllTopLevelComments() {
        $('#load-toplvlcmt').attr('disabled', true) // stop button from being clicked again
                            .find('.spin').css('display', 'inline-block'); // loading indicator

        let rowsToDo = $(rows).filter( function() { return this.dataset.cmttoptevel === "unknown"; } ).toArray(); // only worry about not stored items

        // when clicking the button, loop over all tags on the page
        for (let row of rowsToDo) {
            let resolved = await loadTopLevelComments(row); // grab the top level comments from the page
            if (resolved === "loaded") await waitforXSeconds(2); // if XHR succeeded, creates a x-seconds wait period between function calls
            else { // if XHR failed, we have probably encountered a 403 or 500 error. don't try more pages
                alert('Not all Top Level Comments could be loaded, we encountered errors trying to load the Comment pages.');
                break;
            }
        }

        $('#load-toplvlcmt').attr('disabled', false) // stop button from being clicked again
                            .find('.spin').css('display', 'none'); // remove loading indicator
    }

    function waitforXSeconds(x) {
        return new Promise((resolve) => {
            setTimeout(() => {
                resolve("");
            }, x * 1000);
        });
    }

    /***** COMMON FUNCTIONS FOR BACKGROUND PAGELOADS *****/

    function loadTopLevelComments(row) {
        return new Promise((resolve) => {

            let target = $(row).find('[href$="/comments"]')[0];
            let tagname = $(row).find('th label').text();

            let xhr = $.ajax({ url: $(target).prop('href'), type: 'GET' })
                .fail(function(xhr, status) {
                    console.warn(`Top level comments for ${tagname} could not be loaded due to response error:`, status);
                    resolve("failed");
                }).done(function(response) {
                    // in case we're served a code:200 page that doesn't actually contain the comments page, we quit
                    if ($(response).find('#feedback').length === 0) {
                        console.warn(`Top level comments for ${tagname} could not be loaded because response didn't contain the #feedback`);
                        resolve("failed"); return;
                    }

                    // grab this user's possible pseuds and store it in session
                    pseuds = $(response).find('#add_comment h4.heading')[0].outerHTML;
                    sessionStorage.setItem("cmt_pseud", JSON.stringify(pseuds));

                    // grab top level comments
                    let cmtTopLevel;
                    cmtTopLevel = $(response).find('#comments_placeholder > ol.thread > li.comment').toArray();

                    // retrieve their author and comment text
                    cmtTopLevel = cmtTopLevel.map((v) => `<p class="toplvlby">by ${ $(v).find('.byline a').text() }</p><blockquote class="userstuff">${ $(v).find('.userstuff').html().trim() }</blockquote>`);
                    if ($(response).find('#comments_placeholder > ol.pagination').length > 0) cmtTopLevel.push("+"); // pagination -> more than 20 top-level comments

                    sessionStorage.setItem("cmt_" + tagname, JSON.stringify(cmtTopLevel)); // store all comments in session
                    row.dataset.cmttoptevel = "stored"; // set this tag from unknown to stored, so we don't try to load it again
                    updateCommentButton(target, cmtTopLevel.length, (cmtTopLevel[cmtTopLevel-1] === "+"));

                    resolve("loaded");
                });

        });
    }


    /***** DISPLAY THE COMMENTS DIALOG *****/

    function viewTopLevelComments(target) {
        let tagname = $(target).parents('tr').first().find('th label').text();
        let taglink = $(target).prop('href');
        $(dlg).dialog("option", "title", `Comments on: ${tagname}` ); // dialog title shows tagname for which it was opened

        // create a link to the plain Comments page
        let iconExternalLink = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path fill="currentColor" d="M320 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l82.7 0L201.4 265.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L448 109.3l0 82.7c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160c0-17.7-14.3-32-32-32L320 0zM80 32C35.8 32 0 67.8 0 112L0 432c0 44.2 35.8 80 80 80l320 0c44.2 0 80-35.8 80-80l0-112c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 112c0 8.8-7.2 16-16 16L80 448c-8.8 0-16-7.2-16-16l0-320c0-8.8 7.2-16 16-16l112 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L80 32z"/></svg>`;
        let content = `<p><a href="${$(target).prop('href')}" target="_blank">Open Comment Page <span class="fontawesomeicon">${iconExternalLink}</span></a></p>`;

        // create the preview of the first 20 comments
        let comments = JSON.parse(sessionStorage.getItem("cmt_" + tagname));
        if (comments[comments-1] === "+") comments.pop(); // if the last item in the array is a + get rid of it
        for (let comment of comments) {
            content += `<div class="toplvlcmt">${comment}</div>`;
        }
        if (comments.length === 0) { content += `<div class="toplvlcmt"><i>There are no comments on this tag yet.</i></div>`; }

        $(dlg).find('#toplvlcmt-placeholder').html(content); // write comments to the dialog

        // a textbox to leave a new comment - needs to work with my "Comment Formatting & Preview" script
        let cmtNew = $(dlg).find('#add-toplvlcmt textarea')[0];
        cmtNew.dataset.tagname = encodeURIComponent(tagname);

        $(dlg).find('#pseud-toplvlcmt').html(pseuds);

        $(dlg).dialog("option", "position", { my: "left top", at: "left bottom", of: target } ) // position the dialog at the clicked button
              .dialog('open'); // finally, open the dialog
    }


    /***** SUBMIT A NEW COMMENT *****/

    $('#main').on('click', `${dlg} button.submitComment`, function(e) {
        e.preventDefault();

        $(dlg).find('.submitComment').attr('disabled', true) // stop button from being clicked again
                                     .find('.spin').css('display', 'inline-block').html("(submitting)"); // loading indicator

        let tagname = decodeURIComponent($(dlg).find('textarea').attr('data-tagname'));
        let target = $(dlg).dialog("option", "position").of;

        // collect various input for commenting
        let cmtData = new FormData();
        cmtData.set('comment[comment_content]', $(dlg).find('textarea').prop('value'));
        cmtData.set('comment[pseud_id]', $(dlg).find('[name="comment[pseud_id]"]').prop('value')); // either a hidden <input> or a <select>
        cmtData.set('controller_name', 'comments');
        cmtData.set('tag_id', tagname);
        cmtData.set('authenticity_token', $('input[name="authenticity_token"]').prop('value'));

        // submit the comment
        let xhr = $.ajax({
                url: "/comments",
                type: 'POST',
                data: cmtData,
                contentType: false,
                processData: false
            })
            .fail(function(xhr, status) {
                console.warn(`Posting comment to ${tagname} failed due to response error:`, status);
                alert('Comment could not be submitted, we encountered an error. You can check the Console for details and/or try again.');
            }).done(function(response) {
                console.log(`Posting comment to ${tagname} succeeded`);

                // update the storage and comment button for this tag
                let cmtTopLevel = JSON.parse(sessionStorage.getItem("cmt_" + tagname));
                let pseudname = $(dlg).find('.byline').text() || $(dlg).find('select[name="comment[pseud_id]"] option:selected').text();
                cmtTopLevel.push(`<p class="toplvlby">by ${ pseudname }</p><blockquote class="userstuff"><p>${$(dlg).find('textarea').prop('value')}</p></blockquote>`);
                sessionStorage.setItem("cmt_" + tagname, JSON.stringify(cmtTopLevel));
                updateCommentButton(target, cmtTopLevel.length, (cmtTopLevel[cmtTopLevel-1] === "+"));

                $(dlg).dialog('close');
            }).always(function() {
                $(dlg).find('.submitComment').attr('disabled', false) // allow button to be clicked again
                      .find('.spin').css('display', 'none').html(""); // remove loading indicator
            });
    });

})(jQuery);