Skip to content
Permalink
Branch: master
Find file Copy path
Find file Copy path
Fetching contributors…
Cannot retrieve contributors at this time
6405 lines (6062 sloc) 349 KB
<
// ==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('delete'),
reopen: funcs.ui.createSortingButtonsType('reopen'),
};
const sortingButtons = sortingButtonTypes[useButtonType];
//Original functions
funcs.appendInfo = (request, useShortText) => {
//Add the request-info to the matching message, including the data provided by the SE API, if available.
const info = request.info;
const isAnswer = request.type === 'answer' || (info && info.answer_id);
const isDeleted = typeof info === 'undefined';
//Get the postId
let postId;
if (!isDeleted) {
postId = isAnswer ? info.answer_id : info.question_id;
} else {
postId = +request.post;
}
if (!request.msg.parentNode) {
//The message containing the post for which data was obtained is no longer in the DOM.
// This can happen on the chat page depending on the timing of determining the messages with QAP, an edit?, or the message was scrolled off the transcript
// and getting the data back from the SE API.
//When this happens, try to find a message in the DOM with the same ID and that contains a QAP with with the same QAP ID.
const inDomMessage = document.getElementById(request.msg.id);
if (inDomMessage) {
const content = funcs.getContentFromMessage(inDomMessage);
if (funcs.getQuestionAnswerOrPostIdsOrInfoFromLinksInElement(content, 'any').some((inDomMessagePostId) => inDomMessagePostId == postId)) { // eslint-disable-line eqeqeq
//The in-DOM message contains the same post, so we can use the data which was obtained.
request.msg = inDomMessage;
} else {
//Discard the request, as the data can not be used due to there being no matching post in the message as it now exists.
return;
}
} else {
//Discard the request, as it can not be used due to there being no message with that ID in the DOM.
return;
}
}
let textLong = '';
let textShort = '';
const message = request.msg;
const monologue = funcs.getContainingMonologue(message);
const content = funcs.getContentFromMessage(message);
const isReopen = !!funcs.getFirstReopenRequestTagInElement(content);
let isEdited = false;
let shiftTextLeft = false;
if (!isDeleted) {
//Answer/Question is not deleted
//Determine if the post has been edited.
if (info.last_edit_date && monologue) {
//The monologue may be invalid here on the chat page, if the message/monologue was changed between when messages
// were processed and when the SE API returns data. That should no longer be the case, as we now discard requests
// which point to messages which have gotten disconnected from the DOM, and can not be recovered.
const dateSortDatasetProp = sortingButtons.buttons.date.datasetProp;
const timestamp = +monologue.dataset[dateSortDatasetProp];
const timestampEarliest = +monologue.dataset.timestampEarliest;
if ((timestamp && info.last_edit_date > timestamp / 1000) ||
(timestampEarliest && info.last_edit_date > timestampEarliest / 1000)
) {
isEdited = true;
}
}
if (info.closed_date) {
//Question is closed
//Short text
textShort = 'closed';
if (+info.reopen_vote_count) {
textShort = 'cld r:(' + info.reopen_vote_count + ')';
shiftTextLeft = true;
}
//Display of delete vote count has priority over reopen votes. Only one or the other is displayed when short text is used.
if (info.delete_vote_count && !isReopen) {
textShort = 'cld d:(' + info.delete_vote_count + ')';
shiftTextLeft = true;
}
//Long text
textLong = [
'closed: ',
info.score,
'd:(' + info.delete_vote_count + ')',
'r:(' + info.reopen_vote_count + ')',
].join(' ');
} else {
const closeVoteText = 'c:(' + info.close_vote_count + ')';
//Short text
textShort = isAnswer ? 'An:(' + info.score + ')' : closeVoteText;
//Long text
if (isAnswer) {
textLong = [
'Answer: ',
info.score,
'(+' + info.up_vote_count + '/-' + info.down_vote_count + ')',
].join(' ');
} else {
textLong = [
info.score,
'(+' + info.up_vote_count + '/-' + info.down_vote_count + ')',
closeVoteText,
'v:(' + info.view_count + ')',
].join(' ');
}
}
if (info.locked_date) {
textShort = 'locked';
textLong = 'Locked: ' + textLong;
}
} else {
//Deleted question, convert to number to match what is returned by API.
textShort = 'deleted';
textLong = 'deleted';
}
const existing = message.querySelector('.request-info');
if (existing) {
if ([].slice.call(existing.querySelectorAll('a')).some((requestInfoLink) => (isAnswer ? requestInfoLink.dataset.answerId == postId : requestInfoLink.dataset.questionId == postId))) { // eslint-disable-line eqeqeq
//There is already a request-info link for this post. Don't add a new one.
return;
}
}
let link = document.createElement('a');
if (isEdited) {
link.title = 'The ' + (isAnswer ? 'answer' : 'question') + ' was edited after the message was posted.';
}
if (request.wait) {
//We are just informing the user they need to wait.
link = document.createElement('span');
link.style.cursor = 'wait';
if (typeof request.mostRecentRequestInfoTime === 'number') {
const secondsRemaining = (Math.round(config.nonUi.chatMinimumUpdateDelay - ((Date.now() - request.mostRecentRequestInfoTime) / 1000)));
//As long as we have calculated the remaining time, we might as well check if something is wrong, and fix it.
if (secondsRemaining < 0) {
//Should never have a negative time here. If there is, then we should do what is possible to recover.
// This check is in response to an observed intermittent issue, which is hopefully solved another way,
// but which needs to be root-caused..
funcs.executeIfIsFunction(funcs.mp.sanityCheckTimers);
if (typeof funcs.mp.processAllUnlessHidden === 'function') {
//Call the next step in the process after finished here. This call is farther along in the call-chain that processes
// messages on the chat page, thus it should not have the possibility of introducing an async call-loop.
setTimeout(funcs.mp.processAllUnlessHidden, 0);
}
}
link.title = [
'Question status will be provided in about ' + secondsRemaining + ' seconds from when this note was added,',
'which is when the minimum time between updates will have elapsed (currently ' + config.nonUi.chatMinimumUpdateDelay + ' seconds).',
].join(' ');
} else {
link.title = 'Question status will be provided as soon as the minimum time between updates has elapsed (currently a maximum delay of ' + config.nonUi.chatMinimumUpdateDelay + ' seconds).';
}
link.title += [
' You can adjust the minimum delay between question status updates in the options dialog available from the "requests" (search results) page.',
'To immediately update the question status, you can click the "update" button at the bottom of the page, or switch to a different tab and back to this one.',
].join(' ');
textShort = textLong = 'wait';
}
//The default question link to use
link.href = window.location.protocol + '//stackoverflow.com/' + (isAnswer ? 'a' : 'q') + '/' + postId;
//Find the URL used in the link in the message and use that URL instead. This allows the user to perceive
// only a single link for the question instead of seeing that they followed either the link in the message,
// or the request-info, but not the other link.
const messagePostLink = funcs.getFirstLinkToPostId(content, postId);
if (messagePostLink) {
//The link is valid and goes directly to the question, not an answer. It may have a tracking link in it.
//Use it for the question link so that both turn a :visited color when either is visited.
link.href = messagePostLink.href;
}
//Remember the answer/question/post ID, so it does not need to be parsed out of the link, repeatedly.
link.dataset.postId = postId;
link.dataset.questionId = postId;
if (isAnswer) {
if (!isDeleted) {
link.dataset.questionId = info.question_id;
}
link.dataset.answerId = postId;
}
link.target = '_blank';
if (isEdited) {
textLong += ' [ed]';
}
link.appendChild(document.createTextNode(useShortText ? textShort : textLong));
if (useShortText) {
link.title = textLong + (isEdited ? '\r\n' + link.title : '');
}
if (isEdited && useShortText) {
link.insertAdjacentHTML('beforeend', '<sup>E</sup>');
}
if (shiftTextLeft) {
link.style.marginLeft = '-3px';
}
if (existing !== null) {
//Add additional data to the existing request-info for this message.
existing.appendChild(link);
existing.classList.remove('urrsRequestHasOne');
//Make sure the message has enough height to contain the request-info.
message.style.minHeight = (existing.clientHeight + 1) + 'px';
} else {
//Add the first request-info for this message.
const node = document.createElement('span');
//The messages class appears to be used by SO chat scripts for selection. Thus, the
// relevant styles have been duplicated.
node.className = 'request-info request-info-messages urrsRequestHasOne';
//Even though the relevant styles have been duplicated in CSS, that does not cover the possibility
// of alternate themes. So, copy the color and background color into styles for the request-info span.
//If either reply class is on the message, then the obtained color is the reply color, which is not desired.
const hasReplyParent = message.classList.contains('reply-parent');
const hasReplyChild = message.classList.contains('reply-child');
if (hasReplyParent || hasReplyChild) {
message.classList.remove('reply-parent');
message.classList.remove('reply-child');
}
const messagesNode = funcs.getContainingMonologue(content).querySelector('.messages');
node.style.backgroundColor = funcs.getBackgroundColor(messagesNode);
node.style.color = funcs.getTextColor(messagesNode);
if (hasReplyParent) {
message.classList.add('reply-parent');
}
if (hasReplyChild) {
message.classList.add('reply-child');
}
node.appendChild(link);
//Place the request-info prior to the .flash.
message.insertBefore(node, message.querySelector('.flash'));
}
//The link is now inserted in the request info.
//Add post data to the DOM to enable other functionality.
//Monologues are used for sorting. While it appears the code otherwise handles the possibility of multiple messages per monologue,
// sorting has to assume one message is sorted per monologue (on search results pages, SE delivers each message in a separate monologue).
// Messages are used for show/hide determination
//Add the post ID to the list of post IDs contained in the message (for hiding by visited).
funcs.addToDatasetList(message, 'postIdList', postId);
if (isDeleted) {
link.dataset.postStatus = 'deleted';
//Nothing else to do for deleted posts.
return;
} //else
//Store various explicit properties of the question for use elsewhere in this code.
if (isAnswer) {
link.dataset.postStatus = 'answer';
} else {
link.dataset.postStatus = info.closed_date ? 'closed' : 'open';
}
link.dataset.questionTags = JSON.stringify(info.tags);
funcs.setDatasetIfNotUndefined(link, 'questionTitle', info.title);
//These next two are copied into the message's dataset elsewhere, after all request-info's are added (in case there is more than one per message).
funcs.setDatasetIfNotUndefined(link, 'closeVotes', info.close_vote_count);
funcs.setDatasetIfNotUndefined(link, 'reopenVotes', info.reopen_vote_count);
funcs.setDatasetIfNotUndefined(link, 'deleteVotes', info.delete_vote_count);
funcs.setDatasetIfNotUndefined(link, 'views', info.view_count);
funcs.setDatasetIfNotUndefined(link, 'score', info.score);
funcs.setDatasetIfNotUndefined(link, 'lastEditDate', info.last_edit_date);
funcs.setDatasetIfNotUndefined(link, 'closedDate', info.closed_date);
link.dataset.isLocked = !!info.locked_date;
//Add the tags to the list contained in the message (used when hiding by tag). Used to add tag to messages w/o a question tag.
funcs.addToDatasetList(message, 'tagList', info.tags);
};
//Format the IDs in the array as they need to be when sending the SE API call.
funcs.formatPosts = (arr) => arr.map((item) => item.post).filter((postId) => {
const postIdNum = +postId;
//SE API returns an error if the postId doesn't fit into 63 bits (e.g. an int).
return (postIdNum && postIdNum <= 2147483647 && /^\d+$/.test(postId));
}).join(';');
funcs.chunkArray = (array, chunkSize) => {
//Chop a single array into an array of arrays. Each new array contains chunkSize number of
// elements, except the last one.
var chunkedArray = [];
var startIndex = 0;
while (array.length > startIndex) {
chunkedArray.push(array.slice(startIndex, startIndex + chunkSize));
startIndex += chunkSize;
}
return chunkedArray;
};
funcs.checkRequests = (status, requests, allMessages) => {
//This calls the SE API to get data for all requests (answers and questions) that have been identified.
// It is called in an async loop (via the XHR requests) until all chunks of the total desired
// requests have been processed.
if (status === null) {
status = {
open: [],
closed: [],
deleted: [],
};
}
//Get the next request set, either questions or answers.
let currentreq;
let isAnswers = false;
if (requests.questions && requests.questions.length > 0) {
currentreq = requests.questions.pop();
isAnswers = false;
} else if (requests.answers && requests.answers.length > 0) {
currentreq = requests.answers.pop();
isAnswers = true;
}
if (typeof currentreq === 'undefined') {
//Do processing, even if there is nothing to process (which will delete all the messages when there are no posts).
funcs.checkDone(status);
return;
}
//Set up the SE API request.
const xhr = new XMLHttpRequest();
//Handle the response from the API.
xhr.addEventListener('load', () => {
if (xhr.status !== 200) {
//If there is a non 200 status returned, assume no more requests should be processed. Log the response and
// process any data already obtained from prior SE API calls.
console.error('Error in response to SE API call: status,', xhr.status, ':: statusText,', xhr.statusText, ':: responseText:', xhr.responseText);
funcs.checkDone(status);
return;
}
//A non-error response.
const response = JSON.parse(xhr.responseText);
const items = response.items;
//Process each post for which data was received.
for (const item of items) {
let openOrClosed = status.open;
if (item.closed_date) {
//Cause the record to be placed in the closed list instead of the open list.
openOrClosed = status.closed;
}
//Find requests which match this question_id, or answer_id for answers
for (const j in currentreq) {
if (currentreq.hasOwnProperty(j)) {
if ((!isAnswers && currentreq[j].post == item.question_id) || (isAnswers && currentreq[j].post == item.answer_id)) { // eslint-disable-line eqeqeq
//Not a funcs.mp.Request, perhaps should have a new class (e.g. responseRecord).
openOrClosed.push({
msg: currentreq[j].msg,
type: (isAnswers ? 'answer' : 'question'),
post: currentreq[j].post,
info: item,
});
delete currentreq[j];
if (allMessages) {
//If we "continue" here, then all messages which are about this question_id will get request-info.
//This is done on the chat page, as we are not deleting all but the first request.
continue;
}
//However, if we only want to insert the request-info in the first one found, then we "break".
// All additional messages with this post ID will be marked as deleted. A drawback of doing this is
// the case where someone makes two duplicate requests with the same dup-target. The second request will
// not contain a request-info for the dup-target, but will contain one for the question about which the
// request is being made. This is no longer a problem, as we no longer delete based on unfulfilled requests,
// but only if there are no requests which were fulfilled for the message.
break;
}
}
}
}
//Add any remaining requests to the "deleted" list. This should be requests for questions and answers which
// did not produce any data from the API (deleted).
for (const request of currentreq) {
if (typeof request !== 'undefined') {
status.deleted.push(request);
}
}
//Start the main backoff timer
//XXX This function does not yet obey a backoff timer which existed prior to it sending out the first API call, when the backoff
// response was received in another tab. It does obey any backoff which it receives in response to its own requests.
if (response.backoff > 0 && typeof funcs.backoff.setAndStoreInConfig === 'function') {
//Start the backoff timer for whatever the API specified, but
// only if the function to do so exists. This implements complying with the backoff stated by the API
// both when it is on the last request of a set, and to inform other instances that a backoff is in effect.
// In the non-chat pages, this only sets the cross-instance backoff timer, as the search page does not.
// only requests once per user-reload of the page.
//NOTE: Backoff is treated as requiring backing off of all requests, but it is actually only required that the backoff
// be honored per "method".
//XXX It should be implemented across tabs. The code is written, but is untested. In fact, the user is not supposed to be able to override it.
funcs.backoff.setAndStoreInConfig(response.backoff);
}
//If there are no more requests to make, process all the data received from all API calls.
if (!((requests.questions && requests.questions.length > 0) || (requests.answers && requests.answers.length > 0))) {
//There are no more requests to process.
//We need to account for posts which were processed as both questions and answers. When it is unknown if
// the ID applies to a question or answer, information is requested as both. Thus, there may be requests marked
// as deleted, which have actually been fulfilled by the other type of request.
//Filter out any requests marked as deleted for which there is a valid response, or duplicate "deleted" response.
const allValid = status.open.concat(status.closed);
//Remove duplicate deleted requests (as a result of trying posts as both a question and answer)
status.deleted = status.deleted
.filter((deletedPostRequest) => !allValid.some((valid) => deletedPostRequest.post === valid.post))
.filter((deletedPostRequest, index, array) => array.indexOf(deletedPostRequest) === index);
funcs.checkDone(status);
return;
}
//Make the next request of the API, complying with any backoff time required.
setTimeout(funcs.checkRequests, response.backoff * 1000, status, requests, allMessages);
}, false);
xhr.addEventListener('error', (event) => {
//Some error occurred on the request. This should catch CORS error which appear to happen *very* intermittently on the chat page, for some
// yet to be determined reason. I've only seen this on the chat page. There was a grouping of "CORS header 'Access-Control-Allow-Origin' missing" errors.
// Prior to this event handler existing, that error resulted in recovery via sanityCheckTimers.
// The CORS errors appear to happen only on the period of weeks, or longer. The last issue happened 2017-04-12/13 (a Wed-Thurs). Epoch time: 1492087005623
// With a few happening in closely together, but interspersed with valid requests and responses.
console.error('Error event in sending to SE API: xhr.status:', xhr.status, '\n:: statusText:', xhr.statusText, '\n:: responseText:', xhr.responseText, '\n:: event:', event, '\n:: status:', status);
funcs.generateError('Got XHR error event');
//It is assumed that checkDone will handle the error case. On the chat page this is done by not updating when there is nothing in status.
funcs.checkDone(status);
});
//Construct and send the API request.
const url = window.location.protocol + '//api.stackexchange.com/2.2/' + (isAnswers ? 'answers' : 'questions') + '/' + funcs.formatPosts(currentreq) + '?' + [
'pagesize=100',
'site=stackoverflow',
'key=YvvkfBc3LOSK*mwaTPkUVQ((',
//The filter used here could be pruned back a bit. It was expanded for functionality that was
// moved out of this script, then partially pruned back, then expanded a bit, without double
// checking that all requested information is used.
// It doesn't hurt to get the extra data, but it is not needed.
//Add bounties & things for Roomba
'filter=!m)9LJxKwexI9h92EPpSH6vR(2S7pz3L9cXWiH9ar04WP8BSWy0Mtyl7P',
].join('&');
xhr.open('GET', url);
xhr.send();
};
//Message processing
if (typeof funcs.mp !== 'object') {
funcs.mp = {};
}
funcs.mp.Request = function(_message, _post, _type, _wait, _mostRecentRequestInfoTime) {
//class for a new processing request
this.msg = _message;
this.post = _post;
this.type = _type;
if (typeof _wait !== 'undefined') {
this.wait = _wait;
}
if (typeof _mostRecentRequestInfoTime !== 'undefined') {
this.mostRecentRequestInfoTime = _mostRecentRequestInfoTime;
}
};
funcs.mp.getRequestsInMessagesListText = (messages, regexes) => {
//Modified from original funcs.processMessages
//Looks though message HTML searching for text that might indicate a QAP.
//Parsing is rudimentary. It only understands the basic /question/id, /a/id, and /posts/id formats.
//It does not understand the /question/id/title/answerId/#answerId
//This is now used as a secondary pass for messages where the question/answer are not found in links.
//If called with a single RegExp
if (!Array.isArray(regexes)) {
regexes = [regexes];
}
const newRequests = {};
MESSAGE_PROCESSING_REQUEST_TYPES.forEach((type) => {
newRequests[type] = [];
});
for (const message of messages) {
//Get stripped, cloned content
const contentEl = funcs.getContentFromMessage(message);
if (!contentEl) {
//No content.
continue;
}
const contentNoTagsLinksOrCode = funcs.removeTagsLinksAndCodeFromElement(contentEl.cloneNode(true));
//If the message has no content, continue.
const content = contentNoTagsLinksOrCode.textContent.trim();
if (content === '') {
continue;
}
//Find things that look like they might be URLs to questions/post, but currently not answers
//Restrict matches to only Stack Overflow
getQuestionIdFromURLRegEx.lastIndex = 0;
const matches = funcs.getAllRegExListMatchesInText(content, getSOQuestionOrAnswerIdFfromURLRegExes);
//If there is not something that looks like a question/post URL, then go to next message.
if (matches === null) {
continue;
}
//For each URL (match) create a requests entry which associates the post with the message.
const posts = [];
//We can have duplicates here. This used to be common, due to possible HTML: <a href="questionURL">questionURL</a>,
// but, at this point in time, we eliminate HTML links prior to processing. However, duplicates are still possible.
const idTypesRegexes = {
posts: /\/(?:posts)\/(\d+)/,
answers: /\/(?:a)\/(\d+)/,
questions: /\/(?:q[^/]*)\/(\d+)/,
};
for (const key of Object.keys(matches)) {
const idType = {};
MESSAGE_PROCESSING_REQUEST_TYPES.forEach((type) => {
const idMatch = idTypesRegexes[type].exec(matches[key]);
if (idMatch) {
idType[type] = idMatch[1];
} else {
idType[type] = null;
}
});
const post = MESSAGE_PROCESSING_REQUEST_TYPES.reduce((sum, type) => (sum ? sum : idType[type]), null);
//Don't add duplicate posts for the same question.
if (posts.indexOf(post) === -1) {
posts.push(post);
MESSAGE_PROCESSING_REQUEST_TYPES.some((type) => {
if (idType[type]) {
const request = new funcs.mp.Request(message, post, type);
newRequests[type].push(request);
return true;
} // else
return false;
});
}
}
}
if (MESSAGE_PROCESSING_REQUEST_TYPES.some((type) => newRequests[type] && newRequests[type].length)) {
return newRequests;
}
//Explicitly indicate nothing was found.
return null;
};
funcs.mp.markAllRequestInfoOnNonRequests = (searchText) => {
//Visually differentiate requests from just info on post URLs contained in a message.
[].slice.call(document.querySelectorAll('.message > .request-info')).forEach((requestInfo) => {
//I disagree with searching the text for [cv-pls], etc. within the text of the message. It is not
// something that is searched for on the search pages. Thus, people should not be given the inaccurate
// impression that it will be treated as a request. Once it goes off the chat transcript, it will be
// forgotten.
//This could be changed by fetching/searching events, rather than relying on the site search. Current plan
// is to integrate the popup from the request archiver into this to be what views requests. However, that's
// a considerable change.
const contentEl = funcs.getContentFromMessage(funcs.getContainingMessage(requestInfo));
if (!(funcs.getFirstRequestTagInElement(contentEl) || (searchText && funcs.doesElementContainRequestTagAsText(contentEl)))) {
//The content does not contain a request tag.
requestInfo.classList.add('urrsRequestNoRequestTag');
}
});
};
funcs.mp.markAllMessagesByRequestState = () => {
//Have all messages and monologues which have requests which are completed include the class urrsRequestComplete.
const fakeRequestTags = {
close: funcs.makeTagTagElement('cv-pls'),
reopen: funcs.makeTagTagElement('reopen-pls'),
delete: funcs.makeTagTagElement('del-pls'),
undelete: funcs.makeTagTagElement('undel-pls'),
};
const handledRequestTypes = [
'close',
'delete',
'reopen',
'undelete',
'flag',
'offensive',
'spam',
'reject',
];
function resetHandledRequestTypeRegexes() {
handledRequestTypes.forEach((type) => {
tagsInTextContentRegExes[type].lastIndex = 0;
});
}
[].slice.call(document.querySelectorAll('.message > .request-info')).forEach((requestInfo) => {
//There is only ever one request-info per message
//XXX This is currently not going to handle duplicate requests where the duplicate-target is also included.
//XXX No attempt is made to detect request tags in text.
const message = funcs.getContainingMessage(requestInfo);
const monologue = funcs.getContainingMonologue(message);
const contentEl = funcs.getContentFromMessage(message);
const requestTags = funcs.getAllRequestTagsInElement(contentEl).filter((tag) => {
//This function currently only understands a limited subset of request tags.
const tagText = tag.textContent;
resetHandledRequestTypeRegexes();
return handledRequestTypes.some((type) => tagsInTextContentRegExes[type].test(tagText));
});
if (requestTags.length === 0) {
if (monologue.classList.contains('user-3735529')) { // chat.stackoverflow
//SmokeDetector: Treat as a del-pls request
const sdLink = contentEl.querySelector('a');
if (sdLink && sdLink.textContent.indexOf('SmokeDetector') > -1) {
//We only want actual SmokeDetector reports, which always start with a link to SmokeDetector.
// SD can have other messages which include links to deleted posts which are not reports.
requestTags.push(fakeRequestTags.delete);
}
}
if (/^\s*!!\/report\s/.test(contentEl.textContent)) {
//Someone reporting a post to SmokeDetector
requestTags.push(fakeRequestTags.delete);
}
if (monologue.classList.contains('user-6373379') || monologue.classList.contains('user-6294609')) { // chat.stackoverflow
//FireAlarm && Queen: Treat as a cv-pls request
requestTags.push(fakeRequestTags.close);
}
//The code for Natty is originally by Filnor (https://chat.stackoverflow.com/users/4733879/filnor)
// Found: https://github.com/SOBotics/Userscripts/blob/master/UnclosedRequestReview2.user.js#L1988
// Released under an MIT license:
// https://chat.stackoverflow.com/transcript/message/45507145#45507145
if (monologue.classList.contains('user-6817005')) {
//Natty: Treat as a del-pls request
const nattyLink = contentEl.querySelector('a');
if (nattyLink && nattyLink.textContent.indexOf('Natty') > -1) {
//We only want actual Natty reports, which always start with a link to Natty.
// Nat can have other messages which include links to deleted posts which are not reports.
requestTags.push(fakeRequestTags.delete);
}
}
if (/^@Natty (?:feedback|tp|fp|ne|report)\b/i.test(contentEl.textContent)) {
//Natty feedback: Treat as a del-pls request
requestTags.push(fakeRequestTags.delete);
}
}
//Consider it active if it's not a request, or if the request is active.
var requestIsActive = requestTags.length === 0 || requestTags.some((tag) => {
const tagText = tag.textContent;
return [].slice.call(requestInfo.children).some((requestInfoLink) => {
if (requestInfoLink.nodeName !== 'A') {
//Child isn't a anchor
return false;
}
const postStatus = requestInfoLink.dataset.postStatus;
const postIsDeleted = postStatus === 'deleted';
//const postIsClosed = postStatus === 'closed';
const postIsOpen = postStatus === 'open';
const postIsLocked = requestInfoLink.dataset.isLocked === 'true';
resetHandledRequestTypeRegexes();
/* beautify preserve:start *//* eslint-disable no-multi-spaces */
const completedPostStateByRequestType = {
close: postIsOpen,
delete: !postIsDeleted,
reopen: !postIsOpen,
undelete: postIsDeleted,
flag: !postIsDeleted,
offensive: !postIsDeleted,
spam: !postIsDeleted,
reject: !postIsDeleted,
};
/* beautify preserve:end */ /* eslint-enable no-multi-spaces */
return !postIsLocked && ( //Post isn't locked (can't take action on locked posts)
Object.keys(completedPostStateByRequestType).some((type) => (tagsInTextContentRegExes[type].test(tagText) && completedPostStateByRequestType[type])));
});
});
requestInfo.dataset.requestComplete = !requestIsActive;
if (requestIsActive) {
message.classList.remove('urrsRequestComplete');
} else {
message.classList.add('urrsRequestComplete');
}
});
//Set complete class on monologues, if all messages are complete.
funcs.doForAllMonologues((monologue) => {
if (monologue.querySelector('.monologue > .messages > .message:not(.urrsRequestComplete)')) {
monologue.classList.remove('urrsRequestComplete');
} else {
monologue.classList.add('urrsRequestComplete');
}
});
};
funcs.mp.makeRequestsFromAllMessagesForType = (what) => {
//For each link of the 'what' type requested in all messages in the DOM, create request objects representing the desire to fetch data from the SE API for that post.
const requests = [];
what = what.toLowerCase();
const doQuestions = what.indexOf('q') > -1;
const doAnswers = what.indexOf('a') > -1;
const doPosts = what.indexOf('p') > -1;
let getWhat = '';
let type = '';
if (doQuestions) {
getWhat = 'direct questions';
type = 'question';
} else if (doAnswers) {
getWhat = 'answers';
type = 'answer';
} else if (doPosts) {
getWhat = 'posts';
type = 'post';
} else {
return requests;
}
[].slice.call(document.querySelectorAll('.messages > .message')).reverse().forEach((message) => {
const content = funcs.getContentFromMessage(message);
if (!content) {
return;
}
let foundMessage = false;
let getWhatThisTime = getWhat;
const questionOnlyTags = funcs.getAllQuestionOnlyRequestTagsInElement(content);
if (questionOnlyTags && questionOnlyTags.length) {
//This is a request which can only be made of questions.
if (doQuestions) {
getWhatThisTime = 'questions';
} else if (doAnswers) {
//If it's this type of request, we don't want status from an answer.
// If this is a URL which only points to an answer (e.g. /a/123456), then we recognize no URL, and
// consider the request invalid.
// Ideally, we'd do a request which fetched the question ID associated with any answer found, but,
// for now, we just ignore answer URLs which don't also include the question (when the request
// must be to a question).
return;
}
}
funcs.getQuestionAnswerOrPostIdsOrInfoFromLinksInElement(content, getWhatThisTime).forEach((postId) => {
requests.push(new funcs.mp.Request(message, postId, type));
foundMessage = true;
});
//Go searching back references.
if (!foundMessage) {
//Found a request, but not a questionId, and no other link in the content.
//Add the question from any requests which are replies to messages with question links.
funcs.getQuestionAnswerOrPostInfoListFromReplyToIfIsRequestAndNoLinks(message, getWhat).forEach((refInfo) => {
const refPostId = refInfo.postId;
//Add the request
requests.push(new funcs.mp.Request(message, refPostId, type));
//Should we add a link to the question in the request?
if (config.nonUi.useQuestionTitleAsLink) {
const newSpan = document.createElement('span');
newSpan.title = 'This link did not exist in the original message. It has been added from the post linked in the message to which this message is a reply.';
newSpan.className = 'urrsAddedQuestionLink';
newSpan.appendChild(document.createTextNode(' '));
const newLink = document.createElement('a');
newLink.href = refInfo.url;
//Add a textContent used as a semaphore to indicate the text of the link is to be set after we obtain the data from the SE API call.
newLink.textContent = refInfo.text;
newLink.dataset.urrsReplacement = true;
newSpan.appendChild(newLink);
content.appendChild(newSpan);
}
});
//Currently we do nothing with the cv-pls if it is a reply and there is no data available in the current DOM. Should consider
// marking it for the user in some way (would need to be after data is back from the SE API), or fetching the data for the
// required message. Doing the latter would put all the question data behind 2 async API accesses, or the data for the
// affected message would not be available until the next update.
// Not doing anything leaves good information in the question if it has been updated with the question link, which is
// the default. Thus, under normal conditions the message will continue to have status until it is removed from the DOM.
// The primary time that this will be seen without status information is when the user first comes to the page, or reloads
// the page when the referenced message is not in the transcript displayed on the page.
}
});
return requests;
};
funcs.mp.mergeRequests = (primary, secondary) => {
//Merge two objects containing lists of each request types.
MESSAGE_PROCESSING_REQUEST_TYPES.forEach((type) => {
if (secondary[type] && Array.isArray(secondary[type]) && secondary[type].length > 0) {
primary[type] = primary[type].concat(secondary[type]);
}
});
};
funcs.mp.generateRequestsForAllAppropriateMessages = (onlyQuestions) => {
//Go through the existing messages, look for links, or just text which indicates a SO post.
const requests = {};
requests.questions = funcs.mp.makeRequestsFromAllMessagesForType('questions');
if (!onlyQuestions) {
requests.answers = funcs.mp.makeRequestsFromAllMessagesForType('answers');
} else {
requests.answers = [];
}
requests.posts = funcs.mp.makeRequestsFromAllMessagesForType('posts');
//XXX testing
if (typeof funcs.mp.getRequestsInMessagesListText === 'function') {
const textRequests = funcs.mp.getRequestsInMessagesListText([].slice.call(document.querySelectorAll('.message')), getActionTagInTextRegEx);
if (textRequests) {
funcs.mp.mergeRequests(requests, textRequests);
}
}
if (onlyQuestions && requests.answers) {
delete requests.answers;
}
return requests;
};
funcs.mp.processAllMessageLinks = (onlyQuestions) => {
//Actually process the messages
// Scan all the messages in the DOM, determine which ones should have a request-info attached and send the requests off to be send tot he SE API.
//We are unconditionally going to process all messages we find.
messageProcessing.isRequested = false;
//Add timestamp dataset to all posts.
funcs.addTimestampDatasetToAllMonologues();
if (isChat) {
//XXX It would be better to use the CHAT API to fetch the events and get actual times for each message.
funcs.addEarliestAndLatestTimestampDatasetToAllMonologues();
}
funcs.mp.processRequests(funcs.mp.generateRequestsForAllAppropriateMessages(onlyQuestions), onlyQuestions);
};
funcs.mp.cloneRequests = (requests) => {
//Clone the requests object (Object with properties which are Array).
//Not intended as a complete deep clone of an Object.
var clone = {};
Object.keys(requests).forEach((prop) => {
if (Array.isArray(requests[prop])) {
clone[prop] = [].concat(requests[prop]);
} else if (typeof requests[prop] === 'object') {
//This is a naive implementation of cloning an Object. To do it
// properly, you should preserve the Object's prototype, which might, arguably,
// require copying the prototype (instead of a reference), which would prevent future changes to the
// prototype from affecting the clone. A user of cloning might desire either way of doing it.
clone[prop] = funcs.mp.cloneRequests(requests[prop]);
} else {
clone[prop] = requests[prop];
}
});
return clone;
};
funcs.mp.processRequests = (requests, onlyQuestions) => {
//Put the requests in the final shape they need to be in prior to sending the Object to the API processing.
//The API only understands questions and answers. If data is asked about a post, about the only thing it will tell
// us is if that post is a question, or an answer. So, we request "posts" as both questions and answers and use
// whichever actually returns data.
//Add post requests to both the question and answer requests.
//XXX What we should be doing is requesting info for each Q/A as both, in case it's misidentified as a Q when really an A, or vice-versa.
// Even better would be to request as an answer, and get the associated question (when needed for cv-pls and reopen-pls).
if (requests.posts) {
requests.questions = requests.questions.concat(requests.posts);
if (!onlyQuestions) {
requests.answers = requests.answers.concat(requests.posts);
}
delete requests.posts;
}
//API requests are max 100 questions each. So, break the arrays into 100 post long chunks.
['questions', 'answers'].forEach((prop) => {
if (requests[prop] && requests[prop].length > 0) {
requests[prop] = funcs.chunkArray(requests[prop], 100);
} else {
delete requests[prop];
}
});
//Process all requests, even if there are no requests.
funcs.checkRequests(null, requests, !isSearchReviewUIActive);
};
//General utility functions
funcs.generateErrorAndThrow = (errorText, throwText) => {
//Throw an error after logging an error to the console.
funcs.generateError(errorText);
throw throwText;
};
funcs.generateError = (errorText) => {
//Generate an error in the console.
var error = new Error(errorText);
console.error(error);
};
funcs.addStylesToDOM = (id, styles) => {
//Add styles passed in as text to the DOM as a <style> element with the ID supplied. Verify
// that the styles have not previously been added.
const style = document.createElement('style');
style.type = 'text/css';
style.id = id;
const oldStyle = document.getElementById(style.id);
if (oldStyle) {
//Don't duplicate the styles if called again with the same ID.
oldStyle.remove();
}
style.textContent = styles;
document.head.appendChild(style);
};
funcs.getTextColor = (element) => { // eslint-disable-line arrow-body-style
//Get an actual color for the text in the supplied element. Search ancestors if the "color" is not an actual color.
return funcs.getEffectiveColorStyle(element, 'color', 'black');
};
funcs.getBackgroundColor = (element) => { // eslint-disable-line arrow-body-style
//Get an actual color for the background-color in the supplied element. Search ancestors if the "color" is not an actual color.
return funcs.getEffectiveColorStyle(element, 'background-color', 'white');
};
funcs.getEffectiveColorStyle = (element, colorStyle, rejectRegex, defaultValue) => { // eslint-disable-line arrow-body-style
//Find the color used, ignoring anything other than a straight color without transparency or alpha channel.
// Should really do a numeric check on rgba() alpha values, but a RegExp appears
// sufficient for the programmatically generated values.
return funcs.getEffectiveStyleValue(element, colorStyle, /(?:transparent|initial|inherit|currentColor|unset|rgba.*,\s*0(?:\.\d*)?\s*\))/i, defaultValue);
};
funcs.getEffectiveStyleValue = (element, styleText, rejectRegex, defaultValue) => {
//Find the style used on an element. Search ancestors until a style is found that does not match the rejection RegExp.
// Used to get an actual value for the background-color and color, rather than something like "transparent".
if (!element) {
return defaultValue;
}
var foundStyleValue;
do {
foundStyleValue = window.getComputedStyle(element).getPropertyValue(styleText);
element = element.parentNode;
rejectRegex.lastIndex = 0; //Clear the RegExp
} while (element && rejectRegex.test(foundStyleValue));
//Could test for the element being null instead of re-testing the RegExp.
rejectRegex.lastIndex = 0; //Clear the RegExp
if (rejectRegex.test(foundStyleValue)) {
//If no valid style was found, use the default provided.
foundStyleValue = defaultValue;
}
return foundStyleValue;
};
funcs.getListFromDataset = (element, prop) => {
//Get a list stored in an element's dataset in the specified property which is in JSON format.
var list = element.dataset[prop];
return list ? JSON.parse(list) : [];
};
funcs.setDatasetList = (element, prop, info) => {
//Set a list stored in an element's dataset in the specified property. Store it in JSON format.
if (!Array.isArray(info)) {
info = [info];
}
element.dataset[prop] = JSON.stringify(info);
};
funcs.addToDatasetList = (element, prop, info) => {
//Add a string, or array of strings to an array stored in an element's dataset, maintaining unique values.
var list = funcs.getListFromDataset(element, prop);
if (!Array.isArray(info)) {
info = [info];
}
//Verify each item being added does not already exist in the list.
info.forEach((item) => {
if (list.indexOf(item) === -1) {
list.push(item);
}
});
element.dataset[prop] = JSON.stringify(list);
};
//Utility functions specific to Monologues/messages
funcs.doForAllMonologues = (doing) => {
//Call the supplied function for each .message in the page
if (typeof doing !== 'function') {
return;
}
[].slice.call(document.querySelectorAll('.monologue')).forEach(doing);
};
funcs.doForAllMessages = (doing) => {
//Call the supplied function for each .message in the page
if (typeof doing !== 'function') {
return;
}
[].slice.call(document.querySelectorAll('.monologue .message')).forEach(doing);
};
funcs.doForAllMessagesWithRequestInfo = (doing) => {
//Call the supplied function for each .message in the page
if (typeof doing !== 'function') {
return;
}
[].slice.call(document.querySelectorAll('.monologue .message')).filter((message) => {
if (funcs.getRequestInfoFromMessage(message)) {
return true;
} //else
return false;
}).forEach(doing);
};
funcs.makeTagTagElementWithSpace = (tag, noLink, tooltip) => {
//Create a tag tag for the specified tag name. Include a space prior to the tag.
const docFrag = document.createDocumentFragment();
docFrag.appendChild(document.createTextNode(' '));
docFrag.appendChild(funcs.makeTagTagElement(tag, noLink, tooltip));
return docFrag;
};
funcs.makeTagTagHref = (tag) => { // eslint-disable-line arrow-body-style
//Create the URL used for a tag-tag.
return '//stackoverflow.com/questions/tagged/' + tag;
};
funcs.makeTagTagElement = (tag, noLink, tooltip) => {
//Create a tag tag for the specified tag name.
var tagLink;
if (noLink) {
tagLink = document.createElement('span');
tagLink.className = 'ob-post-tag-no-link';
} else {
tagLink = document.createElement('a');
}
if (tooltip) {
tagLink.title = tooltip;
}
tagLink.target = '_blank';
tagLink.insertAdjacentHTML('beforeend', '<span class="ob-post-tag" style="background-color: #E0EAF1; color: #3E6D8E; border-color: #3E6D8E; border-style: solid;"></span>');
//Link to the tag
tagLink.href = funcs.makeTagTagHref(tag);
//Add the tag's text
tagLink.querySelector('.ob-post-tag').textContent = tag;
return tagLink;
};
funcs.getRequestInfoLinksFromMessage = (message) => { // eslint-disable-line arrow-body-style
//Get all links in all .request-info for the message.
return message.parentNode.querySelectorAll('#' + message.id + ' > .request-info > a');
};
funcs.getContentFromMessage = (message) => {
//Get the first (assumed only) .content for the message, which is a direct child of the message.
if (!message) {
return null;
}
var child = message.firstChild;
while (child) {
if (child.classList && child.classList.contains('content')) {
return child;
}
child = child.nextSibling;
}
return null;
};
funcs.getRequestInfoFromMessage = (message) => { // eslint-disable-line arrow-body-style
//Get the first (assumed only) .request-info for the message
return message.parentNode.querySelector('#' + message.id + ' > .request-info');
};
funcs.getFirstRequestInfoLinkFromMessage = (message) => { // eslint-disable-line arrow-body-style
//Get the first (assumed only) .request-info <a> for the message
return message.parentNode.querySelector('#' + message.id + ' > .request-info > a');
};
funcs.doesElementContainRequestTagAsText = (element) => {
getActionTagInTextRegEx.lastIndex = 0;
return getActionTagInTextRegEx.test(funcs.removeTagsLinksAndCodeFromElement(element.cloneNode(true)).innerHTML);
};
funcs.removeTagsLinksAndCodeFromElement = (element) => {
//Remove any tags, links, and code from an element. Used to eliminate those from text searches.
[].slice.call(element.querySelectorAll('.ob-post-tag, a, code')).forEach((el) => {
el.remove();
});
return element;
};
funcs.getFirstLinkToPostId = (element, questionId) => {
//Find the first link which has an href which points to a specified post.
//Avoid more convenient Array methods w/o good support
const links = [].slice.call(element.querySelectorAll('a'));
let foundLink = null;
links.some((link) => {
if (funcs.getPostIdFromURL(link.href) == questionId) { // eslint-disable-line eqeqeq
//Found a match, indicate that and remember the link.
foundLink = link;
return true;
}
return false;
});
return foundLink;
};
/* eslint-disable arrow-body-style */
funcs.getFirst20kTagInElement = (element) => {
//Find the first actual 20k+ tag in the element.
return funcs.getFirstMatchingOrNonMatchingTagInElement(tagsInTextContentRegExes.tag20k, element, true);
};
funcs.getFirstN0kTagInElement = (element) => {
//Find the first N0k+ tag in the element.
return funcs.getFirstMatchingOrNonMatchingTagInElement(tagsInTextContentRegExes.tagN0k, element, true);
};
funcs.getFirstReopenRequestTagInElement = (element) => {
//Find the first reopen tag in the element.
return funcs.getFirstMatchingOrNonMatchingTagInElement(tagsInTextContentRegExes.reopen, element, true);
};
funcs.getFirstDeleteRequestTagInElement = (element) => {
//Find the first delete tag in the element.
return funcs.getFirstMatchingOrNonMatchingTagInElement(tagsInTextContentRegExes.delete, element, true);
};
funcs.getFirstUndeleteRequestTagInElement = (element) => {
//Find the first undelete tag in the element.
return funcs.getFirstMatchingOrNonMatchingTagInElement(tagsInTextContentRegExes.undelete, element, true);
};
funcs.getFirstNonRequestTagInElement = (element) => {
//Get the first tag tag in the content that does NOT match the action tag RegExp.
return funcs.getFirstRequestOrNonRequestTagInElement(element, false);
};
funcs.getFirstRequestTagInElement = (element) => {
//Get the first tag tag in the content that does match the action tag RegExp.
return funcs.getFirstRequestOrNonRequestTagInElement(element, true);
};
funcs.getFirstRequestOrNonRequestTagInElement = (element, isActionable) => {
//Find the first request or non-request tag in the element.
return funcs.getFirstMatchingOrNonMatchingTagInElement(tagsInTextContentRegExes.request, element, isActionable);
};
/* eslint-enable arrow-body-style */
funcs.getFirstMatchingOrNonMatchingTagInElement = (regEx, element, isMatch) => {
//Find the first tag in the element that matches the provided RegEx. Avoid more convenient Array methods w/o good support
let foundTag = null;
[].slice.call(element.querySelectorAll('.ob-post-tag')).some((tagSpan) => {
//Look through all tags (by class) for action tags
regEx.lastIndex = 0; //Clear the RegEx
const isTagARequest = regEx.test(tagSpan.textContent);
if ((isMatch && isTagARequest) || (!isMatch && !isTagARequest)) {
//Found an appropriate tag. Remember it and stop looking.
foundTag = tagSpan;
return true;
}
return false;
});
return foundTag ? foundTag.parentNode : null;
};
/* eslint-disable arrow-body-style */
funcs.getAllQuestionOnlyRequestTagsInElement = (element) => {
//Find all the tags in the element which are request tags that indicate only questions should be considered.
return funcs.getAllMatchingOrNonMatchingTagsInElement([tagsInTextContentRegExes.close, tagsInTextContentRegExes.reopen], element, true);
};
funcs.getAll20kTagsInElement = (element) => {
//Return all the tags which indicate that 20k reputation is required for this request.
return funcs.getAllMatchingOrNonMatchingTagsInElement(tagsInTextContentRegExes.tag20k, element, true);
};
funcs.getAllN0kTagsInElement = (element) => {
//Return all the tags which indicate that N0k reputation is required for this request.
return funcs.getAllMatchingOrNonMatchingTagsInElement(tagsInTextContentRegExes.tagN0k, element, true);
};
funcs.getAllDeleteRequestTagsInElement = (element) => {
//Return all the tags indicating a delete request.
return funcs.getAllMatchingOrNonMatchingTagsInElement(tagsInTextContentRegExes.delete, element, true);
};
funcs.getAllNonRequestTagsInElement = (element) => {
//Return all the tags which are not request tags.
return funcs.getAllRequestOrNonRequestTagsInElement(element, false);
};