modules/nukecomments.js

import $ from 'jquery';

import * as TBApi from '../tbapi.ts';
import * as TBCore from '../tbcore.js';
import * as TBHelpers from '../tbhelpers.js';
import TBListener from '../tblistener.js';
import {Module} from '../tbmodule.jsx';
import * as TBStorage from '../tbstorage.js';
import * as TBui from '../tbui.js';

export default new Module({
    name: 'Comment Nuke',
    id: 'CommentNuke',
    enabledByDefault: false,
    settings: [
        {
            id: 'ignoreDistinguished',
            description: 'Ignore distinguished comments from mods and admins when nuking a chain.',
            type: 'boolean',
            default: true,
        },
        {
            id: 'executionType',
            description: 'Default nuke type selected when nuking',
            type: 'selector',
            values: ['remove', 'lock'],
            default: 'remove',
            advanced: true,
        },
        // Settings for old reddit only
        {
            id: 'showNextToUser',
            description: 'Show nuke button next to the username instead of under the comment.',
            oldReddit: true,
            type: 'boolean',
            default: true,
            advanced: true,
        },
    ],
}, function init ({ignoreDistinguished, showNextToUser, executionType}) {
    // This will contain a flat listing of all comments to be removed.
    let removalChain = [];
    // Distinguished chain
    let distinguishedComments = [];
    // If we do get api errors we put the comment id in here so we can retry removing them.
    let missedComments = [];
    let retryExecutionType = '';
    let removalRunning = false;
    let nukeOpen = false;
    const $body = $('body');

    const self = this;

    // Nuke button clicked
    $body.on('click', '.tb-nuke-button', function (event) {
        self.log('nuke button clicked.');
        if (nukeOpen) {
            TBui.textFeedback('Nuke popup is already open.', TBui.FEEDBACK_NEGATIVE);
            return;
        }
        TBui.longLoadSpinner(true);

        nukeOpen = true;
        removalChain = [];
        missedComments = [];
        retryExecutionType = '';
        distinguishedComments = [];

        const $this = $(this);
        const commentID = $this.attr('data-comment-id');
        const postID = $this.attr('data-post-id');
        const subreddit = $this.attr('data-subreddit');
        const positions = TBui.drawPosition(event);

        const fetchURL = `/r/${subreddit}/comments/${postID}/slug/${commentID}.json?limit=1500`;

        const $popupContents = $(`<div class="tb-nuke-popup-content">
                <div class="tb-nuke-feedback">Fetching all comments belonging to chain.</div>
                <div class="tb-nuke-details"></div>
            </div>`);

        // Pop-up
        const $popup = TBui.popup({
            title: 'Nuke comment chain',
            tabs: [
                {
                    title: 'Nuke tab',
                    tooltip: '',
                    content: $popupContents,
                    footer: `
                        ${TBui.actionButton('Execute', 'tb-execute-nuke')}
                        ${TBui.actionButton('Retry', 'tb-retry-nuke')}
                    `,
                },
            ],
            cssClass: 'nuke-button-popup',
            draggable: true,
            // We don't let the user close this popup while items are being processed, so we use a custom handler
            closable: false,
        }).appendTo($body)
            .css({
                left: positions.leftPosition,
                top: positions.topPosition,
                display: 'block',
            });

        TBApi.getJSON(fetchURL, {raw_json: 1}).then(data => {
            TBStorage.purifyObject(data);
            parseComments(data[1].data.children[0], postID, subreddit).then(() => {
                TBui.longLoadSpinner(false);
                $popup.find('.tb-nuke-feedback').text('Finished analyzing comments.');

                const removalChainLength = removalChain.length;
                // Distinguished chain
                const distinguishedCommentsLength = distinguishedComments.length;
                $popup.find('.tb-nuke-details').html(TBStorage.purify(`
                    <p>${
                    removalChainLength + distinguishedCommentsLength
                } comments found (Already removed comments not included).</p>
                    <p>${distinguishedCommentsLength} distinguished comments found.</p>
                    <p><label><input type="checkbox" class="tb-ignore-distinguished-checkbox" ${
                    ignoreDistinguished ? ' checked="checked"' : ''
                }>Ignore distinguished comments from mods and admins</label></p>
                    <p>
                        <label><input type="radio" value="remove" name="tb-execution-type-radio" class="tb-execution-type-radio" ${
                    executionType === 'remove' ? ' checked="checked"' : ''
                }>Remove comments</label>
                        <label><input type="radio" value="lock" name="tb-execution-type-radio" class="tb-execution-type-radio" ${
                    executionType === 'lock' ? ' checked="checked"' : ''
                }>Lock comments</label>
                    </p>
                    `));
                $popup.find('.tb-execute-nuke').show();
            });
        });

        $popup.on('click', '.tb-execute-nuke, .tb-retry-nuke', function () {
            removalRunning = true;
            TBui.longLoadSpinner(true);
            const $this = $(this);
            $this.hide();
            let commentArray;
            const $nukeFeedback = $popup.find('.tb-nuke-feedback');
            const $nukeDetails = $popup.find('.tb-nuke-details');
            const temptIgnoreDistinguished = $popup.find('.tb-ignore-distinguished-checkbox').prop('checked');
            let executionType = '';
            if ($this.hasClass('tb-retry-nuke')) {
                executionType = retryExecutionType;
                commentArray = missedComments;
                missedComments = [];
            } else {
                executionType = $popup.find('.tb-execution-type-radio:checked').val();
                if (temptIgnoreDistinguished) {
                    commentArray = removalChain;
                } else {
                    commentArray = removalChain.concat(distinguishedComments);
                }
            }

            $nukeFeedback.text(`${executionType === 'remove' ? 'Removing' : 'Locking'} comments.`);
            $nukeDetails.html('');

            // Oldest comments first.
            commentArray = TBHelpers.saneSort(commentArray);
            const removalArrayLength = commentArray.length;
            let removalCount = 0;
            Promise.all(commentArray.map(async comment => {
                removalCount++;
                TBui.textFeedback(
                    `${
                        executionType === 'remove'
                            ? 'Removing'
                            : 'Locking'
                    } comment ${removalCount}/${removalArrayLength}`,
                    TBui.FEEDBACK_NEUTRAL,
                );
                if (executionType === 'remove') {
                    await TBApi.removeThing(`t1_${comment}`, false, false).catch(() => {
                        missedComments.push(comment);
                    });
                } else {
                    await TBApi.lock(`t1_${comment}`, false).catch(() => {
                        missedComments.push(comment);
                    });
                }
            })).then(() => {
                removalRunning = false;
                TBui.longLoadSpinner(false);
                $nukeFeedback.text(`Done ${executionType === 'remove' ? 'removing' : 'locking'} comments.`);
                const missedLength = missedComments.length;
                if (missedLength) {
                    retryExecutionType = executionType;
                    $nukeDetails.text(
                        `${missedLength}: not ${
                            executionType === 'remove' ? 'removed' : 'locked'
                        } because of API errors. Hit retry to attempt removing them again.`,
                    );
                    $popup.find('.tb-retry-nuke').show();
                } else {
                    setTimeout(() => {
                        $popup.find('.close').click();
                    }, 1500);
                }
            });
        });

        // Handle popup close button, with custom logic to prevent the close if currently running
        $popup.on('click', '.close', event => {
            event.stopPropagation();
            if (removalRunning) {
                TBui.textFeedback('Comment chain nuke in progress, cannot close popup.', TBui.FEEDBACK_NEGATIVE);
            } else {
                $popup.remove();
                nukeOpen = false;
            }
        });
    });

    /**
     * Will given a reddit API comment object go through the chain and put all comments
     * @function parseComments
     * @param {object} object Comment chain object
     * @param {string} postID Post id the comments belong to
     * @param {string} subreddit Subreddit the comment chain belongs to.
     * @returns {Promise}
     */

    async function parseComments (object, postID, subreddit) {
        switch (object.kind) {
            case 'Listing': {
                for (let i = 0; i < object.data.children.length; i++) {
                    await parseComments(object.data.children[i], postID, subreddit);
                }
                break;
            }

            case 't1': {
                const distinguishedType = object.data.distinguished;
                if (
                    (distinguishedType === 'admin' || distinguishedType === 'moderator')
                    && !distinguishedComments.includes(object.data.id)
                ) {
                    distinguishedComments.push(object.data.id);
                    // Ignore already removed stuff to lower the amount of calls we need to make.
                } else if (!removalChain.includes(object.data.id) && !object.data.removed && !object.data.spam) {
                    removalChain.push(object.data.id);
                }

                if (
                    Object.prototype.hasOwnProperty.call(object.data, 'replies') && object.data.replies
                    && typeof object.data.replies === 'object'
                ) {
                    await parseComments(object.data.replies, postID, subreddit); // we need to go deeper.
                }
                break;
            }

            case 'more':
                {
                    self.log('"load more" encountered, going even deeper');
                    let commentIDs = object.data.children;
                    if (!commentIDs.length) {
                        // "continue this thread" links generated when a thread gets
                        // too deep return empty `children` lists, thanks Reddit
                        commentIDs = [object.data.parent_id.substring(3)];
                    }

                    for (const id of commentIDs) {
                        const fetchUrl = `/r/${subreddit}/comments/${postID}/slug/${id}.json?limit=1500`;
                        // Lets get the comments.
                        const data = await TBApi.getJSON(fetchUrl, {raw_json: 1});
                        TBStorage.purifyObject(data);
                        await parseComments(data[1].data.children[0], postID, subreddit);
                    }
                }
                break;
            default: {
                self.log('default, this should not happen...');
                // This shouldn't actually happen...
            }
        }
    }

    // Add nuke buttons where needed
    TBListener.on('comment', async e => {
        const pageType = TBCore.pageDetails.pageType;
        const $target = $(e.target);
        const subreddit = e.detail.data.subreddit.name;
        const commentID = e.detail.data.id.substring(3);
        const postID = e.detail.data.post.id.substring(3);

        const isMod = await TBCore.isModSub(subreddit);
        // We have to mod the subreddit to show the button
        if (!isMod) {
            return;
        }
        // We also have to be on a comments page or looking at a context popup
        if (
            pageType !== 'subredditCommentsPage' && pageType !== 'subredditCommentPermalink'
            && !$target.closest('.context-button-popup').length
        ) {
            return;
        }

        const NukeButtonHTML =
            `<span class="tb-nuke-button tb-bracket-button" data-comment-id="${commentID}" data-post-id="${postID}" data-subreddit="${subreddit}" title="Remove comment chain starting with this comment">${
                e.detail.type === 'TBcommentOldReddit' && !showNextToUser ? 'Nuke' : 'R'
            }</span>`;
        if (showNextToUser && TBCore.isOldReddit) {
            const $userContainter = $target.closest('.entry, .tb-comment-entry').find(
                '.tb-jsapi-author-container .tb-frontend-container',
            );
            $userContainter.append(NukeButtonHTML);
        } else {
            $target.append(NukeButtonHTML);
        }
    });
});