Skip to content
Permalink
master
Switch branches/tags
Go to file
 
 
Cannot retrieve contributors at this time
// ==UserScript==
// @name Unclosed Request Review Script
// @namespace http://github.com/Tiny-Giant
// @version 2.1.0
// @description Adds buttons to the chat buttons controls; clicking on the button takes you to the recent unclosed close vote request, or delete request query, then it scans the results and displays them along with additional information.
// @author @TinyGiant @rene @mogsdad @Makyen
// @include /^https?://chat\.stackoverflow\.com/rooms/(?:41570|90230|126195|68414|111347|126814|123602|167908|167826)(?:\b.*$|$)/
// @include /^https?://chat\.stackoverflow\.com/search.*[?&]room=(?:41570|90230|126195|68414|111347|126814|123602|167908|167826)(?:\b.*$|$)/
// @include /^https?://chat\.stackoverflow\.com/transcript/(?:41570|90230|126195|68414|111347|126814|123602|167908|167826)(?:\b.*$|$)/
// @include /^https?://chat\.stackoverflow\.com/transcript/.*$/
// @include /^https?://chat\.stackoverflow\.com/users/.*$/
// @require https://github.com/SO-Close-Vote-Reviewers/UserScripts/raw/master/gm4-polyfill.js
// @downloadURL https://github.com/SO-Close-Vote-Reviewers/UserScripts/raw/master/UnclosedRequestReview.user.js
// @updateURL https://github.com/SO-Close-Vote-Reviewers/UserScripts/raw/master/UnclosedRequestReview.user.js
// @grant GM_openInTab
// @grant GM.openInTab
// ==/UserScript==
/* jshint -W097 */
/* jshint -W107 */
/* jshint esnext:true */
/* globals CHAT */
(function() {
'use strict';
if (window !== window.top) {
//If this is running in an iframe, then we do nothing.
return;
}
if (window.location.pathname.indexOf('/transcript/message') > -1) {
//This is a transcript without an indicator in the URL that it is a room for which we should be active.
if (document.title.indexOf('SO Close Vote Reviewers') === -1 &&
document.title.indexOf('SOCVR Request Graveyard') === -1 &&
document.title.indexOf('SOCVR /dev/null') === -1 &&
document.title.indexOf('SOCVR Testing Facility') === -1 &&
document.title.indexOf('SOBotics') === -1
) {
//The script should not be active on this page.
return;
}
}
const NUMBER_UI_GROUPS = 8;
const LSPREFIX = 'unclosedRequestReview-';
const MAX_DAYS_TO_REMEMBER_VISITED_LINKS = 7;
const MAX_BACKOFF_TIMER_SECONDS = 120;
const MESSAGE_THROTTLE_PROCESSING_ACTIVE = -9999;
const MESSAGE_PROCESSING_DELAY_FOR_MESSAGE_VALID = 1000;
const MESSAGE_PROCESSING_DELAYED_ATTEMPTS = 5;
const MESSAGE_PROCESSING_ASSUMED_MAXIMUM_PROCESSING_SECONDS = 10;
const DEFAULT_MINIMUM_UPDATE_DELAY = 5; // (seconds)
const DEFAULT_AUTO_UPDATE_RATE = 5; // (minutes)
const MESSAGE_PROCESSING_REQUEST_TYPES = ['questions', 'answers', 'posts'];
const UI_CONFIG_DEL_PAGES = 'uiConfigDel';
const UI_CONFIG_CV_PAGES = 'uiConfigCv';
const UI_CONFIG_REOPEN_PAGES = 'uiConfigReopen';
const months3charLowerCase = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
const weekdays3charLowerCase = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
/* The following code for detecting browsers is from my answer at:
* http://stackoverflow.com/a/41820692/3773011
* which is based on code from:
* http://stackoverflow.com/a/9851769/3773011
*/
//Opera 8.0+ (tested on Opera 42.0)
const isOpera = (!!window.opr && !!window.opr.addons) || !!window.opera || navigator.userAgent.indexOf(' OPR/') >= 0;
//Firefox 1.0+ (tested on Firefox 45 - 53)
const isFirefox = typeof InstallTrigger !== 'undefined';
//Internet Explorer 6-11
// Untested on IE (of course). Here because it shows some logic for isEdge.
const isIE = /*@cc_on!@*/false || !!document.documentMode;
//Edge 20+ (tested on Edge 38.14393.0.0)
const isEdge = !isIE && !!window.StyleMedia;
//The other browsers are trying to be more like Chrome, so picking
// capabilities which are in Chrome, but not in others, is a moving
// target. Just default to Chrome if none of the others is detected.
const isChrome = !isOpera && !isFirefox && !isIE && !isEdge;
// Blink engine detection (tested on Chrome 55.0.2883.87 and Opera 42.0)
const isBlink = (isChrome || isOpera) && !!window.CSS; // eslint-disable-line no-unused-vars
//Various objects to hold functions and current state.
const funcs = {
visited: {},
config: {},
backoff: {},
ui: {},
mmo: {},
mp: {},
orSearch: {},
};
//Current state information
const config = {
ui: {},
nonUi: {},
backoff: {},
};
//Global backoff timer, which is synced between tabs.
const backoffTimer = {
timer: 0,
isPrimary: false,
timeActivated: 0,
milliseconds: 0,
};
//State for message processing
const messageProcessing = {
throttle: 0,
throttleTimeActivated: 0,
isRequested: false,
interval: 0,
mostRecentRequestInfoTime: 0,
};
//State information for adding OR functionality to searches.
const orSearch = {
framesToProces: 0,
maxPages: 0,
};
//Update RegExp from list here: https://github.com/AWegnerGitHub/SE_Zephyr_VoteRequest_bot
const pleaseRegExText = '(?:pl(?:ease|s|z)|p.?[sz]|.?l[sz]|pl.?|.pl[sz]|p.l[sz]|pl.[sz]|pl[sz].)';
const requestTagRegExStandAlonePermittedTags = '(?:spam|off?en[cs]ive|abb?u[cs]ive|(?:re)?-?flag(?:-?(?:naa|spam|off?en[cs]ive|rude|abb?u[cs]ive))|(?:(?:naa|spam|off?en[cs]ive|rude|abb?u[cs]ive)-?(?:re)?-?flag))'; //spam is an actual SO tag, so we're going to need to deal with that.
const requestTagRequirePleaseRegExText = '(?:cv|(?:un-?)?(?:del(?:v)?|dv|delete)|rov?|re-?open|app?rove?|reject|rv|review|(?:re)?-?flag|nuke?|spam|off?en[cs]ive|naa|abbu[cs]ive)';
const requestTagRequirePleaseOrStandAloneRegExText = '(?:' + requestTagRequirePleaseRegExText + '|' + requestTagRegExStandAlonePermittedTags + ')';
const requestTagRequirePleasePleaseFirstRegExText = '(?:' + pleaseRegExText + '[-.]?' + requestTagRequirePleaseOrStandAloneRegExText + ')';
const requestTagRequirePleasePleaseLastRegExText = '(?:' + requestTagRequirePleaseOrStandAloneRegExText + '[-.]?' + pleaseRegExText + ')';
const requestTagRegExText = '\\b(?:' + requestTagRegExStandAlonePermittedTags + '|' + requestTagRequirePleasePleaseFirstRegExText + '|' + requestTagRequirePleasePleaseLastRegExText + ')\\b';
//Current, now older, result: https://regex101.com/r/dPtRnS/3
/*Need to update with (?:re\W?)? for flags
\b(?:(?:spam|off?ensive|abb?usive|flag(?:-?(?:naa|spam|off?ensive|rude|abb?usive))|(?:(?:naa|spam|off?ensive|rude|abb?usive)-?flag))|(?:(?:pl(?:ease|s|z)|p.?[sz]|.?l[sz]|pl.?|.pl[sz]|p.l[sz]|pl.[sz]|pl[sz].)-(?:(?:cv|(?:un)?(?:del(?:v)?|dv|delete)|rov?|reopen|app?rove?|reject|rv|review|flag|nuke?|spam|off?ensive|naa|abbusive)|(?:spam|off?ensive|abb?usive|flag(?:-?(?:naa|spam|off?ensive|rude|abb?usive))|(?:(?:naa|spam|off?ensive|rude|abb?usive)-?flag))))|(?:(?:(?:cv|(?:un)?(?:del(?:v)?|dv|delete)|rov?|reopen|app?rove?|reject|rv|review|flag|nuke?|spam|off?ensive|naa|abbusive)|(?:spam|off?ensive|abb?usive|flag(?:-?(?:naa|spam|off?ensive|rude|abb?usive))|(?:(?:naa|spam|off?ensive|rude|abb?usive)-?flag)))-(?:pl(?:ease|s|z)|p.?[sz]|.?l[sz]|pl.?|.pl[sz]|p.l[sz]|pl.[sz]|pl[sz].)))\b
*/
//Used to look in text to see if there are any messages which contain the action tag as text.
//Only a limited set of action types are recognized in text format.
const getActionTagInTextRegEx = /(?:\[(?:tag\W?)?(?:cv|(?:un-?)?del(?:ete|v)?|re-?open)-[^\]]*\])/;
//Detect the type of request based on tag text content.
const tagsInTextContentRegExes = {
delete: /\b(?:delv?|dv|delete)(?:pls)?\b/i,
undelete: /\b(?:un?-?delv?|un?-?dv|un?-?delete)(?:pls)?\b/i,
close: /\b(?:cv)(?:pls)?\b/i,
reopen: /\b(?:re-?open)(?:pls)?\b/i,
spam: /\bspam\b/i,
offensive: /\b(?:off?en[cs]ive|rude|abb?u[cs]ive)\b/i,
flag: /\b(?:re)?-?flag-?(?:pl(?:ease|s|z)|p.?[sz]|.?l[sz]|pl.?|.pl[sz]|p.l[sz]|pl.[sz]|pl[sz].)?\b/i,
reject: /\b(?:reject|review)(?:pls)?\b/i,
//20k+ tags
tag20k: /^(?:20k\+?(?:-only)?)$/i,
tagN0k: /^(?:\d0k\+?(?:-only)?)$/i,
request: new RegExp(requestTagRegExText, 'i'),
};
//The extra escapes in RegExp are due to bugs in the syntax highlighter in an editor. They are only there because it helps make the syntax highlighting not be messed up.
const getQuestionIdFromURLRegEx = /(?:^|[\s"])(?:(?:https?:)?(?:(?:\/\/)?(?:www\.|\/\/)?stackoverflow\.com\/))(?:q[^\/]*|posts)\/+(\d+)/g; // eslint-disable-line no-useless-escape
//https://regex101.com/r/QzH8Jf/2
const getSOQuestionIdFfromURLButNotIfAnswerRegEx = /(?:^|[\s"(])(?:(?:https?:)?(?:(?:\/\/)?(?:www\.|\/\/)?stackoverflow\.com\/))(?:q[^\/]*)\/+(\d+)(?:(?:\/[^#\s]*)#?)?(?:$|[\s")])/g; // eslint-disable-line no-useless-escape
//XXX Temp continue to use above variable name until other uses resolved.
const getSOQuestionIdFfromURLNotPostsNotAnswerRegEx = getSOQuestionIdFfromURLButNotIfAnswerRegEx;
//https://regex101.com/r/w2wQoC/1/
//https://regex101.com/r/SMVJv6/3/
const getSOAnswerIdFfromURLRegExes = [
/(?:^|[\s"(])(?:(?:https?:)?(?:(?:\/\/)?(?:www\.|\/\/)?stackoverflow\.com\/))(?:a[^\/]*)\/+(\d+)(?:\s*|\/[^/#]*\/?\d*\s*)(?:$|[\s")])/g, // eslint-disable-line no-useless-escape
/(?:^|[\s"'(])(?:(?:https?:)?(?:(?:\/\/)?(?:www\.|\/\/)?stackoverflow\.com\/))(?:q[^\/]*|posts)[^\s#]*#(\d+)(?:$|[\s"')])/g, // eslint-disable-line no-useless-escape
];
const getSOPostIdFfromURLButNotIfAnswerRegEx = /(?:^|[\s"(])(?:(?:https?:)?(?:(?:\/\/)?(?:www\.|\/\/)?stackoverflow\.com\/))(?:posts)\/+(\d+)(?:\s*|\/[^\/#]*\/?\d*\s*)(?:\s|$|[\s")])/g; // eslint-disable-line no-useless-escape
const getSOQuestionOrAnswerIdFfromURLRegExes = [getSOQuestionIdFfromURLNotPostsNotAnswerRegEx].concat(getSOAnswerIdFfromURLRegExes);
//Some constants which it helps to have some functions in order to determine
const isChat = window.location.pathname.indexOf('/rooms/') === 0;
const isSearch = window.location.pathname === '/search';
const isTranscript = window.location.pathname.indexOf('/transcript') === 0;
const isUserPage = window.location.pathname.indexOf('/users') === 0;
var uiConfigStorage;
//Functions needed on both the chat page and the search page
//Utility functions
funcs.executeInPage = function(functionToRunInPage, leaveInPage, id) { // + any additional JSON-ifiable arguments for functionToRunInPage
//Execute a function in the page context.
// Any additional arguments passed to this function are passed into the page to the
// functionToRunInPage.
// Such arguments must be Object, Array, functions, RegExp,
// Date, and/or other primitives (Boolean, null, undefined,
// Number, String, but not Symbol). Circular references are
// not supported. Prototypes are not copied.
// Using () => doesn't set arguments, so can't use it to define this function.
// This has to be done without jQuery, as jQuery creates the script
// within this context, not the page context, which results in
// permission denied to run the function.
function convertToText(args) {
//This uses the fact that the arguments are converted to text which is
// interpreted within a <script>. That means we can create other types of
// objects by recreating their normal JavaScript representation.
// It's actually easier to do this without JSON.stringify() for the whole
// Object/Array.
var asText = '';
var level = 0;
function lineSeparator(adj, isntLast) {
level += adj - ((typeof isntLast === 'undefined' || isntLast) ? 0 : 1);
asText += (isntLast ? ',' : '') + '\n' + (new Array((level * 2) + 1)).join('');
}
function recurseObject(obj) {
if (Array.isArray(obj)) {
asText += '[';
lineSeparator(1);
obj.forEach(function(value, index, array) {
recurseObject(value);
lineSeparator(0, index !== array.length - 1);
});
asText += ']';
} else if (obj === null) {
asText += 'null';
} else if (obj === void (0)) {
//undefined
asText += 'void(0)';
} else if (Number.isNaN(obj)) {
//Special cases for Number
//Not a Number (NaN)
asText += 'Number.NaN';
} else if (obj === 1 / 0) {
// +Infinity
asText += '1/0';
} else if (obj === 1 / -0) {
// -Infinity
asText += '1/-0';
} else if (obj instanceof RegExp || typeof obj === 'function') {
//function
asText += obj.toString();
} else if (obj instanceof Date) {
asText += 'new Date("' + obj.toJSON() + '")';
} else if (typeof obj === 'object') {
asText += '{';
lineSeparator(1);
Object.keys(obj).forEach(function(prop, index, array) {
asText += JSON.stringify(prop) + ': ';
recurseObject(obj[prop]);
lineSeparator(0, index !== array.length - 1);
});
asText += '}';
} else if (['boolean', 'number', 'string'].indexOf(typeof obj) > -1) {
asText += JSON.stringify(obj);
} else {
console.log('Didn\'t handle: typeof obj:', typeof obj, ':: obj:', obj);
}
}
recurseObject(args);
return asText;
}
var newScript = document.createElement('script');
if (typeof id === 'string' && id) {
newScript.id = id;
}
var args = [];
//Using .slice(), or other Array methods, on arguments prevents optimization.
for (var index = 3; index < arguments.length; index++) {
args.push(arguments[index]);
}
newScript.textContent = '(' + functionToRunInPage.toString() + ').apply(null,' +
convertToText(args) + ');';
(document.head || document.documentElement).appendChild(newScript);
if (!leaveInPage) {
//Synchronous scripts are executed immediately and can be immediately removed.
//Scripts with asynchronous functionality *may* need to remain in the page
// until complete. Exactly what's needed depends on actual usage.
document.head.removeChild(newScript);
}
return newScript;
};
funcs.removeAllRequestInfo = () => {
//Remove old request-info in preparation for replacing them.
[].slice.call(document.querySelectorAll('.request-info')).forEach((request) => {
request.remove();
});
};
funcs.getElementEffectiveWidth = (element) => {
//Get the "width" to which the "width" style needs to be set to match the size of the specified element, assuming the
// margin and padding are the same as on the specified element. Used to match the button spacing to the "maximum"
// defined by the sizing buttons.
const computedWidth = element.getBoundingClientRect().width;
const style = window.getComputedStyle(element);
const paddingLeft = parseInt(style.getPropertyValue('padding-left'));
const paddingRight = parseInt(style.getPropertyValue('padding-right'));
const marginLeft = parseInt(style.getPropertyValue('margin-left'));
const marginRight = parseInt(style.getPropertyValue('margin-right'));
return (computedWidth - paddingLeft - paddingRight - marginLeft - marginRight);
};
funcs.executeIfIsFunction = (doFunction) => {
//Only execute a function if it exists; no frills; does not bother to account for potential arguments
if (typeof doFunction === 'function') {
return doFunction();
}
};
funcs.ifNotNonNullObjectUseDefault = (obj, defaultValue) => {
//Use the supplied default if the first argument is not a non-null Object.
if (typeof obj !== 'object' || obj === null) {
return defaultValue;
}
return obj;
};
funcs.getFirstRegExListMatchInText = (text, regexes) => {
//Make the match only work on host-relative-links, protocol-relative and fully-qualified links to stackoverflow.com only.
// The goal is to pick up plain text that is ' /q/875121087' and stackoverflow.com links, but not links to questions
// on other sites.
// If nothing is found null is returned.
if (!Array.isArray(regexes)) {
regexes = [regexes];
}
return regexes.reduce((accum, regex) => {
if (accum) {
//Already found
return accum;
}
regex.lastIndex = 0;
const match = regex.exec(text);
return match ? match[1] : match;
}, null);
};
funcs.getAllRegExListMatchesInText = (text, regexes) => {
//Make the match only work on host-relative-links, protocol-relative and fully-qualified links to stackoverflow.com only.
// The goal is to pick up plain text that is ' /q/875121087' and stackoverflow.com links, but not links to questions
// on other sites.
// If nothing is found null is returned.
// Relies on the RegExps having the /g flag.
if (!Array.isArray(regexes)) {
regexes = [regexes];
}
return regexes.reduce((accum, regex) => {
regex.lastIndex = 0;
const matches = text.match(regex);
if (matches) {
if (!Array.isArray(accum)) {
accum = [];
}
return accum.concat(matches);
}
return accum;
}, null);
};
funcs.getPostIdFromURL = (url) => {
//In a URL, find the postId, be it an answer, a question, or just stated as a post.
//Test for answers first
var postId = funcs.getFirstRegExListMatchInText(url, getSOAnswerIdFfromURLRegExes);
if (postId) {
return postId;
}
//Only questions
postId = funcs.getFirstRegExListMatchInText(url, getSOQuestionIdFfromURLNotPostsNotAnswerRegEx);
if (postId) {
return postId;
}
//Posts
postId = funcs.getFirstRegExListMatchInText(url, getSOPostIdFfromURLButNotIfAnswerRegEx);
if (postId) {
return postId;
}
return null;
};
funcs.getAllQAPIdsFromLinksInElement = (element) => { // eslint-disable-line arrow-body-style
//Get all the Question, Answer, or Post links contained in an element.
return funcs.getQuestionAnswerOrPostIdsOrInfoFromLinksInElement(element, 'any', false);
};
funcs.getQuestionAnswerOrPostIdsOrInfoFromLinksInElement = (element, what, returnInfo) => {
//Get a list of one unique question, answer IDs which are pointed to by the href of <A> links within an element.
// The RegExp currently restricts this to stackoverflow only.
// If what includes a 'd', then only URLs which point directly to questions (i.e. not #answer number or #comment)
// will be returned.
what = what.toLowerCase();
let regexes;
if (what.indexOf('q') > -1) {
regexes = getQuestionIdFromURLRegEx;
if (what.indexOf('d') > -1) {
regexes = getSOQuestionIdFfromURLNotPostsNotAnswerRegEx;
}
} else if (what.indexOf('a') > -1) {
if (what.indexOf('any') > -1) {
//If we are looking for any, use the regexes for answers, questions w/o answer, and posts.
regexes = [].concat(getSOAnswerIdFfromURLRegExes, getQuestionIdFromURLRegEx, getSOPostIdFfromURLButNotIfAnswerRegEx);
} else {
regexes = getSOAnswerIdFfromURLRegExes;
}
} else if (what.indexOf('p') > -1) {
regexes = getSOPostIdFfromURLButNotIfAnswerRegEx;
} else {
return [];
}
if (!Array.isArray(regexes)) {
regexes = [regexes];
}
if (!element || element.nodeName === '#text') {
//Return an empty array, as there are no valid question IDs in a null element, and no links in text
return [];
}
//Scan the links in the element and return an array of those that are to the appropriate type of question/answer/post.
return [].slice.call(element.querySelectorAll('a')).filter((link) => { // eslint-disable-line arrow-body-style
//Keep the link if it is to a URL that produces the desired ID type (matches the regexes).
return regexes.some((tryRegex) => {
tryRegex.lastIndex = 0;
return tryRegex.test(link.href);
});
}).map((link) => {
//Have List of links which match. Convert them to the data desired: either an ID, or an Object with some of the link's attributes.
if (returnInfo) {
return {
link: link,
text: link.textContent,
url: link.href,
postId: funcs.getFirstRegExListMatchInText(link.href, regexes),
};
} // else
return funcs.getFirstRegExListMatchInText(link.href, regexes);
}).filter((id, index, array) => {
//Remove duplicates
//This is only a reasonable way to remove duplicates in short arrays, which this is.
if (returnInfo) {
//Filter the Objects that are for duplicate postId's.
for (let testIndex = 0; testIndex < index; testIndex++) {
if (+id.postId === +array[testIndex].postId) {
return false;
}
}
return true;
} // else
return array.indexOf(id) === index;
});
};
funcs.sortMessageRequestInfoEntries = (message) => {
//For request info entries that have more than one link, make the order of those entries
// match the order of the links in the content of the associated .message.
const requestInfo = funcs.getRequestInfoFromMessage(message);
if (!requestInfo) {
//Can't do anything without request-info
return;
}
const requestInfoLinks = [].slice.call(funcs.getRequestInfoLinksFromMessage(message));
if (requestInfoLinks.length < 2) {
//No need to sort a single item
return;
}
const content = funcs.getContentFromMessage(message);
if (!content) {
return;
}
//Get the list of question IDs that are in links in the content.
const postsInContent = funcs.getAllQAPIdsFromLinksInElement(content);
requestInfoLinks.sort((a, b) => {
const aIndex = postsInContent.indexOf(a.dataset.postId);
const bIndex = postsInContent.indexOf(b.dataset.postId);
return aIndex - bIndex;
});
//Apply the sort to the request-info links
requestInfoLinks.forEach((link) => {
requestInfo.appendChild(link);
});
};
//Should consider if the criteria for following a back-reference should be expanded. Should a back-reference
// be followed if there is a link in the reply, just not one that is to a question, post, answer. And etc.?
// For now, the back-reference is not followed if there is a link in the referring message, as that is safer.
funcs.getQuestionAnswerOrPostInfoListFromReplyToIfIsRequestAndNoLinks = (message, what) => {
const content = funcs.getContentFromMessage(message);
if (content && funcs.getFirstRequestTagInElement(content) && !funcs.removeTagLinks(content.cloneNode(true)).querySelector('a')) {
//There's no link in the content (e.g. the request is not contain a link to a question, that happens to also be a reply).
return funcs.getQuestionAnswerOrPostInfoListFromReplyTo(message, what);
} //else
return [];
};
funcs.getQuestionAnswerOrPostInfoListFromReplyTo = (message, what) => {
//Obtain the info from a post to which this message is a reply, if it is in the transcript.
const replyInfo = message.querySelector('.reply-info');
if (replyInfo) {
//It is a reply to something.
const refMessageId = replyInfo.href.replace(/^[^#]*#/, '');
if (refMessageId) {
const refMessage = document.getElementById('message-' + refMessageId);
if (refMessage) {
//The referenced comment is currently on the page
const info = funcs.getQuestionAnswerOrPostIdsOrInfoFromLinksInElement(funcs.getContentFromMessage(refMessage), what, true);
return info;
}
}
}
//Is invalid in some way. Return an empty array.
return [];
};
funcs.setDatasetIfNotUndefined = (element, dataProp, value) => {
if (!element || typeof value === 'undefined') {
return;
}
element.dataset[dataProp] = value;
};
//Calculate some values used to adjust what the script does, but which depend on the utility functions.
const currentRoom = (() => {
if (isSearch) {
return funcs.getFirstRegExListMatchInText(window.location.search, /\bRoom=(\d+)/i);
} // else
if (isChat) {
return funcs.getFirstRegExListMatchInText(window.location.pathname, /\/(\d+)\b/i);
} //else
//Transcript (there is not always a room defined).
return funcs.getFirstRegExListMatchInText(document.querySelector('.room-mini .room-name a'), /chat\.stack(?:overflow|exchange)\.com\/rooms\/(\d+)/);
})();
const urlReviewType = funcs.getFirstRegExListMatchInText(window.location.search, /\brequestReviewType=(\w+)/i);
const urlReviewShow = funcs.getFirstRegExListMatchInText(window.location.search, /\brequestReviewShow=(\w+)/i);
const urlSearchString = funcs.getFirstRegExListMatchInText(window.location.search, /\bq=([^?&#]+)/i);
const urlSearchOrs = typeof urlSearchString === 'string' ? urlSearchString.split(/\+(?:or|\||(?:%7c){1,2})\+/ig) : null;
//Allow the URL to specify showing closed and deleted posts.
const isForceShowClosed = /closed?/i.test(urlReviewShow);
const isForceShowOpen = /open/i.test(urlReviewShow);
const isForceShowDeleted = /deleted?/i.test(urlReviewShow);
const isForceShowLinks = /links?/i.test(urlReviewShow);
const isForceShowReplies = /repl(?:y|ies)/i.test(urlReviewShow);
//Allow the URL to specify that it is a cv- search, del- search, or not using the cv-/del- UI.
const isSearchCv = isSearch && ((/(?:tagged%2F|^)cv(?:\b|$)/.test(urlSearchString) || /(?:cv|close)/i.test(urlReviewType)) && !/none/i.test(urlReviewType));
const isSearchDel = isSearch && ((/(?:tagged%2F|^)(?:del(?:ete|v)?|dv)(?:\b|$)/.test(urlSearchString) || /del/i.test(urlReviewType)) && !/none/i.test(urlReviewType));
const isSearchReopen = isSearch && ((/(?:tagged%2F|^)(?:re-?open)(?:\b|$)/.test(urlSearchString) || /del/i.test(urlReviewType)) && !/none/i.test(urlReviewType));
const isSearchReviewUIActive = isSearchCv || isSearchDel || isSearchReopen;
//Adjust the page links to have the same reviewRequest options
if (urlReviewShow || urlReviewType) {
[].slice.call(document.querySelectorAll('a .page-numbers')).forEach((linkSpan) => {
const link = linkSpan.parentNode;
if (link && link.nodeName === 'A') {
if (urlReviewShow) {
link.href += '&requestReviewShow=' + urlReviewShow;
}
if (urlReviewType) {
link.href += '&requestReviewType=' + urlReviewType;
}
}
});
}
//Visited: Watch for user clicks on links to posts
//Use Ctrl-right-click to open the CV-review queue for the tag clicked on.
var ignoreWindowClicks = false;
funcs.windowCtrlClickListener = (event) => {
//Clicks with Alt/Ctrl/Shift do not travel the DOM (at least not in Firefox).
if (ignoreWindowClicks) {
return;
} //else
ignoreWindowClicks = true;
setTimeout(function() {
//Ignore window clicks for 100ms, to prevent the user from double clicking to cause two votes to be attempted,
// as that just causes a notification to be shown.
ignoreWindowClicks = false;
}, 100);
const target = event.target;
if (target.classList.contains('urrs-receiveAllClicks')) {
const detail = {};
[
// These are of primary interest
'ctrlKey',
'shiftKey',
'altKey',
'metaKey',
'button',
// The rest aren't of that much interest
'screenX',
'screenY',
'clientX',
'clientY',
'buttons',
'relatedTarget',
'region',
'layerX',
'layerY',
'movementX',
'movementY',
'offsetX',
'offsetY',
'detail',
'composed',
'mozInputSource',
'mozPresure',
].forEach((prop) => {
detail[prop] = event[prop];
});
const newEvent = new CustomEvent('urrs-allClicks', {
detail: detail,
bubbles: true,
cancelable: true,
});
target.dispatchEvent(newEvent);
} else if (config.nonUi.clickTagTagToOpenCVQ && event.isTrusted && Object.keys(config.nonUi.clickTagTagToOpenCVQButtonInfo).every((key) => event[key] === config.nonUi.clickTagTagToOpenCVQButtonInfo[key])) {
//A real user Ctrl-click on button 2
if (target.classList.contains('ob-post-tag')) {
const tagName = target.textContent;
//Force this to SO. Other sites don't have chat in a separate domain, so would need to find the domain for
// the room.
GM.openInTab('https://stackoverflow.com/review/close/?filter-tags=' + encodeURIComponent(tagName), false);
//These don't prevent Firefox from displaying the context menu.
event.preventDefault();
event.stopPropagation();
}
}
};
//Now done by monitoring mousedown and mouseup, and click and auxclick in order to get around a Chrome "feature" which results in click events not
// being fired for any button other than button 1. Chrome recently implemented that non-button 1 clicks are an "auxclick".
//Remembering visited questions.
if (typeof funcs.visited !== 'object') {
funcs.visited = {};
}
//Work-around for Chrome not firing a 'click' event for buttons other than #1.
var mostRecentMouseDownEvent;
funcs.visited.listenForLinkMouseDown = (event) => {
// Remember which element the mouse is on when the button is pressed.
mostRecentMouseDownEvent = event;
};
funcs.visited.listenForLinkMouseUp = (event) => {
//If a mouseup occurs, consider it a click, if the target is the same as the last mousedown.
// This will have issues with detecting clicks when the user presses multiple buttons at the same time.
if (mostRecentMouseDownEvent.target === event.target && mostRecentMouseDownEvent.button === event.button) {
//Delay so the 'click' event can fire. If not, we may make the message display:none prior to the
// click taking effect.
funcs.visited.listenForClicks(event);
}
};
var mostRecentClick = null;
funcs.visited.listenForClicks = (event) => {
//Clicks are sometimes detected through mousedown/mouseup pairs, click events, or auxclick events.
// But, we want to fire our listeners only once per user action. In addition, we want to have the
// same effect of preventing the default action, on associated events (i.e. click), if our action called preventDefault().
if (mostRecentClick) {
//There has been a prior event, which may be the same user action.
const mustMatch = [
'target',
'button',
'screenX',
'screenY',
'clientX',
'clientY',
'buttons',
'ctrlKey',
'shiftKey',
'altKey',
'metaKey',
];
if (mustMatch.every((key) => mostRecentClick[key] === event[key]) && (event.timeStamp - mostRecentClick.timeStamp) < 50) {
//Same action
if (mostRecentClick.defaultPrevented) {
event.preventDefault();
}
return;
} //else
}
//It's a new user action
funcs.windowCtrlClickListener(event);
if (!event.defaultPrevented) {
funcs.visited.listenForLinkClicks(event);
}
if (event.target.classList.contains('action-link') || (event.target.parentNode && event.target.parentNode.classList && event.target.parentNode.classList.contains('action-link'))) {
funcs.ui.listenForActionLinkClicks(event);
}
mostRecentClick = event;
};
window.addEventListener('click', funcs.visited.listenForClicks, false);
window.addEventListener('auxclick', funcs.visited.listenForClicks, false);
funcs.ui.listenForActionLinkClicks = (event) => {
const target = event.target;
const message = funcs.getContainingMessage(target);
if (!message || !message.classList.contains('urrsRequestComplete')) {
return;
}
setTimeout(() => {
const popup = message.querySelector('.message > .popup');
if (popup) {
message.classList.add('urrsRequestComplete-temp-disable');
const popupObserver = new MutationObserver(function(mutations, observer) {
if (mutations.some((mutation) => (mutation.removedNodes && [].slice.call(mutation.removedNodes).some((node) => node.classList.contains('popup'))))) {
observer.disconnect();
message.classList.remove('urrsRequestComplete-temp-disable');
}
});
popupObserver.observe(message, {
childList: true,
});
}
}, 100);
};
funcs.visited.listenForLinkClicks = (event) => {
//Intended as main listener for clicks on links. Because Chrome doesn't fire click events for buttons other than the main one
// this is called when the listeners to mousedown and mouseup determine that a click should have fired.
if (!config.nonUi.trackVisitedLinks) {
return;
}
var affectedLink = funcs.visited.findYoungestAnchor(event.target);
if (!affectedLink) {
return;
}
funcs.visited.addPostFromURLToVisitedAndUpdateShown(affectedLink.href, event.target);
};
funcs.visited.addPostsFromAnchorListToVisitedAndUpdateShown = (links) => {
// Add the posts associated with a list of links to the
// visited list and update the UI, if so configured on the
// search page (i.e. hide the messages if not showing
// visited).
const ids = [];
const filtered = links.filter((link) => {
const postId = funcs.getPostIdFromURL(link.href);
if (postId === null || isNaN(+postId)) {
//Not a question link.
return false;
}
ids.push(postId);
return true;
});
//Add all the valid Ids to the visited list
funcs.config.addPostIdsToVisitedAndRetainMostRecentList(ids);
funcs.visited.invalidateElementsMessageVisitedAndUpdateUi(filtered);
};
funcs.visited.addPostFromURLToVisitedAndUpdateShown = (url, element) => {
// If a URL is a post, add the post to the visited list, cause
// the element's message to be reevaluated wrt. visited and
// hide the message if in the search page, and so specified by the UI.
const postId = funcs.getPostIdFromURL(url);
if (postId === null || isNaN(+postId)) {
//Not a question link.
return;
}
//This may be running in multiple tabs. Make sure to sync up to the most recently saved config prior to adding
// new questions. If this is not done, then changes in other tabs get lost. While there could be a inter-tab race
// condition here, this running is based on user input, which shouldn't be faster than the code.
//Add the question to the visited list.
funcs.config.addPostIdsToVisitedAndRetainMostRecentList(postId);
funcs.visited.invalidateElementsMessageVisitedAndUpdateUi(element);
};
funcs.visited.invalidateElementsMessageVisitedAndUpdateUi = (elements) => {
//For a single element, or list of elements, clear the visited status and update the UI, if on the search page.
if (!Array.isArray(elements)) {
elements = [elements];
}
let didUpate = false;
elements.forEach((element) => {
//Cause the message to be re-tested wrt. having been visited.
if (element) {
const message = funcs.getContainingMessage(element);
if (message) {
const visited = message.dataset.visited;
if (visited) {
message.dataset.visited = '';
}
didUpate = true;
}
}
});
if (didUpate) {
//Only need to show/hide messages here, not sort, and only on search page.
funcs.executeIfIsFunction(funcs.ui.showHideMessagesPerUI);
}
};
funcs.visited.findYoungestAnchor = (element) => {
//Find the closest ancestor, including the element itself which is an anchor.
while (element && element.nodeName !== 'A') {
element = element.parentNode;
}
return element;
};
funcs.visited.findYoungestInteractiveElement = (element) => {
//Find the closest ancestor, including the element itself which is interactive.
//This would be a bit faster if the Array did not have to be created each time the function is entered.
const interactiveNodeNames = ['A', 'BUTTON', 'INPUT', 'MAP', 'OBJECT', 'TEXTAREA', 'VIDEO'];
while (element && interactiveNodeNames.indexOf(element.nodeName) === -1) {
element = element.parentNode;
}
return element;
};
funcs.visited.beginRememberingPostVisits = () => {
//Start listening to click events so we can record when the user clicks on a link.
//Let all other event handlers deal with or cancel the event.
//We only want to record the click if the default isn't prevented. Thus, we need to
// get it last.
window.addEventListener('mousedown', funcs.visited.listenForLinkMouseDown, false);
window.addEventListener('mouseup', funcs.visited.listenForLinkMouseUp, false);
};
//Functions for the backoff timer (functional across instances)
if (typeof funcs.backoff !== 'object') {
funcs.backoff = {};
}
//XXX The backoff timer needs to be obeyed across different instances of the same script (i.e. across tabs).
//XXX Backoff timer is not fully tested.
funcs.backoff.done = () => {
if (backoffTimer.isPrimary) {
funcs.backoff.clearAndInConfig();
} else {
funcs.backoff.clear();
}
//Update on the chat page.
funcs.executeIfIsFunction(funcs.mp.processAllIfTimeElapsedAndScheduled);
//XXX Need to do something for the search page.
};
funcs.backoff.clearAndInConfig = () => {
//Clear the currently active backoff timer, and store in the config that it is cleared.
// This is only done by the instance which considers itself to be primary.
funcs.backoff.clear();
//Record that the timer has been cleared.
config.backoff.active = false;
config.backoff.timeActivated = 0;
config.backoff.milliseconds = 0;
funcs.config.saveBackoff();
};
funcs.backoff.clear = () => {
//Clear the currently active backoff timer
clearTimeout(backoffTimer.timer);
backoffTimer.timer = 0;
backoffTimer.isPrimary = false;
backoffTimer.timeActivated = 0;
backoffTimer.milliseconds = 0;
};
funcs.backoff.setAndStoreInConfig = (seconds) => {
//Set the backoff timer, and store in the config that it is set.
// This is only done by the instance which considers itself to be primary. Doing so is effectively defined as being primary.
funcs.backoff.set(seconds);
//Record that the timer has been set.
backoffTimer.isPrimary = true;
config.backoff.active = true;
config.backoff.timeActivated = backoffTimer.timeActivated;
config.backoff.milliseconds = backoffTimer.milliseconds;
funcs.config.saveBackoff();
};
funcs.backoff.set = (seconds) => {
//Set the backoff timer.
//Clear it first so multiple timers are not running.
funcs.backoff.clear();
backoffTimer.timer = setTimeout(funcs.backoff.done, seconds * 1000);
backoffTimer.isPrimary = false;
backoffTimer.timeActivated = Date.now();
backoffTimer.milliseconds = seconds * 1000;
};
//Functions for remembering the configuration.
if (typeof funcs.config !== 'object') {
funcs.config = {};
}
funcs.config.localStorageChangeListener = (event) => {
//Listen to changes to localStorage. Only call handlers for those storage locations which are being listened to.
const handlers = {
[LSPREFIX + 'nonUiConfig']: funcs.config.handleNonUiConfigChange,
[LSPREFIX + 'backoff']: funcs.config.handleBackoffTimerChange,
};
if (handlers.hasOwnProperty(event.key)) {
const handler = handlers[event.key];
const key = event.key.replace(LSPREFIX, '');
//Mimic how the handler would be called by GM_addValueChangeListener().
//localStorage only notifies for remote events, never for changes in the current tab.
handler(key, event.oldValue, event.newValue, true);
}
};
funcs.config.listenForConfigChangesIfPossible = () => {
//If the platform permits listening for config changes, then do so.
//Determining if it was possible to listen for changes was only needed when using GM storage,
// as listening for changes wasn't available in Firefox/GM3 (GM4?). This was switched to using
// localStorage. All browsers can listen for localStorage changes.
window.addEventListener('storage', funcs.config.localStorageChangeListener);
};
funcs.config.handleBackoffTimerChange = (name, oldValueJSON, newValueJSON, remote) => {
//Receive an event that the backoff timer changed.
if (remote && name === 'backoff') {
funcs.config.restoreBackoffAndCheckIfNeedBackoff();
}
};
funcs.config.handleNonUiConfigChange = (name, oldValue, newValue, remote) => {
//Receive notification that there was a change in the configuration in another tab.
if (remote && name === 'nonUiConfig') {
//Reading it is redundant vs. the newValue, but there is already a function to do everything needed.
funcs.config.restoreNonUi(config.nonUi);
funcs.addRequestStylesToDOM();
//Only need to show/hide messages here, and only on search page.
funcs.executeIfIsFunction(funcs.ui.showHideMessagesPerUI);
//Update the options dialog
funcs.executeIfIsFunction(funcs.ui.setGeneralOptionsDialogCheckboxesToConfig);
}
};
funcs.config.getStoredNonUiConfigUpdateUiOrOptionsIfNeeded = () => {
//Handle Visited Questions (should probably be storing visited questions in their own storage location).
//XXX This needs to handle a change to the watched/not watched selection.
//The nonUi config is _always_ saved if it is changed in the script, and never changed except
// due to user interaction. Thus, we can accept that the stored version is primary.
funcs.config.getStoredVisitedPostsIntoConfigAndUpdateShownMessagesifNeeded();
//Deal with the properties other than visited questions.
var oldNonUiConfig = config.nonUi;
config.nonUi = {};
funcs.config.setNonUiDefaults();
funcs.config.restoreNonUi();
//delete the visited Posts, as that is not being compared.
delete oldNonUiConfig.visitedPosts;
if (Object.keys(oldNonUiConfig).some((key) => oldNonUiConfig[key] !== config.nonUi[key])) {
//At least one of the config values does not match what is in the current config.
// Update the options UI with the stored values.
funcs.executeIfIsFunction(funcs.ui.setGeneralOptionsDialogCheckboxesToConfig);
funcs.executeIfIsFunction(funcs.ui.setVisitedButtonEnabledDisabledByConfig);
funcs.config.clearVisitedPostsInConfigIfSetNoTracking();
}
};
funcs.config.getStoredVisitedPostsIntoConfigAndUpdateShownMessagesifNeeded = () => {
//Get the most recent version of the stored
funcs.config.pruneVisitedPosts();
const origVisitedPosts = config.nonUi.visitedPosts;
funcs.config.getStoredVisitedPostsIntoConfig();
if (origVisitedPosts.length !== config.nonUi.visitedPosts.length) {
//While this is not an entry by entry comparison, comparing just the
// length of both lists, which were both just pruned, should result in detecting
// any changes which were made in another tab (with possible millisecond differences in what was pruned).
//Update the currently displayed questions.
funcs.executeIfIsFunction(funcs.ui.showHideMessagesPerUI);
}
};
funcs.config.getStoredVisitedPostsIntoConfig = () => {
//This relies on the stored visited questions list to have always been updated when
// that list is updated locally, which is how it is done.
config.nonUi.visitedPosts = funcs.config.getStoredVisitedPosts();
};
funcs.config.getStoredVisitedPosts = () => {
//Read in the stored version of the visited questions list without disturbing the other data
// stored in that location.
var tmpConfig = {};
funcs.config.setNonUiDefaults(tmpConfig);
funcs.config.restoreNonUi(tmpConfig);
return tmpConfig.visitedPosts;
};
funcs.config.addPostIdsToVisitedAndRetainMostRecentList = (idIds) => {
//Add a post IDs to the most recently saved version of the list of visitedPosts list.
// The overall config.nonUi may have been updated in another tab. (possibly updated in another tab)
// This just syncs the visited questions, it does not change the other values in storage to match
// any changes in the local config.nonUi.
var tmpConfig = {};
funcs.config.setNonUiDefaults(tmpConfig);
funcs.config.restoreNonUi(tmpConfig);
if (!Array.isArray(idIds)) {
idIds = [idIds];
}
//Add all ids to the visited list.
const now = Date.now();
idIds.forEach((id) => {
tmpConfig.visitedPosts[id] = now;
});
funcs.config.saveNonUi(tmpConfig);
//Keep the most current version without updating GUI info
config.nonUi.visitedPosts = tmpConfig.visitedPosts;
};
funcs.config.clear = () => {
//Clear all configuration information.
funcs.config.clearUi();
funcs.config.clearNonUi();
funcs.config.clearBackoff();
};
funcs.config.clearItem = (itemName) => {
//Clear a single item from storage
localStorage.removeItem(LSPREFIX + itemName);
};
funcs.config.clearUi = () => {
//Delete all UI configuration information for the UI.
['close', 'delete'].forEach((whichType) => {
for (let group = 1; group <= NUMBER_UI_GROUPS; group++) {
funcs.config.clearItem(funcs.config.getUILocationId(group, whichType));
}
funcs.config.setWhichUIGroupIsMostRecentlySelected(1, whichType);
});
};
funcs.config.clearNonUi = () => {
//Delete all configuration information that is not the UI (i.e. visited questions).
funcs.config.clearItem('nonUiConfig');
};
funcs.config.clearBackoff = () => {
//Delete all configuration information that is not the UI (i.e. visited questions).
funcs.config.clearItem('backoff');
};
funcs.config.saveNonUi = (obj) => {
//Store the non-UI configuration. This is a bit of a misnomer, as the list
// of visited questions is stored here.
//XXX The list of visited questions should change to being per-site, just for possible
// use in the future.
obj = funcs.ifNotNonNullObjectUseDefault(obj, config.nonUi);
//Prune any questions that are too old.
funcs.config.pruneVisitedPosts(obj.visitedPosts);
funcs.config.setValue('nonUiConfig', obj);
};
funcs.config.saveUi = (obj) => {
//Store the configuration of the UI.
obj = funcs.ifNotNonNullObjectUseDefault(obj, config.ui);
funcs.config.setValue(uiConfigStorage, obj);
};
funcs.config.saveBackoff = (obj) => {
//Store the configuration of the backoff timer.
obj = funcs.ifNotNonNullObjectUseDefault(obj, config.backoff);
funcs.config.setValue('backoff', obj);
};
funcs.config.saveUiAndGetSavedNonUi = (obj) => {
//Save the UI config, while also restoring the non-UI config (visited questions). This is,
// effectively, polling for changes that might have happened in other tabs.
obj = funcs.ifNotNonNullObjectUseDefault(obj, config);
funcs.config.saveUi(obj.ui);
funcs.config.restoreNonUi(obj.nonUi);
};
funcs.config.setValue = (name, value) => {
//Save a value to a named location
try {
localStorage[LSPREFIX + name] = JSON.stringify(value);
} catch (e) {
console.error(e);
}
};
funcs.config.clearVisitedPostsInConfigIfSetNoTracking = () => {
//If the option in the config is set to indicate that the visited questions should not be
// tracked, prune the questions (which will remove all from the list), and update the
// displayed questions..
if (!config.nonUi.trackVisitedLinks) {
//If set to not remember visited links, prune all the questions.
funcs.config.pruneVisitedPosts();
//Need to show the questions, if any were hidden.
funcs.ui.showHideMessagesPerUI();
}
};
funcs.config.pruneVisitedPosts = (list) => {
//Remove questions from the list of those "visited" if they were visited more than
// 7 days ago. This is intended to keep that list from infinitely growing.
// This length of time was chosen because cv-pls requests are kept for a maximum of
// 3 days. 7 is 2* + 1
if (typeof list === 'undefined') {
list = config.nonUi.visitedPosts;
}
const cutoffTime = Date.now() - (MAX_DAYS_TO_REMEMBER_VISITED_LINKS * 24 * 60 * 60 * 1000);
var isRemoved = false;
for (const id in list) {
//Remove the questionId from the list if the user has selected not to remember visited
// questions, or if the last time it was visited was too long ago.
if (list.hasOwnProperty(id)) {
if (!config.nonUi.trackVisitedLinks || list[id] < cutoffTime) {
delete list[id];
isRemoved = true;
}
}
}
return isRemoved;
};
funcs.config.restore = (obj) => {
//Restore the complete config from saved values
obj = funcs.ifNotNonNullObjectUseDefault(obj, config);
funcs.config.restoreUi(obj.ui);
funcs.config.restoreNonUi(obj.nonUi);
funcs.config.restoreBackoffAndCheckIfNeedBackoff(obj.backoff);
};
funcs.config.sanityCheckBackoffTimerConfig = (obj) => {
//Check if the information in the backoff timer config is reasonably sane. If not,
// then reset it. It is checked to see if more than the defined-for-this-script
// maximum seconds have passed since when the backoff timer was set. Also checked is
// that the backoff timer was not activated more than 1 second into the future.
obj = funcs.ifNotNonNullObjectUseDefault(obj, config.backoff);
const now = Date.now();
const remainingTime = (obj.timeActivated > (now + 1000)) ? -1 : (obj.timeActivated + obj.milliseconds) - now;
if (obj.active && (remainingTime > MAX_BACKOFF_TIMER_SECONDS * 1000 || remainingTime < 0)) {
//The backoff timer appears to be invalid
funcs.config.setBackoffDefaults(obj);
funcs.config.saveBackoff(obj);
}
};
funcs.config.restoreBackoffAndCheckIfNeedBackoff = (obj) => {
//Read the backoff information from the stored config and determine if the backoff timer needs to
// be started.
obj = funcs.ifNotNonNullObjectUseDefault(obj, config.backoff);
funcs.config.restoreBackoff(obj);
const now = Date.now();
const remainingTimeConfig = (obj.timeActivated + obj.milliseconds) - now;
const remainingTimeTimer = backoffTimer.timeActivated + backoffTimer.milliseconds - now;
if (backoffTimer.timer !== 0 && backoffTimer.isPrimary && remainingTimeTimer > remainingTimeConfig) {
//A backoff timer is already currently active in this script.
//This instance received a backoff timer response and has set the backoff timer
//The timer for this instance will expire after the one in the config.
//Overwrite the config information:
obj.timeActivated = backoffTimer.timeActivated;
obj.milliseconds = backoffTimer.milliseconds;
funcs.config.saveBackoff();
} else if (obj.active && (backoffTimer.timer === 0 || remainingTimeTimer < remainingTimeConfig)) {
//The backoff timer should be active, if it is not, start it
//There is no current timer for this instance, or
//the timer for this instance will expire before the one in the config.
funcs.backoff.clear();
funcs.backoff.set(remainingTimeConfig / 1000);
//Make the record of active timer match the config.
backoffTimer.timeActivated = obj.timeActivated;
backoffTimer.milliseconds = obj.milliseconds;
} else {
//The timer for this instance was set for the same time as he one in the config.
//Or this is not the primary and it is set to expire after the one in the config (should not happen).
//Or the config indicates the backoff timer is inactive.
//Do nothing.
}
};
funcs.config.restoreBackoff = (obj) => {
//Read the backoff config from storage, and sanity check it.
obj = funcs.ifNotNonNullObjectUseDefault(obj, config.backoff);
funcs.config.getValue('backoff', obj);
funcs.config.sanityCheckBackoffTimerConfig(obj);
};
funcs.config.getWhichUITypeLocationId = (whichType) => {
//Return the text used to identify the UI type storage location.
if (typeof whichType === 'undefined') {
whichType = 'close';
if (isSearchDel) {
whichType = 'delete';
} else if (isSearchReopen) {
whichType = 'reopen';
}
}
let typeText = UI_CONFIG_CV_PAGES;
if (/delete/i.test(whichType)) {
typeText = UI_CONFIG_DEL_PAGES;
} else if (/reopen/i.test(whichType)) {
typeText = UI_CONFIG_REOPEN_PAGES;
}
return typeText;
};
funcs.config.getWhichUIGroupIsMostRecentLocationId = (whichType) => { // eslint-disable-line arrow-body-style
//Get the text to identify the storage location holding the number of the most recently selected group.
return funcs.config.getWhichUITypeLocationId(whichType) + '-recentGroup';
};
funcs.config.setWhichUIGroupIsMostRecentlySelected = (group, whichType) => {
//Set the stored value of the most recently selected group.
funcs.config.setValue(funcs.config.getWhichUIGroupIsMostRecentLocationId(whichType), {group: group});
};
funcs.config.getWhichUIGroupIsMostRecentlySelected = (whichType) => {
//Return the number of the group which was most recently selected for this type.
const recentId = funcs.config.getWhichUIGroupIsMostRecentLocationId(whichType);
const groupObj = {};
funcs.config.getValue(recentId, groupObj);
return groupObj.group ? groupObj.group : 1;
};
funcs.config.setGlobalUILocationIdToMostRecent = (whichType) => {
//Set the value holding the ID used for the current UI config to the most recent for this type.
// By default the type is chosen by the search in the page, or defaults to "close".
funcs.config.setGlobalUILocationId(funcs.config.getWhichUIGroupIsMostRecentlySelected(whichType), whichType);
};
funcs.config.getUILocationId = (group, whichType) => { // eslint-disable-line arrow-body-style
//Get the full UI location ID for this group and type. Default whichType is 'close'.
return funcs.config.getWhichUITypeLocationId(whichType) + '-group-' + group;
};
funcs.config.setGlobalUILocationId = (group, whichType) => {
//Set the location used to store/retrieve the UI config to the location for the type and group specified.
uiConfigStorage = funcs.config.getUILocationId(group, whichType);
};
funcs.config.restoreUi = (obj) => {
//Restore the UI config from saved values
obj = funcs.ifNotNonNullObjectUseDefault(obj, config.ui);
funcs.config.getValue(uiConfigStorage, obj);
//The excluded tag matches are dependent on the UI config value.
//This is only available on the search page.
funcs.executeIfIsFunction(funcs.ui.invalidateAllDatasetExcludedTags);
};
funcs.config.restoreNonUi = (obj) => {
//Restore the non-UI config from saved values
obj = funcs.ifNotNonNullObjectUseDefault(obj, config.nonUi);
funcs.config.getValue('nonUiConfig', obj);
if (funcs.config.pruneVisitedPosts(obj.visitedPosts)) {
//Some questions were pruned. Save the config back so they are removed in storage
//This needs to not be done here. If it is, it's possible to have multiple iterations of
// restore-save-restore across multiple tabs. Such loops should not go long, but it is better
// to avoid the possibility.
}
//Invalidate the stored matches to the visited questions, but only on the search page
//XXX This is overly aggressive on invalidating the visited question dataset.
funcs.executeIfIsFunction(funcs.ui.invalidateAllDatasetVisited);
};
funcs.config.getValue = (storageName, obj) => {
//Restore a storage configuration from a named storage location.
if (obj === null || typeof obj !== 'object') {
throw new Error('Trying to get config into a invalid object.');
}
var storedConfig = {};
try {
let inStorage = localStorage[LSPREFIX + storageName];
inStorage = typeof inStorage === 'undefined' ? JSON.stringify({}) : inStorage;
storedConfig = JSON.parse(inStorage);
Object.keys(storedConfig).forEach((key) => {
//Restore the key if the obj[key] is currently undefined, or is the same type as the current obj.
// This prevents restoring stored information when the type of information has changed (e.g. in development).
const curValue = obj[key];
const storedValue = storedConfig[key];
const storedValueNumber = +storedConfig[key];
if (typeof curValue === 'undefined' || typeof curValue === typeof storedValue) {
obj[key] = storedValue;
} else if (typeof curValue === 'number' && typeof storedValue === 'string' && ((storedValueNumber + '') === storedValue)) {
obj[key] = storedValueNumber;
} else {
console.log('Not restoring config key:', key, ' current=', obj[key], ' stored:', storedConfig[key]);
}
});
} catch (e) {
console.log('Issue restoring config. Storage is invalid and parsing it as JSON likely failed. storageName:', storageName, ':: obj:', obj);
console.error(e);
}
};
funcs.config.setDefaults = (obj, showingButtons, sortingButtons) => {
//Populate the obj Object with all default configuration information.
//Create the config object if it does not exist.
obj = funcs.ifNotNonNullObjectUseDefault(obj, config);
obj.ui = funcs.ifNotNonNullObjectUseDefault(obj.ui, {});
obj.nonUi = funcs.ifNotNonNullObjectUseDefault(obj.nonUi, {});
obj.backoff = funcs.ifNotNonNullObjectUseDefault(obj.backoff, {});
funcs.config.setNonUiDefaults(obj.nonUi);
funcs.config.setBackoffDefaults(obj.backoff);
funcs.config.setUiDefaults(obj.ui, showingButtons, sortingButtons);
};
funcs.config.setBackoffDefaults = (obj) => {
//Set the default values for the backoff config Object.
obj = funcs.ifNotNonNullObjectUseDefault(obj, config.backoff);
obj.active = false;
obj.timeActivated = 0;
obj.milliseconds = 0;
};
funcs.config.setNonUiDefaults = (obj) => {
//Add the default non-UI configuration information to the Object
obj = funcs.ifNotNonNullObjectUseDefault(obj, config.nonUi);
obj.visitedPosts = {};
obj.addMisingTagTags = true;
obj.add20kTag = true;
obj.add10kTagToo = false;
obj.clickTagTagToOpenCVQ = true;
obj.clickTagTagToOpenCVQButtonInfo = {
ctrlKey: false,
shiftKey: false,
altKey: true,
metaKey: false,
button: 2,
};
obj.chatShowPostStatus = true;
obj.chatShowModeratorDiamond = true;
obj.visitedLinkStyleActive = true;
obj.visitedLinksShowUsers = false;
obj.visitedLinksShowInSidebar = true;
obj.visitedLinksShowInSidebarUser = true;
obj.chatShowUpdateButton = true;
obj.chatCompleteRequestsFade = true;
obj.chatCompleteRequestsHide = false;
obj.chatCompleteRequestsDoNothing = false; //This is really just a placeholder. It's value isn't actually used.
obj.completedShowOnChat = true;
obj.completedShowOnSearch = true;
obj.completedShowOnTranscript = true;
obj.completedShowOnUser = true;
obj.chatSearchButtonsShowCV = true;
obj.chatSearchButtonsShowDel = true;
obj.chatSearchButtonsShowReopen = true;
obj.chatSearchButtonsShowUndel = true;
obj.transcriptMessagesNotInRoomHide = false;
obj.transcriptMessagesNotInRoomMark = true;
obj.transcriptMessagesNotInRoomDoNothing = false; //This is really just a placeholder. It's value isn't actually used.
obj.useQuestionTitleAsLink = true;
obj.trackVisitedLinks = true;
obj.chatAutoUpdateRate = DEFAULT_AUTO_UPDATE_RATE; //In minutes, 0=disabled
obj.chatMinimumUpdateDelay = DEFAULT_MINIMUM_UPDATE_DELAY; //seconds minimum between updates.
obj.allowMultipleSortCriteria = false;
obj.searchShowDeletedAndClosed = false;
};
funcs.config.setUiDefaults = (obj, showingButtons, sortingButtons) => {
//Set config defaults for the UI while not populating anything that is dependent on
// being on the search page.
obj = funcs.ifNotNonNullObjectUseDefault(obj, config.ui);
obj.sortingButtonsSortOrder = [];
obj.excludeTagsList = {};
//Populate defaults for the showing buttons
if (typeof showingButtons !== 'undefined') {
showingButtons.order.forEach((prop) => {
obj[prop] = showingButtons.buttons[prop].default;
});
}
//Populate defaults for the sorting buttons
if (typeof sortingButtons !== 'undefined') {
sortingButtons.order.forEach((prop) => {
obj[prop] = sortingButtons.buttons[prop].default;
if (obj[prop]) {
obj.sortingButtonsSortOrder.push(prop);
}
});
}
};
//Use a different UI config on the cv-pls search and the del-pls search
funcs.config.setGlobalUILocationIdToMostRecent();
//UI Buttons (part of UI)
if (typeof funcs.ui !== 'object') {
funcs.ui = {};
}
funcs.ui.UiButton = function(_text, _id, _default, _tooltip) {
//Basic UI button Object.
this.text = _text;
this.id = _id;
this.default = _default;
this.tooltip = _tooltip;
};
funcs.ui.ShowingButton = function(_text, _id, _default, _excluding, _textRegex, _tooltip) {
//Extend ui.UiButton for buttons used to show/hide.
funcs.ui.UiButton.call(this, _text, _id, _default, _tooltip);
this.excluding = _excluding;
this.textRegex = _textRegex;
};
funcs.ui.SortingButton = function(_text, _id, _default, _sortType, _datasetProp, _stateOrderReversed, _tooltip) {
//Extend ui.UiButton for buttons used to sort.
funcs.ui.UiButton.call(this, _text, _id, _default, _tooltip);
this.sortType = _sortType;
this.datasetProp = _datasetProp;
this.stateOrderReversed = typeof _stateOrderReversed === 'boolean' ? _stateOrderReversed : false;
};
funcs.ui.createShowingButtonsType = (type) => {
//Create an Object representing the showing buttons for a specified type.
let show20k = false;
if (type === 'delete') {
show20k = true;
} else if (type === 'reopen') {
show20k = false;
}
const buttons = {
buttons: {
/* beautify preserve:start *//* eslint-disable no-multi-spaces */
// button text , ID default, excluding, match RegEx (match question text), tooltip text
myRequests: new funcs.ui.ShowingButton('my requests', 'showMyRequests', false, true, null, 'When selected, your requests are shown if they match one of the selected including criteria and are not excluded by "visited" or "tags".'),
duplicates: new funcs.ui.ShowingButton('duplicate', 'showDuplicates', true, false, /\bdup(?:e?s?|licates?|repost)\b/ig, 'Duplicate questions.'),
tooBroad: new funcs.ui.ShowingButton('too broad', 'showTooBroad', true, false, /\b(?:too[ -]broad|tb|broad)\b/ig, 'Questions which are too broad.'),
generalCServer: new funcs.ui.ShowingButton('gen comp/serv', 'showGenCServ', true, false, /\b(?:server(?:fault)?(?!\s*error)|gen(?:eral)?|comp(?:ut(?:e|ing))|super(?:user)?)\b/ig, 'General computing / Server Fault'),
noMCVE: new funcs.ui.ShowingButton('mcve', 'showMCVE', true, false, /\b[mcve]{3,4}\b/ig, 'Debugging questions that do not have a MCVE, or other required information'),
offSite: new funcs.ui.ShowingButton('off-site req', 'showOffSite', true, false, /\b(?:off[ -]?site|library|tool|ress?ources?|recc?omm?end(?:ations?|ed||ing)|external)\b/ig, 'Requests for off-site resources'),
unclear: new funcs.ui.ShowingButton('unclear', 'showUnclear', true, false, /\b(?:unclear|uc)\b/ig, 'Unclear questions'),
typo: new funcs.ui.ShowingButton('typo', 'showTypo', true, false, /\b(?:[typo]{4}\b|(?:un)?repro(?:duced|duce|duc[ai]ble)?|typographical)/ig, 'Typo or can not reproduce'),
opinion: new funcs.ui.ShowingButton('opinion', 'showOpinion', true, false, /\b(?:pob?|opinion)\b/ig, 'Primarily opinion based'),
otherIncluding: new funcs.ui.ShowingButton('other', 'showOther', true, false, null, /*Matches all messages not matched by other includes*/ 'Questions that don\'t match any of the other criteria'),
user20k: new funcs.ui.ShowingButton('20k+', 'show20k', true, true, null, /*Only used on Delete Search pages*/ 'Show messages for delete requests which can only be acted upon by users with more than 19,999 reputation.'),
visited: new funcs.ui.ShowingButton('visited', 'showVisited', false, true, null, 'If not selected, questions you have "visited" will be excluded from those shown. "Visited" means a questions for which you clicked (any button) on a link to that question on this page or the SO Close Vote Reviewers chat room page. This can be inaccurate, because normal JavaScript does not have access to if you have _actually_ visited a page. Visits (clicks) are remembered for only 7 days. If you want a question to be considered "visited" without actually visiting the page, you can right-click on the link to open the context menu (there is no way to detect that you didn\'t use the context menu to open the link in a new tab or window).'),
excludedTags: new funcs.ui.ShowingButton('tags', 'showExclTags', true, true, null, 'If not selected, questions that match the tags you have selected in the Options dialog will be excluded from those shown. The Options dialog can be opened by clicking the "options (edit \'tags\' list)" button, which is above this one and a bit to the left.'),
/* beautify preserve:end */ /* eslint-enable no-multi-spaces */
},
order: [
'duplicates',
'tooBroad',
'generalCServer',
'opinion',
'myRequests',
show20k ? 'user20k' : null, // Only used on delete searches
'noMCVE',
'offSite',
'unclear',
'typo',
'otherIncluding',
'excludedTags',
'visited',
],
numberFirstRow: 5 + (show20k ? 1 : 0),
};
//Filter out from the order any buttons not used on this page.
buttons.order = buttons.order.filter((prop) => prop);
//Predetermine some groupings that will be desired/used elsewhere.
buttons.orderIncluding = buttons.order.filter((prop) => !buttons.buttons[prop].excluding);
buttons.orderExcluding = buttons.order.filter((prop) => buttons.buttons[prop].excluding);
return buttons;
};
let useButtonType = 'close';
if (isSearchDel) {
useButtonType = 'delete';
} else if (isSearchReopen) {
useButtonType = 'reopen';
}
const showingButtonTypes = {
close: funcs.ui.createShowingButtonsType('close'),
delete: funcs.ui.createShowingButtonsType('delete'),
reopen: funcs.ui.createShowingButtonsType('reopen'),
};
const showingButtons = showingButtonTypes[useButtonType];
funcs.ui.createSortingButtonsType = (type) => {
//Create the Object representing the sorting buttons for a specified type of search.
const buttons = {
buttons: {
/* beautify preserve:start *//* eslint-disable no-multi-spaces */
// button text , ID default, sort , dataset property , reverse state, tooltip text
closeVotes: new funcs.ui.SortingButton('cv', 'sortCloseVotes', 0, 'number', 'closeVoteCount', false, 'Sort by close vote count.'),
deleteVotes: new funcs.ui.SortingButton('dv', 'sortDeleteVotes', 0, 'number', 'deleteVoteCount', false, 'Sort by delete vote count. Unfortunately, the SE API doesn\'t provide delete vote counts for answers. Thus, answers will be sorted into their own group. In addition, when a question is not closed, the number of delete votes is, of course, 0. However, it\'s convenient to have such invalid delete vote requests sorted into their own group.'),
reopenVotes: new funcs.ui.SortingButton('rv', 'sortReopenVotes', 0, 'number', 'reopenVoteCount', false, 'Sort by reopen vote count.'),
views: new funcs.ui.SortingButton('views', 'sortViews', 0, 'number', 'viewsCount', false, 'Sort by number of question views.'),
reason: new funcs.ui.SortingButton('reason', 'sortCloseReason', 0, 'number', 'reasonValue', true, 'Sort by the show/hide request reasons.'), //Things the user considers sorted by strings are usually reverse state order.
//The dataset property, 'timestamp', for date is repeated as a literal in findMessage() defined in listenToChat(), which is in funcs.inPageCHATListener(), because this Object isn't available in the page context.
date: new funcs.ui.SortingButton('date', 'sortAge', 0, 'number', 'timestamp', false, 'Sort by the date of the request.'),
user: new funcs.ui.SortingButton('user', 'sortUser', 0, 'string', 'requestUser', true, 'Sort by the user that made the request.'), //Things the user considers sorted by strings are usually reverse state order.
sortTag: new funcs.ui.SortingButton('tag', 'sortTag', 0, 'string', 'primaryTag', true, 'Sort by the question\'s primary tag.'), //Things the user considers sorted by strings are usually reverse state order.
/* beautify preserve:end */ /* eslint-enable no-multi-spaces */
},
order: [
'reason',
'date',
type + 'Votes',
'views',
'user',
'sortTag',
],
sortingStates: ['', '↓', '↑'],
numberFirstRow: 3,
};
//Create a convenient way to get the button property from the button element's ID.
buttons.propsById = {};
buttons.order.forEach((prop) => {
buttons.propsById[buttons.buttons[prop].id] = prop;
});
return buttons;
};
const sortingButtonTypes = {
close: funcs.ui.createSortingButtonsType('close'),
delete: funcs.ui.createSortingButtonsType(<