// ==UserScript==
// @name Autoflagging Information & More
// @namespace https://github.com/Charcoal-SE/
// @description AIM adds display of autoflagging, deletion, and feedback information to SmokeDetector report messages in chat rooms.
// @author Glorfindel
// @author J F
// @contributor angussidney
// @contributor ArtOfCode
// @contributor Cerbrus
// @contributor Makyen
// @version 0.31
// @updateURL https://raw.githubusercontent.com/Charcoal-SE/Userscripts/master/autoflagging/autoflagging.meta.js
// @downloadURL https://raw.githubusercontent.com/Charcoal-SE/Userscripts/master/autoflagging/autoflagging.user.js
// @supportURL https://github.com/Charcoal-SE/Userscripts/issues
// @include /^https?://chat\.stackexchange\.com/(?:rooms/)(?:11|27|95|201|388|468|511|2165|3877|8089|11540|22462|24938|34620|35068|38932|46061|47869|56223|58631|59281|61165|65945|84778|96491|106445|109836|109841|129590)(?:[&/].*$|$)/
// @include /^https?://chat\.meta\.stackexchange\.com/(?:rooms/)(?:89|1037|1181)(?:[&/].*$|$)/
// @include /^https?://chat\.stackoverflow\.com/(?:rooms/)(?:41570|90230|111347|126195|167826|170175|202954)(?:[&/].*$|$)/
// @require https://cdn.jsdelivr.net/gh/joewalnes/reconnecting-websocket@5c66a7b0e436815c25b79c5579c6be16a6fd76d2/reconnecting-websocket.js
// @require https://cdn.jsdelivr.net/gh/Charcoal-SE/userscripts/vendor/debug.min.js
// @require https://cdn.jsdelivr.net/gh/Charcoal-SE/userscripts/emoji/emoji.js
// @grant none
// ==/UserScript==
/* global autoflagging, ReconnectingWebSocket, unsafeWindow, CHAT, $, Notifier, jQuery */ // eslint-disable-line no-redeclare
// To enable/disable trace information, type autoflagging.trace(true) or
// autoflagging.trace(false), respectively, in your browser's console.
(function () {
"use strict";
const MINUTE_IN_MILLISECONDS = 60 * 1000;
const HOUR_IN_MILLISECONDS = 60 * MINUTE_IN_MILLISECONDS;
const HOURS_2_IN_MILLISECONDS = 2 * HOUR_IN_MILLISECONDS;
const MAX_AGE_QUEUED_SOCKET_MESSAGE_DECORATE = HOURS_2_IN_MILLISECONDS;
const SOCKET_MESSAGE_DECORATE_QUEUE_EXPIRE_INTERVAL = 10 * MINUTE_IN_MILLISECONDS;
const isConversation = /^\/rooms\/\d+\/conversation\/.+$/.test(window.location.pathname);
const isChat = !isConversation && /^\/rooms\/\d+\/[^/]*$/.test(window.location.pathname);
let minContentHeight = 15;
function doWhenRoomReadyIfMainChat(toCall) {
// This should probably change to looking at window.location.
if (!isChat) {
return;
}
if (typeof CHAT === "object" && CHAT && CHAT.Hub && CHAT.Hub.roomReady && typeof CHAT.Hub.roomReady.add === "function") {
if (CHAT.Hub.roomReady.fired()) {
// The room is ready now.
toCall();
} else {
CHAT.Hub.roomReady.add(toCall);
}
}
}
const createDebug = typeof unsafeWindow === "undefined" ? window.debug : unsafeWindow.debug || window.debug;
const debug = createDebug("aim");
debug.decorate = createDebug("aim:decorate");
debug.ws = createDebug("aim:ws");
debug.queue = createDebug("aim:queue");
debug("started");
// Inject CSS
$(document.head).append(`
`);
let doWhenReady = $(document).ready;
if (typeof CHAT === "object" && CHAT && CHAT.Hub && CHAT.Hub.roomReady && typeof CHAT.Hub.roomReady.add === "function" && !CHAT.Hub.roomReady.fired()) {
doWhenReady = CHAT.Hub.roomReady.add;
}
doWhenReady(() => {
function getEffectiveBackgroundColor(element, defaultColor) {
element = element instanceof jQuery ? element : $(element);
defaultColor = defaultColor ? defaultColor : "rgb(255,255,255)";
let testEl = element.first();
const colors = [];
do {
try {
const current = testEl.css("background-color").replace(/\s+/g, "").toLowerCase();
if (current && current !== "transparent" && current !== "rgba(0,0,0,0)") {
colors.push(current);
}
if (current.indexOf("rgb(") === 0) {
// There's a color without transparency.
break;
}
} catch (err) {
// This should always get pushed if we make it up to the document element.
colors.push(defaultColor);
}
testEl = testEl.parent();
} while (testEl.length);
return "rgb(" + colors.reduceRight((sum, color) => {
color = color.replace(/rgba?\((.*)\)/, "$1").split(/,/g);
if (color.length < 4) {
// rgb, not rgba
return color;
}
if (color.length !== 4 || sum.length !== 3) {
throw new Error("Something went wrong getting the effective color");
}
for (let index = 0; index < 3; index++) {
const start = Number(sum[index]);
const end = Number(color[index]);
const distance = Number(color[3]);
sum[index] = start + ((end - start) * distance);
}
return sum;
}, []).join(", ") + ")";
}
const backgroundColor = getEffectiveBackgroundColor($(".monologue:not(.mine) .messages"));
const rgbAverage = backgroundColor.replace(/rgba?\((.*)\)/, "$1").split(/,/g).reduce((sum, value) => (Number(value) + sum), 0) / 3;
// Add the CSS to adjust the colors used when there's a dark background.
$(document.head).append(`
`);
minContentHeight = Math.min.apply(null, $(".message .content")
.map(function () {
return $(this).height();
})
.toArray()
.filter(height => height > 5)) || minContentHeight;
});
// Constants
var hOP = Object.prototype.hasOwnProperty.call.bind(Object.prototype.hasOwnProperty);
window.autoflagging = {};
autoflagging.smokeyIds = { // this is Smokey's user ID for each supported domain
"chat.stackexchange.com": 120914,
"chat.stackoverflow.com": 3735529,
"chat.meta.stackexchange.com": 266345,
};
autoflagging.smokeyID = autoflagging.smokeyIds[location.host];
autoflagging.key = "d897aa9f315174f081309cef13dfd7caa4ddfec1c2f8641204506636751392a4"; // this script's MetaSmoke API key
autoflagging.apiURL = "https://metasmoke.erwaysoftware.com/api/v2.0";
autoflagging.baseURL = autoflagging.apiURL + "/posts/urls?filter=HFHNHJFMGNKNFFFIGGOJLNNOFGNMILLJ&key=" + autoflagging.key;
autoflagging.selector = ".user-" + autoflagging.smokeyID + " .message ";
// autoflagging.messageRegex is used both to detect the HTML of SD messages which are to be decorated and parse the SE URL out of the SD report.
// https://regex101.com/r/tnUyUI/1
// Group 1: URL for SE post (This is the only group that is currently used.)
// Group 2: SE post title
// Group 3: SE user's user ID
// Group 4: SE username
// Group 5: SE site
autoflagging.messageRegex = /\[ ]+>SmokeDetector<\/a>(?: \| ]+>MS<\/a>)?.*?(.+?)<\/a>.* by (?:(.+?)<\/a>|a deleted user) on ([^<]+)/;
autoflagging.hasMoreRegex = /\+\d+ more \(\d+\)/;
autoflagging.hasNotificationRegex = /^ \(@.*\)$/;
// Error handling
autoflagging.notify = Notifier().notify; // eslint-disable-line new-cap
/*!
* Decorates a message DOM element with information from the API or websocket.
* It will add the information both to the message itself
* and to the 'meta'-element shown on hovering over the message.
*
* The parameter 'data' is supposed to have an optional property 'flagged' with flagging information, and an optional property 'users' with user information.
* `element` is a message (i.e. has the .message class)
*/
autoflagging.decorateMessage = function ($message, data) {
debug.decorate(data, $message);
autoflagging.decorate($message.children(".ai-information"), data);
autoflagging.decorate($message.find(".meta .ai-information"), data);
// Remove @ notifications
var lastTextNode = $message.find(".content").get(0).lastChild;
if (autoflagging.hasNotificationRegex.test(lastTextNode.nodeValue)) {
lastTextNode.parentNode.removeChild(lastTextNode);
}
// Temporarily disabled following https://chat.stackexchange.com/transcript/message/44456641#44456641
// autoflagging.getAllReasons($message, data);
};
/*!
* Extend a report message's reasons when the message was cropped.
*/
/* Temporarily disabled following https://chat.stackexchange.com/transcript/message/44456641#44456641
autoflagging.getAllReasons = function ($message, data) {
if (autoflagging.hasMoreRegex.test($message.html())) {
$.get(
"https://metasmoke.erwaysoftware.com/api/v2.0/post/" + data.id + "/reasons?per_page=30",
{key: autoflagging.key},
function (response) {
if (response && response.items) {
// The textnode containing "] +X more (weight)"
var textNode = $message
.find(".content")
.contents()
.filter(function () {
return this.nodeType === 3 &&
autoflagging.hasMoreRegex.test($(this).text());
});
var reasons = response.items.map(function (reason) {
return reason.reason_name;
});
var fullReason = textNode
.text()
.replace(/[^\]]+ \(/, " " + reasons.join(", ") + " (");
// Replace the textnode with the new text.
textNode.replaceWith(fullReason);
}
})
.fail(function (xhr) {
autoflagging.notify("AIM: Failed to load MS reason data (1):", xhr);
debug("Failed to load reasons:", xhr);
});
}
};
*/
/*!
* Adds the AIM information to the provided element.
* Don't call this method directly, use decorateMessage instead.
*/
autoflagging.decorate = function ($element, data) {
// Remove spinner
$element.find(".ai-spinner").remove();
$element.addClass("ai-loaded");
var names = {
before: "prepend",
after: "append"
};
// The decorate operation consists currently of two parts:
// - autoflag
// - feedback
Object.keys(autoflagging.decorate).forEach(function (key) {
var f = autoflagging.decorate[key];
if ($element.find(".ai-" + key).length === 0) {
$element[names[f.location]]($("<" + (f.el || "span") + "/>").addClass("ai-" + key));
}
if (!f.key) {
f($element.find(".ai-" + key), data);
} else if (hOP(data, f.key)) {
f($element.find(".ai-" + key), data[f.key], data);
}
});
};
/*
* Specification for methods of autoflagging.decorate:
*
* - 1) [required] a DOM element to update
* - 2) [required] data from the API or websocket, usually only the parts
* which is relevant
* - 3) [optional] complete post data
*
* It is best to make the method “idempotent,” meaning that it will display
* the same thing when called repeatedly with the same parameters.
*
* Properties:
* - location [required] ("before" | "after") Where to add the element if it
* doesn’t exist
* - key [optional] The key that must be present on the data. The value of
* this key is passed as the second parameter.
* - el [optional] the name of the element to create.
*/
/*!
* Adds autoflag information to an autoflag DOM element.
*/
autoflagging.decorate.autoflag = function ($autoflag, data, post) {
// Determine if you (i.e. the current user) autoflagged this post.
var site = "";
switch (location.hostname) {
case "chat.stackexchange.com":
site = "stackexchange";
break;
case "chat.meta.stackexchange.com":
site = "meta";
break;
case "chat.stackoverflow.com":
site = "stackoverflow";
break;
default:
console.error("Invalid site for autoflagging: " + location.hostname);
break;
}
data.youFlagged = data.users.filter(function (user) {
return user[site + "_chat_id"] === CHAT.CURRENT_USER_ID;
}).length === 1;
if ($autoflag.find(".ai-you-flagged").length === 0) {
$autoflag.prepend($("").text("You autoflagged.").addClass("ai-you-flagged"));
}
if ($autoflag.find(".ai-flag-count").length === 0) {
$autoflag.append($("").addClass("ai-flag-count"));
}
if ($autoflag.data("users")) {
data.users = $autoflag.data("users").concat(data.users);
var uniqUsers = {};
data.users.forEach(function (user) {
uniqUsers[user.stackexchange_chat_id] = user;
});
data.users = Object.keys(uniqUsers).map(function (key) {
return uniqUsers[key];
});
}
$autoflag.data("users", data.users);
if (post.id && data.users.length > 0) {
$autoflag.find(".ai-flag-count").attr("href", "https://metasmoke.erwaysoftware.com/post/" + post.id + "/flag_logs");
}
$autoflag.find(".ai-you-flagged").toggle(data.flagged && data.youFlagged);
$autoflag.find(".ai-flag-count")
.text(data.flagged ? String(data.users.length) : "")
.toggleClass("ai-not-autoflagged", !data.flagged)
.attr("title", data.flagged ? "Flagged by " + data.users.map(function (user) {
return user.username || user.user_name;
}).join(", ") : "Not Autoflagged");
$autoflag.data("users", data.users);
};
autoflagging.decorate.autoflag.key = "autoflagged";
autoflagging.decorate.autoflag.location = "after";
/*!
* Adds reason weight to message
*/
autoflagging.decorate.weight = function ($weight, weight) {
$weight.text(" • " + weight).attr("title", "Reason Weight");
};
autoflagging.decorate.weight.key = "reason_weight";
autoflagging.decorate.weight.location = "after";
/*!
* Adds feedback information to a feedback DOM element.
*/
autoflagging.decorate.feedback = function ($feedback, data) {
data.forEach(function (item) {
autoflagging.decorate.feedback._each($feedback, item);
});
};
autoflagging.decorate.feedback.key = "feedbacks";
autoflagging.decorate.feedback.location = "before";
/*!
* Adds feedback information to a feedback DOM element.
*/
autoflagging.decorate.feedback._each = function ($feedback, data) {
// Group feedback by type
var allFeedbacks = $feedback.data("feedbacks") || {};
// Don't show multiple feedbacks by the same user. New feedbacks override old feedbacks.
// Unfortunately, MS only sends the user_name with WebScocket feedbacks, which makes
// this incorrect if there is an actual duplicate username.
// In addition, it is possible, under some conditions, for MS to have more than one
// feedback from the same user, which this will hide.
const currentUsername = data.user_name;
Object.keys(allFeedbacks).forEach(function (feedbackType) {
if (Array.isArray(allFeedbacks[feedbackType])) {
allFeedbacks[feedbackType] = allFeedbacks[feedbackType].filter(function (testFeedback) {
return testFeedback.user_name !== currentUsername;
});
if (allFeedbacks[feedbackType].length === 0) {
delete allFeedbacks[feedbackType];
}
}
});
allFeedbacks[data.feedback_type] = (allFeedbacks[data.feedback_type] || []).concat(data);
$feedback.data("feedbacks", allFeedbacks);
var simpleFeedbacks = {
k: {},
f: {},
n: {},
i: {}
};
for (var type in allFeedbacks) {
if (hOP(allFeedbacks, type) && allFeedbacks[type] instanceof Array) {
var users = allFeedbacks[type].map(function (user) {
return user.user_name;
});
if (type.indexOf("t") !== -1) {
simpleFeedbacks.k[type] = users;
} else if (type.indexOf("f") !== -1) {
simpleFeedbacks.f[type] = users;
} else if (type.indexOf("naa") !== -1) {
simpleFeedbacks.n[type] = users;
} else if (type.indexOf("ignore") !== -1) {
simpleFeedbacks.i[type] = users;
}
}
}
// Update feedback DOM element
$feedback.empty();
autoflagging.decorate.feedback.addFeedback(simpleFeedbacks.k, $feedback, "tpu");
autoflagging.decorate.feedback.addFeedback(simpleFeedbacks.f, $feedback, "fp");
autoflagging.decorate.feedback.addFeedback(simpleFeedbacks.n, $feedback, "naa");
autoflagging.decorate.feedback.addFeedback(simpleFeedbacks.i, $feedback, "ignore");
};
/*!
* Adds feedback of one type (tpu-, naa-, fp-, ignore-) to a feedback DOM element.
*/
autoflagging.decorate.feedback.addFeedback = function (simpleFeedbacksByType, $feedback, defaultFeedbackType) {
const minusDefaultKey = defaultFeedbackType + "-";
// We don't care about the difference between feedbacks with "-" and the same feedback without it (e.g. "tpu-" vs "tpu"), so consolidate all those.
Object.keys(simpleFeedbacksByType).forEach(feedbackType => {
const nonMinusFeedbackType = feedbackType.replace(/-$/, "");
if (feedbackType !== nonMinusFeedbackType) {
simpleFeedbacksByType[nonMinusFeedbackType] = (simpleFeedbacksByType[nonMinusFeedbackType] || []).concat(simpleFeedbacksByType[feedbackType]);
delete simpleFeedbacksByType[feedbackType];
}
});
const count = Object.keys(simpleFeedbacksByType)
.map(feedbackType => simpleFeedbacksByType[feedbackType].length)
.reduce(function (a, b) {
return a + b;
}, 0);
if (count) {
// Put all the names in a list organized by feedback type, but make sure the default feedback type is first.
const defaultFeedbackTitle = (simpleFeedbacksByType[defaultFeedbackType] || []).join(", ");
const titles = Object.keys(simpleFeedbacksByType)
.filter(feedbackType => feedbackType !== defaultFeedbackType)
.map(feedbackType => feedbackType + ": " + simpleFeedbacksByType[feedbackType].join(", "));
if (defaultFeedbackTitle) {
titles.unshift(defaultFeedbackTitle);
}
$feedback.append(
$("").addClass("ai-feedback-info")
.addClass("ai-feedback-info-" + defaultFeedbackType)
.text(count).attr("title", titles.join("; "))
);
}
};
/*!
* Decorates a message DOM element with a spinner. It will add it both to the
* message itself and to the 'meta'-element shown on hovering over the message.
*/
autoflagging.addSpinnerToMessage = function ($message) {
debug("add spinner to", $message);
autoflagging.addSpinner($message);
autoflagging.addSpinner($message.find(".meta"), true);
};
/*!
* Decorates a DOM element with a spinner. Don't call this method directly,
* use addSpinnerToMessage instead.
*/
autoflagging.addSpinner = function ($message, inline) {
$message.append("" +
"" +
"");
const contentHeight = $message.children(".content").height();
if ($message.parent().children(":first-child").hasClass("timestamp") && $message.is(":nth-child(2)") && contentHeight < minContentHeight + 5) {
$message.parent().addClass("ai-short-message-after-timestamp");
}
};
/*!
* Calls the API to get information about multiple posts at once, considering the paging system of the API.
* If there are more than 100 URLs requested, then the list of URLs is broken into chunks of 100 max and
* the API is called on each chunk.
* It will use the results to decorate the Smokey reports which are already on the page.
*/
autoflagging.callAPI = function (urls) {
debug("Call API");
if (!Array.isArray(urls)) {
return;
}
// chunkArray is from SOCVR's Archiver; copied by Makyen
function 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;
}
// Split the array into chunks that are a max of 100 URLs each and call the API.
// There isn't a specified number that is a maximum for the API, but there appear to be
// problems when requesting a large number of URLs.
const chunkedArray = chunkArray(urls, 100);
chunkedArray.forEach(chunk => autoflagging.callAPIChunk(chunk.join(",")));
};
/*!
* Calls the API to get information about multiple posts at once, considering the paging system of the API.
* It will use the results to decorate the Smokey reports which are already on the page.
*/
autoflagging.callAPIChunk = function (urls, page = 1) {
debug("Call APIChunk");
if (!urls) {
return;
}
var autoflagData = {};
// After changes to MS, requesting max 100 URLs appears to be working well.
var url = autoflagging.baseURL + "&page=" + page + "&per_page=100&urls=" + urls;
debug("URL:", url);
$.get(url, function (data) {
// Group information by link
for (var i = 0; i < data.items.length; i++) {
const link = data.items[i].link;
if (autoflagData[link] && autoflagData[link].id > data.items[i].id) {
// If there's more than one MS post for this URL, then we want to use the
// most recent one. This is a stopgap rather than re-writing this to
// use the MS post info which is closest in time to the SD report.
// Normally, the most recent MS post is listed first.
continue;
}
autoflagData[link] = data.items[i];
}
// Loop over all Smokey reports and decorate them
$(autoflagging.selector).each(function () {
// this is a .message
const $element = $(this);
const postURL = autoflagging.getPostURL(this);
const postData = autoflagData[postURL];
if (typeof postData === "undefined") {
return;
}
// Post deleted?
if (postData.deleted_at != null) {
$element.addClass("ai-deleted");
}
if (postData.autoflagged === true) {
// Get flagging data
url = autoflagging.apiURL + "/posts/" + postData.id + "/flags?key=" + autoflagging.key;
debug("URL:", url);
$.get(url, function (flaggingData) {
autoflagging.decorateMessage($element, flaggingData.items[0]);
}).fail(function (xhr) {
autoflagging.notify("AIM: Failed to load MS flag data:", xhr);
});
} else {
// No autoflags
autoflagging.decorateMessage($element, {autoflagged: {flagged: false, users: []}});
}
// Get feedback
url = autoflagging.apiURL + "/feedbacks/post/" + postData.id + "?filter=HNKJJKGNHOHLNOKINNGOOIHJNLHLOJOHIOFFLJIJJHLNNF&key=" + autoflagging.key + "&per_page=20";
debug("URL:", url);
$.get(url, function (feedbackData) {
autoflagging.decorateMessage($element, {feedbacks: feedbackData.items});
}).fail(function (xhr) {
autoflagging.notify("AIM: Failed to load MS feedback data:", xhr);
});
// Get weight
url = autoflagging.apiURL + "/posts/" + postData.id + "/reasons?key=" + autoflagging.key + "&per_page=30";
debug("URL:", url);
$.get(url, function (reasonsData) {
var totalWeight = 0;
for (var i = 0; i < reasonsData.items.length; i++) {
totalWeight += reasonsData.items[i].weight;
}
autoflagging.decorateMessage($element, {reason_weight: totalWeight});
}).fail(function (xhr) {
autoflagging.notify("AIM: Failed to load MS reason data:", xhr);
});
});
if (data.has_more) {
// There are more items on the next 'page'
autoflagging.callAPIChunk(urls, ++page);
}
}).fail(function (xhr) {
autoflagging.notify("AIM: Failed to load MS post data:", xhr);
});
};
/*!
* Returns the post URL in a Smokey report message (if there is any).
*/
autoflagging.getPostURL = function (selector) {
var matches = autoflagging.messageRegex.exec($(selector).html());
return matches && matches[1];
};
// Wait for the chat messages to be loaded.
var chat = $("#chat");
/*!
* Handle the chat room being ready when on a main chat page.
*/
autoflagging.handleChatRoomReady = function () {
if (chat.html().length !== 0) {
// Chat messages loaded
autoflagging.markupAllReportsInChat();
}
};
/*!
* Add AIM markup to all messages in the DOM.
*/
autoflagging.markupAllReportsInChat = function () {
// Find all Smokey reports (they are characterized by having an MS link) and extract the post URLs from them
var urls = [];
$(autoflagging.selector).filter(function () {
const eachSelected = $(this);
// Clean out any empty AI infos
eachSelected.find(".ai-information").each(function () {
const eachAiInfo = $(this);
if (eachAiInfo.children().length === 0) {
// There's no content in the AI info. Something went wrong elsewhere, so we just remove it.
eachAiInfo.remove();
}
});
// Clean out AI Info from any messages without exactly 2 AI Infos: Something is wrong, and we should redo adding AI Info.
const aiInfo = eachSelected.find(".ai-information");
if (aiInfo.length !== 2) {
aiInfo.remove();
return true;
} // else
return false;
}).each(function () {
const url = autoflagging.getPostURL(this);
if (typeof url === "string" && url) {
autoflagging.addSpinnerToMessage($(this));
urls.push(url);
}
});
// MS API call
autoflagging.callAPI(urls);
$(".message:has(.ai-information)").addClass("ai-message");
};
if (chat.length > 0) {
doWhenRoomReadyIfMainChat(autoflagging.handleChatRoomReady);
}
// Add autoflagging information to older messages as they are loaded
$(document).ajaxComplete(function (event, jqXHR, ajaxSettings) {
// By the time this gets called, the messages are in the DOM.
if (/chats\/\d+\/events/i.test(ajaxSettings.url)) {
// The URL for fetching more messages is:
// /chats/11540/events?before=[previous oldest message]&mode=Messages&msgCount=100
autoflagging.markupAllReportsInChat();
}
});
// Listen to MS events
autoflagging.msgQueue = [];
autoflagging.decorateOrQueueBySelector = function (selector, data, receivedTime) {
// If we get a jQuery 'this' value, then we are checking only a single message (i.e. no need to do a DOM walk).
// This is optimized such that in subsequent runs through the queue we are only looking for links in the content
// part of new messages. Thus, while 'selector' can be anything the first time through, it will only be tested
// against links in the new message.
// We even pre-filter those links, such that we only look at links that match
// .filter('a:not([href^="//git.io/"]):not([href^="//m.erwaysoftware.com/"]):not([href*="/users/"])');
// That's sufficient for what we currently are looking for, but it's intensionally limited, such that
// we do only a small amount of work for each queue entry.
receivedTime = receivedTime || Date.now();
debug.decorate("Attempting to decorate \"" + selector + "\" with", data, "message:", $(selector).parents(".message"));
let messages;
if (this && this.length > 0) {
messages = this.filter(selector).parents(".message");
} else {
// Full search of the DOM. This is only done on the first check of this.
messages = $(selector).parents(".message");
}
const messagesWithAIInfo = messages.filter(function () {
return $(this).find(".ai-spinner, .ai-information.ai-loaded").length > 0;
});
if (messagesWithAIInfo.length > 0) {
// There's at least one message with AI info or a spinner.
messagesWithAIInfo.each(function () {
const thisMessage = $(this);
autoflagging.decorateMessage(thisMessage, data);
});
} else if (isChat && Date.now() < (receivedTime + MAX_AGE_QUEUED_SOCKET_MESSAGE_DECORATE)) {
// We only have a queue for received MS WebSocket data in main chat pages and only keep things in the queue for 2 hours, which
// is about double the longest delay we've seen SD have. Note: we've significantly improved things since then, so even that's
// unlikely.
// If we didn't expire things, then we'd just continuously build up a deeper and deeper queue.
// MS is faster than chat; add the decorate operation to the queue
debug.queue("Queueing", selector);
// This could result in data from an earlier run overwriting later data, if later run is also in the queue
// and the apprporiate SD message appears between this run and that next run.
autoflagging.msgQueue.push([selector, data, receivedTime]);
}
};
autoflagging.expireMSGQueue = function () {
const now = Date.now();
autoflagging.msgQueue = autoflagging.msgQueue.filter(([selector, data, receivedTime]) => now < (receivedTime + MAX_AGE_QUEUED_SOCKET_MESSAGE_DECORATE));
};
autoflagging.setupMSWebSocket = function () {
if (autoflagging.socket) {
// Don't set up the WebSocket more tha once.
return;
}
// Expire old queue entries every 10 minutes.
setInterval(autoflagging.expireMSGQueue, SOCKET_MESSAGE_DECORATE_QUEUE_EXPIRE_INTERVAL);
autoflagging.socket = new ReconnectingWebSocket("wss://metasmoke.erwaysoftware.com/cable");
autoflagging.socket.onmessage = function (message) {
// Parse message
var jsonData = JSON.parse(message.data);
switch (jsonData.type) {
case "confirm_subscription":
case "ping":
case "welcome":
case "statistic":
break;
default: {
// Analyze socket message
debug.ws("got message", jsonData.message);
var flagLog = jsonData.message.flag_log;
var deletionLog = jsonData.message.deletion_log;
var feedback = jsonData.message.feedback;
var notFlagged = jsonData.message.not_flagged;
if (typeof flagLog !== "undefined") {
// Autoflagging information
debug.ws(flagLog.user, "autoflagged", flagLog.post);
let selector = autoflagging.selector + "a[href^='" + flagLog.post.link + "']";
autoflagging.decorateOrQueueBySelector(selector, flagLog.post);
} else if (typeof deletionLog !== "undefined") {
// Deletion log
debug.ws("deleted:", deletionLog);
let selector = autoflagging.selector + "a[href^='" + deletionLog.post_link + "']";
$(selector).closest(".message").addClass("ai-deleted");
} else if (typeof feedback !== "undefined") {
// Feedback
debug.ws(feedback.user, "posted", feedback.symbol, "on", feedback.post_link, feedback); // feedback_type
let selector = autoflagging.selector + "a[href^='" + feedback.post_link + "']";
autoflagging.decorateOrQueueBySelector(selector, {
feedbacks: [feedback]
});
} else if (typeof notFlagged !== "undefined") {
// Not flagged
debug.ws(notFlagged.post, "not flagged");
let selector = autoflagging.selector + "a[href^='" + notFlagged.post.link + "']";
autoflagging.decorateOrQueueBySelector(selector, notFlagged.post);
}
break;
}
}
};
autoflagging.socket.onopen = function () {
debug.ws("WebSocket opened.");
// Send authentication
autoflagging.socket.send(JSON.stringify({
identifier: JSON.stringify({
channel: "ApiChannel",
key: autoflagging.key
}),
command: "subscribe"
}));
};
autoflagging.socket.onclose = function (close) {
debug.ws("WebSocket closed:", close);
};
};
autoflagging.setupMSWebSocket();
autoflagging.processSocketMessageDecorateQueue = function (newMessageSelector) {
// Attempt to apply each decorate from a WebSocket message.
// If we are passed a newMessageSelector, then we are only checking a single message for all queue entries.
let singleMessageContentLinks = null;
if (typeof newMessageSelector === "string") {
singleMessageContentLinks = $(newMessageSelector).find(".content a").filter("a:not([href^='//git.io/']):not([href^='//m.erwaysoftware.com/']):not([href*='/users/'])");
}
const queueWhenMessagePosted = autoflagging.msgQueue;
autoflagging.msgQueue = [];
// The entire existing queue needs to be handled in one context in order to maintain consistent order.
setTimeout(function () {
const currentQueue = autoflagging.msgQueue;
autoflagging.msgQueue = [];
queueWhenMessagePosted.forEach(function (queueEntry) {
debug.queue("Resolving queue:", queueEntry);
autoflagging.decorateOrQueueBySelector.apply(singleMessageContentLinks, queueEntry);
});
// Make sure anything that's been put back on the queue is prior to anything that's been put on the queue later.
// This assumes that any changes to the queue are made synchronously.
autoflagging.msgQueue = autoflagging.msgQueue.concat(currentQueue);
}, 100);
};
function aimChatListener(chatEvent) {
if (chatEvent.event_type === 1 && chatEvent.user_id === autoflagging.smokeyID) {
if (!autoflagging.messageRegex.test(chatEvent.content)) {
return;
}
// Do this after the message has been added to the DOM.
setTimeout(() => {
// Show spinner
const newMessageSelector = `#message-${chatEvent.message_id}`;
autoflagging.addSpinnerToMessage($(newMessageSelector));
// Sometimes, autoflagging information arrives before the chat message.
// The code below makes sure the queued decorations are executed.
autoflagging.processSocketMessageDecorateQueue(newMessageSelector);
}, 25);
}
}
if (typeof CHAT === "object" && CHAT && typeof CHAT.addEventHandlerHook === "function") {
CHAT.addEventHandlerHook(aimChatListener);
}
let resizeDebounceTimer;
function debounceResize() {
clearTimeout(resizeDebounceTimer);
resizeDebounceTimer = setTimeout(resizeHandler, 50);
}
function resizeHandler() {
const messages = $(autoflagging.selector);
$(".messages").each(function () {
const messagesContainer = $(this);
const aiMessageContentAfterTimestamp = messagesContainer.children(".timestamp + .message.ai-message").find(".content");
const contentHeight = aiMessageContentAfterTimestamp.height();
messagesContainer.toggleClass("ai-short-message-after-timestamp", contentHeight && contentHeight < minContentHeight + 5);
});
}
$(window).on("resize", debounceResize);
})();