Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
// <nowiki>
( function () {
    var UNBLOCK_REQ_COLOR = "rgb(235, 244, 255)";
    var SIGNATURE = "~~" + "~~";
    var DECLINE_REASON_HERE = "{" + "{subst:Decline reason here}}"; // broken up to prevent processing
    var ADVERT = " ([[User:Enterprisey/unblock-review|unblock-review]])";

    // Making this a function for unit test reasons.
    function getInitialText(wikitext, appealReason) {
        // https://stackoverflow.com/a/6969486/3480193
        function escapeRegExp(string) {
            return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
        }

        let regEx = new RegExp(escapeRegExp(appealReason), 'g');
        let matches = wikitext.matchAll(regEx);
        matches = [...matches];
        if( matches.length === 0 ) {
            throw new Error( "Searching for target text failed!" );
        }
        for ( let match of matches ) {
            var textIdx = match.index;
            var startIdx = textIdx;

            // check for {{tlx|unblock. if found, this isn't what we want, skip.
            let startOfSplice = startIdx - 50 < 0 ? 0 : startIdx - 50;
            var chunkFiftyCharactersWide = wikitext.substring(startOfSplice, startIdx);
            if ( /\{\{\s*tlx\s*\|\s*unblock/i.test(chunkFiftyCharactersWide) ) {
                continue;
            }

            let i = 0;
            while( wikitext[startIdx] != "{" && i < 50 ) {
                startIdx--;
                i++;
            }
            if( i == 50 ) {
                continue;
            }

            startIdx--; // templates start with two opening curly braces

            var initialText = wikitext.substring( startIdx, textIdx );
            return initialText;
        }

        throw new Error( "Searching backwards failed!" );
    }

    if( mw.config.get( "wgNamespaceNumber" ) === 3 ) {
        /**
         * Is there a signature (four tildes) present in the given text,
         * outside of a nowiki element?
         */
        function hasSig( text ) {
            // no literal signature?
            if( text.indexOf( SIGNATURE ) < 0 ) return false;

            // if there's a literal signature and no nowiki elements,
            // there must be a real signature
            if( text.indexOf( "<nowiki>" ) < 0 ) return true;

            // Save all nowiki spans
            var nowikiSpanStarts = []; // list of ignored span beginnings
            var nowikiSpanLengths = []; // list of ignored span lengths
            var NOWIKI_RE = /<nowiki>.*?<\/nowiki>/g;
            var spanMatch;
            do {
                spanMatch = NOWIKI_RE.exec( text );
                if( spanMatch ) {
                    nowikiSpanStarts.push( spanMatch.index );
                    nowikiSpanLengths.push( spanMatch[0].length );
                }
            } while( spanMatch );

            // So that we don't check every ignore span every time
            var nowikiSpanStartIdx = 0;

            var SIG_RE = new RegExp( SIGNATURE, "g" );
            var sigMatch;

            matchLoop:
            do {
                sigMatch = SIG_RE.exec( text );
                if( sigMatch ) {
                    // Check that we're not inside a nowiki
                    for( var nwIdx = nowikiSpanStartIdx; nwIdx <
                        nowikiSpanStarts.length; nwIdx++ ) {
                        if( sigMatch.index > nowikiSpanStarts[nwIdx] ) {
                            if ( sigMatch.index + sigMatch[0].length <=
                                nowikiSpanStarts[nwIdx] + nowikiSpanLengths[nwIdx] ) {

                                // Invalid sig
                                continue matchLoop;
                            } else {

                                // We'll never encounter this span again, since
                                // headers only get later and later in the wikitext
                                nowikiSpanStartIdx = nwIdx;
                            }
                        }
                    }

                    // We aren't inside a nowiki
                    return true;
                }
            } while( sigMatch );
            return false;
        }

        /**
         * Given the div of an unblock request, set up the UI and event
         * listeners.
         */
        function setUpUi( unblockDiv ) {
            var container = document.createElement( "table" );
            container.className = "unblock-review";
            var hrEl = unblockDiv.querySelector( "hr" );
            container.innerHTML = "<tr><td class='reason-container' rowspan='2'>" +
                "<textarea class='unblock-review-reason mw-ui-input'" +
                " placeholder='Reason for accepting/declining here'>" + DECLINE_REASON_HERE + "</textarea></td>" +
                "<td><button class='unblock-review-accept mw-ui-button mw-ui-progressive'>Accept</button></td></tr>" +
                "<tr><td><button class='unblock-review-decline mw-ui-button mw-ui-destructive'>Decline</button></td></tr>";
            unblockDiv.insertBefore( container, hrEl.previousElementSibling );
            var reasonArea = container.querySelector( "textarea" );
            $( container ).find( "button" ).click( function () {
                var action = $( this ).text().toLowerCase();
                var appealReason = hrEl.nextElementSibling.nextElementSibling.childNodes[0].textContent;
                $.getJSON(
                    mw.util.wikiScript( "api" ),
                    {
                        format: "json",
                        action: "query",
                        prop: "revisions",
                        rvprop: "content",
                        rvlimit: 1,
                        titles: mw.config.get( "wgPageName" )
                    }
                ).done( function ( data ) {
                    // Extract wikitext from API response
                    var pageId = Object.keys(data.query.pages)[0];
                    wikitext = data.query.pages[pageId].revisions[0]["*"];

                    var initialText = getInitialText(wikitext, appealReason);

                    // Build accept/decline reason
                    var reason = reasonArea.value;
                    if( !reason.trim() ) {
                        reason = DECLINE_REASON_HERE + " " + SIGNATURE;
                    } else if( !hasSig( reason ) ) {
                        reason = reason + " " + SIGNATURE;
                    }
                    wikitext = wikitext.replace( initialText + appealReason, "{" +
                        "{unblock reviewed|" + action + "=" + reason + "|1=" + appealReason );

                    var summary = ( action === "accept" ? "Accepting" : "Declining" ) +
                        " unblock request" + ADVERT;

                    ( new mw.Api() ).postWithToken( "csrf", {
                        action: "edit",
                        title: mw.config.get( "wgPageName" ),
                        summary: summary,
                        text: wikitext
                    } ).done ( function ( data ) {
                        if ( data && data.edit && data.edit.result && data.edit.result == "Success" ) {
                            window.location.reload( true );
                        } else {
                            console.log( data );
                        }
                    } );
                } );
            } );
        }

        $.when( $.ready, mw.loader.using( [ "mediawiki.api", "mediawiki.util" ] ) ).then( function () {
            mw.util.addCSS(
                ".unblock-review td { padding: 0 }" +
                "td.reason-container { padding-right: 1em; width: 30em }" +
                ".unblock-review-reason { height: 5em }" );
            importStylesheet( "User:Enterprisey/mw-ui-button.css" );
            importStylesheet( "User:Enterprisey/mw-ui-input.css" );
            var userBlockBoxes = document.querySelectorAll( "div.user-block" );
            for( var i = 0, n = userBlockBoxes.length; i < n; i++ ) {
                if( userBlockBoxes[i].style["background-color"] !== UNBLOCK_REQ_COLOR ) {
                    continue;
                }
                
                // We now have a pending unblock request - add UI
                setUpUi( userBlockBoxes[i] );
            }
        } );
    }
} )();
// </nowiki>