Difference between revisions of "MediaWiki:Gadget-rtrc.js"

From Wikispooks
Jump to navigation Jump to search
m (1 revision)
 
(update JS)
Line 3: Line 3:
 
  * https://github.com/Krinkle/mw-gadget-rtrc
 
  * https://github.com/Krinkle/mw-gadget-rtrc
 
  *
 
  *
  * @license http://krinkle.mit-license.org/
+
  * @copyright 2010-2018 Timo Tijhof
* @author Timo Tijhof, 2010–2013
 
 
  */
 
  */
/*global alert */
 
(function ($, mw) {
 
'use strict';
 
  
/**
+
// Array#includes polyfill (ES2016/ES7)
* Configuration
+
// eslint-disable-next-line
* -------------------------------------------------
+
Array.prototype.includes||Object.defineProperty(Array.prototype,"includes",{value:function(r,e){if(null==this)throw new TypeError('"this" is null or not defined');var t=Object(this),n=t.length>>>0;if(0===n)return!1;var i,o,a=0|e,u=Math.max(a>=0?a:n-Math.abs(a),0);for(;u<n;){if((i=t[u])===(o=r)||"number"==typeof i&&"number"==typeof o&&isNaN(i)&&isNaN(o))return!0;u++}return!1}});
*/
 
var
 
appVersion = 'v0.9.8',
 
apiUrl = mw.util.wikiScript('api'),
 
conf = mw.config.get([
 
'skin',
 
'wgAction',
 
'wgCanonicalSpecialPageName',
 
'wgPageName',
 
'wgServer',
 
'wgTitle',
 
'wgUserLanguage',
 
'wgDBname'
 
]),
 
cvnApiUrl = '//cvn.wmflabs.org/api.php',
 
intuitionLoadUrl = '//tools.wmflabs.org/intuition/load.php?env=mw',
 
docUrl = '//meta.wikimedia.org/wiki/User:Krinkle/Tools/Real-Time_Recent_Changes?uselang=' + conf.wgUserLanguage,
 
// 32x32px
 
ajaxLoaderUrl = '//upload.wikimedia.org/wikipedia/commons/d/de/Ajax-loader.gif',
 
patrolCacheSize = 20,
 
  
/**
+
/* global alert, mw, $ */
* Info from the wiki
+
(function () {
* -------------------------------------------------
+
  'use strict';
*/
 
userHasPatrolRight = false,
 
userPatrolTokenCache = false,
 
rcTags = [],
 
wikiTimeOffset,
 
  
/**
+
  /**
* State
+
  * Configuration
* -------------------------------------------------
+
  * -------------------------------------------------
*/
+
  */
updateFeedTimeout,
+
  var
 +
    appVersion = 'v1.3.5',
 +
    conf = mw.config.get([
 +
      'skin',
 +
      'wgAction',
 +
      'wgCanonicalSpecialPageName',
 +
      'wgPageName',
 +
      'wgTitle',
 +
      'wgUserLanguage',
 +
      'wgDBname',
 +
      'wgScriptPath'
 +
    ]),
 +
    // Can't use mw.util.wikiScript until after #init
 +
    apiUrl = conf.wgScriptPath + '/api.php',
 +
    cvnApiUrl = 'https://cvn.wmflabs.org/api.php',
 +
    oresApiUrl = 'https://ores.wikimedia.org/scores/' + conf.wgDBname + '/',
 +
    oresModel = false,
 +
    intuitionLoadUrl = 'https://meta.wikimedia.org/w/index.php?title=User:Krinkle/Scripts/Intuition.js&action=raw&ctype=text/javascript',
 +
    docUrl = 'https://meta.wikimedia.org/wiki/User:Krinkle/Tools/Real-Time_Recent_Changes?uselang=' + conf.wgUserLanguage,
 +
    // 32x32px
 +
    ajaxLoaderUrl = 'https://upload.wikimedia.org/wikipedia/commons/d/de/Ajax-loader.gif',
 +
    annotationsCache = {
 +
      patrolled: Object.create(null),
 +
      cvn: Object.create(null),
 +
      ores: Object.create(null)
 +
    },
 +
    // See annotationsCacheUp()
 +
    annotationsCacheSize = 0,
  
rcPrevDayHeading,
+
    // Info from the wiki - see initData()
skippedRCIDs = [],
+
    userHasPatrolRight = false,
patrolledRCIDs = [],
+
    rcTags = [],
monthNames,
+
    wikiTimeOffset,
  
prevFeedHtml,
+
    // State
isUpdating = false,
+
    updateFeedTimeout,
 +
    rcDayHeadPrev,
 +
    skippedRCIDs = [],
 +
    monthNames,
 +
    prevFeedHtml,
 +
    updateReq,
  
/**
+
    // Default settings for the feed
* Feed options
+
    defOpt = {
* -------------------------------------------------
+
      rc: {
*/
+
        // Timestamp
defOpt = {
+
        start: undefined,
rc: {
+
        // Timestamp
// Timestamp
+
        end: undefined,
start: undefined,
+
        // Direction "older" (descending) or "newer" (ascending)
// Timestamp
+
        dir: 'older',
end: undefined,
+
        // Array of namespace ids
// Direction "older" (descending) or "newer" (ascending)
+
        namespace: undefined,
dir: 'older',
+
        // User name
// Array of namespace ids
+
        user: undefined,
namespace: undefined,
+
        // Tag ID
// User name
+
        tag: undefined,
user: undefined,
+
        // Filters
// Tag ID
+
        hideliu: false,
tag: undefined,
+
        hidebots: true,
// Show filters: exclude, include, filter
+
        unpatrolled: false,
showAnonOnly: false,
+
        limit: 25,
showUnpatrolledOnly: false,
+
        // Type filters are "show matches only"
limit: 25,
+
        typeEdit: true,
// Type filters are "show matches only"
+
        typeNew: true
typeEdit: false,
+
      },
typeNew: false
 
},
 
  
app: {
+
      app: {
refresh: 3,
+
        refresh: 5,
cvnDB: false,
+
        cvnDB: false,
massPatrol: false,
+
        ores: false,
autoDiff: false
+
        massPatrol: false,
}
+
        autoDiff: false
},
+
      }
opt = $(true, {}, defOpt),
+
    },
 +
    aliasOpt = {
 +
      // Back-compat for v1.0.4 and earlier
 +
      showAnonOnly: 'hideliu',
 +
      showUnpatrolledOnly: 'unpatrolled'
 +
    },
 +
    // Current settings for the feed
 +
    opt = makeOpt(),
  
timeUtil,
+
    timeUtil,
message,
+
    message,
msg,
+
    msg,
navCollapsed,
+
    rAF = window.requestAnimationFrame || setTimeout,
navSupported = conf.skin === 'vector' && !!window.localStorage,
 
nextFrame = window.requestAnimationFrame || setTimeout,
 
  
currentDiff,
+
    currentDiff,
currentDiffRcid,
+
    currentDiffRcid,
$wrapper, $body, $feed,
+
    $wrapper, $body, $feed,
$RCOptions_submit;
+
    $RCOptionsSubmit;
  
/**
+
  /**
* Utility functions
+
  * Utility functions
* -------------------------------------------------
+
  * -------------------------------------------------
*/
+
  */
  
if (!String.prototype.ucFirst) {
+
  function makeOpt () {
String.prototype.ucFirst = function () {
+
    // Create a recursive copy of defOpt without exposing
// http://jsperf.com/ucfirst/4
+
    // any of its arrays or objects in the returned value,
// http://jsperf.com/ucfirst-replace-vs-substr/3
+
    // so that the returned value can be modified in every way,
// str.charAt(0).toUpperCase() + str.substr(1);
+
    // without causing defOpt to change.
// str[0].toUpperCase() + str.slice(1);
+
    return $.extend(true, {}, defOpt);
// str.charAt(0).toUpperCase() + str.substring(1);
+
  }
// str.substr(0, 1).toUpperCase() + str.substr(1, this.length);
 
return this.charAt(0).toUpperCase() + this.substring(1);
 
};
 
}
 
  
// Prepends a leading zero if value is under 10
+
  /**
function leadingZero(i) {
+
  * Prepend a leading zero if value is under 10
if (i < 10) {
+
  *
i = '0' + i;
+
  * @param {number} num Value between 0 and 99.
}
+
  * @return {string}
return i;
+
  */
}
+
  function pad (num) {
 +
    return (num < 10 ? '0' : '') + num;
 +
  }
  
timeUtil = {
+
  timeUtil = {
// Create new Date instance from MediaWiki API timestamp string
+
    // Create new Date object from an ISO-8601 formatted timestamp, as
newDateFromApi: function (s) {
+
    // returned by the MediaWiki API (e.g. "2010-04-25T23:24:02Z")
// Possible number/integer to string
+
    newDateFromISO: function (s) {
var t = Date.UTC(
+
      return new Date(Date.parse(s));
// "2010-04-25T23:24:02Z" => 2010, 3, 25, 23, 24, 2
+
    },
parseInt(s.slice(0, 4), 10), // Year
 
parseInt(s.slice(5, 7), 10) - 1, // Month
 
parseInt(s.slice(8, 10), 10), // Day
 
parseInt(s.slice(11, 13), 10), // Hour
 
parseInt(s.slice(14, 16), 10), // Minutes
 
parseInt(s.slice(17, 19), 10) // Seconds
 
);
 
return new Date(t);
 
},
 
  
/**
+
    /**
* Apply user offset.
+
    * Apply user offset
*
+
    *
* Only use this if you're extracting individual values
+
    * Only use this if you're extracting individual values from the object (e.g. getUTCDay or
* from the object (e.g. getUTCDay or getUTCMinutes).
+
    * getUTCMinutes). The internal timestamp will be wrong.
* The full timestamp will incorrectly claim "GMT".
+
    *
*/
+
    * @param {Date} d
applyUserOffset: function (d) {
+
    * @return {Date}
var offset = mw.user.options.get('timecorrection');
+
    */
// This preference has no default value, it is null for users that don't
+
    applyUserOffset: function (d) {
// override the site's default timeoffset.
+
      var parts,
if (offset) {
+
        offset = mw.user.options.get('timecorrection');
offset = Number(offset.split('|')[1]);
 
} else {
 
offset = wikiTimeOffset;
 
}
 
// There is no way to set a timezone in javascript, so we instead pretend the real unix
 
// time is different and then get the values from that.
 
d.setTime(d.getTime() + (offset * 60 * 1000));
 
return d;
 
},
 
  
// Get clocktime string adjusted to timezone of wiki
+
      // This preference has no default value, it is null for users that don't
// from MediaWiki timestamp string
+
      // override the site's default timeoffset.
getClocktimeFromApi: function (s) {
+
      if (offset) {
var d = timeUtil.applyUserOffset(timeUtil.newDateFromApi(s));
+
        parts = offset.split('|');
// Return clocktime with leading zeros
+
        if (parts[0] === 'System') {
return leadingZero(d.getUTCHours()) + ':' + leadingZero(d.getUTCMinutes());
+
          // Ignore offset value, as system may have started or stopped
}
+
          // DST since the preferences were saved.
};
+
          offset = wikiTimeOffset;
 +
        } else {
 +
          offset = Number(parts[1]);
 +
        }
 +
      } else {
 +
        offset = wikiTimeOffset;
 +
      }
 +
      // There is no way to set a timezone in javascript, so instead we pretend the
 +
      // UTC timestamp is different and use getUTC* methods everywhere.
 +
      d.setTime(d.getTime() + (offset * 60 * 1000));
 +
      return d;
 +
    },
  
 +
    // Get clocktime string adjusted to timezone of wiki
 +
    // from MediaWiki timestamp string
 +
    getClocktimeFromApi: function (s) {
 +
      var d = timeUtil.applyUserOffset(timeUtil.newDateFromISO(s));
 +
      // Return clocktime with leading zeros
 +
      return pad(d.getUTCHours()) + ':' + pad(d.getUTCMinutes());
 +
    }
 +
  };
  
/**
+
  /**
* Main functions
+
  * Main functions
* -------------------------------------------------
+
  * -------------------------------------------------
*/
+
  */
  
function buildRcDayHead(time) {
+
  /**
var current = time.getDate();
+
  * @param {Date} date
if (current === rcPrevDayHeading) {
+
  * @return {string} HTML
return '';
+
  */
}
+
  function buildRcDayHead (date) {
rcPrevDayHeading = current;
+
    var current = date.getDate();
return '<div class="mw-rtrc-heading"><div><strong>' + time.getDate() + ' ' + monthNames[time.getMonth()] + '</strong></div></div>';
+
    if (current === rcDayHeadPrev) {
}
+
      return '';
 +
    }
 +
    rcDayHeadPrev = current;
 +
    return '<div class="mw-rtrc-heading"><div><strong>' + date.getDate() + ' ' + monthNames[date.getMonth()] + '</strong></div></div>';
 +
  }
  
/**
+
  /**
* @param {Object} rc Recent change object from API
+
  * @param {Object} rc Recent change object from API
* @return {string} HTML
+
  * @return {string} HTML
*/
+
  */
function buildRcItem(rc) {
+
  function buildRcItem (rc) {
var diffsize, isPatrolled, isAnon,
+
    var diffsize, isUnpatrolled, typeSymbol, itemClass, diffLink, el, item;
typeSymbol, itemClass, diffLink,
 
commentHtml, el, item;
 
  
// Get size difference (can be negative, zero or positive)
+
    // Get size difference (can be negative, zero or positive)
diffsize = rc.newlen - rc.oldlen;
+
    diffsize = rc.newlen - rc.oldlen;
  
// Convert undefined/empty-string values from API into booleans
+
    // Convert undefined/empty-string values from API into booleans
isPatrolled = rc.patrolled !== undefined;
+
    isUnpatrolled = rc.unpatrolled !== undefined;
isAnon = rc.anon !== undefined;
 
  
// typeSymbol, diffLink & itemClass
+
    // typeSymbol, diffLink & itemClass
typeSymbol = '&nbsp;';
+
    typeSymbol = '&nbsp;';
itemClass = '';
+
    itemClass = [];
  
if (rc.type === 'new') {
+
    if (rc.type === 'new') {
typeSymbol += '<span class="newpage">N</span>';
+
      typeSymbol += '<span class="newpage">' + mw.message('newpageletter').escaped() + '</span>';
}
+
    }
  
if ((rc.type === 'edit' || rc.type === 'new') && userHasPatrolRight && !isPatrolled) {
+
    if ((rc.type === 'edit' || rc.type === 'new') && userHasPatrolRight && isUnpatrolled) {
typeSymbol += '<span class="unpatrolled">!</span>';
+
      typeSymbol += '<span class="unpatrolled">!</span>';
}
+
    }
  
commentHtml = rc.parsedcomment;
+
    if (rc.oldlen > 0 && rc.newlen === 0) {
 +
      itemClass.push('mw-rtrc-item-alert');
 +
    }
  
// Check if edit summary is an AES
+
    /*
if (commentHtml.indexOf('<a href="/wiki/Commons:AES" class="mw-redirect" title="Commons:AES">\u2190</a>') === 0) {
+
Example:
// TODO: This is specific to commons.wikimedia.org
+
<div class="mw-rtrc-item mw-rtrc-item-patrolled" data-diff="0" data-rcid="0" user="Abc">
itemClass += ' mw-rtrc-item-aes';
+
  <div first>(<a>diff</a>) <span class="unpatrolled">!</span> 00:00 <a>Page</a></div>
}
+
  <div user><a class="user" href="/User:Abc">Abc</a></div>
 +
  <div comment><a href="/User talk:Abc">talk</a> / <a href="/Special:Contributions/Abc">contribs</a>&nbsp;<span class="comment">Abc</span></div>
 +
  <div class="mw-rtrc-meta"><span class="mw-plusminus mw-plusminus-null">(0)</span></div>
 +
</div>
 +
    */
  
// Anon-attribute
+
    // build & return item
if (isAnon) {
+
    item = buildRcDayHead(timeUtil.newDateFromISO(rc.timestamp));
itemClass = ' mw-rtrc-item-anon';
+
    item += '<div class="mw-rtrc-item ' + itemClass.join(' ') + '" data-diff="' + rc.revid + '" data-rcid="' + rc.rcid + '" user="' + rc.user + '">';
} else {
 
itemClass = ' mw-rtrc-item-liu';
 
}
 
/*
 
Example:
 
  
<div class="mw-rtrc-item mw-rtrc-item-patrolled" data-diff="0" data-rcid="0" user="Abc">
+
    if (rc.type === 'edit') {
<div diff>(<a class="diff" href="//">diff</a>)</div>
+
      diffLink = '<a class="rcitemlink diff" href="' +
<div type><span class="unpatrolled">!</span></div>
+
        mw.util.wikiScript() + '?diff=' + rc.revid + '&oldid=' + rc.old_revid + '&rcid=' + rc.rcid +
<div timetitle>00:00 <a href="//?rcid=0" target="_blank">Abc</a></div>
+
        '">' + mw.message('diff').escaped() + '</a>';
<div user><a class="user" href="//User:Abc">Abc</a></div>
+
    } else if (rc.type === 'new') {
<div other><a href="//User talk:Abc">talk</a> / <a href="//Special:Contributions/Abc">contribs</a>&nbsp;<span class="comment">Abc</span></div>
+
      diffLink = '<a class="rcitemlink newPage">' + message('new-short').escaped() + '</a>';
<div size><span class="mw-plusminus-null">(0)</span></div>
+
    } else {
</div>
+
      diffLink = mw.message('diff').escaped();
*/
+
    }
// build & return item
 
item = buildRcDayHead(timeUtil.newDateFromApi(rc.timestamp));
 
item += '<div class="mw-rtrc-item ' + itemClass + '" data-diff="' + rc.revid + '" data-rcid="' + rc.rcid + '" user="' + rc.user + '">';
 
  
if (rc.type === 'edit') {
+
    item += '<div first>' +
diffLink = '<a class="rcitemlink diff" href="' +
+
      '(' + diffLink + ') ' + typeSymbol + ' ' +
mw.util.wikiScript() + '?diff=' + rc.revid + '&oldif=' + rc.old_revid + '&rcid=' + rc.rcid +
+
      timeUtil.getClocktimeFromApi(rc.timestamp) +
'">' + mw.message('diff').escaped() + '</a>';
+
      ' <a class="mw-title" href="' + mw.util.getUrl(rc.title) + '?rcid=' + rc.rcid + '" target="_blank">' + rc.title + '</a>' +
} else if (rc.type === 'new') {
+
      '</div>' +
diffLink = '<a class="rcitemlink newPage">new</a>';
+
      '<div user>&nbsp;<small>&middot;&nbsp;' +
} else {
+
      '<a href="' + mw.util.getUrl('User talk:' + rc.user) + '" target="_blank">' + mw.message('talkpagelinktext').escaped() + '</a>' +
diffLink = mw.message('diff').escaped();
+
      ' &middot; ' +
}
+
      '<a href="' + mw.util.getUrl('Special:Contributions/' + rc.user) + '" target="_blank">' + mw.message('contribslink').escaped() + '</a>' +
 +
      '&nbsp;</small>&middot;&nbsp;' +
 +
      '<a class="mw-userlink" href="' + mw.util.getUrl((mw.util.isIPv4Address(rc.user) || mw.util.isIPv6Address(rc.user) ? 'Special:Contributions/' : 'User:') + rc.user) + '" target="_blank">' + rc.user + '</a>' +
 +
      '</div>' +
 +
      '<div comment>&nbsp;<span class="comment">' + rc.parsedcomment + '</span></div>';
  
item += '<div first>(' + diffLink + ') ' + typeSymbol + ' ';
+
    if (diffsize > 0) {
item += timeUtil.getClocktimeFromApi(rc.timestamp) + ' <a class="page" href="' + mw.util.wikiGetlink(rc.title) + '?rcid=' + rc.rcid + '" target="_blank">' + rc.title + '</a></div>';
+
      el = diffsize > 399 ? 'strong' : 'span';
item += '<div user>&nbsp;<small>&middot;&nbsp;<a href="' + mw.util.wikiGetlink('User talk:' + rc.user) + '" target="_blank">T</a> &middot; <a href="' + mw.util.wikiGetlink('Special:Contributions/' + rc.user) + '" target="_blank">C</a>&nbsp;</small>&middot;&nbsp;<a class="user" href="' + mw.util.wikiGetlink('User:' + rc.user) + '" target="_blank">' + rc.user + '</a></div>';
+
      item += '<div class="mw-rtrc-meta"><' + el + ' class="mw-plusminus mw-plusminus-pos">(+' + diffsize.toLocaleString() + ')</' + el + '></div>';
item += '<div other>&nbsp;<span class="comment">' + commentHtml + '</span></div>';
+
    } else if (diffsize === 0) {
 +
      item += '<div class="mw-rtrc-meta"><span class="mw-plusminus mw-plusminus-null">(0)</span></div>';
 +
    } else {
 +
      el = diffsize < -399 ? 'strong' : 'span';
 +
      item += '<div class="mw-rtrc-meta"><' + el + ' class="mw-plusminus mw-plusminus-neg">(' + diffsize.toLocaleString() + ')</' + el + '></div>';
 +
    }
  
if (diffsize > 0) {
+
    item += '</div>';
el = diffsize > 399 ? 'strong' : 'span';
+
    return item;
item += '<div size><' + el + ' class="mw-plusminus-pos">(' + diffsize + ')</' + el + '></div>';
+
  }
} else if (diffsize === 0) {
 
item += '<div size><span class="mw-plusminus-null">(0)</span></div>';
 
} else {
 
el = diffsize < -399 ? 'strong' : 'span';
 
item += '<div size><' + el + ' class="mw-plusminus-neg">(' + diffsize + ')</' + el + '></div>';
 
}
 
  
item += '</div>';
+
  /**
return item;
+
  * @param {Object} newOpt
}
+
  * @param {string} [mode=normal] One of 'quiet' or 'normal'
 +
  * @return {boolean} True if no changes were made, false otherwise
 +
  */
 +
  function normaliseSettings (newOpt, mode) {
 +
    var mod = false;
  
/**
+
    // MassPatrol requires a filter to be active
* @param {Object} newOpt
+
    if (newOpt.app.massPatrol && !newOpt.rc.user) {
* @param {string} [mode=normal] One of 'quiet' or 'normal'
+
      newOpt.app.massPatrol = false;
* @return {boolean} True if no changes were made, false otherwise
+
      mod = true;
*/
+
      if (mode !== 'quiet') {
function normaliseSettings(newOpt, mode) {
+
        alert(msg('masspatrol-requires-userfilter'));
var mod = false;
+
      }
 +
    }
  
// MassPatrol requires a filter to be active
+
    // MassPatrol implies AutoDiff
if (newOpt.app.massPatrol && !newOpt.rc.user) {
+
    if (newOpt.app.massPatrol && !newOpt.app.autoDiff) {
newOpt.app.massPatrol = false;
+
      newOpt.app.autoDiff = true;
mod = true;
+
      mod = true;
if (mode !== 'quiet') {
+
    }
alert(msg('masspatrol-requires-userfilter'));
+
    // MassPatrol implies fetching only unpatrolled changes
}
+
    if (newOpt.app.massPatrol && !newOpt.rc.unpatrolled) {
}
+
      newOpt.rc.unpatrolled = true;
 +
      mod = true;
 +
    }
  
// MassPatrol requires AutoDiff
+
    return !mod;
if (newOpt.app.massPatrol && !newOpt.app.autoDiff) {
+
  }
newOpt.app.autoDiff = true;
 
mod = true;
 
}
 
  
return !mod;
+
  function fillSettingsForm (newOpt) {
}
+
    var $settings = $($wrapper.find('.mw-rtrc-settings')[0].elements).filter(':input');
  
function readSettingsForm() {
+
    if (newOpt.rc) {
// jQuery#serializeArray is nice, but doesn't include "value: false" for unchecked
+
      $.each(newOpt.rc, function (key, value) {
// checkboxes that are not disabled. Using raw .elements instead and filtering
+
        var $setting = $settings.filter(function () {
// out <fieldset>.
+
            return this.name === key;
var $settings = $($wrapper.find('.mw-rtrc-settings')[0].elements).filter(':input');
+
          }),
 +
          setting = $setting[0];
  
opt = $.extend(true, {}, defOpt);
+
        if (!setting) {
 +
          return;
 +
        }
  
$settings.each(function (i, el) {
+
        switch (key) {
var name = el.name;
+
          case 'limit':
 +
            setting.value = value;
 +
            break;
 +
          case 'namespace':
 +
            if (value === undefined) {
 +
            // Value "" (all) is represented by undefined.
 +
              $setting.find('option').eq(0).prop('selected', true);
 +
            } else {
 +
              $setting.val(value);
 +
            }
 +
            break;
 +
          case 'user':
 +
          case 'start':
 +
          case 'end':
 +
          case 'tag':
 +
            setting.value = value || '';
 +
            break;
 +
          case 'hideliu':
 +
          case 'hidebots':
 +
          case 'unpatrolled':
 +
          case 'typeEdit':
 +
          case 'typeNew':
 +
            setting.checked = value;
 +
            break;
 +
          case 'dir':
 +
            if (setting.value === value) {
 +
              setting.checked = true;
 +
            }
 +
            break;
 +
        }
 +
      });
 +
    }
  
switch (name) {
+
    if (newOpt.app) {
// RC
+
      $.each(newOpt.app, function (key, value) {
case 'limit':
+
        var $setting = $settings.filter(function () {
opt.rc[name] = Number(el.value);
+
            return this.name === key;
break;
+
          }),
case 'namespace':
+
          setting = $setting[0];
// Can be "0".
 
// Value "" (all) is represented by undefined.
 
// TODO: Turn this into a multi-select, the API supports it.
 
opt.rc[name] = el.value.length ? Number(el.value) : undefined;
 
break;
 
case 'user':
 
case 'start':
 
case 'end':
 
case 'tag':
 
opt.rc[name] = el.value || undefined;
 
break;
 
case 'showAnonOnly':
 
case 'showUnpatrolledOnly':
 
case 'typeEdit':
 
case 'typeNew':
 
opt.rc[name] = el.checked;
 
break;
 
case 'dir':
 
// There's more than 1 radio button with this name in this loop,
 
// use the value of the first (and only) checked one.
 
if (el.checked) {
 
opt.rc[name] = el.value;
 
}
 
break;
 
// APP
 
case 'cvnDB':
 
case 'massPatrol':
 
case 'autoDiff':
 
opt.app[name] = el.checked;
 
break;
 
case 'refresh':
 
opt.app[name] = Number(el.value);
 
break;
 
}
 
});
 
  
if (!normaliseSettings(opt)) {
+
        if (!setting) {
// TODO: Optimise this, no need to repopulate the entire settings form
+
          setting = document.getElementById('rc-options-' + key);
// if only 1 thing changed.
+
          $setting = $(setting);
fillSettingsForm(opt);
+
        }
}
 
}
 
  
function fillSettingsForm(newOpt) {
+
        if (!setting) {
var $settings = $($wrapper.find('.mw-rtrc-settings')[0].elements).filter(':input');
+
          return;
 +
        }
  
if (newOpt.rc) {
+
        switch (key) {
$.each(newOpt.rc, function (key, value) {
+
          case 'cvnDB':
var $setting = $settings.filter(function () {
+
          case 'ores':
return this.name === key;
+
          case 'massPatrol':
}),
+
          case 'autoDiff':
setting = $setting[0];
+
            setting.checked = value;
 +
            break;
 +
          case 'refresh':
 +
            setting.value = value;
 +
            break;
 +
        }
 +
      });
 +
    }
 +
  }
  
if (!setting) {
+
  function readSettingsForm () {
return;
+
    // jQuery#serializeArray is nice, but doesn't include "value: false" for unchecked
}
+
    // checkboxes that are not disabled. Using raw .elements instead and filtering
 +
    // out <fieldset>.
 +
    var $settings = $($wrapper.find('.mw-rtrc-settings')[0].elements).filter(':input');
  
switch (key) {
+
    opt = makeOpt();
case 'limit':
 
setting.value = value;
 
break;
 
case 'namespace':
 
if (value === undefined) {
 
// Value "" (all) is represented by undefined.
 
$setting.find('option').eq(0).prop('selected', true);
 
} else {
 
$setting.val(value);
 
}
 
break;
 
case 'user':
 
case 'start':
 
case 'end':
 
case 'tag':
 
setting.value = value || '';
 
break;
 
case 'showAnonOnly':
 
case 'showUnpatrolledOnly':
 
case 'typeEdit':
 
case 'typeNew':
 
setting.checked = value;
 
break;
 
case 'dir':
 
if (setting.value === value) {
 
setting.checked = true;
 
}
 
break;
 
}
 
});
 
}
 
  
if (newOpt.app) {
+
    $settings.each(function (i, el) {
$.each(newOpt.app, function (key, value) {
+
      var name = el.name;
var $setting = $settings.filter(function () {
+
      switch (name) {
return this.name === key;
+
        // RC
}),
+
        case 'limit':
setting = $setting[0];
+
          opt.rc[name] = Number(el.value);
 +
          break;
 +
        case 'namespace':
 +
        // Can be "0".
 +
        // Value "" (all) is represented by undefined.
 +
        // TODO: Turn this into a multi-select, the API supports it.
 +
          opt.rc[name] = el.value.length ? Number(el.value) : undefined;
 +
          break;
 +
        case 'user':
 +
        case 'start':
 +
        case 'end':
 +
        case 'tag':
 +
          opt.rc[name] = el.value || undefined;
 +
          break;
 +
        case 'hideliu':
 +
        case 'hidebots':
 +
        case 'unpatrolled':
 +
        case 'typeEdit':
 +
        case 'typeNew':
 +
          opt.rc[name] = el.checked;
 +
          break;
 +
        case 'dir':
 +
          // There's more than 1 radio button with this name in this loop,
 +
          // use the value of the first (and only) checked one.
 +
          if (el.checked) {
 +
            opt.rc[name] = el.value;
 +
          }
 +
          break;
 +
        // APP
 +
        case 'cvnDB':
 +
        case 'ores':
 +
        case 'massPatrol':
 +
        case 'autoDiff':
 +
          opt.app[name] = el.checked;
 +
          break;
 +
        case 'refresh':
 +
          opt.app[name] = Number(el.value);
 +
          break;
 +
      }
 +
    });
  
if (!setting) {
+
    if (!normaliseSettings(opt)) {
setting = document.getElementById('rc-options-' + key);
+
      fillSettingsForm(opt);
$setting = $(setting);
+
    }
}
+
  }
  
if (!setting) {
+
  function getPermalink () {
return;
+
    var uri = new mw.Uri(mw.util.getUrl(conf.wgPageName)),
}
+
      reducedOpt = {};
  
switch (key) {
+
    $.each(opt.rc, function (key, value) {
case 'cvnDB':
+
      if (defOpt.rc[key] !== value) {
case 'massPatrol':
+
        if (!reducedOpt.rc) {
case 'autoDiff':
+
          reducedOpt.rc = {};
setting.checked = value;
+
        }
break;
+
        reducedOpt.rc[key] = value;
case 'refresh':
+
      }
setting.value = value;
+
    });
break;
 
}
 
});
 
}
 
  
}
+
    $.each(opt.app, function (key, value) {
 +
      // Don't permalink MassPatrol (issue Krinkle/mw-rtrc-gadget#59)
 +
      if (key !== 'massPatrol' && defOpt.app[key] !== value) {
 +
        if (!reducedOpt.app) {
 +
          reducedOpt.app = {};
 +
        }
 +
        reducedOpt.app[key] = value;
 +
      }
 +
    });
  
function getPermalink() {
+
    reducedOpt = JSON.stringify(reducedOpt);
var uri = new mw.Uri(mw.util.wikiGetlink(conf.wgPageName)),
 
reducedOpt = {};
 
  
$.each(opt.rc, function (key, value) {
+
    uri.extend({
if (defOpt.rc[key] !== value) {
+
      opt: reducedOpt === '{}' ? '' : reducedOpt
if (!reducedOpt.rc) {
+
    });
reducedOpt.rc = {};
 
}
 
reducedOpt.rc[key] = value;
 
}
 
});
 
  
$.each(opt.app, function (key, value) {
+
    return uri.toString();
if (defOpt.app[key] !== value) {
+
  }
if (!reducedOpt.app) {
 
reducedOpt.app = {};
 
}
 
reducedOpt.app[key] = value;
 
}
 
});
 
  
reducedOpt = $.toJSON(reducedOpt);
+
  function updateFeedNow () {
 +
    $('#rc-options-pause').prop('checked', false);
 +
    if (updateReq) {
 +
      // Try to abort the current request
 +
      updateReq.abort();
 +
    }
 +
    clearTimeout(updateFeedTimeout);
 +
    return updateFeed();
 +
  }
  
uri.extend({
+
  /**
opt: reducedOpt === '{}' ? undefined : reducedOpt,
+
  * @param {jQuery} $element
kickstart: 1
+
  */
});
+
  function scrollIntoView ($element) {
 +
    $element[0].scrollIntoView({ block: 'start', behavior: 'smooth' });
 +
  }
  
return uri.toString();
+
  /**
}
+
  * @param {jQuery} $element
 +
  */
 +
  function scrollIntoViewIfNeeded ($element) {
 +
    if ($element[0].scrollIntoViewIfNeeded) {
 +
      $element[0].scrollIntoViewIfNeeded({ block: 'start', behavior: 'smooth' });
 +
    } else {
 +
      $element[0].scrollIntoView({ block: 'start', behavior: 'smooth' });
 +
    }
 +
  }
  
// Read permalink into the program and reflect into settings form.
+
  // Read permalink into the program and reflect into settings form.
// TODO: Refactor into init, as this does more than read permalink.
+
  function readPermalink () {
// It also inits the settings form and handles kickstart
+
    var group, oldKey, newKey, newOpt,
function readPermalink() {
+
      url = new mw.Uri();
var url = new mw.Uri(),
 
newOpt = url.query.opt,
 
kickstart = url.query.kickstart;
 
  
newOpt = newOpt ? $.parseJSON(newOpt): {};
+
    if (url.query.opt) {
 +
      try {
 +
        newOpt = JSON.parse(url.query.opt);
 +
      } catch (e) {
 +
        // Ignore
 +
      }
 +
    }
 +
    if (newOpt) {
 +
      // Rename values for old aliases
 +
      for (group in newOpt) {
 +
        for (oldKey in newOpt[group]) {
 +
          newKey = aliasOpt[oldKey];
 +
          if (newKey && !Object.hasOwnProperty.call(newOpt[group], newKey)) {
 +
            newOpt[group][newKey] = newOpt[group][oldKey];
 +
            delete newOpt[group][oldKey];
 +
          }
 +
        }
 +
      }
  
newOpt = $.extend(true, {}, defOpt, newOpt);
+
      if (newOpt.app) {
 +
        // Don't permalink MassPatrol (issue Krinkle/mw-rtrc-gadget#59)
 +
        delete newOpt.app.massPatrol;
 +
      }
 +
    }
  
normaliseSettings(newOpt, 'quiet');
+
    newOpt = $.extend(true, makeOpt(), newOpt);
  
fillSettingsForm(newOpt);
+
    normaliseSettings(newOpt, 'quiet');
 +
    fillSettingsForm(newOpt);
  
opt = newOpt;
+
    opt = newOpt;
 +
  }
  
if (kickstart === '1') {
+
  function getApiRcParams (rc) {
updateFeedNow();
+
    var params,
if ($wrapper[0].scrollIntoView) {
+
      rcprop = [
$wrapper[0].scrollIntoView();
+
        'flags',
}
+
        'timestamp',
}
+
        'user',
}
+
        'title',
 +
        'parsedcomment',
 +
        'sizes',
 +
        'ids'
 +
      ],
 +
      rcshow = [],
 +
      rctype = [];
  
function getApiRcParams(rc) {
+
    if (userHasPatrolRight) {
var rcprop = [
+
      rcprop.push('patrolled');
'flags',
+
    }
'timestamp',
 
'user',
 
'title',
 
'parsedcomment',
 
'sizes',
 
'ids'
 
],
 
rcshow = ['!bot'],
 
rctype = [],
 
params = {};
 
  
params.rcdir = rc.dir;
+
    if (rc.hideliu) {
 +
      rcshow.push('anon');
 +
    }
 +
    if (rc.hidebots) {
 +
      rcshow.push('!bot');
 +
    }
 +
    if (rc.unpatrolled) {
 +
      rcshow.push('!patrolled');
 +
    }
  
if (rc.dir === 'older') {
+
    if (rc.typeEdit) {
if (rc.end !== undefined) {
+
      rctype.push('edit');
params.rcstart = rc.end;
+
    }
}
+
    if (rc.typeNew) {
if (rc.start !== undefined) {
+
      rctype.push('new');
params.rcend = rc.start;
+
    }
}
+
    if (!rctype.length) {
} else if (rc.dir === 'newer') {
+
      // Custom default instead of MediaWiki's default (in case both checkboxes were unchecked)
if (rc.start !== undefined) {
+
      rctype = ['edit', 'new'];
params.rcstart = rc.start;
+
    }
}
 
if (rc.end !== undefined) {
 
params.rcend = rc.end;
 
}
 
}
 
  
if (rc.namespace !== undefined) {
+
    params = {
params.rcnamespace = rc.namespace;
+
      rcdir: rc.dir,
}
+
      rclimit: rc.limit,
 +
      rcshow: rcshow.join('|'),
 +
      rcprop: rcprop.join('|'),
 +
      rctype: rctype.join('|')
 +
    };
  
if (rc.user !== undefined) {
+
    if (rc.dir === 'older') {
params.rcuser = rc.user;
+
      if (rc.end !== undefined) {
}
+
        params.rcstart = rc.end;
 +
      }
 +
      if (rc.start !== undefined) {
 +
        params.rcend = rc.start;
 +
      }
 +
    } else if (rc.dir === 'newer') {
 +
      if (rc.start !== undefined) {
 +
        params.rcstart = rc.start;
 +
      }
 +
      if (rc.end !== undefined) {
 +
        params.rcend = rc.end;
 +
      }
 +
    }
  
// params.titles: Title filter option (rctitles) is no longer supported by MediaWiki,
+
    if (rc.namespace !== undefined) {
// see https://bugzilla.wikimedia.org/show_bug.cgi?id=12394#c5.
+
      params.rcnamespace = rc.namespace;
 +
    }
  
if (rc.tag !== undefined) {
+
    if (rc.user !== undefined) {
params.rctag = rc.tag;
+
      params.rcuser = rc.user;
}
+
    }
  
if (userHasPatrolRight) {
+
    if (rc.tag !== undefined) {
rcprop.push('patrolled');
+
      params.rctag = rc.tag;
}
+
    }
  
params.rcprop = rcprop.join('|');
+
    // params.titles: Title filter (rctitles) is no longer supported by MediaWiki,
 +
    // see https://bugzilla.wikimedia.org/show_bug.cgi?id=12394#c5.
  
if (rc.showAnonOnly) {
+
    return params;
rcshow.push('anon');
+
  }
}
 
  
if (rc.showUnpatrolledOnly) {
+
  // Called when the feed is regenerated before being inserted in the document
rcshow.push('!patrolled');
+
  function applyRtrcAnnotations ($feedContent) {
}
+
    // Re-apply item classes
 +
    $feedContent.filter('.mw-rtrc-item').each(function () {
 +
      var $el = $(this),
 +
        rcid = Number($el.data('rcid'));
  
params.rcshow = rcshow.join('|');
+
      // Mark skipped and patrolled items as such
 +
      if (skippedRCIDs.includes(rcid)) {
 +
        $el.addClass('mw-rtrc-item-skipped');
 +
      } else if (rcid in annotationsCache.patrolled) {
 +
        $el.addClass('mw-rtrc-item-patrolled');
 +
      } else if (rcid === currentDiffRcid) {
 +
        $el.addClass('mw-rtrc-item-current');
 +
      }
 +
    });
 +
  }
  
params.rclimit = rc.limit;
+
  function applyOresAnnotations ($feedContent) {
 +
    var dAnnotations, revids, fetchRevids;
  
if (rc.typeEdit) {
+
    if (!oresModel) {
rctype.push('edit');
+
      return $.Deferred().resolve();
}
+
    }
  
if (rc.typeNew) {
+
    // Find all revids names inside the feed
rctype.push('new');
+
    revids = $.map($feedContent.filter('.mw-rtrc-item'), function (node) {
}
+
      return $(node).attr('data-diff');
 +
    });
  
params.rctype = rctype.length ? rctype.join('|') : 'edit|new';
+
    if (!revids.length) {
 +
      return $.Deferred().resolve();
 +
    }
  
return params;
+
    fetchRevids = revids.filter(function (revid) {
}
+
      return !(revid in annotationsCache.ores);
 +
    });
  
// Called when the feed is regenerated before being inserted in the document
+
    if (!fetchRevids.length) {
function applyRtrcAnnotations($feedContent) {
+
      // No (new) revisions
 +
      dAnnotations = $.Deferred().resolve(annotationsCache.ores);
 +
    } else {
 +
      dAnnotations = $.ajax({
 +
        url: oresApiUrl,
 +
        data: {
 +
          models: oresModel,
 +
          revids: fetchRevids.join('|')
 +
        },
 +
        timeout: 10000,
 +
        dataType: $.support.cors ? 'json' : 'jsonp',
 +
        cache: true
 +
      }).then(function (resp) {
 +
        var len;
 +
        if (resp) {
 +
          len = Object.keys ? Object.keys(resp).length : fetchRevids.length;
 +
          annotationsCacheUp(len);
 +
          $.each(resp, function (revid, item) {
 +
            if (!item || item.error || !item[oresModel] || item[oresModel].error) {
 +
              return;
 +
            }
 +
            annotationsCache.ores[revid] = item[oresModel].probability['true'];
 +
          });
 +
        }
 +
        return annotationsCache.ores;
 +
      });
 +
    }
  
// Re-apply item classes
+
    return dAnnotations.then(function (annotations) {
$feedContent.filter('.mw-rtrc-item').each(function () {
+
      // Loop through all revision ids
var $el = $(this),
+
      revids.forEach(function (revid) {
rcid = Number($el.data('rcid'));
+
        var tooltip,
 +
          score = annotations[revid];
 +
        // Only highlight high probability scores
 +
        if (!score || score <= 0.45) {
 +
          return;
 +
        }
 +
        tooltip = msg('ores-damaging-probability', (100 * score).toFixed(0) + '%');
  
// Mark skipped and patrolled items as such
+
        // Add alert
if ($.inArray(rcid, skippedRCIDs) !== -1) {
+
        $feedContent
$el.addClass('mw-rtrc-item-skipped');
+
          .filter('.mw-rtrc-item[data-diff="' + Number(revid) + '"]')
} else if ($.inArray(rcid, patrolledRCIDs) !== -1) {
+
          .addClass('mw-rtrc-item-alert mw-rtrc-item-alert-rev')
$el.addClass('mw-rtrc-item-patrolled');
+
          .find('.mw-rtrc-meta')
} else if (rcid === currentDiffRcid) {
+
          .prepend(
$el.addClass('mw-rtrc-item-current');
+
            $('<span>')
}
+
              .addClass('mw-rtrc-revscore')
});
+
              .attr('title', tooltip)
}
+
          );
 +
      });
 +
    });
 +
  }
  
/**
+
  function applyCvnAnnotations ($feedContent) {
* @param {Object} update
+
    var dAnnotations,
* @param {jQuery} update.$feedContent
+
      users = [];
* @param {string} update.rawHtml
 
*/
 
function pushFeedContent(update) {
 
// TODO: Only do once
 
$body.removeClass('placeholder');
 
  
$feed.find('.mw-rtrc-feed-update').html(
+
    // Collect user names
message('lastupdate-rc', new Date().toLocaleString()).escaped() +
+
    $feedContent.filter('.mw-rtrc-item').each(function () {
' | <a href="' + getPermalink() + '">' +
+
      var user = $(this).attr('user');
message('permalink').escaped() +
+
      // Don't query the same user multiple times
'</a>'
+
      if (user && users.includes(user) && !(user in annotationsCache.cvn)) {
);
+
        users.push(user);
 +
      }
 +
    });
  
if (update.rawHtml !== prevFeedHtml) {
+
    if (!users.length) {
prevFeedHtml = update.rawHtml;
+
      // No (new) users
applyRtrcAnnotations(update.$feedContent);
+
      dAnnotations = $.Deferred().resolve(annotationsCache.cvn);
$feed.find('.mw-rtrc-feed-content').empty().append(update.$feedContent);
+
    } else {
}
+
      dAnnotations = $.ajax({
 +
        url: cvnApiUrl,
 +
        data: { users: users.join('|') },
 +
        timeout: 2000,
 +
        dataType: $.support.cors ? 'json' : 'jsonp',
 +
        cache: true
 +
      })
 +
        .then(function (resp) {
 +
          if (resp.users) {
 +
            $.each(resp.users, function (name, user) {
 +
              annotationsCacheUp();
 +
              annotationsCache.cvn[name] = user;
 +
            });
 +
          }
 +
          return annotationsCache.cvn;
 +
        });
 +
    }
  
// Schedule next update
+
    return dAnnotations.then(function (annotations) {
updateFeedTimeout = setTimeout(updateFeed, opt.app.refresh * 1000);
+
      // Loop through all cvn user annotations
$('#krRTRC_loader').hide();
+
      $.each(annotations, function (name, user) {
}
+
        var tooltip;
  
function applyCvnAnnotations($feedContent, callback) {
+
        // Only if blacklisted, otherwise don't highlight
var users;
+
        if (user.type === 'blacklist') {
 +
          tooltip = '';
  
// Find all user names inside the feed
+
          if (user.comment) {
users = [];
+
            tooltip += msg('cvn-reason') + ': ' + user.comment + '. ';
$feedContent.filter('.mw-rtrc-item').each(function () {
+
          } else {
var user = $(this).attr('user');
+
            tooltip += msg('cvn-reason') + ': ' + msg('cvn-reason-empty');
if (user) {
+
          }
users.push(user);
 
}
 
});
 
  
if (!users.length) {
+
          if (user.adder) {
callback();
+
            tooltip += msg('cvn-adder') + ': ' + user.adder;
return;
+
          } else {
}
+
            tooltip += msg('cvn-adder') + ': ' + msg('cvn-adder-empty');
 +
          }
  
$.ajax({
+
          // Add alert
url: cvnApiUrl,
+
          $feedContent
data: {
+
            .filter('.mw-rtrc-item')
users: users.join('|'),
+
            .filter(function () {
},
+
              return $(this).attr('user') === name;
dataType: 'jsonp'
+
            })
})
+
            .addClass('mw-rtrc-item-alert mw-rtrc-item-alert-user')
.fail(function () {
+
            .find('.mw-userlink')
callback();
+
            .attr('title', tooltip);
})
+
        }
.done(function (data) {
+
      });
var d;
+
    });
 +
  }
  
if (!data.users) {
+
  /**
callback();
+
  * @param {Object} update
return;
+
  * @param {jQuery} update.$feedContent
}
+
  * @param {string} update.rawHtml
 +
  */
 +
  function pushFeedContent (update) {
 +
    $body.removeClass('placeholder');
  
// Loop through all users
+
    $feed.find('.mw-rtrc-feed-update').html(
$.each(data.users, function (name, user) {
+
      message('lastupdate-rc', new Date().toLocaleString()).escaped() +
var tooltip;
+
      ' | <a href="' + mw.html.escape(getPermalink()) + '">' +
 +
      message('permalink').escaped() +
 +
      '</a>'
 +
    );
  
// Only if blacklisted, otherwise dont highlight
+
    if (update.rawHtml !== prevFeedHtml) {
if (user.type === 'blacklist') {
+
      prevFeedHtml = update.rawHtml;
tooltip = '';
+
      applyRtrcAnnotations(update.$feedContent);
 +
      $feed.find('.mw-rtrc-feed-content').empty().append(update.$feedContent);
 +
    }
 +
  }
  
if (user.comment) {
+
  function updateFeed () {
tooltip += msg('cvn-reason') + ': ' + user.comment + '. ';
+
    if (updateReq) {
} else {
+
      updateReq.abort();
tooltip += msg('cvn-reason') + ': ' + msg('cvn-reason-empty');
+
    }
}
 
  
if (user.adder) {
+
    // Indicate updating
tooltip += msg('cvn-adder') + ': ' + user.adder;
+
    $('#krRTRC_loader').show();
} else {
 
tooltip += msg('cvn-adder') + ': ' + msg('cvn-adder-empty');
 
}
 
  
// Apply blacklisted-class, and insert icon with tooltip
+
    // Download recent changes
$feedContent
+
    updateReq = $.ajax({
.filter('.mw-rtrc-item')
+
      url: apiUrl,
.filter(function () {
+
      dataType: 'json',
return $(this).attr('user') === name;
+
      data: $.extend(getApiRcParams(opt.rc), {
})
+
        format: 'json',
.find('.user')
+
        action: 'query',
.addClass('blacklisted')
+
        list: 'recentchanges'
.attr('title', tooltip);
+
      })
}
+
    });
 +
    // This waterfall flows in one of two ways:
 +
    // - Everything casts to success and results in a UI update (maybe an error message),
 +
    //  loading indicator hidden, and the next update scheduled.
 +
    // - Request is aborted and nothing happens (instead, the final handling will
 +
    //  be done by the new request).
 +
    return updateReq.always(function () {
 +
      updateReq = null;
 +
    })
 +
      .then(function onRcSuccess (data) {
 +
        var recentchanges, $feedContent, client,
 +
          feedContentHTML = '';
  
});
+
        if (data.error) {
 +
          // Account doesn't have patrol flag
 +
          if (data.error.code === 'rcpermissiondenied') {
 +
            feedContentHTML += '<h3>Downloading recent changes failed</h3><p>Please untick the "Unpatrolled only"-checkbox or request the Patroller-right.</a>';
  
// Either way, push the feed to the frontend
+
          // Other error
callback();
+
          } else {
 +
            client = $.client.profile();
 +
            feedContentHTML += '<h3>Downloading recent changes failed</h3>' +
 +
            '<p>Please check the settings above and try again. If you believe this is a bug, please <strong>' +
 +
            '<a href="https://github.com/Krinkle/mw-gadget-rtrc/issues/new?body=' + encodeURIComponent('\n\n\n----' +
 +
            '\npackage: mw-gadget-rtrc ' + appVersion +
 +
            mw.format('\nbrowser: $1 $2 ($3)', client.name, client.version, client.platform)
 +
            ) + '" target="_blank">let me know</a></strong>.';
 +
          }
 +
        } else {
 +
          recentchanges = data.query.recentchanges;
  
d = new Date();
+
          if (recentchanges.length) {
d.setTime(data.lastUpdate * 1000);
+
            $.each(recentchanges, function (i, rc) {
$feed.find('.mw-rtrc-feed-cvninfo').text('CVN DB ' + msg('lastupdate-cvn', d.toUTCString()));
+
              feedContentHTML += buildRcItem(rc);
});
+
            });
}
+
          } else {
 +
            // Evserything is OK - no results
 +
            feedContentHTML += '<strong><em>' + message('nomatches').escaped() + '</em></strong>';
 +
          }
  
function updateFeedNow() {
+
          // Reset day
$('#rc-options-pause').prop('checked', false);
+
          rcDayHeadPrev = undefined;
clearTimeout(updateFeedTimeout);
+
        }
updateFeed();
 
}
 
  
function updateFeed() {
+
        $feedContent = $($.parseHTML(feedContentHTML));
var rcparams;
+
        return $.when(
if (!isUpdating) {
+
          opt.app.cvnDB && applyCvnAnnotations($feedContent),
 +
          oresModel && opt.app.ores && applyOresAnnotations($feedContent)
 +
        ).then(null, function () {
 +
          // Ignore errors from annotation handlers
 +
          return $.Deferred().resolve();
 +
        }).then(function () {
 +
          return {
 +
            $feedContent: $feedContent,
 +
            rawHtml: feedContentHTML
 +
          };
 +
        });
 +
      }, function onRcError (jqXhr, textStatus) {
 +
        var feedContentHTML;
 +
        if (textStatus === 'abort') {
 +
          // No rendering
 +
          return $.Deferred().reject();
 +
        }
 +
        feedContentHTML = '<h3>Downloading recent changes failed</h3>';
 +
        // Error is handled, continue to rendering.
 +
        return {
 +
          $feedContent: $(feedContentHTML),
 +
          rawHtml: feedContentHTML
 +
        };
 +
      })
 +
      .then(function (obj) {
 +
        // Render
 +
        pushFeedContent(obj);
 +
      })
 +
      .then(function () {
 +
        $RCOptionsSubmit.prop('disabled', false).css('opacity', '1.0');
  
// Indicate updating
+
        // Schedule next update
$('#krRTRC_loader').show();
+
        updateFeedTimeout = setTimeout(updateFeed, opt.app.refresh * 1000);
isUpdating = true;
+
        $('#krRTRC_loader').hide();
 +
      });
 +
  }
  
// Download recent changes
+
  function nextDiff () {
 +
    var $lis = $feed.find('.mw-rtrc-item:not(.mw-rtrc-item-current, .mw-rtrc-item-patrolled, .mw-rtrc-item-skipped)');
 +
    $lis.eq(0).find('a.rcitemlink').click();
 +
  }
  
rcparams = getApiRcParams(opt.rc);
+
  function wakeupMassPatrol (settingVal) {
rcparams.format = 'json';
+
    if (settingVal === true) {
rcparams.action = 'query';
+
      if (!currentDiff) {
rcparams.list = 'recentchanges';
+
        nextDiff();
 +
      } else {
 +
        $('.patrollink a').click();
 +
      }
 +
    }
 +
  }
  
$.ajax({
+
  // Build the main interface
url: apiUrl,
+
  function buildInterface () {
dataType: 'json',
+
    var namespaceOptionsHtml, tagOptionsHtml, key,
data: rcparams
+
      fmNs = mw.config.get('wgFormattedNamespaces');
}).fail(function () {
 
var feedContentHTML = '<h3>Downloading recent changes failed</h3>';
 
pushFeedContent({
 
$feedContent: $(feedContentHTML),
 
rawHtml: feedContentHTML
 
});
 
isUpdating = false;
 
$RCOptions_submit.prop('disabled', false).css('opacity', '1.0');
 
  
}).done(function (data) {
+
    namespaceOptionsHtml = '<option value>' + mw.message('namespacesall').escaped() + '</option>';
var recentchanges, $feedContent, feedContentHTML = '';
+
    namespaceOptionsHtml += '<option value="0">' + mw.message('blanknamespace').escaped() + '</option>';
  
if (data.error) {
+
    for (key in fmNs) {
$body.removeClass('placeholder');
+
      if (key > 0) {
 +
        namespaceOptionsHtml += '<option value="' + key + '">' + fmNs[key] + '</option>';
 +
      }
 +
    }
  
// Account doesn't have patrol flag
+
    tagOptionsHtml = '<option value selected>' + message('select-placeholder-none').escaped() + '</option>';
if (data.error.code === 'rcpermissiondenied') {
+
    for (key = 0; key < rcTags.length; key++) {
feedContentHTML += '<h3>Downloading recent changes failed</h3><p>Please untick the "Unpatrolled only"-checkbox or request the Patroller-right.</a>';
+
      tagOptionsHtml += '<option value="' + mw.html.escape(rcTags[key]) + '">' + mw.html.escape(rcTags[key]) + '</option>';
 +
    }
  
// Other error
+
    $wrapper = $($.parseHTML(
} else {
+
      '<div class="mw-rtrc-wrapper">' +
feedContentHTML += '<h3>Downloading recent changes failed</h3><p>Please check the settings above and try again. If you believe this is a bug, please <a href="//meta.wikimedia.org/w/index.php?title=User_talk:Krinkle/Tools&action=edit&section=new&preload=User_talk:Krinkle/Tools/Preload" target="_blank"><strong>let me know</strong></a>.';
+
      '<div class="mw-rtrc-head">' +
}
+
        message('title').escaped() + ' <small>(' + appVersion + ')</small>' +
 +
        '<div class="mw-rtrc-head-links">' +
 +
          (!mw.user.isAnon() ? (
 +
            '<a target="_blank" href="' + mw.util.getUrl('Special:Log', { type: 'patrol', user: mw.user.getName(), subtype: 'patrol' }) + '">' +
 +
              message('mypatrollog').escaped() +
 +
            '</a>'
 +
          ) : '') +
 +
          '<a id="mw-rtrc-toggleHelp">' + message('help').escaped() + '</a>' +
 +
        '</div>' +
 +
      '</div>' +
 +
      '<form id="krRTRC_RCOptions" class="mw-rtrc-settings mw-rtrc-nohelp make-switch"><fieldset>' +
 +
        '<div class="panel-group">' +
 +
          '<div class="panel">' +
 +
            '<label class="head">' + message('filter').escaped() + '</label>' +
 +
            '<div class="sub-panel">' +
 +
              '<label>' +
 +
                '<input type="checkbox" name="hideliu" />' +
 +
                ' ' + message('filter-hideliu').escaped() +
 +
              '</label>' +
 +
              '<br />' +
 +
              '<label>' +
 +
                '<input type="checkbox" name="hidebots" />' +
 +
                ' ' + message('filter-hidebots').escaped() +
 +
              '</label>' +
 +
            '</div>' +
 +
            '<div class="sub-panel">' +
 +
              '<label>' +
 +
                '<input type="checkbox" name="unpatrolled" />' +
 +
                ' ' + message('filter-unpatrolled').escaped() +
 +
              '</label>' +
 +
              '<br />' +
 +
              '<label>' +
 +
                message('userfilter').escaped() +
 +
                '<span section="Userfilter" class="helpicon"></span>: ' +
 +
                '<input type="search" size="16" name="user" />' +
 +
              '</label>' +
 +
            '</div>' +
 +
          '</div>' +
 +
          '<div class="panel">' +
 +
            '<label class="head">' + message('type').escaped() + '</label>' +
 +
            '<div class="sub-panel">' +
 +
              '<label>' +
 +
                '<input type="checkbox" name="typeEdit" checked />' +
 +
                ' ' + message('typeEdit').escaped() +
 +
              '</label>' +
 +
              '<br />' +
 +
              '<label>' +
 +
                '<input type="checkbox" name="typeNew" checked />' +
 +
                ' ' + message('typeNew').escaped() +
 +
              '</label>' +
 +
            '</div>' +
 +
          '</div>' +
 +
          '<div class="panel">' +
 +
            '<label  class="head">' +
 +
              mw.message('namespaces').escaped() +
 +
              ' <br />' +
 +
              '<select class="mw-rtrc-setting-select" name="namespace">' +
 +
              namespaceOptionsHtml +
 +
              '</select>' +
 +
            '</label>' +
 +
          '</div>' +
 +
          '<div class="panel">' +
 +
            '<label class="head">' +
 +
              message('timeframe').escaped() +
 +
              '<span section="Timeframe" class="helpicon"></span>' +
 +
            '</label>' +
 +
            '<div class="sub-panel" style="text-align: right;">' +
 +
              '<label>' +
 +
                message('time-from').escaped() + ': ' +
 +
                '<input type="text" size="16" placeholder="YYYYMMDDHHIISS" name="start" />' +
 +
              '</label>' +
 +
              '<br />' +
 +
              '<label>' +
 +
                message('time-untill').escaped() + ': ' +
 +
                '<input type="text" size="16" placeholder="YYYYMMDDHHIISS" name="end" />' +
 +
              '</label>' +
 +
            '</div>' +
 +
          '</div>' +
 +
          '<div class="panel">' +
 +
            '<label class="head">' +
 +
              message('order').escaped() +
 +
              ' <br />' +
 +
              '<span section="Order" class="helpicon"></span>' +
 +
            '</label>' +
 +
            '<div class="sub-panel">' +
 +
              '<label>' +
 +
                '<input type="radio" name="dir" value="newer" />' +
 +
                ' ' + message('asc').escaped() +
 +
              '</label>' +
 +
              '<br />' +
 +
              '<label>' +
 +
                '<input type="radio" name="dir" value="older" checked />' +
 +
                ' ' + message('desc').escaped() +
 +
              '</label>' +
 +
            '</div>' +
 +
          '</div>' +
 +
          '<div class="panel">' +
 +
            '<label for="mw-rtrc-settings-refresh" class="head">' +
 +
              message('reload-interval').escaped() + '<br />' +
 +
              '<span section="Reload_Interval" class="helpicon"></span>' +
 +
            '</label>' +
 +
            '<input type="number" value="3" min="0" max="99" size="2" id="mw-rtrc-settings-refresh" name="refresh" />' +
 +
          '</div>' +
 +
          '<div class="panel panel-last">' +
 +
            '<input class="button" type="button" id="RCOptions_submit" value="' + message('apply').escaped() + '" />' +
 +
          '</div>' +
 +
        '</div>' +
 +
        '<div class="panel-group panel-group-mini">' +
 +
          '<div class="panel">' +
 +
            '<label for="mw-rtrc-settings-limit" class="head">' + message('limit').escaped() + '</label>' +
 +
            ' <select id="mw-rtrc-settings-limit" name="limit">' +
 +
              '<option value="10">10</option>' +
 +
              '<option value="25" selected>25</option>' +
 +
              '<option value="50">50</option>' +
 +
              '<option value="75">75</option>' +
 +
              '<option value="100">100</option>' +
 +
              '<option value="250">250</option>' +
 +
              '<option value="500">500</option>' +
 +
            '</select>' +
 +
          '</div>' +
 +
          '<div class="panel">' +
 +
            '<label class="head">' +
 +
              message('tag').escaped() +
 +
              ' <select class="mw-rtrc-setting-select" name="tag">' +
 +
              tagOptionsHtml +
 +
              '</select>' +
 +
            '</label>' +
 +
          '</div>' +
 +
          '<div class="panel">' +
 +
            '<label class="head">' +
 +
              message('cvn-scores').escaped() +
 +
              '<span section="CVN_Scores" class="helpicon"></span>' +
 +
              '<input type="checkbox" class="switch" name="cvnDB" />' +
 +
            '</label>' +
 +
          '</div>' +
 +
          (oresModel ? (
 +
            '<div class="panel">' +
 +
              '<label class="head">' +
 +
                message('ores-scores').escaped() +
 +
                '<span section="ORES_Scores" class="helpicon"></span>' +
 +
                '<input type="checkbox" class="switch" name="ores" />' +
 +
              '</label>' +
 +
            '</div>'
 +
          ) : '') +
 +
          '<div class="panel">' +
 +
            '<label class="head">' +
 +
              message('masspatrol').escaped() +
 +
              '<span section="MassPatrol" class="helpicon"></span>' +
 +
              '<input type="checkbox" class="switch" name="massPatrol" />' +
 +
            '</label>' +
 +
          '</div>' +
 +
          '<div class="panel">' +
 +
            '<label class="head">' +
 +
              message('autodiff').escaped() +
 +
              '<span section="AutoDiff" class="helpicon"></span>' +
 +
              '<input type="checkbox" class="switch" name="autoDiff" />' +
 +
            '</label>' +
 +
          '</div>' +
 +
          '<div class="panel">' +
 +
            '<label class="head">' +
 +
              message('pause').escaped() +
 +
              '<input class="switch" type="checkbox" id="rc-options-pause" />' +
 +
            '</label>' +
 +
          '</div>' +
 +
        '</div>' +
 +
      '</fieldset></form>' +
 +
      '<a name="krRTRC_DiffTop" />' +
 +
      '<div class="mw-rtrc-diff mw-rtrc-diff-closed" id="krRTRC_DiffFrame"></div>' +
 +
      '<div class="mw-rtrc-body placeholder">' +
 +
        '<div class="mw-rtrc-feed">' +
 +
          '<div class="mw-rtrc-feed-update"></div>' +
 +
          '<div class="mw-rtrc-feed-content"></div>' +
 +
        '</div>' +
 +
        '<img src="' + ajaxLoaderUrl + '" id="krRTRC_loader" style="display: none;" />' +
 +
        '<div class="mw-rtrc-legend">' +
 +
          message('legend').escaped() + ': ' +
 +
          '<div class="mw-rtrc-item mw-rtrc-item-patrolled">' + mw.message('markedaspatrolled').escaped() + '</div>, ' +
 +
          '<div class="mw-rtrc-item mw-rtrc-item-current">' + message('currentedit').escaped() + '</div>, ' +
 +
          '<div class="mw-rtrc-item mw-rtrc-item-skipped">' + message('skippededit').escaped() + '</div>' +
 +
        '</div>' +
 +
      '</div>' +
 +
      '<div style="clear: both;"></div>' +
 +
      '<div class="mw-rtrc-foot">' +
 +
        '<div class="plainlinks" style="text-align: right;">' +
 +
          'Real-Time Recent Changes by ' +
 +
          '<a href="//meta.wikimedia.org/wiki/User:Krinkle">Krinkle</a>' +
 +
          ' | <a href="' + docUrl + '">' + message('documentation').escaped() + '</a>' +
 +
          ' | <a href="https://github.com/Krinkle/mw-gadget-rtrc/releases">' + message('changelog').escaped() + '</a>' +
 +
          ' | <a href="https://github.com/Krinkle/mw-gadget-rtrc/issues">' + message('feedback').escaped() + '</a>' +
 +
        '</div>' +
 +
      '</div>' +
 +
    '</div>'
 +
    ));
  
} else {
+
    // Add helper element for switch checkboxes
recentchanges = data.query.recentchanges;
+
    $wrapper.find('input.switch').after('<div class="switched"></div>');
  
if (recentchanges.length) {
+
    // All links within the diffframe should open in a new window
$.each(recentchanges, function (i, rc) {
+
    $wrapper.find('#krRTRC_DiffFrame').on('click', 'table.diff a', function () {
feedContentHTML += buildRcItem(rc);
+
      var $el = $(this);
});
+
      if ($el.is('[href^="http://"], [href^="https://"], [href^="//"]')) {
} else {
+
        $el.attr('target', '_blank');
// Everything is OK - no results
+
      }
feedContentHTML += '<strong><em>' + message('nomatches').escaped() + '</em></strong>';
+
    });
}
 
  
// Reset day
+
    $('#content').empty().append($wrapper);
rcPrevDayHeading = undefined;
 
}
 
  
$feedContent = $($.parseHTML(feedContentHTML));
+
    $body = $wrapper.find('.mw-rtrc-body');
if (opt.app.cvnDB) {
+
    $feed = $body.find('.mw-rtrc-feed');
applyCvnAnnotations($feedContent, function () {
+
  }
pushFeedContent({
 
$feedContent: $feedContent,
 
rawHtml: feedContentHTML
 
});
 
isUpdating = false;
 
});
 
} else {
 
pushFeedContent({
 
$feedContent: $feedContent,
 
rawHtml: feedContentHTML
 
});
 
isUpdating = false;
 
}
 
  
$RCOptions_submit.prop('disabled', false).css('opacity', '1.0');
+
  function annotationsCacheUp (increment) {
});
+
    annotationsCacheSize += increment || 1;
}
+
    if (annotationsCacheSize > 1000) {
}
+
      annotationsCache.patrolled = Object.create(null);
 +
      annotationsCache.ores = Object.create(null);
 +
      annotationsCache.cvn = Object.create(null);
 +
    }
 +
  }
  
function krRTRC_NextDiff() {
+
  // Bind event hanlders in the user interface
var $lis = $feed.find('.mw-rtrc-item:not(.mw-rtrc-item-current, .mw-rtrc-item-patrolled, .mw-rtrc-item-skipped)');
+
  function bindInterface () {
$lis.eq(0).find('a.rcitemlink').click();
+
    var api = new mw.Api();
}
+
    $RCOptionsSubmit = $('#RCOptions_submit');
  
function krRTRC_ToggleMassPatrol(b) {
+
    // Apply button
if (b === true) {
+
    $RCOptionsSubmit.on('click', function () {
if (!currentDiff) {
+
      $RCOptionsSubmit.prop('disabled', true).css('opacity', '0.5');
krRTRC_NextDiff();
 
} else {
 
$('.patrollink a').click();
 
}
 
}
 
}
 
  
function navToggle() {
+
      readSettingsForm();
navCollapsed = String(navCollapsed !== 'true');
 
$('html').toggleClass('mw-rtrc-navtoggle-collapsed');
 
localStorage.setItem('mw-rtrc-navtoggle-collapsed', navCollapsed);
 
}
 
  
// Build the main interface
+
      updateFeedNow().then(function () {
function buildInterface() {
+
        wakeupMassPatrol(opt.app.massPatrol);
var namespaceOptionsHtml, tagOptionsHtml,
+
      });
key,
+
      return false;
fmNs = mw.config.get('wgFormattedNamespaces');
+
    });
  
namespaceOptionsHtml = '<option value>' + mw.message('namespacesall').escaped() + '</option>';
+
    // Close Diff
namespaceOptionsHtml += '<option value="0">' + mw.message('blanknamespace').escaped() + '</option>';
+
    $wrapper.on('click', '#diffClose', function () {
 +
      $('#krRTRC_DiffFrame').addClass('mw-rtrc-diff-closed');
 +
      currentDiff = currentDiffRcid = false;
 +
    });
  
for (key in fmNs) {
+
    // Load diffview on (diff)-link click
if (key > 0) {
+
    $feed.on('click', 'a.diff', function (e) {
namespaceOptionsHtml += '<option value="' + key + '">' + fmNs[key] + '</option>';
+
      var $item = $(this).closest('.mw-rtrc-item').addClass('mw-rtrc-item-current'),
}
+
        title = $item.find('.mw-title').text(),
}
+
        href = $(this).attr('href'),
 +
        $frame = $('#krRTRC_DiffFrame');
  
tagOptionsHtml = '<option value selected>' + message('select-placeholder-none').escaped() + '</option>';
+
      $feed.find('.mw-rtrc-item-current').not($item).removeClass('mw-rtrc-item-current');
for (key = 0; key < rcTags.length; key++) {
 
tagOptionsHtml += '<option value="' + mw.html.escape(rcTags[key]) + '">' + mw.html.escape(rcTags[key]) + '</option>';
 
}
 
  
 +
      currentDiff = Number($item.data('diff'));
 +
      currentDiffRcid = Number($item.data('rcid'));
  
$wrapper = $($.parseHTML(
+
      $frame
'<div class="mw-rtrc-wrapper">' +
+
        .addClass('mw-rtrc-diff-loading')
'<div class="mw-rtrc-head">' +
+
        // Reset class potentially added by a.newPage or diffClose
'Real-Time Recent Changes <small>(' + appVersion + ')</small>' +
+
        .removeClass('mw-rtrc-diff-newpage mw-rtrc-diff-closed');
'<div class="mw-rtrc-head-links">' +
 
(!mw.user.isAnon() ? (
 
'<a target="_blank" href="' + mw.util.wikiGetlink('Special:Log/patrol') + '?user=' + encodeURIComponent(mw.user.name()) + '">' +
 
message('mypatrollog').escaped().ucFirst() +
 
'</a>') :
 
''
 
) +
 
'<a id="mw-rtrc-toggleHelp">Help</a>' +
 
'</div>' +
 
'</div>' +
 
'<form id="krRTRC_RCOptions" class="mw-rtrc-settings mw-rtrc-nohelp make-switch"><fieldset>' +
 
'<div class="panel-group">' +
 
'<div class="panel">' +
 
'<label for="mw-rtrc-settings-limit" class="head">' + message('limit').escaped() + '</label>' +
 
'<select id="mw-rtrc-settings-limit" name="limit">' +
 
'<option value="10">10</option>' +
 
'<option value="25" selected>25</option>' +
 
'<option value="50">50</option>' +
 
'<option value="75">75</option>' +
 
'<option value="100">100</option>' +
 
'</select>' +
 
'</div>' +
 
'<div class="panel">' +
 
'<label class="head">' + message('filter').escaped() + '</label>' +
 
'<div style="text-align: left;">' +
 
'<label>' +
 
'<input type="checkbox" name="showAnonOnly" />' +
 
' ' + message('showAnonOnly').escaped() +
 
'</label>' +
 
'<br />' +
 
'<label>' +
 
'<input type="checkbox" name="showUnpatrolledOnly" />' +
 
' ' + message('showUnpatrolledOnly').escaped() +
 
'</label>' +
 
'</div>' +
 
'</div>' +
 
'<div class="panel">' +
 
'<label for="mw-rtrc-settings-user" class="head">' +
 
message('userfilter').escaped() +
 
'<span section="Userfilter" class="helpicon"></span>' +
 
'</label>' +
 
'<div style="text-align: center;">' +
 
'<input type="text" size="16" id="mw-rtrc-settings-user" name="user" />' +
 
'<br />' +
 
'<input class="button button-small" type="button" id="mw-rtrc-settings-user-clr" value="' + message('clear').escaped() + '" />' +
 
'</div>' +
 
'</div>' +
 
'<div class="panel">' +
 
'<label class="head">' + message('type').escaped() + '</label>' +
 
'<div style="text-align: left;">' +
 
'<label>' +
 
'<input type="checkbox" name="typeEdit" checked />' +
 
' ' + message('typeEdit').escaped() +
 
'</label>' +
 
'<br />' +
 
'<label>' +
 
'<input type="checkbox" name="typeNew" checked />' +
 
' ' + message('typeNew').escaped() +
 
'</label>' +
 
'</div>' +
 
'</div>' +
 
'<div class="panel">' +
 
'<label class="head">' +
 
message('timeframe').escaped() +
 
'<span section="Timeframe" class="helpicon"></span>' +
 
'</label>' +
 
'<div style="text-align: right;">' +
 
'<label>' +
 
message('time-from').escaped() + ': ' +
 
'<input type="text" size="18" name="start" />' +
 
'</label>' +
 
'<br />' +
 
'<label>' +
 
message('time-untill').escaped() + ': ' +
 
'<input type="text" size="18" name="end" />' +
 
'</label>' +
 
'</div>' +
 
'</div>' +
 
'<div class="panel">' +
 
'<label  class="head">' +
 
mw.message('namespaces').escaped() +
 
' <br />' +
 
'<select class="mw-rtrc-setting-select" name="namespace">' +
 
namespaceOptionsHtml +
 
'</select>' +
 
'</label>' +
 
'</div>' +
 
'<div class="panel">' +
 
'<label class="head">' +
 
message('order').escaped() +
 
' <br />' +
 
'<span section="Order" class="helpicon"></span>' +
 
'</label>' +
 
'<div style="text-align: left;">' +
 
'<label>' +
 
'<input type="radio" name="dir" value="newer" />' +
 
' ' + message('asc').escaped() +
 
'</label>' +
 
'<br />' +
 
'<label>' +
 
'<input type="radio" name="dir" value="older" checked />' +
 
' ' + message('desc').escaped() +
 
'</label>' +
 
'</div>' +
 
'</div>' +
 
'<div class="panel">' +
 
'<label for="mw-rtrc-settings-refresh" class="head">' +
 
'R<br />' +
 
'<span section="Reload_Interval" class="helpicon"></span>' +
 
'</label>' +
 
'<input type="number" value="3" min="0" max="99" size="2" id="mw-rtrc-settings-refresh" name="refresh" />' +
 
'</div>' +
 
'<div class="panel">' +
 
'<label class="head">' +
 
'CVN DB<br />' +
 
'<span section="IRC_Blacklist" class="helpicon"></span>' +
 
'<input type="checkbox" class="switch" name="cvnDB" />' +
 
'</label>' +
 
'</div>' +
 
'<div class="panel panel-last">' +
 
'<input class="button" type="button" id="RCOptions_submit" value="' + message('apply').escaped() + '" />' +
 
'</div>' +
 
'</div>' +
 
'<div class="panel-group panel-group-mini">' +
 
'<div class="panel">' +
 
'<label class="head">' +
 
message('tag').escaped() +
 
' <select class="mw-rtrc-setting-select" name="tag">' +
 
tagOptionsHtml +
 
'</select>' +
 
'</label>' +
 
'</div>' +
 
'<div class="panel">' +
 
'<label class="head">' +
 
'MassPatrol' +
 
'<span section="MassPatrol" class="helpicon"></span>' +
 
'<input type="checkbox" class="switch" name="massPatrol" />' +
 
'</label>' +
 
'</div>' +
 
'<div class="panel">' +
 
'<label class="head">' +
 
'AutoDiff' +
 
'<span section="AutoDiff" class="helpicon"></span>' +
 
'<input type="checkbox" class="switch" name="autoDiff" />' +
 
'</label>' +
 
'</div>' +
 
'<div class="panel">' +
 
'<label class="head">' +
 
'Pause' +
 
'<input class="switch" type="checkbox" id="rc-options-pause" />' +
 
'</label>' +
 
'</div>' +
 
'</div>' +
 
'</fieldset></form>' +
 
'<a name="krRTRC_DiffTop" />' +
 
'<div class="mw-rtrc-diff" id="krRTRC_DiffFrame" style="display: none;"></div>' +
 
'<div class="mw-rtrc-body placeholder">' +
 
'<div class="mw-rtrc-feed">' +
 
'<div class="mw-rtrc-feed-update"></div>' +
 
'<div class="mw-rtrc-feed-content"></div>' +
 
'<small class="mw-rtrc-feed-cvninfo"></small>' +
 
'</div>' +
 
'<img src="' + ajaxLoaderUrl + '" id="krRTRC_loader" style="display: none;" />' +
 
'<div class="mw-rtrc-legend">' +
 
'Colors: <div class="mw-rtrc-item mw-rtrc-item-patrolled inline-block">&nbsp;' +
 
mw.message('markedaspatrolled').escaped() + '&nbsp;</div>, <div class="mw-rtrc-item mw-rtrc-item-current inline-block">&nbsp;' +
 
message('currentedit').escaped() + '&nbsp;</div>, ' +
 
'<div class="mw-rtrc-item mw-rtrc-item-skipped inline-block">&nbsp;' + message('skippededit').escaped() + '&nbsp;</div>, ' +
 
'<div class="mw-rtrc-item mw-rtrc-item-aes inline-block">&nbsp;Edit with an Automatic Edit Summary&nbsp;</div>' +
 
'<br />Abbreviations: T - ' + mw.message('talkpagelinktext').escaped() + ', C - ' + mw.message('contributions', mw.user).escaped() +
 
'</div>' +
 
'</div>' +
 
'<div style="clear: both;"></div>' +
 
'<div class="mw-rtrc-foot">' +
 
'<div class="plainlinks" style="text-align: right;">' +
 
'Real-Time Recent Changes by ' +
 
'<a href="//meta.wikimedia.org/wiki/User:Krinkle" class="external text" rel="nofollow">Krinkle</a>' +
 
' | <a href="//meta.wikimedia.org/wiki/User:Krinkle/Tools/Real-Time_Recent_Changes" class="external text" rel="nofollow">' + message('documentation').escaped() + '</a>' +
 
' | <a href="https://github.com/Krinkle/mw-gadget-rtrc/releases" class="external text" rel="nofollow">' + message('changelog').escaped() + '</a>' +
 
' | <a href="https://github.com/Krinkle/mw-gadget-rtrc/issues" class="external text" rel="nofollow">Feedback</a>' +
 
' | <a href="http://krinkle.mit-license.org" class="external text" rel="nofollow">License</a>' +
 
'</div>' +
 
'</div>' +
 
'</div>'
 
));
 
  
// Add helper element for switch checkboxes
+
      $.ajax({
$wrapper.find('input.switch').after('<div class="switched"></div>');
+
        url: mw.util.wikiScript(),
 +
        dataType: 'html',
 +
        data: {
 +
          action: 'render',
 +
          diff: currentDiff,
 +
          diffonly: '1',
 +
          uselang: conf.wgUserLanguage
 +
        }
 +
      }).fail(function (jqXhr) {
 +
        $frame
 +
          .append(jqXhr.responseText || 'Loading diff failed.')
 +
          .removeClass('mw-rtrc-diff-loading');
 +
      }).done(function (data) {
 +
        var skipButtonHtml, $diff;
 +
        if (skippedRCIDs.includes(currentDiffRcid)) {
 +
          skipButtonHtml = '<span class="tab"><a id="diffUnskip">' + message('unskip').escaped() + '</a></span>';
 +
        } else {
 +
          skipButtonHtml = '<span class="tab"><a id="diffSkip">' + message('skip').escaped() + '</a></span>';
 +
        }
  
// All links within the diffframe should open in a new window
+
        $frame
$wrapper.find('#krRTRC_DiffFrame').on('click', 'table.diff a', function () {
+
          .html(data)
var $el = $(this);
+
          .prepend(
if ($el.is('[href^="http://"], [href^="https://"], [href^="//"]')) {
+
            '<h3>' + mw.html.escape(title) + '</h3>' +
$el.attr('target', '_blank');
+
            '<div class="mw-rtrc-diff-tools">' +
}
+
              '<span class="tab"><a id="diffClose">' + message('close').escaped() + '</a></span>' +
});
+
              '<span class="tab"><a href="' + href + '" target="_blank" id="diffNewWindow">' + message('open-in-wiki').escaped() + '</a></span>' +
 +
              (userHasPatrolRight
 +
                ? '<span class="tab"><a onclick="(function(){ if($(\'.patrollink a\').length){ $(\'.patrollink a\').click(); } else { $(\'#diffSkip\').click(); } })();">[mark]</a></span>'
 +
                : ''
 +
              ) +
 +
              '<span class="tab"><a id="diffNext">' + mw.message('next').escaped() + ' »</a></span>' +
 +
              skipButtonHtml +
 +
            '</div>'
 +
          )
 +
          .removeClass('mw-rtrc-diff-loading');
  
$('#content').empty().append($wrapper);
+
        if (opt.app.massPatrol) {
nextFrame(function () {
+
          $frame.find('.patrollink a').click();
$('html').addClass('mw-rtrc-ready');
+
        } else {
});
+
          $diff = $frame.find('table.diff');
 +
          if ($diff.length) {
 +
            mw.hook('wikipage.diff').fire($diff.eq(0));
 +
          }
 +
          // Only scroll up if the user scrolled down
 +
          // Leave scroll offset unchanged otherwise
 +
          scrollIntoViewIfNeeded($frame);
 +
        }
 +
      });
  
$body = $wrapper.find('.mw-rtrc-body');
+
      e.preventDefault();
$feed = $body.find('.mw-rtrc-feed');
+
    });
}
 
  
// Bind event hanlders in the user interface
+
    $feed.on('click', 'a.newPage', function (e) {
function bindInterface() {
+
      var $item = $(this).closest('.mw-rtrc-item').addClass('mw-rtrc-item-current'),
 +
        title = $item.find('.mw-title').text(),
 +
        href = $item.find('.mw-title').attr('href'),
 +
        $frame = $('#krRTRC_DiffFrame');
  
$RCOptions_submit = $('#RCOptions_submit');
+
      $feed.find('.mw-rtrc-item-current').not($item).removeClass('mw-rtrc-item-current');
  
// Apply button
+
      currentDiffRcid = Number($item.data('rcid'));
$RCOptions_submit.click(function () {
 
$RCOptions_submit.prop('disabled', true).css('opacity', '0.5');
 
  
readSettingsForm();
+
      $frame
 +
        .addClass('mw-rtrc-diff-loading mw-rtrc-diff-newpage')
 +
        .removeClass('mw-rtrc-diff-closed');
  
krRTRC_ToggleMassPatrol(opt.app.massPatrol);
+
      $.ajax({
 +
        url: href,
 +
        dataType: 'html',
 +
        data: {
 +
          action: 'render',
 +
          uselang: conf.wgUserLanguage
 +
        }
 +
      }).fail(function (jqXhr) {
 +
        $frame
 +
          .append(jqXhr.responseText || 'Loading diff failed.')
 +
          .removeClass('mw-rtrc-diff-loading');
 +
      }).done(function (data) {
 +
        var skipButtonHtml;
 +
        if (skippedRCIDs.includes(currentDiffRcid)) {
 +
          skipButtonHtml = '<span class="tab"><a id="diffUnskip">' + message('unskip').escaped() + '</a></span>';
 +
        } else {
 +
          skipButtonHtml = '<span class="tab"><a id="diffSkip">' + message('skip').escaped() + '</a></span>';
 +
        }
  
updateFeedNow();
+
        $frame
return false;
+
          .html(data)
});
+
          .prepend(
 +
            '<h3>' + title + '</h3>' +
 +
            '<div class="mw-rtrc-diff-tools">' +
 +
              '<span class="tab"><a id="diffClose">' + message('close').escaped() + '</a></span>' +
 +
              '<span class="tab"><a href="' + href + '" target="_blank" id="diffNewWindow">' + message('open-in-wiki').escaped() + '</a></span>' +
 +
              '<span class="tab"><a onclick="$(\'.patrollink a\').click()">[' + message('mark').escaped() + ']</a></span>' +
 +
              '<span class="tab"><a id="diffNext">' + mw.message('next').escaped() + ' »</a></span>' +
 +
              skipButtonHtml +
 +
            '</div>'
 +
          )
 +
          .removeClass('mw-rtrc-diff-loading');
  
// Close Diff
+
        if (opt.app.massPatrol) {
$('#diffClose').live('click', function () {
+
          $frame.find('.patrollink a').click();
$('#krRTRC_DiffFrame').fadeOut('fast');
+
        }
currentDiff = currentDiffRcid = false;
+
      });
});
 
  
// Load diffview on (diff)-link click
+
      e.preventDefault();
$('a.diff').live('click', function (e) {
+
    });
var $item = $(this).closest('.mw-rtrc-item').addClass('mw-rtrc-item-current'),
 
title = $item.find('.page').text(),
 
href = $(this).attr('href'),
 
$frame = $('#krRTRC_DiffFrame');
 
  
$feed.find('.mw-rtrc-item-current').not($item).removeClass('mw-rtrc-item-current');
+
    // Mark as patrolled
 +
    $wrapper.on('click', '.patrollink', function () {
 +
      var $el = $(this);
 +
      $el.find('a').text(mw.msg('markaspatrolleddiff') + '...');
 +
      api.postWithToken('patrol', {
 +
        action: 'patrol',
 +
        rcid: currentDiffRcid
 +
      }).done(function (data) {
 +
        if (!data || data.error) {
 +
          $el.empty().append(
 +
            $('<span style="color: red;"></span>').text(mw.msg('markedaspatrollederror'))
 +
          );
 +
          mw.log('Patrol error:', data);
 +
          return;
 +
        }
 +
        $el.empty().append(
 +
          $('<span style="color: green;"></span>').text(mw.msg('markedaspatrolled'))
 +
        );
 +
        $feed.find('.mw-rtrc-item[data-rcid="' + currentDiffRcid + '"]').addClass('mw-rtrc-item-patrolled');
  
currentDiff = Number($item.data('diff'));
+
        // Feed refreshes may overlap with patrol actions, which can cause patrolled edits
currentDiffRcid = Number($item.data('rcid'));
+
        // to show up in an "Unpatrolled only" feed. This is make nextDiff() skip those.
 +
        annotationsCacheUp();
 +
        annotationsCache.patrolled[currentDiffRcid] = true;
  
// Reset style="max-height: 400;" from a.newPage below
+
        if (opt.app.autoDiff) {
$frame.fadeOut().removeAttr('style');
+
          nextDiff();
 +
        }
 +
      }).fail(function () {
 +
        $el.empty().append(
 +
          $('<span style="color: red;"></span>').text(mw.msg('markedaspatrollederror'))
 +
        );
 +
      });
  
$.ajax({
+
      return false;
url: mw.util.wikiScript(),
+
    });
dataType: 'html',
 
data: {
 
action: 'render',
 
diff: currentDiff,
 
diffonly: '1',
 
uselang: conf.wgUserLanguage
 
}
 
}).fail(function (jqXhr) {
 
$frame
 
.stop(true, true)
 
.append(jqXhr.responseText || 'Loading diff failed.')
 
.fadeIn();
 
}).done(function (data) {
 
var skipButtonHtml;
 
if ($.inArray(currentDiffRcid, skippedRCIDs) !== -1) {
 
skipButtonHtml = '<span class="tab"><a id="diffUnskip">Unskip</a></span>';
 
} else {
 
skipButtonHtml = '<span class="tab"><a id="diffSkip">Skip</a></span>';
 
}
 
  
$frame
+
    // Trigger NextDiff
.stop(true, true)
+
    $wrapper.on('click', '#diffNext', function () {
.html(data)
+
      nextDiff();
.prepend(
+
    });
'<h3>' + mw.html.escape(title) + '</h3>' +
 
'<div class="mw-rtrc-diff-tools">' +
 
'<span class="tab"><a id="diffClose">X</a></span>' +
 
'<span class="tab"><a href="' + href + '" target="_blank" id="diffNewWindow">Open in Wiki</a></span>' +
 
(userPatrolTokenCache ?
 
'<span class="tab"><a onclick="(function(){ if($(\'.patrollink a\').length){ $(\'.patrollink a\').click(); } else { $(\'#diffSkip\').click(); } })();">[mark]</a></span>' :
 
''
 
) +
 
'<span class="tab"><a id="diffNext">' + mw.message('next').escaped().ucFirst() + ' &raquo;</a></span>' +
 
skipButtonHtml +
 
'</div>'
 
)
 
.fadeIn();
 
  
if (opt.app.massPatrol) {
+
    // SkipDiff
$frame.find('.patrollink a').click();
+
    $wrapper.on('click', '#diffSkip', function () {
}
+
      $feed.find('.mw-rtrc-item[data-rcid="' + currentDiffRcid + '"]').addClass('mw-rtrc-item-skipped');
});
+
      // Add to array, to re-add class after refresh
 +
      skippedRCIDs.push(currentDiffRcid);
 +
      nextDiff();
 +
    });
  
e.preventDefault();
+
    // UnskipDiff
});
+
    $wrapper.on('click', '#diffUnskip', function () {
 +
      $feed.find('.mw-rtrc-item[data-rcid="' + currentDiffRcid + '"]').removeClass('mw-rtrc-item-skipped');
 +
      // Remove from array, to no longer re-add class after refresh
 +
      skippedRCIDs.splice(skippedRCIDs.indexOf(currentDiffRcid), 1);
 +
    });
  
$('a.newPage').live('click', function (e) {
+
    // Show helpicons
var $item = $(this).closest('.mw-rtrc-item').addClass('mw-rtrc-item-current'),
+
    $('#mw-rtrc-toggleHelp').on('click', function (e) {
title = $item.find('.page').text(),
+
      e.preventDefault();
href = $item.find('.page').attr('href'),
+
      $('#krRTRC_RCOptions').toggleClass('mw-rtrc-nohelp mw-rtrc-help');
$frame = $('#krRTRC_DiffFrame');
+
    });
  
$feed.find('.mw-rtrc-item-current').not($item).removeClass('mw-rtrc-item-current');
+
    // Link helpicons
 +
    $('.mw-rtrc-settings .helpicon')
 +
      .attr('title', msg('helpicon-tooltip'))
 +
      .click(function (e) {
 +
        e.preventDefault();
 +
        window.open(docUrl + '#' + $(this).attr('section'), '_blank');
 +
      });
  
currentDiffRcid = Number($item.data('rcid'));
+
    // Mark as patrolled when rollbacking
 +
    // Note: As of MediaWiki r(unknown) rollbacking does already automatically patrol all reverted revisions.
 +
    // But by doing it anyway it saves a click for the AutoDiff-users
 +
    $wrapper.on('click', '.mw-rollback-link a', function () {
 +
      $('.patrollink a').click();
 +
    });
  
$frame.fadeOut().css('max-height', '400px');
+
    // Button: Pause
 +
    $('#rc-options-pause').on('click', function () {
 +
      if (!this.checked) {
 +
        // Unpause
 +
        updateFeedNow();
 +
        return;
 +
      }
 +
      clearTimeout(updateFeedTimeout);
 +
    });
 +
  }
  
$.ajax({
+
  function showUnsupported () {
url: href,
+
    $('#content').empty().append(
dataType: 'html',
+
      $('<p>').addClass('errorbox').text(
data: {
+
        'This program requires functionality not supported in this browser.'
action: 'render',
+
      )
uselang: conf.wgUserLanguage
+
    );
}
+
  }
}).fail(function (jqXhr) {
 
$frame
 
.stop(true, true)
 
.append(jqXhr.responseText || 'Loading diff failed.')
 
.fadeIn();
 
}).done(function (data) {
 
var skipButtonHtml;
 
if ($.inArray(currentDiffRcid, skippedRCIDs) !== -1) {
 
skipButtonHtml = '<span class="tab"><a id="diffUnskip">Unskip</a></span>';
 
} else {
 
skipButtonHtml = '<span class="tab"><a id="diffSkip">Skip</a></span>';
 
}
 
  
$frame
+
  /**
.stop(true, true)
+
  * @param {string} [errMsg]
.html(data)
+
  */
.prepend(
+
  function showFail (errMsg) {
'<h3>' + title + '</h3>' +
+
    $('#content').empty().append(
'<div class="mw-rtrc-diff-tools">' +
+
      $('<p>').addClass('errorbox').text(errMsg || 'An unexpected error occurred.')
'<span class="tab"><a id="diffClose">X</a></span>' +
+
    );
'<span class="tab"><a href="' + href + '" target="_blank" id="diffNewWindow">Open in Wiki</a></span>' +
+
  }
'<span class="tab"><a onclick="$(\'.patrollink a\').click()">[mark]</a></span>' +
 
'<span class="tab"><a id="diffNext">' + mw.message('next').escaped().ucFirst() + ' &raquo;</a></span>' +
 
skipButtonHtml +
 
'</div>'
 
)
 
.fadeIn();
 
  
if (opt.app.massPatrol) {
+
  /**
$frame.find('.patrollink a').click();
+
  * Init functions
}
+
  * -------------------------------------------------
});
+
  */
  
e.preventDefault();
+
  /**
});
+
  * Fetches all external data we need.
 +
  *
 +
  * This runs in parallel with loading of modules and i18n.
 +
  *
 +
  * @return {jQuery.Promise}
 +
  */
 +
  function initData () {
 +
    var promises = [];
  
// Mark as patrolled
+
    // Get userrights
$('.patrollink').live('click', function () {
+
    promises.push(
var $el = $(this);
+
      mw.loader.using('mediawiki.user').then(function () {
$el.find('a').text(mw.msg('markaspatrolleddiff') + '...');
+
        return mw.user.getRights().then(function (rights) {
$.ajax({
+
          if (rights.includes('patrol')) {
type: 'POST',
+
            userHasPatrolRight = true;
url: apiUrl,
+
          }
dataType: 'json',
+
        });
data: {
+
      })
action: 'patrol',
+
    );
format: 'json',
 
list: 'recentchanges',
 
rcid: currentDiffRcid,
 
token: userPatrolTokenCache
 
}
 
}).done(function (data) {
 
if (!data || data.error) {
 
$el.empty().append(
 
$('<span style="color: red;"></span>').text(mw.msg('markedaspatrollederror'))
 
);
 
mw.log('Patrol error:', data);
 
} else {
 
$el.empty().append(
 
$('<span style="color: green;"></span>').text(mw.msg('markedaspatrolled'))
 
);
 
$feed.find('.mw-rtrc-item[data-rcid="' + currentDiffRcid + '"]').addClass('mw-rtrc-item-patrolled');
 
  
// Patrolling/Refreshing sometimes overlap eachother causing patrolled edits to show up in an 'unpatrolled only' feed.
+
    // Get MediaWiki interface messages
// Make sure that any patrolled edits stay marked as such to prevent AutoDiff from picking a patrolled edit
+
    promises.push(
patrolledRCIDs.push(currentDiffRcid);
+
      mw.loader.using('mediawiki.api').then(function () {
 +
        return new mw.Api().loadMessages([
 +
          'blanknamespace',
 +
          'contributions',
 +
          'contribslink',
 +
          'diff',
 +
          'markaspatrolleddiff',
 +
          'markedaspatrolled',
 +
          'markedaspatrollederror',
 +
          'namespaces',
 +
          'namespacesall',
 +
          'newpageletter',
 +
          'next',
 +
          'talkpagelinktext'
 +
        ]);
 +
      })
 +
    );
  
while (patrolledRCIDs.length > patrolCacheSize) {
+
    promises.push($.ajax({
patrolledRCIDs.shift();
+
      url: apiUrl,
}
+
      dataType: 'json',
 +
      data: {
 +
        format: 'json',
 +
        action: 'query',
 +
        list: 'tags',
 +
        tgprop: 'displayname'
 +
      }
 +
    }).then(function (data) {
 +
      var tags = data.query && data.query.tags;
 +
      if (tags) {
 +
        rcTags = tags.map(function (tag) {
 +
          return tag.name;
 +
        });
 +
      }
 +
    }));
  
if (opt.app.autoDiff) {
+
    promises.push($.ajax({
krRTRC_NextDiff();
+
      url: apiUrl,
}
+
      dataType: 'json',
}
+
      data: {
}).fail(function () {
+
        format: 'json',
$el.empty().append(
+
        action: 'query',
$('<span style="color: red;"></span>').text(mw.msg('markedaspatrollederror'))
+
        meta: 'siteinfo'
);
+
      }
});
+
    }).then(function (data) {
 +
      wikiTimeOffset = (data.query && data.query.general.timeoffset) || 0;
 +
    }));
  
return false;
+
    return $.when.apply(null, promises);
});
+
  }
  
// Trigger NextDiff
+
  /**
$('#diffNext').live('click', function () {
+
  * @return {jQuery.Promise}
krRTRC_NextDiff();
+
  */
});
+
  function init () {
 +
    var dModules, dI18N, featureTest, $navToggle, dOres,
 +
      navSupported = conf.skin === 'vector';
  
// SkipDiff
+
    // Transform title and navigation tabs
$('#diffSkip').live('click', function () {
+
    document.title = 'RTRC: ' + conf.wgDBname;
$feed.find('.mw-rtrc-item[data-rcid="' + currentDiffRcid + '"]').addClass('mw-rtrc-item-skipped');
+
    $(function () {
// Add to array, to re-add class after refresh
+
      $('#p-namespaces ul')
skippedRCIDs.push(currentDiffRcid);
+
        .find('li.selected')
krRTRC_NextDiff();
+
        .removeClass('new')
});
+
        .find('a')
 +
        .text('RTRC');
 +
    });
  
// UnskipDiff
+
    featureTest = !!(Date.parse);
$('#diffUnskip').live('click', function () {
 
$feed.find('.mw-rtrc-item[data-rcid="' + currentDiffRcid + '"]').removeClass('mw-rtrc-item-skipped');
 
// Remove from array, to no longer re-add class after refresh
 
skippedRCIDs.splice(skippedRCIDs.indexOf(currentDiffRcid), 1);
 
});
 
  
// Show helpicons
+
    if (!featureTest) {
$('#mw-rtrc-toggleHelp').click(function (e) {
+
      $(showUnsupported);
e.preventDefault();
+
      return;
$('#krRTRC_RCOptions').toggleClass('mw-rtrc-nohelp mw-rtrc-help');
+
    }
});
 
  
// Link helpicons
+
    $('html').addClass('mw-rtrc-available');
$('.mw-rtrc-settings .helpicon')
 
.attr('title', msg('helpicon-tooltip'))
 
.click(function (e) {
 
e.preventDefault();
 
window.open(docUrl + '#' + $(this).attr('section'), '_blank');
 
});
 
  
// Clear rcuser-field
+
    if (navSupported) {
// If MassPatrol is active, warn that clearing rcuser will automatically disable MassPatrol f
+
      $('html').addClass('mw-rtrc-sidebar-toggleable');
$('#mw-rtrc-settings-user-clr').click(function () {
+
      $(function () {
$('#mw-rtrc-settings-user').val('');
+
        $navToggle = $('<div>').addClass('mw-rtrc-navtoggle');
});
+
        $('body').append($('<div>').addClass('mw-rtrc-sidebar-cover'));
 +
        $('#mw-panel')
 +
          .append($navToggle)
 +
          .on('mouseenter', function () {
 +
            $('html').addClass('mw-rtrc-sidebar-on');
 +
          })
 +
          .on('mouseleave', function () {
 +
            $('html').removeClass('mw-rtrc-sidebar-on');
 +
          });
 +
      });
 +
    }
  
// Mark as patrolled when rollbacking
+
    dModules = mw.loader.using([
// Note: As of MediaWiki r(unknown) rollbacking does already automatically patrol all reverted revisions.
+
      'jquery.client',
// But by doing it anyway it saves a click for the AutoDiff-users
+
      'mediawiki.diff.styles',
$('.mw-rollback-link a').live('click', function () {
+
      // mw-plusminus styles etc.
$('.patrollink a').click();
+
      'mediawiki.special.changeslist',
});
+
      'mediawiki.jqueryMsg',
 +
      'mediawiki.Uri',
 +
      'mediawiki.user',
 +
      'mediawiki.util',
 +
      'mediawiki.api'
 +
    ]);
  
// Button: Pause
+
    if (!mw.libs.getIntuition) {
$('#rc-options-pause').click(function () {
+
      mw.libs.getIntuition = $.ajax({ url: intuitionLoadUrl, dataType: 'script', cache: true, timeout: 7000 });
if (this.checked) {
+
    }
clearTimeout(updateFeedTimeout);
 
return;
 
}
 
updateFeedNow();
 
});
 
}
 
  
function showUnsupported() {
+
    dOres = $.ajax({
$('#content').empty().append(
+
      url: oresApiUrl,
$('<p>').addClass('errorbox').text(
+
      dataType: $.support.cors ? 'json' : 'jsonp',
'This program requires functionality not supported in this browser.'
+
      cache: true,
)
+
      timeout: 2000
);
+
    }).then(function (data) {
}
+
      if (data && data.models) {
 +
        if (data.models.damaging) {
 +
          oresModel = 'damaging';
 +
        } else if (data.models.reverted) {
 +
          oresModel = 'reverted';
 +
        }
 +
      }
 +
    }, function () {
 +
      // ORES has have models for this wiki, continue without
 +
      return $.Deferred().resolve();
 +
    });
  
function showFail() {
+
    dI18N = mw.libs.getIntuition
$('#content').empty().append(
+
      .then(function () {
$('<p>').addClass('errorbox').text('An unexpected error occurred.')
+
        return mw.libs.intuition.load('rtrc');
);
+
      })
}
+
      .then(function () {
 +
        message = mw.libs.intuition.message.bind(null, 'rtrc');
 +
        msg = mw.libs.intuition.msg.bind(null, 'rtrc');
 +
      }, function () {
 +
        // Ignore failure. RTRC should load even if Labs is down.
 +
        // Fall back to displaying message keys.
 +
        mw.messages.set('intuition-i18n-gone', '$1');
 +
        message = function (key) {
 +
          return mw.message('intuition-i18n-gone', key);
 +
        };
 +
        msg = function (key) {
 +
          return key;
 +
        };
 +
        return $.Deferred().resolve();
 +
      });
  
 +
    $.when(initData(), dModules, dI18N, dOres, $.ready).fail(showFail).done(function () {
 +
      if ($navToggle) {
 +
        $navToggle.attr('title', msg('navtoggle-tooltip'));
 +
      }
  
/**
+
      // Create map of month names
* Init functions
+
      monthNames = msg('months').split(',');
* -------------------------------------------------
 
*/
 
  
/**
+
      buildInterface();
* Fetches all external data we need.
+
      readPermalink();
*
+
      updateFeedNow();
* This runs in parallel with loading of modules and i18n.
+
      scrollIntoView($wrapper);
*
+
      bindInterface();
* @return {jQuery.Promise}
 
*/
 
function initData() {
 
var dRights = $.Deferred(),
 
promises = [ dRights.promise() ];
 
  
// Get userrights
+
      rAF(function () {
mw.loader.using('mediawiki.user', function () {
+
        $('html').addClass('mw-rtrc-ready');
mw.user.getRights(function (rights) {
+
      });
if ($.inArray('patrol', rights) !== -1) {
+
    });
userHasPatrolRight = true;
+
  }
}
 
dRights.resolve();
 
});
 
});
 
  
// Get a patroltoken
+
  /**
promises.push($.ajax({
+
  * Execution
url: apiUrl,
+
  * -------------------------------------------------
dataType: 'json',
+
  */
data: {
 
format: 'json',
 
action: 'query',
 
list: 'recentchanges',
 
rctoken: 'patrol',
 
rclimit: 1,
 
// Using rctype=new because some wikis only have patrolling of newpages enabled.
 
// If querying all changes returns an edit in that case, it won't have a token on it.
 
// This workaround works as long as there are no wikis with RC-patrol but no NP-patrol.
 
rctype: 'new'
 
}
 
}).done(function (data) {
 
userPatrolTokenCache = data.query.recentchanges[0].patroltoken;
 
}));
 
  
// Get MediaWiki interface messages
+
  // On every page
promises.push($.ajax({
+
  $.when(mw.loader.using('mediawiki.util'), $.ready).then(function () {
url: apiUrl,
+
    if (!$('#t-rtrc').length) {
dataType: 'json',
+
      mw.util.addPortletLink(
data: {
+
        'p-tb',
action: 'query',
+
        mw.util.getUrl('Special:BlankPage/RTRC'),
format: 'json',
+
        'RTRC',
meta: 'allmessages',
+
        't-rtrc',
amlang: conf.wgUserLanguage,
+
        'Monitor and patrol recent changes in real-time',
ammessages: ([
+
        null,
'ascending abbrev',
+
        '#t-specialpages'
'blanknamespace',
+
      );
'contributions',
+
    }
'descending abbrev',
+
    if (conf.wgCanonicalSpecialPageName === 'Recentchanges' && !$('#ca-nstab-rtrc').length) {
'diff',
+
      mw.util.addPortletLink(
'hide',
+
        'p-namespaces',
'markaspatrolleddiff',
+
        mw.util.getUrl('Special:BlankPage/RTRC'),
'markedaspatrolled',
+
        'RTRC',
'markedaspatrollederror',
+
        'ca-nstab-rtrc',
'namespaces',
+
        'Monitor and patrol recent changes in real-time'
'namespacesall',
+
      );
'next',
+
    }
'recentchanges-label-bot',
+
  });
'recentchanges-label-minor',
 
'recentchanges-label-newpage',
 
'recentchanges-label-unpatrolled',
 
'show',
 
'talkpagelinktext'
 
].join('|'))
 
}
 
}).done(function (data) {
 
data = data.query.allmessages;
 
for (var i = 0; i < data.length; i ++) {
 
mw.messages.set(data[i].name, data[i]['*']);
 
}
 
}));
 
  
promises.push($.ajax({
+
  // Initialise if in the right context
url: apiUrl,
+
  if (
dataType: 'json',
+
    (conf.wgTitle === 'Krinkle/RTRC' && conf.wgAction === 'view') ||
data: {
+
    (conf.wgCanonicalSpecialPageName === 'Blankpage' && conf.wgTitle.split('/', 2)[1] === 'RTRC')
format: 'json',
+
  ) {
action: 'query',
+
    init();
list: 'tags',
+
  }
tgprop: 'displayname'
+
}());
}
 
}).done(function (data) {
 
var tags = data.query && data.query.tags;
 
if (tags) {
 
rcTags = $.map(tags, function (tag) {
 
return tag.name;
 
});
 
}
 
}));
 
 
 
promises.push($.ajax({
 
url: apiUrl,
 
dataType: 'json',
 
data: {
 
format: 'json',
 
action: 'query',
 
meta: 'siteinfo'
 
}
 
}).done(function (data) {
 
wikiTimeOffset = (data.query && data.query.general.timeoffset) || 0;
 
}));
 
 
 
return $.when.apply(null, promises);
 
}
 
 
 
/**
 
* @return {jQuery.Promise}
 
*/
 
function init() {
 
var dModules, dI18N, featureTest;
 
 
 
// Transform title and navigation tabs
 
document.title = 'RTRC: ' + conf.wgDBname;
 
$(function () {
 
$('#p-namespaces ul')
 
.find('li.selected')
 
.removeClass('new')
 
.find('a')
 
.text('RTRC');
 
});
 
 
 
featureTest = !!(
 
// For timeUtil
 
Date.UTC &&
 
// For CSS :before and :before
 
$.support.modernizr4rtrc.generatedcontent
 
);
 
 
 
if (!featureTest) {
 
$(showUnsupported);
 
return;
 
}
 
 
 
// These selectors from vector-hd conflict with mw-rtrc-available
 
$('.vector-animateLayout').removeClass('vector-animateLayout');
 
 
 
$('html').addClass('mw-rtrc-available');
 
 
 
if (navSupported) {
 
// Apply stored setting
 
navCollapsed = localStorage.getItem('mw-rtrc-navtoggle-collapsed') || 'true';
 
if (navCollapsed === 'true') {
 
$('html').toggleClass('mw-rtrc-navtoggle-collapsed');
 
}
 
}
 
 
 
dModules = $.Deferred();
 
mw.loader.using(
 
[
 
'jquery.json',
 
'mediawiki.action.history.diff',
 
'mediawiki.jqueryMsg',
 
'mediawiki.Uri',
 
'mediawiki.user',
 
'mediawiki.util'
 
],
 
dModules.resolve,
 
dModules.reject
 
);
 
 
 
if (!mw.libs.getIntuition) {
 
mw.libs.getIntuition = $.ajax({ url: intuitionLoadUrl, dataType: 'script', cache: true });
 
}
 
 
 
dI18N = mw.libs.getIntuition
 
.then(function () {
 
return mw.libs.intuition.load('rtrc');
 
})
 
.then(function () {
 
message = $.proxy(mw.libs.intuition.message, null, 'rtrc');
 
msg = $.proxy(mw.libs.intuition.msg, null, 'rtrc');
 
return this;
 
});
 
 
 
$.when(initData(), dModules, dI18N, $.ready).fail(showFail).done(function () {
 
 
 
// Set up DOM for navtoggle
 
if (navSupported) {
 
// Needs i18n and $.ready
 
$('body').append(
 
$('#p-logo')
 
.clone()
 
.removeAttr('id')
 
.addClass('mw-rtrc-navtoggle-logo'),
 
$('<div>')
 
.addClass('mw-rtrc-navtoggle')
 
.attr('title', msg('navtoggle-tooltip'))
 
.on('click', navToggle)
 
);
 
}
 
 
 
// Map over months
 
monthNames = msg('months').split(',');
 
 
 
buildInterface();
 
readPermalink();
 
bindInterface();
 
});
 
}
 
 
 
 
 
/**
 
* Execution
 
* -------------------------------------------------
 
*/
 
 
 
// On every page
 
$(function () {
 
if (!$('#t-rtrc').length) {
 
mw.util.addPortletLink(
 
'p-tb',
 
mw.util.wikiGetlink('Special:BlankPage/RTRC'),
 
'RTRC',
 
't-rtrc',
 
'Monitor and patrol recent changes in real-time',
 
null,
 
'#t-specialpages'
 
);
 
}
 
});
 
 
 
/**
 
* Modernizr 2.6.2 (Custom Build) | MIT & BSD
 
* Build: http://modernizr.com/download/#-generatedcontent-teststyles
 
*
 
* Customized further for inclusion in mw-gadget-rtrc:
 
* - Remove unused utilities.
 
* - Export to jQuery.support.modernizr4rtrc instead of window.Modernizr.
 
*/
 
(function () {
 
var docElement = document.documentElement,
 
mod = 'modernizr',
 
smile = ':)';
 
 
 
function injectElementWithStyles(rule, callback, nodes, testnames) {
 
var style, ret, node, docOverflow,
 
div = document.createElement('div'),
 
body = document.body,
 
fakeBody = body || document.createElement('body');
 
 
 
if (parseInt(nodes, 10)) {
 
while (nodes--) {
 
node = document.createElement('div');
 
node.id = testnames ? testnames[nodes] : mod + (nodes + 1);
 
div.appendChild(node);
 
}
 
}
 
 
 
style = ['&#173;', '<style id="s', mod, '">', rule, '</style>'].join('');
 
div.id = mod;
 
(body ? div : fakeBody).innerHTML += style;
 
fakeBody.appendChild(div);
 
if (!body) {
 
fakeBody.style.background = '';
 
fakeBody.style.overflow = 'hidden';
 
docOverflow = docElement.style.overflow;
 
docElement.style.overflow = 'hidden';
 
docElement.appendChild(fakeBody);
 
}
 
 
 
ret = callback(div, rule);
 
if (!body) {
 
fakeBody.parentNode.removeChild(fakeBody);
 
docElement.style.overflow = docOverflow;
 
} else {
 
div.parentNode.removeChild(div);
 
}
 
 
 
return !!ret;
 
}
 
 
 
$.support.modernizr4rtrc = {
 
generatedcontent: (function () {
 
return injectElementWithStyles(['#', mod, '{font:0/0 a}#', mod, ':after{content:"', smile, '";visibility:hidden;font:3px/1 a}'].join(''), function (node) {
 
return node.offsetHeight >= 3;
 
});
 
}())
 
};
 
})();
 
 
 
// Initialise if in the right context
 
if (
 
(conf.wgTitle === 'Krinkle/RTRC' && conf.wgAction === 'view') ||
 
(conf.wgCanonicalSpecialPageName === 'Blankpage' && conf.wgTitle.split('/', 2)[1] === 'RTRC')
 
) {
 
init();
 
}
 
 
 
}(jQuery, mediaWiki));
 

Revision as of 20:04, 29 December 2018

/**
 * Real-Time Recent Changes
 * https://github.com/Krinkle/mw-gadget-rtrc
 *
 * @copyright 2010-2018 Timo Tijhof
 */

// Array#includes polyfill (ES2016/ES7)
// eslint-disable-next-line
Array.prototype.includes||Object.defineProperty(Array.prototype,"includes",{value:function(r,e){if(null==this)throw new TypeError('"this" is null or not defined');var t=Object(this),n=t.length>>>0;if(0===n)return!1;var i,o,a=0|e,u=Math.max(a>=0?a:n-Math.abs(a),0);for(;u<n;){if((i=t[u])===(o=r)||"number"==typeof i&&"number"==typeof o&&isNaN(i)&&isNaN(o))return!0;u++}return!1}});

/* global alert, mw, $ */
(function () {
  'use strict';

  /**
   * Configuration
   * -------------------------------------------------
   */
  var
    appVersion = 'v1.3.5',
    conf = mw.config.get([
      'skin',
      'wgAction',
      'wgCanonicalSpecialPageName',
      'wgPageName',
      'wgTitle',
      'wgUserLanguage',
      'wgDBname',
      'wgScriptPath'
    ]),
    // Can't use mw.util.wikiScript until after #init
    apiUrl = conf.wgScriptPath + '/api.php',
    cvnApiUrl = 'https://cvn.wmflabs.org/api.php',
    oresApiUrl = 'https://ores.wikimedia.org/scores/' + conf.wgDBname + '/',
    oresModel = false,
    intuitionLoadUrl = 'https://meta.wikimedia.org/w/index.php?title=User:Krinkle/Scripts/Intuition.js&action=raw&ctype=text/javascript',
    docUrl = 'https://meta.wikimedia.org/wiki/User:Krinkle/Tools/Real-Time_Recent_Changes?uselang=' + conf.wgUserLanguage,
    // 32x32px
    ajaxLoaderUrl = 'https://upload.wikimedia.org/wikipedia/commons/d/de/Ajax-loader.gif',
    annotationsCache = {
      patrolled: Object.create(null),
      cvn: Object.create(null),
      ores: Object.create(null)
    },
    // See annotationsCacheUp()
    annotationsCacheSize = 0,

    // Info from the wiki - see initData()
    userHasPatrolRight = false,
    rcTags = [],
    wikiTimeOffset,

    // State
    updateFeedTimeout,
    rcDayHeadPrev,
    skippedRCIDs = [],
    monthNames,
    prevFeedHtml,
    updateReq,

    // Default settings for the feed
    defOpt = {
      rc: {
        // Timestamp
        start: undefined,
        // Timestamp
        end: undefined,
        // Direction "older" (descending) or "newer" (ascending)
        dir: 'older',
        // Array of namespace ids
        namespace: undefined,
        // User name
        user: undefined,
        // Tag ID
        tag: undefined,
        // Filters
        hideliu: false,
        hidebots: true,
        unpatrolled: false,
        limit: 25,
        // Type filters are "show matches only"
        typeEdit: true,
        typeNew: true
      },

      app: {
        refresh: 5,
        cvnDB: false,
        ores: false,
        massPatrol: false,
        autoDiff: false
      }
    },
    aliasOpt = {
      // Back-compat for v1.0.4 and earlier
      showAnonOnly: 'hideliu',
      showUnpatrolledOnly: 'unpatrolled'
    },
    // Current settings for the feed
    opt = makeOpt(),

    timeUtil,
    message,
    msg,
    rAF = window.requestAnimationFrame || setTimeout,

    currentDiff,
    currentDiffRcid,
    $wrapper, $body, $feed,
    $RCOptionsSubmit;

  /**
   * Utility functions
   * -------------------------------------------------
   */

  function makeOpt () {
    // Create a recursive copy of defOpt without exposing
    // any of its arrays or objects in the returned value,
    // so that the returned value can be modified in every way,
    // without causing defOpt to change.
    return $.extend(true, {}, defOpt);
  }

  /**
   * Prepend a leading zero if value is under 10
   *
   * @param {number} num Value between 0 and 99.
   * @return {string}
   */
  function pad (num) {
    return (num < 10 ? '0' : '') + num;
  }

  timeUtil = {
    // Create new Date object from an ISO-8601 formatted timestamp, as
    // returned by the MediaWiki API (e.g. "2010-04-25T23:24:02Z")
    newDateFromISO: function (s) {
      return new Date(Date.parse(s));
    },

    /**
     * Apply user offset
     *
     * Only use this if you're extracting individual values from the object (e.g. getUTCDay or
     * getUTCMinutes). The internal timestamp will be wrong.
     *
     * @param {Date} d
     * @return {Date}
     */
    applyUserOffset: function (d) {
      var parts,
        offset = mw.user.options.get('timecorrection');

      // This preference has no default value, it is null for users that don't
      // override the site's default timeoffset.
      if (offset) {
        parts = offset.split('|');
        if (parts[0] === 'System') {
          // Ignore offset value, as system may have started or stopped
          // DST since the preferences were saved.
          offset = wikiTimeOffset;
        } else {
          offset = Number(parts[1]);
        }
      } else {
        offset = wikiTimeOffset;
      }
      // There is no way to set a timezone in javascript, so instead we pretend the
      // UTC timestamp is different and use getUTC* methods everywhere.
      d.setTime(d.getTime() + (offset * 60 * 1000));
      return d;
    },

    // Get clocktime string adjusted to timezone of wiki
    // from MediaWiki timestamp string
    getClocktimeFromApi: function (s) {
      var d = timeUtil.applyUserOffset(timeUtil.newDateFromISO(s));
      // Return clocktime with leading zeros
      return pad(d.getUTCHours()) + ':' + pad(d.getUTCMinutes());
    }
  };

  /**
   * Main functions
   * -------------------------------------------------
   */

  /**
   * @param {Date} date
   * @return {string} HTML
   */
  function buildRcDayHead (date) {
    var current = date.getDate();
    if (current === rcDayHeadPrev) {
      return '';
    }
    rcDayHeadPrev = current;
    return '<div class="mw-rtrc-heading"><div><strong>' + date.getDate() + ' ' + monthNames[date.getMonth()] + '</strong></div></div>';
  }

  /**
   * @param {Object} rc Recent change object from API
   * @return {string} HTML
   */
  function buildRcItem (rc) {
    var diffsize, isUnpatrolled, typeSymbol, itemClass, diffLink, el, item;

    // Get size difference (can be negative, zero or positive)
    diffsize = rc.newlen - rc.oldlen;

    // Convert undefined/empty-string values from API into booleans
    isUnpatrolled = rc.unpatrolled !== undefined;

    // typeSymbol, diffLink & itemClass
    typeSymbol = '&nbsp;';
    itemClass = [];

    if (rc.type === 'new') {
      typeSymbol += '<span class="newpage">' + mw.message('newpageletter').escaped() + '</span>';
    }

    if ((rc.type === 'edit' || rc.type === 'new') && userHasPatrolRight && isUnpatrolled) {
      typeSymbol += '<span class="unpatrolled">!</span>';
    }

    if (rc.oldlen > 0 && rc.newlen === 0) {
      itemClass.push('mw-rtrc-item-alert');
    }

    /*
Example:
<div class="mw-rtrc-item mw-rtrc-item-patrolled" data-diff="0" data-rcid="0" user="Abc">
  <div first>(<a>diff</a>) <span class="unpatrolled">!</span> 00:00 <a>Page</a></div>
  <div user><a class="user" href="/User:Abc">Abc</a></div>
  <div comment><a href="/User talk:Abc">talk</a> / <a href="/Special:Contributions/Abc">contribs</a>&nbsp;<span class="comment">Abc</span></div>
  <div class="mw-rtrc-meta"><span class="mw-plusminus mw-plusminus-null">(0)</span></div>
</div>
    */

    // build & return item
    item = buildRcDayHead(timeUtil.newDateFromISO(rc.timestamp));
    item += '<div class="mw-rtrc-item ' + itemClass.join(' ') + '" data-diff="' + rc.revid + '" data-rcid="' + rc.rcid + '" user="' + rc.user + '">';

    if (rc.type === 'edit') {
      diffLink = '<a class="rcitemlink diff" href="' +
        mw.util.wikiScript() + '?diff=' + rc.revid + '&oldid=' + rc.old_revid + '&rcid=' + rc.rcid +
        '">' + mw.message('diff').escaped() + '</a>';
    } else if (rc.type === 'new') {
      diffLink = '<a class="rcitemlink newPage">' + message('new-short').escaped() + '</a>';
    } else {
      diffLink = mw.message('diff').escaped();
    }

    item += '<div first>' +
      '(' + diffLink + ') ' + typeSymbol + ' ' +
      timeUtil.getClocktimeFromApi(rc.timestamp) +
      ' <a class="mw-title" href="' + mw.util.getUrl(rc.title) + '?rcid=' + rc.rcid + '" target="_blank">' + rc.title + '</a>' +
      '</div>' +
      '<div user>&nbsp;<small>&middot;&nbsp;' +
      '<a href="' + mw.util.getUrl('User talk:' + rc.user) + '" target="_blank">' + mw.message('talkpagelinktext').escaped() + '</a>' +
      ' &middot; ' +
      '<a href="' + mw.util.getUrl('Special:Contributions/' + rc.user) + '" target="_blank">' + mw.message('contribslink').escaped() + '</a>' +
      '&nbsp;</small>&middot;&nbsp;' +
      '<a class="mw-userlink" href="' + mw.util.getUrl((mw.util.isIPv4Address(rc.user) || mw.util.isIPv6Address(rc.user) ? 'Special:Contributions/' : 'User:') + rc.user) + '" target="_blank">' + rc.user + '</a>' +
      '</div>' +
      '<div comment>&nbsp;<span class="comment">' + rc.parsedcomment + '</span></div>';

    if (diffsize > 0) {
      el = diffsize > 399 ? 'strong' : 'span';
      item += '<div class="mw-rtrc-meta"><' + el + ' class="mw-plusminus mw-plusminus-pos">(+' + diffsize.toLocaleString() + ')</' + el + '></div>';
    } else if (diffsize === 0) {
      item += '<div class="mw-rtrc-meta"><span class="mw-plusminus mw-plusminus-null">(0)</span></div>';
    } else {
      el = diffsize < -399 ? 'strong' : 'span';
      item += '<div class="mw-rtrc-meta"><' + el + ' class="mw-plusminus mw-plusminus-neg">(' + diffsize.toLocaleString() + ')</' + el + '></div>';
    }

    item += '</div>';
    return item;
  }

  /**
   * @param {Object} newOpt
   * @param {string} [mode=normal] One of 'quiet' or 'normal'
   * @return {boolean} True if no changes were made, false otherwise
   */
  function normaliseSettings (newOpt, mode) {
    var mod = false;

    // MassPatrol requires a filter to be active
    if (newOpt.app.massPatrol && !newOpt.rc.user) {
      newOpt.app.massPatrol = false;
      mod = true;
      if (mode !== 'quiet') {
        alert(msg('masspatrol-requires-userfilter'));
      }
    }

    // MassPatrol implies AutoDiff
    if (newOpt.app.massPatrol && !newOpt.app.autoDiff) {
      newOpt.app.autoDiff = true;
      mod = true;
    }
    // MassPatrol implies fetching only unpatrolled changes
    if (newOpt.app.massPatrol && !newOpt.rc.unpatrolled) {
      newOpt.rc.unpatrolled = true;
      mod = true;
    }

    return !mod;
  }

  function fillSettingsForm (newOpt) {
    var $settings = $($wrapper.find('.mw-rtrc-settings')[0].elements).filter(':input');

    if (newOpt.rc) {
      $.each(newOpt.rc, function (key, value) {
        var $setting = $settings.filter(function () {
            return this.name === key;
          }),
          setting = $setting[0];

        if (!setting) {
          return;
        }

        switch (key) {
          case 'limit':
            setting.value = value;
            break;
          case 'namespace':
            if (value === undefined) {
            // Value "" (all) is represented by undefined.
              $setting.find('option').eq(0).prop('selected', true);
            } else {
              $setting.val(value);
            }
            break;
          case 'user':
          case 'start':
          case 'end':
          case 'tag':
            setting.value = value || '';
            break;
          case 'hideliu':
          case 'hidebots':
          case 'unpatrolled':
          case 'typeEdit':
          case 'typeNew':
            setting.checked = value;
            break;
          case 'dir':
            if (setting.value === value) {
              setting.checked = true;
            }
            break;
        }
      });
    }

    if (newOpt.app) {
      $.each(newOpt.app, function (key, value) {
        var $setting = $settings.filter(function () {
            return this.name === key;
          }),
          setting = $setting[0];

        if (!setting) {
          setting = document.getElementById('rc-options-' + key);
          $setting = $(setting);
        }

        if (!setting) {
          return;
        }

        switch (key) {
          case 'cvnDB':
          case 'ores':
          case 'massPatrol':
          case 'autoDiff':
            setting.checked = value;
            break;
          case 'refresh':
            setting.value = value;
            break;
        }
      });
    }
  }

  function readSettingsForm () {
    // jQuery#serializeArray is nice, but doesn't include "value: false" for unchecked
    // checkboxes that are not disabled. Using raw .elements instead and filtering
    // out <fieldset>.
    var $settings = $($wrapper.find('.mw-rtrc-settings')[0].elements).filter(':input');

    opt = makeOpt();

    $settings.each(function (i, el) {
      var name = el.name;
      switch (name) {
        // RC
        case 'limit':
          opt.rc[name] = Number(el.value);
          break;
        case 'namespace':
        // Can be "0".
        // Value "" (all) is represented by undefined.
        // TODO: Turn this into a multi-select, the API supports it.
          opt.rc[name] = el.value.length ? Number(el.value) : undefined;
          break;
        case 'user':
        case 'start':
        case 'end':
        case 'tag':
          opt.rc[name] = el.value || undefined;
          break;
        case 'hideliu':
        case 'hidebots':
        case 'unpatrolled':
        case 'typeEdit':
        case 'typeNew':
          opt.rc[name] = el.checked;
          break;
        case 'dir':
          // There's more than 1 radio button with this name in this loop,
          // use the value of the first (and only) checked one.
          if (el.checked) {
            opt.rc[name] = el.value;
          }
          break;
        // APP
        case 'cvnDB':
        case 'ores':
        case 'massPatrol':
        case 'autoDiff':
          opt.app[name] = el.checked;
          break;
        case 'refresh':
          opt.app[name] = Number(el.value);
          break;
      }
    });

    if (!normaliseSettings(opt)) {
      fillSettingsForm(opt);
    }
  }

  function getPermalink () {
    var uri = new mw.Uri(mw.util.getUrl(conf.wgPageName)),
      reducedOpt = {};

    $.each(opt.rc, function (key, value) {
      if (defOpt.rc[key] !== value) {
        if (!reducedOpt.rc) {
          reducedOpt.rc = {};
        }
        reducedOpt.rc[key] = value;
      }
    });

    $.each(opt.app, function (key, value) {
      // Don't permalink MassPatrol (issue Krinkle/mw-rtrc-gadget#59)
      if (key !== 'massPatrol' && defOpt.app[key] !== value) {
        if (!reducedOpt.app) {
          reducedOpt.app = {};
        }
        reducedOpt.app[key] = value;
      }
    });

    reducedOpt = JSON.stringify(reducedOpt);

    uri.extend({
      opt: reducedOpt === '{}' ? '' : reducedOpt
    });

    return uri.toString();
  }

  function updateFeedNow () {
    $('#rc-options-pause').prop('checked', false);
    if (updateReq) {
      // Try to abort the current request
      updateReq.abort();
    }
    clearTimeout(updateFeedTimeout);
    return updateFeed();
  }

  /**
   * @param {jQuery} $element
   */
  function scrollIntoView ($element) {
    $element[0].scrollIntoView({ block: 'start', behavior: 'smooth' });
  }

  /**
   * @param {jQuery} $element
   */
  function scrollIntoViewIfNeeded ($element) {
    if ($element[0].scrollIntoViewIfNeeded) {
      $element[0].scrollIntoViewIfNeeded({ block: 'start', behavior: 'smooth' });
    } else {
      $element[0].scrollIntoView({ block: 'start', behavior: 'smooth' });
    }
  }

  // Read permalink into the program and reflect into settings form.
  function readPermalink () {
    var group, oldKey, newKey, newOpt,
      url = new mw.Uri();

    if (url.query.opt) {
      try {
        newOpt = JSON.parse(url.query.opt);
      } catch (e) {
        // Ignore
      }
    }
    if (newOpt) {
      // Rename values for old aliases
      for (group in newOpt) {
        for (oldKey in newOpt[group]) {
          newKey = aliasOpt[oldKey];
          if (newKey && !Object.hasOwnProperty.call(newOpt[group], newKey)) {
            newOpt[group][newKey] = newOpt[group][oldKey];
            delete newOpt[group][oldKey];
          }
        }
      }

      if (newOpt.app) {
        // Don't permalink MassPatrol (issue Krinkle/mw-rtrc-gadget#59)
        delete newOpt.app.massPatrol;
      }
    }

    newOpt = $.extend(true, makeOpt(), newOpt);

    normaliseSettings(newOpt, 'quiet');
    fillSettingsForm(newOpt);

    opt = newOpt;
  }

  function getApiRcParams (rc) {
    var params,
      rcprop = [
        'flags',
        'timestamp',
        'user',
        'title',
        'parsedcomment',
        'sizes',
        'ids'
      ],
      rcshow = [],
      rctype = [];

    if (userHasPatrolRight) {
      rcprop.push('patrolled');
    }

    if (rc.hideliu) {
      rcshow.push('anon');
    }
    if (rc.hidebots) {
      rcshow.push('!bot');
    }
    if (rc.unpatrolled) {
      rcshow.push('!patrolled');
    }

    if (rc.typeEdit) {
      rctype.push('edit');
    }
    if (rc.typeNew) {
      rctype.push('new');
    }
    if (!rctype.length) {
      // Custom default instead of MediaWiki's default (in case both checkboxes were unchecked)
      rctype = ['edit', 'new'];
    }

    params = {
      rcdir: rc.dir,
      rclimit: rc.limit,
      rcshow: rcshow.join('|'),
      rcprop: rcprop.join('|'),
      rctype: rctype.join('|')
    };

    if (rc.dir === 'older') {
      if (rc.end !== undefined) {
        params.rcstart = rc.end;
      }
      if (rc.start !== undefined) {
        params.rcend = rc.start;
      }
    } else if (rc.dir === 'newer') {
      if (rc.start !== undefined) {
        params.rcstart = rc.start;
      }
      if (rc.end !== undefined) {
        params.rcend = rc.end;
      }
    }

    if (rc.namespace !== undefined) {
      params.rcnamespace = rc.namespace;
    }

    if (rc.user !== undefined) {
      params.rcuser = rc.user;
    }

    if (rc.tag !== undefined) {
      params.rctag = rc.tag;
    }

    // params.titles: Title filter (rctitles) is no longer supported by MediaWiki,
    // see https://bugzilla.wikimedia.org/show_bug.cgi?id=12394#c5.

    return params;
  }

  // Called when the feed is regenerated before being inserted in the document
  function applyRtrcAnnotations ($feedContent) {
    // Re-apply item classes
    $feedContent.filter('.mw-rtrc-item').each(function () {
      var $el = $(this),
        rcid = Number($el.data('rcid'));

      // Mark skipped and patrolled items as such
      if (skippedRCIDs.includes(rcid)) {
        $el.addClass('mw-rtrc-item-skipped');
      } else if (rcid in annotationsCache.patrolled) {
        $el.addClass('mw-rtrc-item-patrolled');
      } else if (rcid === currentDiffRcid) {
        $el.addClass('mw-rtrc-item-current');
      }
    });
  }

  function applyOresAnnotations ($feedContent) {
    var dAnnotations, revids, fetchRevids;

    if (!oresModel) {
      return $.Deferred().resolve();
    }

    // Find all revids names inside the feed
    revids = $.map($feedContent.filter('.mw-rtrc-item'), function (node) {
      return $(node).attr('data-diff');
    });

    if (!revids.length) {
      return $.Deferred().resolve();
    }

    fetchRevids = revids.filter(function (revid) {
      return !(revid in annotationsCache.ores);
    });

    if (!fetchRevids.length) {
      // No (new) revisions
      dAnnotations = $.Deferred().resolve(annotationsCache.ores);
    } else {
      dAnnotations = $.ajax({
        url: oresApiUrl,
        data: {
          models: oresModel,
          revids: fetchRevids.join('|')
        },
        timeout: 10000,
        dataType: $.support.cors ? 'json' : 'jsonp',
        cache: true
      }).then(function (resp) {
        var len;
        if (resp) {
          len = Object.keys ? Object.keys(resp).length : fetchRevids.length;
          annotationsCacheUp(len);
          $.each(resp, function (revid, item) {
            if (!item || item.error || !item[oresModel] || item[oresModel].error) {
              return;
            }
            annotationsCache.ores[revid] = item[oresModel].probability['true'];
          });
        }
        return annotationsCache.ores;
      });
    }

    return dAnnotations.then(function (annotations) {
      // Loop through all revision ids
      revids.forEach(function (revid) {
        var tooltip,
          score = annotations[revid];
        // Only highlight high probability scores
        if (!score || score <= 0.45) {
          return;
        }
        tooltip = msg('ores-damaging-probability', (100 * score).toFixed(0) + '%');

        // Add alert
        $feedContent
          .filter('.mw-rtrc-item[data-diff="' + Number(revid) + '"]')
          .addClass('mw-rtrc-item-alert mw-rtrc-item-alert-rev')
          .find('.mw-rtrc-meta')
          .prepend(
            $('<span>')
              .addClass('mw-rtrc-revscore')
              .attr('title', tooltip)
          );
      });
    });
  }

  function applyCvnAnnotations ($feedContent) {
    var dAnnotations,
      users = [];

    // Collect user names
    $feedContent.filter('.mw-rtrc-item').each(function () {
      var user = $(this).attr('user');
      // Don't query the same user multiple times
      if (user && users.includes(user) && !(user in annotationsCache.cvn)) {
        users.push(user);
      }
    });

    if (!users.length) {
      // No (new) users
      dAnnotations = $.Deferred().resolve(annotationsCache.cvn);
    } else {
      dAnnotations = $.ajax({
        url: cvnApiUrl,
        data: { users: users.join('|') },
        timeout: 2000,
        dataType: $.support.cors ? 'json' : 'jsonp',
        cache: true
      })
        .then(function (resp) {
          if (resp.users) {
            $.each(resp.users, function (name, user) {
              annotationsCacheUp();
              annotationsCache.cvn[name] = user;
            });
          }
          return annotationsCache.cvn;
        });
    }

    return dAnnotations.then(function (annotations) {
      // Loop through all cvn user annotations
      $.each(annotations, function (name, user) {
        var tooltip;

        // Only if blacklisted, otherwise don't highlight
        if (user.type === 'blacklist') {
          tooltip = '';

          if (user.comment) {
            tooltip += msg('cvn-reason') + ': ' + user.comment + '. ';
          } else {
            tooltip += msg('cvn-reason') + ': ' + msg('cvn-reason-empty');
          }

          if (user.adder) {
            tooltip += msg('cvn-adder') + ': ' + user.adder;
          } else {
            tooltip += msg('cvn-adder') + ': ' + msg('cvn-adder-empty');
          }

          // Add alert
          $feedContent
            .filter('.mw-rtrc-item')
            .filter(function () {
              return $(this).attr('user') === name;
            })
            .addClass('mw-rtrc-item-alert mw-rtrc-item-alert-user')
            .find('.mw-userlink')
            .attr('title', tooltip);
        }
      });
    });
  }

  /**
   * @param {Object} update
   * @param {jQuery} update.$feedContent
   * @param {string} update.rawHtml
   */
  function pushFeedContent (update) {
    $body.removeClass('placeholder');

    $feed.find('.mw-rtrc-feed-update').html(
      message('lastupdate-rc', new Date().toLocaleString()).escaped() +
      ' | <a href="' + mw.html.escape(getPermalink()) + '">' +
      message('permalink').escaped() +
      '</a>'
    );

    if (update.rawHtml !== prevFeedHtml) {
      prevFeedHtml = update.rawHtml;
      applyRtrcAnnotations(update.$feedContent);
      $feed.find('.mw-rtrc-feed-content').empty().append(update.$feedContent);
    }
  }

  function updateFeed () {
    if (updateReq) {
      updateReq.abort();
    }

    // Indicate updating
    $('#krRTRC_loader').show();

    // Download recent changes
    updateReq = $.ajax({
      url: apiUrl,
      dataType: 'json',
      data: $.extend(getApiRcParams(opt.rc), {
        format: 'json',
        action: 'query',
        list: 'recentchanges'
      })
    });
    // This waterfall flows in one of two ways:
    // - Everything casts to success and results in a UI update (maybe an error message),
    //   loading indicator hidden, and the next update scheduled.
    // - Request is aborted and nothing happens (instead, the final handling will
    //   be done by the new request).
    return updateReq.always(function () {
      updateReq = null;
    })
      .then(function onRcSuccess (data) {
        var recentchanges, $feedContent, client,
          feedContentHTML = '';

        if (data.error) {
          // Account doesn't have patrol flag
          if (data.error.code === 'rcpermissiondenied') {
            feedContentHTML += '<h3>Downloading recent changes failed</h3><p>Please untick the "Unpatrolled only"-checkbox or request the Patroller-right.</a>';

          // Other error
          } else {
            client = $.client.profile();
            feedContentHTML += '<h3>Downloading recent changes failed</h3>' +
            '<p>Please check the settings above and try again. If you believe this is a bug, please <strong>' +
            '<a href="https://github.com/Krinkle/mw-gadget-rtrc/issues/new?body=' + encodeURIComponent('\n\n\n----' +
            '\npackage: mw-gadget-rtrc ' + appVersion +
            mw.format('\nbrowser: $1 $2 ($3)', client.name, client.version, client.platform)
            ) + '" target="_blank">let me know</a></strong>.';
          }
        } else {
          recentchanges = data.query.recentchanges;

          if (recentchanges.length) {
            $.each(recentchanges, function (i, rc) {
              feedContentHTML += buildRcItem(rc);
            });
          } else {
            // Evserything is OK - no results
            feedContentHTML += '<strong><em>' + message('nomatches').escaped() + '</em></strong>';
          }

          // Reset day
          rcDayHeadPrev = undefined;
        }

        $feedContent = $($.parseHTML(feedContentHTML));
        return $.when(
          opt.app.cvnDB && applyCvnAnnotations($feedContent),
          oresModel && opt.app.ores && applyOresAnnotations($feedContent)
        ).then(null, function () {
          // Ignore errors from annotation handlers
          return $.Deferred().resolve();
        }).then(function () {
          return {
            $feedContent: $feedContent,
            rawHtml: feedContentHTML
          };
        });
      }, function onRcError (jqXhr, textStatus) {
        var feedContentHTML;
        if (textStatus === 'abort') {
          // No rendering
          return $.Deferred().reject();
        }
        feedContentHTML = '<h3>Downloading recent changes failed</h3>';
        // Error is handled, continue to rendering.
        return {
          $feedContent: $(feedContentHTML),
          rawHtml: feedContentHTML
        };
      })
      .then(function (obj) {
        // Render
        pushFeedContent(obj);
      })
      .then(function () {
        $RCOptionsSubmit.prop('disabled', false).css('opacity', '1.0');

        // Schedule next update
        updateFeedTimeout = setTimeout(updateFeed, opt.app.refresh * 1000);
        $('#krRTRC_loader').hide();
      });
  }

  function nextDiff () {
    var $lis = $feed.find('.mw-rtrc-item:not(.mw-rtrc-item-current, .mw-rtrc-item-patrolled, .mw-rtrc-item-skipped)');
    $lis.eq(0).find('a.rcitemlink').click();
  }

  function wakeupMassPatrol (settingVal) {
    if (settingVal === true) {
      if (!currentDiff) {
        nextDiff();
      } else {
        $('.patrollink a').click();
      }
    }
  }

  // Build the main interface
  function buildInterface () {
    var namespaceOptionsHtml, tagOptionsHtml, key,
      fmNs = mw.config.get('wgFormattedNamespaces');

    namespaceOptionsHtml = '<option value>' + mw.message('namespacesall').escaped() + '</option>';
    namespaceOptionsHtml += '<option value="0">' + mw.message('blanknamespace').escaped() + '</option>';

    for (key in fmNs) {
      if (key > 0) {
        namespaceOptionsHtml += '<option value="' + key + '">' + fmNs[key] + '</option>';
      }
    }

    tagOptionsHtml = '<option value selected>' + message('select-placeholder-none').escaped() + '</option>';
    for (key = 0; key < rcTags.length; key++) {
      tagOptionsHtml += '<option value="' + mw.html.escape(rcTags[key]) + '">' + mw.html.escape(rcTags[key]) + '</option>';
    }

    $wrapper = $($.parseHTML(
      '<div class="mw-rtrc-wrapper">' +
      '<div class="mw-rtrc-head">' +
        message('title').escaped() + ' <small>(' + appVersion + ')</small>' +
        '<div class="mw-rtrc-head-links">' +
          (!mw.user.isAnon() ? (
            '<a target="_blank" href="' + mw.util.getUrl('Special:Log', { type: 'patrol', user: mw.user.getName(), subtype: 'patrol' }) + '">' +
              message('mypatrollog').escaped() +
            '</a>'
          ) : '') +
          '<a id="mw-rtrc-toggleHelp">' + message('help').escaped() + '</a>' +
        '</div>' +
      '</div>' +
      '<form id="krRTRC_RCOptions" class="mw-rtrc-settings mw-rtrc-nohelp make-switch"><fieldset>' +
        '<div class="panel-group">' +
          '<div class="panel">' +
            '<label class="head">' + message('filter').escaped() + '</label>' +
            '<div class="sub-panel">' +
              '<label>' +
                '<input type="checkbox" name="hideliu" />' +
                ' ' + message('filter-hideliu').escaped() +
              '</label>' +
              '<br />' +
              '<label>' +
                '<input type="checkbox" name="hidebots" />' +
                ' ' + message('filter-hidebots').escaped() +
              '</label>' +
            '</div>' +
            '<div class="sub-panel">' +
              '<label>' +
                '<input type="checkbox" name="unpatrolled" />' +
                ' ' + message('filter-unpatrolled').escaped() +
              '</label>' +
              '<br />' +
              '<label>' +
                message('userfilter').escaped() +
                '<span section="Userfilter" class="helpicon"></span>: ' +
                '<input type="search" size="16" name="user" />' +
              '</label>' +
            '</div>' +
          '</div>' +
          '<div class="panel">' +
            '<label class="head">' + message('type').escaped() + '</label>' +
            '<div class="sub-panel">' +
              '<label>' +
                '<input type="checkbox" name="typeEdit" checked />' +
                ' ' + message('typeEdit').escaped() +
              '</label>' +
              '<br />' +
              '<label>' +
                '<input type="checkbox" name="typeNew" checked />' +
                ' ' + message('typeNew').escaped() +
              '</label>' +
            '</div>' +
          '</div>' +
          '<div class="panel">' +
            '<label  class="head">' +
              mw.message('namespaces').escaped() +
              ' <br />' +
              '<select class="mw-rtrc-setting-select" name="namespace">' +
              namespaceOptionsHtml +
              '</select>' +
            '</label>' +
          '</div>' +
          '<div class="panel">' +
            '<label class="head">' +
              message('timeframe').escaped() +
              '<span section="Timeframe" class="helpicon"></span>' +
            '</label>' +
            '<div class="sub-panel" style="text-align: right;">' +
              '<label>' +
                message('time-from').escaped() + ': ' +
                '<input type="text" size="16" placeholder="YYYYMMDDHHIISS" name="start" />' +
              '</label>' +
              '<br />' +
              '<label>' +
                message('time-untill').escaped() + ': ' +
                '<input type="text" size="16" placeholder="YYYYMMDDHHIISS" name="end" />' +
              '</label>' +
            '</div>' +
          '</div>' +
          '<div class="panel">' +
            '<label class="head">' +
              message('order').escaped() +
              ' <br />' +
              '<span section="Order" class="helpicon"></span>' +
            '</label>' +
            '<div class="sub-panel">' +
              '<label>' +
                '<input type="radio" name="dir" value="newer" />' +
                ' ' + message('asc').escaped() +
              '</label>' +
              '<br />' +
              '<label>' +
                '<input type="radio" name="dir" value="older" checked />' +
                ' ' + message('desc').escaped() +
              '</label>' +
            '</div>' +
          '</div>' +
          '<div class="panel">' +
            '<label for="mw-rtrc-settings-refresh" class="head">' +
              message('reload-interval').escaped() + '<br />' +
              '<span section="Reload_Interval" class="helpicon"></span>' +
            '</label>' +
            '<input type="number" value="3" min="0" max="99" size="2" id="mw-rtrc-settings-refresh" name="refresh" />' +
          '</div>' +
          '<div class="panel panel-last">' +
            '<input class="button" type="button" id="RCOptions_submit" value="' + message('apply').escaped() + '" />' +
          '</div>' +
        '</div>' +
        '<div class="panel-group panel-group-mini">' +
          '<div class="panel">' +
            '<label for="mw-rtrc-settings-limit" class="head">' + message('limit').escaped() + '</label>' +
            ' <select id="mw-rtrc-settings-limit" name="limit">' +
              '<option value="10">10</option>' +
              '<option value="25" selected>25</option>' +
              '<option value="50">50</option>' +
              '<option value="75">75</option>' +
              '<option value="100">100</option>' +
              '<option value="250">250</option>' +
              '<option value="500">500</option>' +
            '</select>' +
          '</div>' +
          '<div class="panel">' +
            '<label class="head">' +
              message('tag').escaped() +
              ' <select class="mw-rtrc-setting-select" name="tag">' +
              tagOptionsHtml +
              '</select>' +
            '</label>' +
          '</div>' +
          '<div class="panel">' +
            '<label class="head">' +
              message('cvn-scores').escaped() +
              '<span section="CVN_Scores" class="helpicon"></span>' +
              '<input type="checkbox" class="switch" name="cvnDB" />' +
            '</label>' +
          '</div>' +
          (oresModel ? (
            '<div class="panel">' +
              '<label class="head">' +
                message('ores-scores').escaped() +
                '<span section="ORES_Scores" class="helpicon"></span>' +
                '<input type="checkbox" class="switch" name="ores" />' +
              '</label>' +
            '</div>'
          ) : '') +
          '<div class="panel">' +
            '<label class="head">' +
              message('masspatrol').escaped() +
              '<span section="MassPatrol" class="helpicon"></span>' +
              '<input type="checkbox" class="switch" name="massPatrol" />' +
            '</label>' +
          '</div>' +
          '<div class="panel">' +
            '<label class="head">' +
              message('autodiff').escaped() +
              '<span section="AutoDiff" class="helpicon"></span>' +
              '<input type="checkbox" class="switch" name="autoDiff" />' +
            '</label>' +
          '</div>' +
          '<div class="panel">' +
            '<label class="head">' +
              message('pause').escaped() +
              '<input class="switch" type="checkbox" id="rc-options-pause" />' +
            '</label>' +
          '</div>' +
        '</div>' +
      '</fieldset></form>' +
      '<a name="krRTRC_DiffTop" />' +
      '<div class="mw-rtrc-diff mw-rtrc-diff-closed" id="krRTRC_DiffFrame"></div>' +
      '<div class="mw-rtrc-body placeholder">' +
        '<div class="mw-rtrc-feed">' +
          '<div class="mw-rtrc-feed-update"></div>' +
          '<div class="mw-rtrc-feed-content"></div>' +
        '</div>' +
        '<img src="' + ajaxLoaderUrl + '" id="krRTRC_loader" style="display: none;" />' +
        '<div class="mw-rtrc-legend">' +
          message('legend').escaped() + ': ' +
          '<div class="mw-rtrc-item mw-rtrc-item-patrolled">' + mw.message('markedaspatrolled').escaped() + '</div>, ' +
          '<div class="mw-rtrc-item mw-rtrc-item-current">' + message('currentedit').escaped() + '</div>, ' +
          '<div class="mw-rtrc-item mw-rtrc-item-skipped">' + message('skippededit').escaped() + '</div>' +
        '</div>' +
      '</div>' +
      '<div style="clear: both;"></div>' +
      '<div class="mw-rtrc-foot">' +
        '<div class="plainlinks" style="text-align: right;">' +
          'Real-Time Recent Changes by ' +
          '<a href="//meta.wikimedia.org/wiki/User:Krinkle">Krinkle</a>' +
          ' | <a href="' + docUrl + '">' + message('documentation').escaped() + '</a>' +
          ' | <a href="https://github.com/Krinkle/mw-gadget-rtrc/releases">' + message('changelog').escaped() + '</a>' +
          ' | <a href="https://github.com/Krinkle/mw-gadget-rtrc/issues">' + message('feedback').escaped() + '</a>' +
        '</div>' +
      '</div>' +
    '</div>'
    ));

    // Add helper element for switch checkboxes
    $wrapper.find('input.switch').after('<div class="switched"></div>');

    // All links within the diffframe should open in a new window
    $wrapper.find('#krRTRC_DiffFrame').on('click', 'table.diff a', function () {
      var $el = $(this);
      if ($el.is('[href^="http://"], [href^="https://"], [href^="//"]')) {
        $el.attr('target', '_blank');
      }
    });

    $('#content').empty().append($wrapper);

    $body = $wrapper.find('.mw-rtrc-body');
    $feed = $body.find('.mw-rtrc-feed');
  }

  function annotationsCacheUp (increment) {
    annotationsCacheSize += increment || 1;
    if (annotationsCacheSize > 1000) {
      annotationsCache.patrolled = Object.create(null);
      annotationsCache.ores = Object.create(null);
      annotationsCache.cvn = Object.create(null);
    }
  }

  // Bind event hanlders in the user interface
  function bindInterface () {
    var api = new mw.Api();
    $RCOptionsSubmit = $('#RCOptions_submit');

    // Apply button
    $RCOptionsSubmit.on('click', function () {
      $RCOptionsSubmit.prop('disabled', true).css('opacity', '0.5');

      readSettingsForm();

      updateFeedNow().then(function () {
        wakeupMassPatrol(opt.app.massPatrol);
      });
      return false;
    });

    // Close Diff
    $wrapper.on('click', '#diffClose', function () {
      $('#krRTRC_DiffFrame').addClass('mw-rtrc-diff-closed');
      currentDiff = currentDiffRcid = false;
    });

    // Load diffview on (diff)-link click
    $feed.on('click', 'a.diff', function (e) {
      var $item = $(this).closest('.mw-rtrc-item').addClass('mw-rtrc-item-current'),
        title = $item.find('.mw-title').text(),
        href = $(this).attr('href'),
        $frame = $('#krRTRC_DiffFrame');

      $feed.find('.mw-rtrc-item-current').not($item).removeClass('mw-rtrc-item-current');

      currentDiff = Number($item.data('diff'));
      currentDiffRcid = Number($item.data('rcid'));

      $frame
        .addClass('mw-rtrc-diff-loading')
        // Reset class potentially added by a.newPage or diffClose
        .removeClass('mw-rtrc-diff-newpage mw-rtrc-diff-closed');

      $.ajax({
        url: mw.util.wikiScript(),
        dataType: 'html',
        data: {
          action: 'render',
          diff: currentDiff,
          diffonly: '1',
          uselang: conf.wgUserLanguage
        }
      }).fail(function (jqXhr) {
        $frame
          .append(jqXhr.responseText || 'Loading diff failed.')
          .removeClass('mw-rtrc-diff-loading');
      }).done(function (data) {
        var skipButtonHtml, $diff;
        if (skippedRCIDs.includes(currentDiffRcid)) {
          skipButtonHtml = '<span class="tab"><a id="diffUnskip">' + message('unskip').escaped() + '</a></span>';
        } else {
          skipButtonHtml = '<span class="tab"><a id="diffSkip">' + message('skip').escaped() + '</a></span>';
        }

        $frame
          .html(data)
          .prepend(
            '<h3>' + mw.html.escape(title) + '</h3>' +
            '<div class="mw-rtrc-diff-tools">' +
              '<span class="tab"><a id="diffClose">' + message('close').escaped() + '</a></span>' +
              '<span class="tab"><a href="' + href + '" target="_blank" id="diffNewWindow">' + message('open-in-wiki').escaped() + '</a></span>' +
              (userHasPatrolRight
                ? '<span class="tab"><a onclick="(function(){ if($(\'.patrollink a\').length){ $(\'.patrollink a\').click(); } else { $(\'#diffSkip\').click(); } })();">[mark]</a></span>'
                : ''
              ) +
              '<span class="tab"><a id="diffNext">' + mw.message('next').escaped() + ' »</a></span>' +
              skipButtonHtml +
            '</div>'
          )
          .removeClass('mw-rtrc-diff-loading');

        if (opt.app.massPatrol) {
          $frame.find('.patrollink a').click();
        } else {
          $diff = $frame.find('table.diff');
          if ($diff.length) {
            mw.hook('wikipage.diff').fire($diff.eq(0));
          }
          // Only scroll up if the user scrolled down
          // Leave scroll offset unchanged otherwise
          scrollIntoViewIfNeeded($frame);
        }
      });

      e.preventDefault();
    });

    $feed.on('click', 'a.newPage', function (e) {
      var $item = $(this).closest('.mw-rtrc-item').addClass('mw-rtrc-item-current'),
        title = $item.find('.mw-title').text(),
        href = $item.find('.mw-title').attr('href'),
        $frame = $('#krRTRC_DiffFrame');

      $feed.find('.mw-rtrc-item-current').not($item).removeClass('mw-rtrc-item-current');

      currentDiffRcid = Number($item.data('rcid'));

      $frame
        .addClass('mw-rtrc-diff-loading mw-rtrc-diff-newpage')
        .removeClass('mw-rtrc-diff-closed');

      $.ajax({
        url: href,
        dataType: 'html',
        data: {
          action: 'render',
          uselang: conf.wgUserLanguage
        }
      }).fail(function (jqXhr) {
        $frame
          .append(jqXhr.responseText || 'Loading diff failed.')
          .removeClass('mw-rtrc-diff-loading');
      }).done(function (data) {
        var skipButtonHtml;
        if (skippedRCIDs.includes(currentDiffRcid)) {
          skipButtonHtml = '<span class="tab"><a id="diffUnskip">' + message('unskip').escaped() + '</a></span>';
        } else {
          skipButtonHtml = '<span class="tab"><a id="diffSkip">' + message('skip').escaped() + '</a></span>';
        }

        $frame
          .html(data)
          .prepend(
            '<h3>' + title + '</h3>' +
            '<div class="mw-rtrc-diff-tools">' +
              '<span class="tab"><a id="diffClose">' + message('close').escaped() + '</a></span>' +
              '<span class="tab"><a href="' + href + '" target="_blank" id="diffNewWindow">' + message('open-in-wiki').escaped() + '</a></span>' +
              '<span class="tab"><a onclick="$(\'.patrollink a\').click()">[' + message('mark').escaped() + ']</a></span>' +
              '<span class="tab"><a id="diffNext">' + mw.message('next').escaped() + ' »</a></span>' +
              skipButtonHtml +
            '</div>'
          )
          .removeClass('mw-rtrc-diff-loading');

        if (opt.app.massPatrol) {
          $frame.find('.patrollink a').click();
        }
      });

      e.preventDefault();
    });

    // Mark as patrolled
    $wrapper.on('click', '.patrollink', function () {
      var $el = $(this);
      $el.find('a').text(mw.msg('markaspatrolleddiff') + '...');
      api.postWithToken('patrol', {
        action: 'patrol',
        rcid: currentDiffRcid
      }).done(function (data) {
        if (!data || data.error) {
          $el.empty().append(
            $('<span style="color: red;"></span>').text(mw.msg('markedaspatrollederror'))
          );
          mw.log('Patrol error:', data);
          return;
        }
        $el.empty().append(
          $('<span style="color: green;"></span>').text(mw.msg('markedaspatrolled'))
        );
        $feed.find('.mw-rtrc-item[data-rcid="' + currentDiffRcid + '"]').addClass('mw-rtrc-item-patrolled');

        // Feed refreshes may overlap with patrol actions, which can cause patrolled edits
        // to show up in an "Unpatrolled only" feed. This is make nextDiff() skip those.
        annotationsCacheUp();
        annotationsCache.patrolled[currentDiffRcid] = true;

        if (opt.app.autoDiff) {
          nextDiff();
        }
      }).fail(function () {
        $el.empty().append(
          $('<span style="color: red;"></span>').text(mw.msg('markedaspatrollederror'))
        );
      });

      return false;
    });

    // Trigger NextDiff
    $wrapper.on('click', '#diffNext', function () {
      nextDiff();
    });

    // SkipDiff
    $wrapper.on('click', '#diffSkip', function () {
      $feed.find('.mw-rtrc-item[data-rcid="' + currentDiffRcid + '"]').addClass('mw-rtrc-item-skipped');
      // Add to array, to re-add class after refresh
      skippedRCIDs.push(currentDiffRcid);
      nextDiff();
    });

    // UnskipDiff
    $wrapper.on('click', '#diffUnskip', function () {
      $feed.find('.mw-rtrc-item[data-rcid="' + currentDiffRcid + '"]').removeClass('mw-rtrc-item-skipped');
      // Remove from array, to no longer re-add class after refresh
      skippedRCIDs.splice(skippedRCIDs.indexOf(currentDiffRcid), 1);
    });

    // Show helpicons
    $('#mw-rtrc-toggleHelp').on('click', function (e) {
      e.preventDefault();
      $('#krRTRC_RCOptions').toggleClass('mw-rtrc-nohelp mw-rtrc-help');
    });

    // Link helpicons
    $('.mw-rtrc-settings .helpicon')
      .attr('title', msg('helpicon-tooltip'))
      .click(function (e) {
        e.preventDefault();
        window.open(docUrl + '#' + $(this).attr('section'), '_blank');
      });

    // Mark as patrolled when rollbacking
    // Note: As of MediaWiki r(unknown) rollbacking does already automatically patrol all reverted revisions.
    // But by doing it anyway it saves a click for the AutoDiff-users
    $wrapper.on('click', '.mw-rollback-link a', function () {
      $('.patrollink a').click();
    });

    // Button: Pause
    $('#rc-options-pause').on('click', function () {
      if (!this.checked) {
        // Unpause
        updateFeedNow();
        return;
      }
      clearTimeout(updateFeedTimeout);
    });
  }

  function showUnsupported () {
    $('#content').empty().append(
      $('<p>').addClass('errorbox').text(
        'This program requires functionality not supported in this browser.'
      )
    );
  }

  /**
   * @param {string} [errMsg]
   */
  function showFail (errMsg) {
    $('#content').empty().append(
      $('<p>').addClass('errorbox').text(errMsg || 'An unexpected error occurred.')
    );
  }

  /**
   * Init functions
   * -------------------------------------------------
   */

  /**
   * Fetches all external data we need.
   *
   * This runs in parallel with loading of modules and i18n.
   *
   * @return {jQuery.Promise}
   */
  function initData () {
    var promises = [];

    // Get userrights
    promises.push(
      mw.loader.using('mediawiki.user').then(function () {
        return mw.user.getRights().then(function (rights) {
          if (rights.includes('patrol')) {
            userHasPatrolRight = true;
          }
        });
      })
    );

    // Get MediaWiki interface messages
    promises.push(
      mw.loader.using('mediawiki.api').then(function () {
        return new mw.Api().loadMessages([
          'blanknamespace',
          'contributions',
          'contribslink',
          'diff',
          'markaspatrolleddiff',
          'markedaspatrolled',
          'markedaspatrollederror',
          'namespaces',
          'namespacesall',
          'newpageletter',
          'next',
          'talkpagelinktext'
        ]);
      })
    );

    promises.push($.ajax({
      url: apiUrl,
      dataType: 'json',
      data: {
        format: 'json',
        action: 'query',
        list: 'tags',
        tgprop: 'displayname'
      }
    }).then(function (data) {
      var tags = data.query && data.query.tags;
      if (tags) {
        rcTags = tags.map(function (tag) {
          return tag.name;
        });
      }
    }));

    promises.push($.ajax({
      url: apiUrl,
      dataType: 'json',
      data: {
        format: 'json',
        action: 'query',
        meta: 'siteinfo'
      }
    }).then(function (data) {
      wikiTimeOffset = (data.query && data.query.general.timeoffset) || 0;
    }));

    return $.when.apply(null, promises);
  }

  /**
   * @return {jQuery.Promise}
   */
  function init () {
    var dModules, dI18N, featureTest, $navToggle, dOres,
      navSupported = conf.skin === 'vector';

    // Transform title and navigation tabs
    document.title = 'RTRC: ' + conf.wgDBname;
    $(function () {
      $('#p-namespaces ul')
        .find('li.selected')
        .removeClass('new')
        .find('a')
        .text('RTRC');
    });

    featureTest = !!(Date.parse);

    if (!featureTest) {
      $(showUnsupported);
      return;
    }

    $('html').addClass('mw-rtrc-available');

    if (navSupported) {
      $('html').addClass('mw-rtrc-sidebar-toggleable');
      $(function () {
        $navToggle = $('<div>').addClass('mw-rtrc-navtoggle');
        $('body').append($('<div>').addClass('mw-rtrc-sidebar-cover'));
        $('#mw-panel')
          .append($navToggle)
          .on('mouseenter', function () {
            $('html').addClass('mw-rtrc-sidebar-on');
          })
          .on('mouseleave', function () {
            $('html').removeClass('mw-rtrc-sidebar-on');
          });
      });
    }

    dModules = mw.loader.using([
      'jquery.client',
      'mediawiki.diff.styles',
      // mw-plusminus styles etc.
      'mediawiki.special.changeslist',
      'mediawiki.jqueryMsg',
      'mediawiki.Uri',
      'mediawiki.user',
      'mediawiki.util',
      'mediawiki.api'
    ]);

    if (!mw.libs.getIntuition) {
      mw.libs.getIntuition = $.ajax({ url: intuitionLoadUrl, dataType: 'script', cache: true, timeout: 7000 });
    }

    dOres = $.ajax({
      url: oresApiUrl,
      dataType: $.support.cors ? 'json' : 'jsonp',
      cache: true,
      timeout: 2000
    }).then(function (data) {
      if (data && data.models) {
        if (data.models.damaging) {
          oresModel = 'damaging';
        } else if (data.models.reverted) {
          oresModel = 'reverted';
        }
      }
    }, function () {
      // ORES has have models for this wiki, continue without
      return $.Deferred().resolve();
    });

    dI18N = mw.libs.getIntuition
      .then(function () {
        return mw.libs.intuition.load('rtrc');
      })
      .then(function () {
        message = mw.libs.intuition.message.bind(null, 'rtrc');
        msg = mw.libs.intuition.msg.bind(null, 'rtrc');
      }, function () {
        // Ignore failure. RTRC should load even if Labs is down.
        // Fall back to displaying message keys.
        mw.messages.set('intuition-i18n-gone', '$1');
        message = function (key) {
          return mw.message('intuition-i18n-gone', key);
        };
        msg = function (key) {
          return key;
        };
        return $.Deferred().resolve();
      });

    $.when(initData(), dModules, dI18N, dOres, $.ready).fail(showFail).done(function () {
      if ($navToggle) {
        $navToggle.attr('title', msg('navtoggle-tooltip'));
      }

      // Create map of month names
      monthNames = msg('months').split(',');

      buildInterface();
      readPermalink();
      updateFeedNow();
      scrollIntoView($wrapper);
      bindInterface();

      rAF(function () {
        $('html').addClass('mw-rtrc-ready');
      });
    });
  }

  /**
   * Execution
   * -------------------------------------------------
   */

  // On every page
  $.when(mw.loader.using('mediawiki.util'), $.ready).then(function () {
    if (!$('#t-rtrc').length) {
      mw.util.addPortletLink(
        'p-tb',
        mw.util.getUrl('Special:BlankPage/RTRC'),
        'RTRC',
        't-rtrc',
        'Monitor and patrol recent changes in real-time',
        null,
        '#t-specialpages'
      );
    }
    if (conf.wgCanonicalSpecialPageName === 'Recentchanges' && !$('#ca-nstab-rtrc').length) {
      mw.util.addPortletLink(
        'p-namespaces',
        mw.util.getUrl('Special:BlankPage/RTRC'),
        'RTRC',
        'ca-nstab-rtrc',
        'Monitor and patrol recent changes in real-time'
      );
    }
  });

  // Initialise if in the right context
  if (
    (conf.wgTitle === 'Krinkle/RTRC' && conf.wgAction === 'view') ||
    (conf.wgCanonicalSpecialPageName === 'Blankpage' && conf.wgTitle.split('/', 2)[1] === 'RTRC')
  ) {
    init();
  }
}());