1-click feed maintenance

Mark a video in homepage feed as already watched, not interested, don't recommend in 1 click instead of 5

As of 2021-11-26. See the latest version.

// ==UserScript==
// @name         1-click feed maintenance
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Mark a video in homepage feed as already watched, not interested, don't recommend in 1 click instead of 5
// @author       lwkjef
// @match        https://www.youtube.com/
// @icon         https://www.google.com/s2/favicons?domain=youtube.com
// @grant        none
// @require      https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js
// @license      MIT
// ==/UserScript==

/* eslint-env jquery */

// jQuery selectors for Youtube elements
const contentsGridSelector = '#contents.ytd-rich-grid-renderer';
const videoSelector = 'ytd-rich-item-renderer';
const menuButtonSelector = '#menu button';
const popupContainerSelector = 'ytd-popup-container'; // parent of ironDropdownSelector and paperDialogSelector
const ironDropdownSelector = 'tp-yt-iron-dropdown'
const paperDialogSelector = 'tp-yt-paper-dialog';
const notInterestedSelector = 'tp-yt-paper-item:contains("Not interested")';
const dontRecommendSelector = 'tp-yt-paper-item:contains("Don\'t recommend channel")';
const tellUsWhySelector = 'ytd-button-renderer:contains("Tell us why")';
const iDontLikeTheVideoSelector = 'tp-yt-paper-checkbox:contains("I don\'t like the video")';
const iveAlreadyWatchedTheVideoSelector = 'tp-yt-paper-checkbox:contains("I\'ve already watched the video")';
const submitSelector = 'tp-yt-paper-button:contains("Submit")';

// internal constants
const oneClickButtonId = 'one-click-feed-button';
const alreadyWatchedButtonText = 'already watched';
const iDontLikeTheVideoButtonText = 'block video';
const dontRecommendText = 'block channel';

(function(){
    'use strict'
    $(document).ready(function(){
        let observer = new MutationObserver(function contentsMutated(mutationsList, observer) {
            for (let mutation of mutationsList) {
                for (let addedNode of mutation.addedNodes) {
                    addButtons(addedNode);
                }
            }
        });
        $(contentsGridSelector).each(function() {
            // hook up observer first, so we don't miss any new video nodes
            observer.observe(this, {attributes: false, childList: true, subtree: false});

            // immediately backfill all existing video nodes
            addButtons(this);
        });
    });
}())

/*
 * add 1-click buttons to all video nodes contained in the given node that don't already have buttons
 */
function addButtons(node) {
    $(node).find(videoSelector).not(`:has(#${oneClickButtonId})`).each(function() {
        this.appendChild(createButton(alreadyWatchedButtonText, alreadyWatchedOnClick));
        this.appendChild(createButton(iDontLikeTheVideoButtonText, iDontLikeTheVideoOnClick));
        this.appendChild(createButton(dontRecommendText, dontRecommendOnClick));
    });
}

/*
 * create and return a new 1-click button with the given text and onclick callback
 */
function createButton(text, onclick, cssObj) {
    let button = document.createElement('button');
    button.setAttribute ('id', oneClickButtonId);
    button.setAttribute ('type', 'button');
    button.innerHTML = text;
    button.onclick = onclick;

    //let btnStyle = button.style;
    //cssObj = cssObj || {position: 'absolute', bottom: '7%', left:'4%', 'z-index': 3};
    //Object.keys(cssObj).forEach(key => {btnStyle[key] = cssObj[key]});

    return button;
}

/*
 * "already watched" 1-click button callback
 */
async function alreadyWatchedOnClick(e) {
    e.preventDefault();
    let videoNode = e.target.parentNode;
    let ironDropdownNode = await openMenu(videoNode);
    await clickNotInterested(ironDropdownNode);
    let paperDialogNode = await clickTellUsWhy(videoNode);
    await clickAlreadyWatched(paperDialogNode);
    videoNode.style.visibility = 'hidden';
}

/*
 * "I dont like the video" 1-click button callback
 */
async function iDontLikeTheVideoOnClick(e) {
    e.preventDefault();
    let videoNode = e.target.parentNode;
    let ironDropdownNode = await openMenu(videoNode);
    await clickNotInterested(ironDropdownNode);
    let paperDialogNode = await clickTellUsWhy(videoNode);
    await clickIDontLikeTheVideo(paperDialogNode);
    videoNode.style.visibility = 'hidden';
}

/*
 * "dont recommend" 1-click button callback
 */
async function dontRecommendOnClick(e) {
    e.preventDefault();
    let videoNode = e.target.parentNode;
    let ironDropdownNode = await openMenu(videoNode);
    await clickDontRecommend(ironDropdownNode);
    videoNode.style.visibility = 'hidden';
}

/*
 * click menu button on the given video node, wait for iron dropdown to be added, and return iron dropdown node.
 */
async function openMenu(videoNode) {
    return await doAndAwaitNodeAdded(ironDropdownSelector, popupContainerSelector, function() {
        $(videoNode).find(menuButtonSelector).trigger('click');
    });
}

/*
 * click not interested button on the given iron dropdown node, and wait for the iron dropdown to be removed.
 */
async function clickNotInterested(ironDropdownNode) {
    return await doAndAwaitNodeRemoved(ironDropdownSelector, popupContainerSelector, function() {
        $(ironDropdownNode).find(notInterestedSelector).trigger('click');
    });
}

/*
 * click dont recommend channel button on the given popup node, and wait for the popup to be removed.
 */
async function clickDontRecommend(ironDropdownNode) {
    return await doAndAwaitNodeRemoved(ironDropdownSelector, popupContainerSelector, function() {
        $(ironDropdownNode).find(dontRecommendSelector).trigger('click');
    });
}

/*
 * click tell us why button on the given video node, wait for popup to be added, and return popup node.
 */
async function clickTellUsWhy(videoNode) {
    return await doAndAwaitNodeAdded(paperDialogSelector, popupContainerSelector, function() {
        $(videoNode).find(tellUsWhySelector).trigger('click');
    });
}

/*
 * click I dont like the video button on the given popup node, click submit button on the given popup node, and wait for the popup to be removed.
 */
async function clickIDontLikeTheVideo(paperDialogNode) {
    return await doAndAwaitNodeRemoved(paperDialogSelector, popupContainerSelector, function() {
        $(paperDialogNode).find(iDontLikeTheVideoSelector).trigger('click');
        $(paperDialogNode).find(submitSelector).trigger('click');
    });
}

/*
 * click Ive already watched the video button on the given popup node, click submit button on the given popup node, and wait for the popup to be removed.
 */
async function clickAlreadyWatched(paperDialogNode) {
    return await doAndAwaitNodeRemoved(paperDialogSelector, popupContainerSelector, function() {
        $(paperDialogNode).find(iveAlreadyWatchedTheVideoSelector).trigger('click');
        $(paperDialogNode).find(submitSelector).trigger('click');
    });
}

/*
 * Execute the given function, then wait until node is added. ancestorSelector MUST exist and should be as close as possible to target node for efficiency.
 */
async function doAndAwaitNodeAdded(nodeName, ancestorSelector, f) {
    let node;
    let observer;
    await new Promise(resolve => {
        observer = new MutationObserver(function(mutationsList, observer) {
            for (let mutation of mutationsList) {
                if (mutation.type === 'childList') {
                    for (let addedNode of mutation.addedNodes) {
                        if (equalsIgnoreCase(addedNode.nodeName, nodeName) && addedNode.style.display !== 'none') {
                            // entire dropdown node added
                            node = addedNode;
                            resolve(addedNode);
                        }
                    }
                } else if (mutation.type === 'attributes') {
                    if (equalsIgnoreCase(mutation.target.nodeName, nodeName) && mutation.target.style.display !== 'none') {
                        // dropdown added by unsetting display:none
                        node = mutation.target;
                        resolve(mutation.target);
                    }
                }
            }
        });
        $(ancestorSelector).each(function() {
            observer.observe(this, {attributes: true, childList: true, subtree: true});
        });
        f();
    });
    observer.disconnect();
    return node;
}

/*
 * Execute the given function, then wait until given node is removed. ancestorSelector MUST exist and should be as close as possible to target node for efficiency.
 */
async function doAndAwaitNodeRemoved(nodeName, ancestorSelector, f) {
    let observer;
    await new Promise(resolve => {
        observer = new MutationObserver(function(mutationsList, observer) {
            for (let mutation of mutationsList) {
                if (mutation.type === 'childList') {
                    for (let removedNode of mutation.removedNodes) {
                        if (equalsIgnoreCase(removedNode.nodeName, nodeName)) {
                            // entire node removed
                            resolve(removedNode);
                        }
                    }
                } else if (mutation.type === 'attributes') {
                    if (equalsIgnoreCase(mutation.target.nodeName, nodeName) && mutation.target.style.display === 'none') {
                        // removed by setting display:none
                        resolve(mutation.target);
                    }
                }
            }
        });
        $(ancestorSelector).each(function() {
            observer.observe(this, {attributes: true, childList: true, subtree: true});
        });
        f();
    });
    observer.disconnect();
}

/*
 * True if strings lowercased are equivalent, false otherwise.
 */
function equalsIgnoreCase(one, two) {
    return one.toLowerCase() === two.toLowerCase();
}