// ==UserScript== // @name 🔥 FIRE: Feedback Instantly, Rapidly, Effortlessly // @namespace https://github.com/Charcoal-SE/ // @description FIRE adds a button to SmokeDetector reports that allows you to provide feedback & flag, all from chat. // @author Cerbrus [attribution: Michiel Dommerholt (https://github.com/Cerbrus)] // @contributor Makyen // @version 1.6.1 // @icon https://raw.githubusercontent.com/Ranks/emojione-assets/master/png/32/1f525.png // @updateURL https://raw.githubusercontent.com/Charcoal-SE/Userscripts/master/fire/fire.meta.js // @downloadURL https://raw.githubusercontent.com/Charcoal-SE/Userscripts/master/fire/fire.user.js // @supportURL https://github.com/Charcoal-SE/Userscripts/issues // @match *://chat.stackexchange.com/transcript/* // @match *://chat.meta.stackexchange.com/transcript/* // @match *://chat.stackoverflow.com/transcript/* // @match *://chat.stackexchange.com/users/120914/* // @match *://chat.stackexchange.com/users/120914?* // @match *://chat.stackoverflow.com/users/3735529/* // @match *://chat.stackoverflow.com/users/3735529?* // @match *://chat.meta.stackexchange.com/users/266345/* // @match *://chat.meta.stackexchange.com/users/266345?* // @match *://chat.stackexchange.com/users/478536/* // @match *://chat.stackexchange.com/users/478536?* // @match *://chat.stackoverflow.com/users/14262788/* // @match *://chat.stackoverflow.com/users/14262788?* // @match *://chat.meta.stackexchange.com/users/848503/* // @match *://chat.meta.stackexchange.com/users/848503?* // @include /^https?://chat\.stackexchange\.com/(?:rooms/|search.*[?&]room=)(?: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/|search.*[?&]room=)(?:89|1037|1181)(?:[&/].*$|$)/ // @include /^https?://chat\.stackoverflow\.com/(?:rooms/|search.*[?&]room=)(?:41570|90230|111347|126195|167826|170175|202954)(?:[&/].*$|$)/ // @require https://cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/toastr.min.js // @require https://cdn.jsdelivr.net/gh/joewalnes/reconnecting-websocket@5c66a7b0e436815c25b79c5579c6be16a6fd76d2/reconnecting-websocket.js // @grant none // ==/UserScript== /* globals CHAT, GM_info, toastr, $, jQuery, ReconnectingWebSocket, autoflagging */ // eslint-disable-line no-redeclare /** * anonymous function - IIFE to prevent accidental pollution of the global scope.. */ (() => { 'use strict'; let fire; /** * anonymous function - Initialize FIRE. * * @param {object} scope The scope to register FIRE on. Usually, `window`. */ ((scope) => { // Init const hOP = Object.prototype.hasOwnProperty.call.bind(Object.prototype.hasOwnProperty); const smokeDetectorId = { // This is Smokey's user ID for each supported domain 'chat.stackexchange.com': 120914, 'chat.stackoverflow.com': 3735529, 'chat.meta.stackexchange.com': 266345, }[location.host]; // From which, we need the current host's ID const metasmokeId = { // Same as above, but for the metasmoke account 'chat.stackexchange.com': 478536, 'chat.stackoverflow.com': 14262788, 'chat.meta.stackexchange.com': 848503, }[location.host]; const constants = getFireConstants(); /** * FIRE's global object. * * @global * @module fire * */ fire = { /** * The userscript's metadata * * @public * @memberof module:fire * */ metaData: GM_info.script || GM_info['Feedback Instantly, Rapidly, Effortlessly'], /** * The userscript's API URLs and keys * * @public * @memberof module:fire * */ api: { ms: { key: '55c3b1f85a2db5922700c36b49583ce1a047aabc4cf5f06ba5ba5eff217faca6', // This script's metasmoke API key url: 'https://metasmoke.erwaysoftware.com/api/v2.0/', urlV1: 'https://metasmoke.erwaysoftware.com/api/', }, se: { key: 'NDllMffmzoX8A6RPHEPVXQ((', // This script's Stack Exchange API key url: 'https://api.stackexchange.com/2.2/', clientId: 9136, // The backoff Object contains one entry per endpoint. The value of that property is a jQuery.Deferred which // is always resolved after the response is received from the previous call to the endpoint. It's resolved // either immediately, or after any `backoff` which the SE API specifies. It's also delayed by // seAPIErrorDelay in the case of an error. // Overall, this means we only have one request in flight at a time per endpoint. This limitation is // assumed to result in us not making too many requests to the SE API per second (SE API hard limit is 30 requests/s). backoffs: {}, }, }, constants, smokeDetectorId, metasmokeId, SDMessageSelector: `.user-${smokeDetectorId} .message, .user-${metasmokeId} .message `, openOnSiteCodes: keyCodesToArray(['7', 'o', numpad('7', constants)]), openOnMSCodes: keyCodesToArray(['8', 'm', numpad('8', constants)]), buttonKeyCodes: [], webSocket: null, reportCache: {}, openReportPopupForMessage, decorateMessage, }; /** * Add fire to the global scope, but don't override it if it already exists. */ if (!hOP(scope, 'fire')) { scope.fire = fire; } scope.fireNoConflict = fire; /** * Default settings to use in `localStorage`. */ const defaultLocalStorage = { blur: true, flag: true, debug: false, hideImages: true, toastrPosition: 'top-right', toastrDuration: 2500, readOnly: false, version: fire.metaData.version, }; registerLoggingFunctions(); hasEmojiSupport(); initLocalStorage(hOP, defaultLocalStorage); getCurrentChatUser(); loadStackExchangeSites(); injectMainCSS(); injectExternalScripts(); showFireOnExistingMessages(); registerAnchorHover(); registerOpenLastReportKey(); if (CHAT && CHAT.addEventHandlerHook) { CHAT.addEventHandlerHook(fireChatListener); } checkHashForWriteToken(); registerPopupMoveMouseDownListener(); registerReportedPostsMousedownListenerForInputSelection(); })(window); /** * requestStackExchangeToken - Request a Stack Exchange Write token for this app. * * @private * @memberof module:fire * */ function requestStackExchangeToken() { const url = `https://stackexchange.com/oauth/dialog?client_id=${fire.api.se.clientId}&scope=${encodeURIComponent('no_expiry')}&redirect_uri=${encodeURIComponent(location.href)}`; // Register the focus event to check if the write token was successfully obtained $(window).on('focus', checkWriteTokenSuccess); window.open(url); } /** * checkHashForWriteToken - Check the url hash to see if a write token has been obtained. If so, parse it. * * @private * @memberof module:fire * */ function checkHashForWriteToken() { if (location.hash && location.hash.length > 0) { const params = new URLSearchParams(location.hash); const token = params.get('#access_token'); if (token) { /* 2023-08-11 SE is now providing the hash as: * #access_token=&scope=write_access%20no_expiry * It should be (https://api.stackexchange.com/docs/authentication): * #access_token= */ setValue('stackexchangeWriteToken', token); window.close(); } // Clear hash history.pushState('', document.title, window.location ? window.location.pathname + window.location.search : 'https://chat.stackexchange.com/rooms/11540/charcoal-hq'); } } /** * checkWriteTokenSuccess - Check if the write token was successfully obtained. * * @private * @memberof module:fire * */ function checkWriteTokenSuccess() { if (fire.userData.stackexchangeWriteToken) { toastr.success('Successfully obtained Stack Exchange write token!'); $('.fire-popup .fire-request-write-token').remove(); $(window).off('focus', checkWriteTokenSuccess); } } /** * getDataForUrl - Loads metasmoke data for a specified post url. * * @private * @memberof module:fire * * @param {string} reportedUrl The url that's been reported. * @param {singleReportCallback} callback An action to perform after the report is loaded. */ function getDataForUrl(reportedUrl, callback) { const {ms} = fire.api; const url = `${ms.url}posts/urls?key=${ms.key}&filter=HFHNHJFMGNKNFFFIGGOJLNNOFGNMILLJ&page=1&urls=${reportedUrl}`; $.get(url) .done((data) => { if (data && data.items) { if (data.items.length <= 0) { toastr.info(`No metasmoke reports found for url:
${reportedUrl}`); return; } const feedbacksUrl = `${ms.url}feedbacks/post/${data.items[0].id}?key=${ms.key}&filter=HNKJJKGNHOHLNOKINNGOOIHJNLHLOJOHIOFFLJIJJHLNNF&page=1`; $.get(feedbacksUrl).done((feedbacks) => { data.items[0].feedbacks = feedbacks.items; callback(data.items[0]); }); } }) .fail( () => toastr.error( 'This report could not be loaded.', null, {preventDuplicates: true} ) ); } /** * listHasCurrentUser - Checks if the list of users on this flag report contains the current user. * * @private * @memberof module:fire * * @param {object} flags A report's (auto-)flags, where it's `users` array has to be checked. * * @returns {boolean} `true` if the current user is found in the flag list. */ function listHasCurrentUser(flags) { return flags && Array.isArray(flags.users) && fire.chatUser && flags.users.some(({username}) => username === fire.chatUser.name); } /** * loadDataForButtonUponEvent - Wraps loadDataForButton so that it can be called by a jQuery event handler. * * @private * @memberof module:fire * * @param {DOM_node} this The FIRE button where the event happened. */ function loadDataForButtonUponEvent() { loadDataForButton(this); } /** * loadDataForButton - Loads the report and the report's data associated with a FIRE button. * * @private * @memberof module:fire * * @param {DOM_node|jQuery} fireButton The FIRE button * @param {boolean} openAfterLoadOrEvent Open the report popup after load? * */ function loadDataForButton(fireButton, openAfterLoadOrEvent) { const $fireButton = $(fireButton); const openAfterLoad = openAfterLoadOrEvent === true; const url = $fireButton.data('url'); if (openAfterLoad) { $fireButton.addClass('fire-data-loading'); } if (!fire.reportCache[url] || fire.reportCache[url].isExpired) { getDataForUrl(url, (data) => parseDataForReport(data, openAfterLoad, $fireButton)); } else if (openAfterLoad === true) { $fireButton.click(); } } /** * updateReportCache - Loads all MS data on the page. * * @private * @memberof module:fire * */ function updateReportCache() { // eslint-disable-line no-unused-vars const urls = $('.fire-button') .map((index, element) => $(element).data('url')) .toArray() .filter((url) => !fire.reportCache[url]) // Only get un-cached reports .join(','); const {ms} = fire.api; const url = `${ms.url}posts/urls?key=${ms.key}&filter=HFHNHJFMGNKNFFFIGGOJLNNOFGNMILLJ&page=1&urls=${urls}`; $.get(url, (response) => { fire.log('Report cache updated:', response); if (response && response.items) { if (response.items.length <= 0) { toastr.info('No metasmoke reports found.'); } const itemsById = {}; for (const item of response.items) { itemsById[item.id] = item; } // May need to handle the possibility that there will be multiple pages const feedbacksUrl = `${ms.url}feedbacks/post/${Object.keys(itemsById).join(',')}?key=${ms.key}&filter=HNKJJKGNHOHLNOKINNGOOIHJNLHLOJOHIOFFLJIJJHLNNF`; $.get(feedbacksUrl).done((feedbacks) => { // Add the feedbacks to each main item. for (const feedback of feedbacks.items) { itemsById[feedback.id] = feedback; } for (const item of response.items) { parseDataForReport(item, false, null, true); } }); } }); } /** * parseDataForReport - Parse a report's loaded data. * * @private * @memberof module:fire * * @param {object} data A metasmoke report * @param {boolean} openAfterLoad Open the report popup after load? * @param {object} $this The clicked FIRE report button * @param {boolean} skipLoadPost skip loading additional data for the post? * */ function parseDataForReport(data, openAfterLoad, $this, skipLoadPost) { data.is_answer = data.link.includes('/a/'); data.site = getSEApiParamFromUrl(data.link); data.is_deleted = data.deleted_at !== null; data.message_id = Number.parseInt($this.closest('.message')[0].id.split('-')[1], 10); data.has_auto_flagged = listHasCurrentUser(data.autoflagged) && data.autoflagged.flagged; data.has_manual_flagged = listHasCurrentUser(data.manual_flags); data.has_flagged = data.has_auto_flagged || data.has_manual_flagged; if (Array.isArray(data.feedbacks)) { // Has feedback data.has_sent_feedback = data.feedbacks.some( // Feedback has been sent already ({user_name}) => user_name === fire.chatUser.name ); } else { data.has_sent_feedback = false; } const match = data.link.match(/.*\/(\d+)/); if (match && match[1]) { [, data.post_id] = match; } fire.reportCache[data.link] = data; // Store the data fire.log('Loaded report data', data); if (!skipLoadPost) { loadPost(data); } if (openAfterLoad === true) { $this.click(); } } /** * getSEApiParamFromUrl - Parse a site url into a API parameter. * * @private * @memberof module:fire * * @param {string} url A report's Stack Exchange link * * @returns {string} The Stack Exchange API name for the report's site. */ function getSEApiParamFromUrl(url) { return url.replace(/(https?:)?\/+/, '') .split(/\.com|\//)[0] .replace(/\.stackexchange/g, ''); } /** * loadStackExchangeSites - Loads a list of all Stack Exchange Sites. * * @private * @memberof module:fire * */ function loadStackExchangeSites() { const now = new Date().valueOf(); const hasUpdated = fire.metaData.version === fire.userData.version; let {sites} = fire; // If there are no sites or the site data is over 7 days if (hasUpdated || !sites || sites.storedAt < now - fire.constants.siteDataCacheTime) { sites = {}; // Clear the site data delete localStorage['fire-sites']; delete localStorage['fire-user-sites']; } if (!sites.storedAt) { // If the site data is empty const parameters = { filter: '!Fn4IB7S7Yq2UJF5Bh48LrjSpTc', pagesize: 10000, // "sites" endpoint has a special dispensation that it can be any pagesize. }; getSE( 'sites', 'sites', parameters, ({items}) => { for (const item of items) { sites[item.api_site_parameter] = item; } sites.storedAt = now; // Set the storage timestamp fire.sites = sites; // Store the site list loadCurrentSEUser(); fire.log('Loaded Stack Exchange sites'); }); } } /** * loadPost - Loads additional information for a post from the Stack exchange API. * * @private * @memberof module:fire * * @param {object} report The metasmoke report. */ function loadPost(report) { const parameters = {site: report.site}; getSE( 'posts/{}', `posts/${report.post_id}`, parameters, (response) => { if (response.items && response.items.length > 0) { report.se = report.se || {}; [report.se.post] = response.items; showReputation(report); loadPostFlagStatus(report); loadPostRevisions(report); } else { report.is_deleted = true; $('.fire-reported-post').addClass('fire-deleted'); if (typeof autoflagging !== 'undefined') { $(`#message-${report.message_id} .content`).addClass('ai-deleted'); } if (report.has_sent_feedback) { $('a.fire-feedback-button:not([disabled])').attr('disabled', true); } } fire.log('Loaded a post', response); }); } /** * loadPostRevisions - Loads a post's revision history from the Stack Exchange API. * * @private * @memberof module:fire * * @param {object} report The metasmoke report. */ function loadPostRevisions(report) { const parameters = {site: report.site}; getSE( 'posts/{}/revisions', `posts/${report.post_id}/revisions`, parameters, (response) => { if (response && response.items) { report.se.revisions = response.items; report.revision_count = response.items.length; if (report.revision_count > 1) { showEditedIcon(); } fire.log('Loaded a post\'s revision status', response); } }); } /** * showEditedIcon - Render a "Edited" icon on a opened report popup. * * @private * @memberof module:fire * */ function showEditedIcon() { const title = $('.fire-post-title'); if (!title.data('has-edit-icon')) { title .prepend( emojiOrImage('pencil') .attr('fire-tooltip', 'This post has been edited.') .after(' ') ) .data('has-edit-icon', true); } } /* linkifyTextURLs was originally highlight text via RegExp * Copied by Makyen from his use of it in MagicTag2, which was copied from Makyen's * answer to: Highlight a word of text on the page using .replace() at: * https://stackoverflow.com/a/40712458/3773011 * and substantially rewritten for SOCVR's Archiver. * It was then copied by Makyen to here and modified to add the option to not shorten the link text. */ /** * linkifyTextURLs - Modify in place HTTP/HTTPS URLs as plain text within a DOM into links. * * @private * @memberof module:fire * * @param {DOM_node} element The element containing the DOM structure to change. * @param {truthy/falsy} useSpan If true, the new link is wrapped in a . * @param {truthy/falsy} shortenLinkText If true, the text for the link isn't shortened to what SE does for such links in SE Chat. * */ function linkifyTextURLs(element, useSpan = false, shortenLinkText = true) { // This changes bare http/https/ftp URLs into links with link-text a shortened version of the URL. // If useSpan is truthy, then a span with the new elements replaces the text node. // If useSpan is falsy, then the new nodes are added as children of the same element as the text node being replaced. // The [\u200C\u200B] characters are added by SE chat to facilitate word-wrapping & should be removed from the URL. const minLengthToBeURL = 8; const maxLengthShortenedDisplayURL = 32; const maxLengthThresholdShortenedDisplayURL = maxLengthShortenedDisplayURL - 1; const endOfShortenedDisplayURLSlice = maxLengthThresholdShortenedDisplayURL - 2; const urlSplitRegex = /((?:\b(?:https?|ftp):\/\/)(?:[\w.~:\/?#[\]@!$&'()*+%,;=\u200C\u200B-]{2,}))/g; // eslint-disable-line no-useless-escape const urlRegex = /(?:\b(?:https?|ftp):\/\/)([\w.~:\/?#[\]@!$&'()*+%,;=\u200C\u200B-]{2,})/g; // eslint-disable-line no-useless-escape if (!element) { throw new Error('element is invalid'); } /** * handleTextNode - Replace any bare URL in the supplied text Node with an . * * @private * @memberof module:fire * * @param {DOM_Node} textNode The text Node to be checked for containing a bare URL. * @param {truthy} innerShortenLinkText If true, shorten the length of displayed URL text to what's used in SE Chat. */ function handleTextNode(textNode, innerShortenLinkText = true) { const textNodeParent = textNode.parentNode; if (textNode.nodeName !== '#text' || textNodeParent.nodeName === 'SCRIPT' || textNodeParent.nodeName === 'STYLE' ) { // Don't do anything except on text nodes, which are not children of