// ==UserScript== // @name Advanced Flagging // @namespace https://github.com/SOBotics // @version 2.2.3 // @author Robert Rudman // @contributor double-beep // @match *://*.stackexchange.com/* // @match *://*.stackoverflow.com/* // @match *://*.superuser.com/* // @match *://*.serverfault.com/* // @match *://*.askubuntu.com/* // @match *://*.stackapps.com/* // @match *://*.mathoverflow.net/* // @exclude *://chat.stackexchange.com/* // @exclude *://chat.meta.stackexchange.com/* // @exclude *://chat.stackoverflow.com/* // @exclude *://area51.stackexchange.com/* // @exclude *://data.stackexchange.com/* // @exclude *://stackoverflow.com/c/* // @exclude *://winterbash*.stackexchange.com/* // @exclude *://api.stackexchange.com/* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_addStyle // @downloadURL https://github.com/SOBotics/AdvancedFlagging/raw/master/dist/AdvancedFlagging.user.js // @updateURL https://github.com/SOBotics/AdvancedFlagging/raw/master/dist/AdvancedFlagging.user.js // ==/UserScript== /* globals StackExchange, Stacks, $ */ "use strict"; (() => { var __defProp = Object.defineProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; // src/UserscriptTools/Store.ts var Cached = { Configuration: { key: "Configuration", openOnHover: "openOnHover", watchFlags: "watchFlags", watchQueues: "watchQueues", allowComments: "allowComments", linkDisabled: "linkDisabled", addAuthorName: "addAuthorName", debug: "debug" }, Fkey: "fkey", Metasmoke: { userKey: "MetaSmoke.userKey", disabled: "MetaSmoke.disabled" }, FlagTypes: "FlagTypes", FlagCategories: "FlagCategories" }; var Store = class _Store { // cache-related helpers/values // Some information from cache is stored on the variables as objects to make editing easier and simpler // Each time something is changed in the variables, update* must also be called to save the changes to the cache static config = _Store.get(Cached.Configuration.key) ?? { default: {} }; static categories = _Store.get(Cached.FlagCategories) ?? []; static flagTypes = _Store.get(Cached.FlagTypes) ?? []; static updateConfiguration = () => _Store.set(Cached.Configuration.key, this.config); static updateFlagTypes = () => _Store.set(Cached.FlagTypes, this.flagTypes); static dryRun = this.config[Cached.Configuration.debug]; // export const updateCategories = (): void => GreaseMonkeyCache.storeInCache(FlagCategoriesKey, cachedCategories); static async getAndCache(cacheKey, getterPromise, expiresAt) { const cachedItem = _Store.get(cacheKey); if (cachedItem) return cachedItem; const result = await getterPromise(); _Store.set(cacheKey, result, expiresAt); return result; } // There are two kinds of objects that are stored in the cache: // - those that expire (only fkey currently) // - those that are not // // The type of those that are expirable is ExpiryingCacheItem. // The others are strings or objects. // To make TS happy and avoid runtime errors, we need to take into account both cases. static get(cacheKey) { const cachedItem = GM_getValue(cacheKey); if (!cachedItem) return null; const isItemExpired = typeof cachedItem === "object" && "Data" in cachedItem && new Date(cachedItem.Expires) < /* @__PURE__ */ new Date(); if (isItemExpired) return null; return typeof cachedItem === "object" && "Data" in cachedItem ? cachedItem.Data : cachedItem; } static set(cacheKey, item, expiresAt) { const jsonObject = expiresAt ? { Expires: expiresAt.getTime(), Data: item } : item; GM_setValue(cacheKey, jsonObject); } static unset(cacheKey) { GM_deleteValue(cacheKey); } }; // node_modules/@userscripters/stacks-helpers/dist/checkbox.js var checkbox_exports = {}; __export(checkbox_exports, { makeStacksCheckboxes: () => makeStacksCheckboxes }); var makeStacksCheckboxes = (checkboxes, options) => { return input_exports.makeStacksRadiosOrCheckboxes(checkboxes, "checkbox", options); }; // node_modules/@userscripters/stacks-helpers/dist/input.js var input_exports = {}; __export(input_exports, { makeStacksInput: () => makeStacksInput, makeStacksRadiosOrCheckboxes: () => makeStacksRadiosOrCheckboxes }); var makeStacksInput = (id, inputOptions = {}, labelOptions) => { var _a; const { value = "", classes = [], placeholder = "", title, isSearch } = inputOptions; const inputParent = document.createElement("div"); inputParent.classList.add("d-flex", "ps-relative"); const input = document.createElement("input"); input.classList.add("s-input", ...classes); input.type = "text"; input.id = input.name = id; input.placeholder = placeholder; input.value = value; if (title) input.title = title; if (isSearch) { input.classList.add("s-input__search"); const [searchIcon] = icons_exports.makeStacksIcon("iconSearch", "m18 16.5-5.14-5.18h-.35a7 7 0 10-1.19 1.19v.35L16.5 18l1.5-1.5zM12 7A5 5 0 112 7a5 5 0 0110 0z", { classes: ["s-input-icon", "s-input-icon__search"], width: 18 }); inputParent.append(searchIcon); } inputParent.prepend(input); if (labelOptions) { (_a = labelOptions.parentClasses || (labelOptions.parentClasses = [])) === null || _a === void 0 ? void 0 : _a.push("flex--item"); const label = label_exports.makeStacksLabel(id, labelOptions); const container = document.createElement("div"); container.classList.add("d-flex", "gy4", "fd-column"); container.append(label, inputParent); return container; } return inputParent; }; var makeStacksRadiosOrCheckboxes = (inputs, type, options, withoutFieldset) => { const fieldset = document.createElement("fieldset"); fieldset.classList.add("s-check-group"); if (options) { const { legendText = "", legendDescription = "", horizontal, classes = [] } = options; if (horizontal) { fieldset.classList.add("s-check-group__horizontal"); } fieldset.classList.add(...classes); const legend = document.createElement("legend"); legend.classList.add("flex--item", "s-label"); legend.innerText = legendText; if (legendDescription) { const span = document.createElement("span"); span.classList.add("ml4", "fw-normal", "fc-light"); span.innerText = legendDescription; legend.append(" ", span); } fieldset.append(legend); } const items = inputs.map((inputType) => makeFormContainer(inputType, type)); if (withoutFieldset) { return items; } else { fieldset.append(...items); return [fieldset, ...items]; } }; var makeFormContainer = (radioCheckbox, type) => { const { id, labelConfig, selected = false, disabled = false, name } = radioCheckbox; const container = document.createElement("div"); container.classList.add("s-check-control"); const input = document.createElement("input"); input.classList.add(`s-${type}`); input.type = type; input.id = id; input.checked = selected; input.disabled = disabled; if (name) { input.name = name; } const label = label_exports.makeStacksLabel(id, labelConfig); container.append(input, label); return container; }; // node_modules/@userscripters/stacks-helpers/dist/label.js var label_exports = {}; __export(label_exports, { makeStacksLabel: () => makeStacksLabel }); var makeStacksLabel = (forId, labelOptions) => { const { classes = [], parentClasses = [], text, description, statusText, statusType } = labelOptions; const labelParent = document.createElement("div"); labelParent.classList.add(...parentClasses); const label = document.createElement("label"); label.classList.add("s-label", ...classes); label.htmlFor = forId; label.innerHTML = text; if (statusText && statusType) { const status = document.createElement("span"); status.innerHTML = statusText; status.classList.add("s-label--status"); if (statusType !== "optional") { status.classList.add(`s-label--status__${statusType}`); } label.append(" ", status); } if (description) { const p = document.createElement("p"); p.classList.add("s-description", "mt2"); p.innerHTML = description; label.classList.add("d-block"); label.append(p); labelParent.append(label); return labelParent; } else { label.classList.add("flex--item"); return label; } }; // node_modules/@userscripters/stacks-helpers/dist/links.js var links_exports = {}; __export(links_exports, { makeLink: () => makeLink }); var makeLink = (options = {}) => { const { href = "", isButton = false, type = "", blockLink = null, text, click, classes = [] } = options; const anchor = document.createElement(isButton ? "button" : "a"); anchor.classList.add("s-link", ...classes); anchor.textContent = text; if (type) { anchor.classList.add(`s-link__${type}`); } if (blockLink) { anchor.classList.add("s-block-link"); anchor.classList.remove("s-link"); if (blockLink.border) { anchor.classList.add(`s-block-link__${blockLink.border}`); } if (blockLink.selected) { anchor.classList.add("is-selected"); } if (blockLink.danger) { anchor.classList.add("s-block-link__danger"); } } if (href && anchor instanceof HTMLAnchorElement) { anchor.href = href; } if (click) { const { handler, options: options2 } = click; anchor.addEventListener("click", handler, options2); } return anchor; }; // node_modules/@userscripters/stacks-helpers/dist/menus.js var menus_exports = {}; __export(menus_exports, { makeMenu: () => makeMenu }); var makeMenu = (options = {}) => { const { itemsType = "a", childrenClasses = [], navItems, classes = [] } = options; const menu = document.createElement("ul"); menu.classList.add("s-menu", ...classes); menu.setAttribute("role", "menu"); navItems.forEach((navItem) => { var _a; const li = document.createElement("li"); if ("popover" in navItem && navItem.popover) { const { position = "auto", html } = navItem.popover; Stacks.setTooltipHtml(li, html, { placement: position }); } if ("separatorType" in navItem) { const { separatorType, separatorText } = navItem; li.setAttribute("role", "separator"); li.classList.add(`s-menu--${separatorType}`); if (separatorText) li.textContent = separatorText; menu.append(li); return; } else if ("checkbox" in navItem) { const { checkbox, checkboxOptions } = navItem; const [, input] = checkbox_exports.makeStacksCheckboxes([checkbox], checkboxOptions); li.append(input); menu.append(li); return; } (_a = navItem.classes) === null || _a === void 0 ? void 0 : _a.push(...childrenClasses); li.setAttribute("role", "menuitem"); const item = links_exports.makeLink(Object.assign({ isButton: itemsType === "button" || navItem.isButton, blockLink: {} }, navItem)); li.append(item); menu.append(li); }); return menu; }; // node_modules/@userscripters/stacks-helpers/dist/notices.js var notices_exports = {}; __export(notices_exports, { makeStacksNotice: () => makeStacksNotice }); var makeStacksNotice = (options) => { const { type, important = false, icon, text, classes = [] } = options; const notice = document.createElement("aside"); notice.classList.add("s-notice", ...classes); notice.setAttribute("role", important ? "alert" : "status"); if (type) { notice.classList.add(`s-notice__${type}`); } if (important) { notice.classList.add("s-notice__important"); } if (icon) { notice.classList.add("d-flex"); const iconContainer = document.createElement("div"); iconContainer.classList.add("flex--item", "mr8"); const [name, path] = icon; const [svgIcon] = icons_exports.makeStacksIcon(name, path, { width: 18 }); iconContainer.append(svgIcon); const textContainer = document.createElement("div"); textContainer.classList.add("flex--item", "lh-lg"); textContainer.append(text); notice.append(iconContainer, textContainer); } else { const p = document.createElement("p"); p.classList.add("m0"); p.append(text); notice.append(p); } return notice; }; // node_modules/@userscripters/stacks-helpers/dist/radio.js var radio_exports = {}; __export(radio_exports, { makeStacksRadios: () => makeStacksRadios }); var makeStacksRadios = (radios, groupName, options) => { radios.forEach((radio) => { radio.name = groupName; }); return input_exports.makeStacksRadiosOrCheckboxes(radios, "radio", options); }; // node_modules/@userscripters/stacks-helpers/dist/select.js var select_exports = {}; __export(select_exports, { makeStacksSelect: () => makeStacksSelect, toggleValidation: () => toggleValidation }); var makeStacksSelect = (id, items, options = {}, labelOptions) => { const { disabled = false, size, validation, classes = [] } = options; const container = document.createElement("div"); container.classList.add("d-flex", "gy4", "fd-column"); if (labelOptions) { (labelOptions.parentClasses || (labelOptions.parentClasses = [])).push("flex--item"); const label = label_exports.makeStacksLabel(id, labelOptions); container.append(label); } const selectContainer = document.createElement("div"); selectContainer.classList.add("flex--item", "s-select"); if (size) { selectContainer.classList.add(`s-select__${size}`); } const select = document.createElement("select"); select.id = id; select.classList.add(...classes); if (disabled) { container.classList.add("is-disabled"); select.disabled = true; } items.forEach((item) => { const { value, text, selected = false } = item; const option = document.createElement("option"); option.value = value; option.text = text; option.selected = selected; select.append(option); }); selectContainer.append(select); container.append(selectContainer); if (validation) { toggleValidation(container, validation); } return container; }; var toggleValidation = (container, state) => { var _a, _b; container.classList.remove("has-success", "has-warning", "has-error"); (_a = container.querySelector(".s-input-icon")) === null || _a === void 0 ? void 0 : _a.remove(); if (!state) return; container.classList.add(`has-${state}`); const [name, path] = icons_exports.validationIcons[state]; const [icon] = icons_exports.makeStacksIcon(name, path, { classes: ["s-input-icon"], width: 18 }); (_b = container.querySelector(".s-select")) === null || _b === void 0 ? void 0 : _b.append(icon); }; // node_modules/@userscripters/stacks-helpers/dist/spinner.js var spinner_exports = {}; __export(spinner_exports, { makeSpinner: () => makeSpinner }); var makeSpinner = (options = {}) => { const { size = "", hiddenText = "", classes = [] } = options; const spinner = document.createElement("div"); spinner.classList.add("s-spinner", ...classes); if (size) { spinner.classList.add(`s-spinner__${size}`); } if (hiddenText) { const hiddenElement = document.createElement("div"); hiddenElement.classList.add("v-visible-sr"); hiddenElement.innerText = hiddenText; spinner.append(hiddenElement); } return spinner; }; // node_modules/@userscripters/stacks-helpers/dist/textarea.js var textarea_exports = {}; __export(textarea_exports, { makeStacksTextarea: () => makeStacksTextarea, toggleValidation: () => toggleValidation2 }); var makeStacksTextarea = (id, textareaOptions = {}, labelOptions) => { const { value = "", classes = [], placeholder = "", title = "", size, validation } = textareaOptions; const textareaParent = document.createElement("div"); textareaParent.classList.add("d-flex", "fd-column", "gy4", ...classes); if (labelOptions) { const label = label_exports.makeStacksLabel(id, labelOptions); textareaParent.append(label); } const textarea = document.createElement("textarea"); textarea.classList.add("flex--item", "s-textarea"); textarea.id = id; textarea.placeholder = placeholder; textarea.value = value; textarea.title = title; if (size) { textarea.classList.add(`s-textarea__${size}`); } textareaParent.append(textarea); if (validation) { toggleValidation2(textareaParent, validation); } return textareaParent; }; var toggleValidation2 = (textareaParent, validation) => { var _a, _b; textareaParent.classList.remove("has-success", "has-warning", "has-error"); const oldTextarea = textareaParent.querySelector(".s-textarea"); if (!validation) { (_a = textareaParent.querySelector(".s-input-icon")) === null || _a === void 0 ? void 0 : _a.remove(); (_b = textareaParent.querySelector(".s-input-message")) === null || _b === void 0 ? void 0 : _b.remove(); const validationContainer = oldTextarea.parentElement; validationContainer === null || validationContainer === void 0 ? void 0 : validationContainer.replaceWith(oldTextarea); return; } const { state, description } = validation; textareaParent.classList.add(`has-${state}`); const [iconName, iconPath] = icons_exports.validationIcons[state]; const [icon] = icons_exports.makeStacksIcon(iconName, iconPath, { classes: ["s-input-icon"], width: 18 }); if (oldTextarea.nextElementSibling) { oldTextarea.nextElementSibling.replaceWith(icon); const inputMessage = textareaParent.querySelector(".s-input-message"); if (description) { if (inputMessage) { inputMessage.innerHTML = description; } else { createAndAppendDescription(description, textareaParent); } } else if (!description && inputMessage) { inputMessage.remove(); } } else { const validationContainer = document.createElement("div"); validationContainer.classList.add("d-flex", "ps-relative"); validationContainer.append(oldTextarea, icon); textareaParent.append(validationContainer); if (description) { createAndAppendDescription(description, textareaParent); } } }; var createAndAppendDescription = (description, appendTo) => { const message = document.createElement("p"); message.classList.add("flex--item", "s-input-message"); message.innerHTML = description; appendTo.append(message); }; // node_modules/@userscripters/stacks-helpers/dist/toggle.js var toggle_exports = {}; __export(toggle_exports, { makeStacksToggle: () => makeStacksToggle }); var makeStacksToggle = (id, labelOptions, on = false, ...classes) => { const container = document.createElement("div"); container.classList.add("d-flex", "g8", "ai-center", ...classes); const label = label_exports.makeStacksLabel(id, labelOptions); const toggle = document.createElement("input"); toggle.id = id; toggle.classList.add("s-toggle-switch"); toggle.type = "checkbox"; toggle.checked = on; container.append(label, toggle); return container; }; // node_modules/@userscripters/stacks-helpers/dist/buttons/index.js var buttons_exports = {}; __export(buttons_exports, { makeStacksButton: () => makeStacksButton }); var makeStacksButton = (id, text, options = {}) => { const { title, type = [], primary = false, loading = false, selected = false, disabled = false, badge, size, iconConfig, click, classes = [] } = options; const btn = document.createElement("button"); if (id !== "") { btn.id = id; } btn.classList.add("s-btn", ...type.map((name) => `s-btn__${name}`), ...classes); btn.append(text); btn.type = "button"; btn.setAttribute("role", "button"); const ariaLabel = title || (text instanceof HTMLElement ? text.textContent || "" : text); btn.setAttribute("aria-label", ariaLabel); if (primary) { btn.classList.add("s-btn__filled"); } if (loading) { btn.classList.add("is-loading"); } if (title) { btn.title = title; } if (selected) { btn.classList.add("is-selected"); } if (disabled) { btn.disabled = true; } if (badge) { const badgeEl = document.createElement("span"); badgeEl.classList.add("s-btn--badge"); const badgeNumber = document.createElement("span"); badgeNumber.classList.add("s-btn--number"); badgeNumber.textContent = badge.toString(); badgeEl.append(badgeNumber); btn.append(" ", badgeEl); } if (size) { btn.classList.add(`s-btn__${size}`); } if (iconConfig) { btn.classList.add("s-btn__icon"); const { name, path, width, height } = iconConfig; const [icon] = icons_exports.makeStacksIcon(name, path, { width, height }); btn.prepend(icon, " "); } if (click) { const { handler, options: options2 } = click; btn.addEventListener("click", handler, options2); } return btn; }; // node_modules/@userscripters/stacks-helpers/dist/icons/index.js var icons_exports = {}; __export(icons_exports, { makeStacksIcon: () => makeStacksIcon, validationIcons: () => validationIcons }); var validationIcons = { warning: [ "iconAlert", "M7.95 2.71c.58-.94 1.52-.94 2.1 0l7.69 12.58c.58.94.15 1.71-.96 1.71H1.22C.1 17-.32 16.23.26 15.29L7.95 2.71ZM8 6v5h2V6H8Zm0 7v2h2v-2H8Z" ], error: [ "iconAlertCircle", "M9 17c-4.36 0-8-3.64-8-8 0-4.36 3.64-8 8-8 4.36 0 8 3.64 8 8 0 4.36-3.64 8-8 8ZM8 4v6h2V4H8Zm0 8v2h2v-2H8Z" ], success: [ "iconCheckmark", "M16 4.41 14.59 3 6 11.59 2.41 8 1 9.41l5 5 10-10Z" ] }; var makeStacksIcon = (name, pathConfig, { classes = [], width = 14, height = width } = {}) => { const ns = "http://www.w3.org/2000/svg"; const svg = document.createElementNS(ns, "svg"); svg.classList.add("svg-icon", name, ...classes); svg.setAttribute("width", width.toString()); svg.setAttribute("height", height.toString()); svg.setAttribute("viewBox", `0 0 ${width} ${height}`); svg.setAttribute("aria-hidden", "true"); if (typeof pathConfig === "string") { const path = document.createElementNS(ns, "path"); path.setAttribute("d", pathConfig); svg.append(path); return [svg, path]; } else { const paths = []; pathConfig.forEach((svgPath) => { const path = document.createElementNS(ns, "path"); path.setAttribute("d", svgPath); svg.append(path); paths.push(path); }); return [svg, paths[0]]; } }; // node_modules/@userscripters/stacks-helpers/dist/modals/index.js var modals_exports = {}; __export(modals_exports, { makeStacksModal: () => makeStacksModal }); var makeStacksModal = (id, options) => { const { classes = [], danger = false, fullscreen = false, celebratory = false, title: { text, id: titleId, classes: titleClasses = [] }, body: { bodyHtml, id: bodyId, classes: bodyClasses = [] }, footer: { buttons, classes: footerClasses = [] } } = options; const modal = document.createElement("aside"); modal.id = id; modal.classList.add("s-modal", ...classes); modal.setAttribute("role", "dialog"); modal.setAttribute("data-controller", "s-modal"); modal.setAttribute("data-s-modal-target", "modal"); if (danger) { modal.classList.add("s-modal__danger"); } if (celebratory) { modal.classList.add("s-modal__celebration"); } const dialog = document.createElement("div"); dialog.classList.add("s-modal--dialog"); dialog.setAttribute("role", "document"); if (fullscreen) { dialog.classList.add("s-modal__full"); } const header = document.createElement("h1"); header.classList.add("s-modal--header", ...titleClasses); header.append(text); if (titleId) { header.id = titleId; modal.setAttribute("aria-labelledby", titleId); } const body = document.createElement("p"); body.classList.add("s-modal--body", ...bodyClasses); body.append(bodyHtml); if (bodyId) { body.id = bodyId; modal.setAttribute("aria-describedby", bodyId); } const footer = document.createElement("div"); footer.classList.add("d-flex", "gx8", "s-modal--footer", ...footerClasses); buttons.forEach((button) => { const { element, hideOnClick } = button; element.classList.add("flex--item"); if (hideOnClick) { element.setAttribute("data-action", "s-modal#hide"); } footer.append(element); }); const [iconClear] = icons_exports.makeStacksIcon("iconClear", "M15 4.41 13.59 3 9 7.59 4.41 3 3 4.41 7.59 9 3 13.59 4.41 15 9 10.41 13.59 15 15 13.59 10.41 9 15 4.41Z", { width: 18 }); const close = document.createElement("button"); close.classList.add("s-modal--close", "s-btn", "s-btn__muted"); close.setAttribute("type", "button"); close.setAttribute("aria-label", "Close"); close.setAttribute("data-action", "s-modal#hide"); close.append(iconClear); dialog.append(header, body, footer, close); modal.append(dialog); return modal; }; // src/UserscriptTools/Progress.ts var Progress = class { constructor(controller) { this.controller = controller; this.element = this.getPopover(); } element; attach() { if (!this.controller) return; Stacks.attachPopover(this.controller, this.element, { autoShow: true, placement: "bottom-start", toggleOnClick: true }); this.element.style.display = "none"; } updateLocation() { const controller = document.querySelector( '.s-spinner[aria-controls="advanced-flagging-progress-popover"]' ); if (!controller) return; Stacks.hidePopover(controller); Stacks.showPopover(controller); } delete() { if (this.controller) { Stacks.detachPopover(this.controller); } this.element.remove(); } addItem(text) { this.element.style.display = ""; const flexItem = this.createItem(text); const wrapper = flexItem.firstElementChild; this.element.lastElementChild?.append(flexItem); return { completed: () => this.completed(wrapper), failed: (reason) => this.failed(wrapper, reason), addSubItem: (text2) => this.addSubItem(flexItem, text2) }; } createItem(text) { const flexItem = document.createElement("div"); flexItem.classList.add("flex--item"); const wrapper = document.createElement("div"); wrapper.classList.add("d-flex", "g8", "fd-row"); const action = document.createElement("div"); action.classList.add("flex--item"); action.textContent = text; const spinner = spinner_exports.makeSpinner({ size: "sm", classes: ["flex--item"] }); wrapper.append(spinner, action); flexItem.append(wrapper); return flexItem; } completed(wrapper) { const done = document.createElement("div"); done.classList.add("flex--item", "fc-green-500", "fw-bold"); done.textContent = "done!"; const tick = Post.getActionIcons()[0]; tick.style.display = "block"; wrapper.querySelector(".s-spinner")?.remove(); wrapper.prepend(tick); wrapper.append(done); } failed(wrapper, reason) { const failed = document.createElement("div"); failed.classList.add("flex--item", "fc-red-500", "fw-bold"); failed.textContent = `failed${reason ? `: ${reason}` : "!"}`; const cross = Post.getActionIcons()[1]; cross.style.display = "block"; wrapper.querySelector(".s-spinner")?.remove(); wrapper.prepend(cross); wrapper.append(failed); } addSubItem(div, text) { const parent = this.createItem(text); parent.classList.add("ml24", "mt4"); parent.classList.remove("flex--item"); div.append(parent); const wrapper = parent.firstElementChild; return { completed: () => this.completed(wrapper), failed: (reason) => this.failed(wrapper, reason), addSubItem: (text2) => this.addSubItem(parent, text2) }; } getPopover() { const popover = document.createElement("div"); popover.classList.add("s-popover", "wmn4"); popover.id = "advanced-flagging-progress-popover"; const arrow = document.createElement("div"); arrow.classList.add("s-popover--arrow"); const wrapper = document.createElement("div"); wrapper.classList.add("d-flex", "g8", "fd-column"); popover.append(arrow, wrapper); return popover; } }; // src/shared.ts var possibleFeedbacks = { Smokey: ["tpu-", "tp-", "fp-", "naa-", ""], Natty: ["tp", "fp", "ne", ""], Guttenberg: ["tp", "fp", ""], "Generic Bot": ["track", ""] }; var username = document.querySelector( 'a[href^="/users/"] div[title]' )?.title ?? ""; var popupDelay = 4 * 1e3; var configBoxes = [ ["Leave comment", "comment"], ["Flag", "flag"], ["Downvote", "downvote"], ["Delete", "delete"] ]; var getIconPath = (svg) => { const parsed = new DOMParser().parseFromString(svg, "text/html"); const paths = [...parsed.body.querySelectorAll("path")]; return paths.map((path) => path.getAttribute("d") ?? ""); }; function displayStacksToast(message, type, dismissable) { StackExchange.helpers.showToast(message, { type, transientTimeout: popupDelay, // disallow dismissing the popup if inside modal dismissable // so that dismissing the toast won't close the modal // $parent: addParent ? $(parent) : $() }); } function attachPopover(element, text, position = "bottom-start") { Stacks.setTooltipText( element, text, { placement: position } ); } function getFormDataFromObject(object) { return Object.keys(object).reduce((formData, key) => { formData.append(key, object[key]); return formData; }, new FormData()); } async function delay(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } var callbacks = []; var postIds = []; function addXHRListener(callback, postId) { if (postId && postIds.includes(postId)) return; else if (postId) postIds.push(postId); callbacks.push(callback); } function interceptXhr() { const open = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function() { this.addEventListener("load", () => { callbacks.forEach((cb) => setTimeout(() => cb(this))); }, false); open.apply(this, arguments); }; } function getFullFlag(flagType, target, postId) { const placeholderTarget = /\$TARGET\$/g; const placeholderCopypastorLink = /\$COPYPASTOR\$/g; const content = flagType.flagText; if (!content) return null; const copypastorLink = `https://copypastor.sobotics.org/posts/${postId}`; return content.replace(placeholderTarget, `https:${target}`).replace(placeholderCopypastorLink, copypastorLink); } function getFlagTypeFromFlagId(flagId) { return Store.flagTypes.find(({ id }) => id === flagId) ?? null; } function getHumanFromDisplayName(displayName) { const flags = { ["PostSpam" /* Spam */]: "as spam", ["PostOffensive" /* Rude */]: "as R/A", ["AnswerNotAnAnswer" /* NAA */]: "as NAA", ["NoFlag" /* NoFlag */]: "", ["PlagiarizedContent" /* Plagiarism */]: "for plagiarism", ["PostOther" /* ModFlag */]: "for moderator attention" }; return flags[displayName] || ""; } function toggleLoading(button) { button.classList.toggle("is-loading"); button.ariaDisabled = button.ariaDisabled === "true" ? "false" : "true"; button.disabled = !button.disabled; } async function addProgress(event, flagType, post = new Page(true).posts[0]) { const input = document.querySelector("#advanced-flagging-flag-post"); if (!post.filterReporters(flagType.feedbacks).length && !input?.checked) return; event.preventDefault(); event.stopPropagation(); const target = event.target; toggleLoading(target); post.progress = new Progress(target); post.progress.attach(); if (input?.checked && !StackExchange.options.user.isModerator) { const flagProgress = post.progress.addItem("Flagging as NAA..."); try { await post.flag("AnswerNotAnAnswer" /* NAA */, null); flagProgress.completed(); } catch (error) { console.error(error); flagProgress.failed( error instanceof Error ? error.message : "see console for more details" ); } } try { await post.sendFeedbacks(flagType); } finally { await delay(1e3); toggleLoading(target); target.click(); } } function appendLabelAndBoxes(element, post) { const label = label_exports.makeStacksLabel( "noid", { text: "Send feedback to:", classes: ["mt2", "fw-normal"] } ); const boxes = Object.entries(post.getFeedbackBoxes(true)).map(([, box]) => box); const [, ...checkboxes] = checkbox_exports.makeStacksCheckboxes( boxes, { horizontal: true } ); checkboxes.forEach((box) => box.classList.add("flex--item")); element.parentElement?.append(label, ...checkboxes); } // src/UserscriptTools/ChatApi.ts var ChatApi = class _ChatApi { constructor(chatUrl = "https://chat.stackoverflow.com", roomId = 111347) { this.chatUrl = chatUrl; this.roomId = roomId; } nattyId = 6817005; getChatUserId() { return StackExchange.options.user.userId; } async sendMessage(message) { let numTries = 0; const makeRequest = async () => { return await this.sendRequestToChat(message); }; const onFailure = async () => { numTries++; if (numTries < 3) { Store.unset(Cached.Fkey); if (!await makeRequest()) { return onFailure(); } } else { throw new Error("Failed to send message to chat"); } return true; }; if (!await makeRequest()) { return onFailure(); } return true; } async getFinalUrl() { const url = await this.getWsUrl(); const l = await this.getLParam(); return `${url}?l=${l}`; } reportReceived(event) { const data = JSON.parse(event.data); return data[`r${this.roomId}`].e?.filter(({ event_type, user_id }) => { return event_type === 1 && user_id === this.nattyId; }).map((item) => { const { content } = item; if (Store.dryRun) { console.log("New message posted by Natty on room", this.roomId, item); } const matchRegex = /stackoverflow\.com\/a\/(\d+)/; const id = matchRegex.exec(content)?.[1]; return Number(id); }) ?? []; } static getExpiryDate() { const expiryDate = /* @__PURE__ */ new Date(); expiryDate.setDate(expiryDate.getDate() + 1); return expiryDate; } async sendRequestToChat(message) { const url = `${this.chatUrl}/chats/${this.roomId}/messages/new`; if (Store.dryRun) { console.log("Send", message, `to ${this.roomId} via`, url); return Promise.resolve(true); } const fkey = await this.getChannelFKey(); return new Promise((resolve) => { GM_xmlhttpRequest({ method: "POST", url, headers: { "Content-Type": "application/x-www-form-urlencoded" }, data: `text=${encodeURIComponent(message)}&fkey=${fkey}`, onload: ({ status }) => resolve(status === 200), onerror: () => resolve(false) }); }); } getChannelPage() { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: `${this.chatUrl}/rooms/${this.roomId}`, onload: ({ status, responseText }) => { status === 200 ? resolve(responseText) : reject(); }, onerror: () => reject() }); }); } // see https://meta.stackexchange.com/a/218355 async getWsUrl() { const fkey = await this.getChannelFKey(); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "POST", url: `${this.chatUrl}/ws-auth`, headers: { "Content-Type": "application/x-www-form-urlencoded" }, data: `roomid=${this.roomId}&fkey=${fkey}`, onload: ({ status, responseText }) => { if (status !== 200) reject(); const json = JSON.parse(responseText); resolve(json.url); }, onerror: () => reject() }); }); } async getLParam() { const fkey = await this.getChannelFKey(); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "POST", url: `${this.chatUrl}/chats/${this.roomId}/events`, headers: { "Content-Type": "application/x-www-form-urlencoded" }, data: `fkey=${fkey}`, onload: ({ status, responseText }) => { if (status !== 200) reject(); const json = JSON.parse(responseText); resolve(json.time); }, onerror: () => reject() }); }); } getChannelFKey() { const expiryDate = _ChatApi.getExpiryDate(); return Store.getAndCache(Cached.Fkey, async () => { try { const channelPage = await this.getChannelPage(); const parsed = new DOMParser().parseFromString(channelPage, "text/html"); const fkeyInput = parsed.querySelector('input[name="fkey"]'); const fkey = fkeyInput?.value ?? ""; return fkey; } catch (error) { console.error(error); throw new Error("Failed to get chat fkey"); } }, expiryDate); } }; // src/UserscriptTools/Reporter.ts var Reporter = class { name; id; sanitisedName; progress = null; constructor(name, id) { this.name = name; this.sanitisedName = this.name.replace(/\s/g, "").toLowerCase(); this.id = id; } wasReported() { return false; } canBeReported() { return false; } async sendFeedback(feedback) { if (!feedback) return; return new Promise((resolve) => resolve()); } showOnPopover() { return this.wasReported() || this.canBeReported(); } canSendFeedback(feedback) { return Boolean(feedback); } getIcon() { return this.createBotIcon(""); } getProgressMessage(feedback) { return `Sending ${feedback} feedback to ${this.name}`; } createBotIcon(href) { const botImages = { Natty: "https://i.sstatic.net/aMUMt.jpg?s=32&g=1", Smokey: "https://i.sstatic.net/7cmCt.png?s=32&g=1", "Generic Bot": "https://i.sstatic.net/6DsXG.png?s=32&g=1", Guttenberg: "https://i.sstatic.net/kEQs2BQb.png?s=32&g=1" }; const iconWrapper = document.createElement("div"); iconWrapper.classList.add("flex--item", "d-inline-block", "advanced-flagging-icon"); if (!Page.isQuestionPage && !Page.isLqpReviewPage && !Page.isStagingGroundPage) { iconWrapper.classList.add("ml8"); } const iconLink = document.createElement("a"); iconLink.classList.add("s-avatar", "s-avatar__16", "s-user-card--avatar"); if (href) { iconLink.href = href; iconLink.target = "_blank"; attachPopover(iconLink, `Reported by ${this.name}`); } iconWrapper.append(iconLink); const iconImage = document.createElement("img"); iconImage.classList.add("s-avatar--image"); iconImage.src = botImages[this.name]; iconLink.append(iconImage); return iconWrapper; } }; // src/UserscriptTools/CopyPastorAPI.ts var CopyPastorAPI = class _CopyPastorAPI extends Reporter { copypastorId; repost; targetUrl; static copypastorIds = {}; static key = "wgixsmuiz8q8px9kyxgwf8l71h7a41uugfh5rkyj"; static server = "https://copypastor.sobotics.org"; constructor(id) { super("Guttenberg", id); const { copypastorId = 0, repost = false, target_url: targetUrl = "" } = _CopyPastorAPI.copypastorIds[this.id] ?? {}; this.copypastorId = copypastorId; this.repost = repost; this.targetUrl = targetUrl; } static async getAllCopyPastorIds() { if (!Page.isStackOverflow) return; const postUrls = page.getAllPostIds(false, true); if (!postUrls.length) return; try { await this.storeReportedPosts(postUrls); } catch (error) { displayToaster("Could not connect to CopyPastor.", "danger"); console.error(error); } } static storeReportedPosts(postUrls) { const url = `${this.server}/posts/findTarget?url=${postUrls.join(",")}`; return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url, timeout: 1500, onload: ({ responseText }) => { const response = JSON.parse(responseText); if (response.status === "failure") return; response.posts.forEach((item) => { const { post_id: postId, target_url: targetUrl, original_url: originalUrl, repost } = item; const id = /\d+/.exec(originalUrl)?.[0]; const sitePostId = Number(id); this.copypastorIds[sitePostId] = { copypastorId: Number(postId), repost, target_url: targetUrl }; }); resolve(); }, onerror: (error) => reject(error), ontimeout: () => reject("Request timed out") }); }); } sendFeedback(feedback) { const chatId = new ChatApi().getChatUserId(); const payload = { post_id: this.copypastorId, feedback_type: feedback, username, link: `//chat.stackoverflow.com/users/${chatId}`, key: _CopyPastorAPI.key }; const data = Object.entries(payload).map((item) => item.join("=")).join("&"); return new Promise((resolve, reject) => { const url = `${_CopyPastorAPI.server}/feedback/create`; if (Store.dryRun) { console.log("Feedback to Guttenberg via", url, data); resolve(); } GM_xmlhttpRequest({ method: "POST", url, headers: { "Content-Type": "application/x-www-form-urlencoded" }, data, onload: ({ status }) => { status === 200 ? resolve() : reject(); }, onerror: () => reject() }); }); } canBeReported() { return false; } wasReported() { return Boolean(this.copypastorId); } canSendFeedback(feedback) { return this.wasReported() && Boolean(feedback); } getIcon() { return this.createBotIcon( this.copypastorId ? `${_CopyPastorAPI.server}/posts/${this.copypastorId}` : "" ); } }; // src/UserscriptTools/GenericBotAPI.ts var GenericBotAPI = class extends Reporter { constructor(id, deleted) { super("Generic Bot", id); this.deleted = deleted; } key = "Cm45BSrt51FR3ju"; sendFeedback(trackPost) { const flaggerName = encodeURIComponent(username || ""); if (!trackPost) return Promise.resolve(); const answer = document.querySelector(`#answer-${this.id} .js-post-body`); const answerBody = answer?.innerHTML.trim() ?? ""; const contentHash = this.computeContentHash(answerBody); const url = "https://so.floern.com/api/trackpost.php"; const payload = { key: this.key, postId: this.id, contentHash, flagger: flaggerName }; const data = Object.entries(payload).map((item) => item.join("=")).join("&"); if (Store.dryRun) { console.log("Track post via", url, payload); return Promise.resolve(); } return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "POST", url, headers: { "Content-Type": "application/x-www-form-urlencoded" }, data, onload: ({ status, response }) => { if (status !== 200) { console.error("Failed to send track request.", response); reject(); } resolve(); }, onerror: () => reject() }); }); } showOnPopover() { return Page.isStackOverflow; } canSendFeedback(feedback) { return feedback === "track" && !this.deleted && Page.isStackOverflow && Boolean(username); } getProgressMessage(feedback) { return feedback ? "Tracking post with Generic Bot" : ""; } // Ask Floern what this does // https://github.com/SOBotics/Userscripts/blob/master/GenericBot/flagtracker.user.js#L32-L40 computeContentHash(postContent) { if (!postContent) return 0; let hash = 0; for (let i = 0; i < postContent.length; ++i) { hash = (hash << 5) - hash + postContent.charCodeAt(i); hash = hash & hash; } return hash; } }; // src/UserscriptTools/WebsocketUtils.ts var WebsocketUtils = class { constructor(url, id, progress, auth = "", timeout = 1e4) { this.url = url; this.id = id; this.progress = progress; this.auth = auth; this.timeout = timeout; this.initWebsocket(); } websocket = null; async waitForReport(callback) { const connectProgress = this.progress?.addSubItem("Connecting to websocket..."); if (!this.websocket || this.websocket.readyState > 1) { this.websocket = null; if (Store.dryRun) { console.log("Failed to connect to", this.url, "WebSocket"); } connectProgress?.failed(); return; } connectProgress?.completed(); const reportProgress = this.progress?.addSubItem("Waiting for the report to be received..."); await this.withTimeout( this.timeout, reportProgress, new Promise((resolve) => { this.websocket?.addEventListener( "message", (event) => { const ids = callback(event); if (Store.dryRun) { console.log("New message from", this.url, event.data); console.log("Comparing", ids, "to", this.id); } if (ids.includes(this.id)) { reportProgress?.completed(); resolve(); } } ); }) ); } closeWebsocket() { if (!this.websocket) return; this.websocket.close(); this.websocket = null; if (Store.dryRun) { console.log("Closed connection to", this.url); } } initWebsocket() { this.websocket = new WebSocket(this.url); if (this.auth) { this.websocket.addEventListener("open", () => { this.websocket?.send(this.auth); }); } if (Store.dryRun) { console.log("WebSocket", this.url, "initialised."); } } async withTimeout(millis, subItem, promise) { let time; const timeout = new Promise((resolve) => { time = setTimeout(() => { if (Store.dryRun) { console.log("WebSocket connection timeouted after", millis, "ms"); } subItem?.failed("timeouted"); resolve(); }, millis); }); await Promise.race([promise, timeout]).finally(() => { clearTimeout(time); this.closeWebsocket(); }); } }; // src/UserscriptTools/MetaSmokeAPI.ts var MetaSmokeAPI = class _MetaSmokeAPI extends Reporter { constructor(id, postType, deleted) { super("Smokey", id); this.postType = postType; this.deleted = deleted; this.smokeyId = _MetaSmokeAPI.metasmokeIds[this.id] ?? 0; } static accessToken; static isDisabled = Store.get(Cached.Metasmoke.disabled) || false; smokeyId; static appKey = "0a946b9419b5842f99b052d19c956302aa6c6dd5a420b043b20072ad2efc29e0"; static filter = "GGJFNNKKJFHFKJFLJLGIJMFIHNNJNINJ"; static metasmokeIds = {}; failureMessage = "Failed to report post to Smokey"; wsUrl = "wss://metasmoke.erwaysoftware.com/cable"; wsAuth = JSON.stringify({ identifier: JSON.stringify({ channel: "ApiChannel", key: _MetaSmokeAPI.appKey, events: "posts#create" }), command: "subscribe" }); static reset() { Store.unset(Cached.Metasmoke.disabled); Store.unset(Cached.Metasmoke.userKey); } static async setup() { _MetaSmokeAPI.accessToken = await _MetaSmokeAPI.getUserKey(); } static async queryMetaSmokeInternal(urls) { if (_MetaSmokeAPI.isDisabled) return; const urlString = urls ?? page.getAllPostIds(true, true).join(","); if (!urlString) return; const parameters = Object.entries({ urls: urlString, key: _MetaSmokeAPI.appKey, per_page: 1e3, filter: this.filter // only include id and link fields }).map((item) => item.join("=")).join("&"); try { const url = `https://metasmoke.erwaysoftware.com/api/v2.0/posts/urls?${parameters}`; const call = await fetch(url); const result = await call.json(); result.items.forEach(({ link, id }) => { const postId = Number(/\d+$/.exec(link)?.[0]); if (!postId) return; _MetaSmokeAPI.metasmokeIds[postId] = id; }); } catch (error) { displayToaster("Failed to get Metasmoke URLs.", "danger"); console.error(error); _MetaSmokeAPI.isDisabled = true; } } getQueryUrl() { const path = this.postType === "Answer" ? "a" : "questions"; return `//${window.location.hostname}/${path}/${this.id}`; } reportReceived(event) { const data = JSON.parse(event.data); if (data.type) return []; if (Store.dryRun) { console.log("New post reported to Smokey", data); } const { object, event_class: evClass, event_type: type } = data.message; if (type !== "create" || evClass !== "Post") return []; const link = object.link; const url = new URL(link, location.href); const postId = Number(/\d+/.exec(url.pathname)?.[0]); if (url.host !== location.host) return []; return [postId]; } async reportRedFlag() { const urlString = this.getQueryUrl(); const { appKey, accessToken } = _MetaSmokeAPI; const url = "https://metasmoke.erwaysoftware.com/api/w/post/report"; const data = { post_link: urlString, key: appKey, token: accessToken }; if (Store.dryRun) { console.log("Report post via", url, data); return; } const reportRequest = await fetch( url, { method: "POST", body: getFormDataFromObject(data) } ); const requestResponse = await reportRequest.text(); if (!reportRequest.ok || requestResponse !== "OK") { console.error(`Failed to report post ${this.smokeyId} to Smokey`, requestResponse); throw new Error(this.failureMessage); } } canBeReported() { return !_MetaSmokeAPI.isDisabled; } wasReported() { return Boolean(this.smokeyId); } showOnPopover() { return !_MetaSmokeAPI.isDisabled; } canSendFeedback(feedback) { const { isDisabled, accessToken } = _MetaSmokeAPI; return !isDisabled && Boolean(accessToken) && (Boolean(this.smokeyId) || // the post has been reported OR: feedback === "tpu-" && !this.deleted); } async sendFeedback(feedback) { const { appKey, accessToken } = _MetaSmokeAPI; if (!this.smokeyId && feedback === "tpu-" && !this.deleted) { const wsUtils = new WebsocketUtils(this.wsUrl, this.id, this.progress, this.wsAuth); const reportProgress = this.progress?.addSubItem("Sending report..."); try { await this.reportRedFlag(); reportProgress?.completed(); } catch (error) { wsUtils.closeWebsocket(); reportProgress?.failed(); throw error; } await wsUtils.waitForReport((event) => this.reportReceived(event)); await new Promise((resolve) => setTimeout(resolve, 3 * 1e3)); return; } const data = { type: feedback, key: appKey, token: accessToken }; const url = `//metasmoke.erwaysoftware.com/api/w/post/${this.smokeyId}/feedback`; if (Store.dryRun) { console.log("Feedback to Smokey via", url, data); return; } const feedbackRequest = await fetch( url, { method: "POST", body: getFormDataFromObject(data) } ); const feedbackResponse = await feedbackRequest.json(); if (!feedbackRequest.ok) { console.error(`Failed to send feedback for ${this.smokeyId} to Smokey`, feedbackResponse); throw new Error(); } } getIcon() { return this.createBotIcon( this.smokeyId ? `//metasmoke.erwaysoftware.com/post/${this.smokeyId}` : "" ); } getProgressMessage(feedback) { return this.wasReported() || feedback !== "tpu-" ? super.getProgressMessage(feedback) : "Reporting post to Smokey"; } static getMetasmokeTokenPopup() { const codeInput = input_exports.makeStacksInput( "advanced-flagging-metasmoke-token-input", { placeholder: "Enter the code here" }, { text: "Metasmoke access token", description: "Once you've authenticated Advanced Flagging with metasmoke, you'll be given a code; enter it below:" } ); const authModal = modals_exports.makeStacksModal( "advanced-flagging-metasmoke-token-modal", { title: { text: "Authenticate MS with AF" }, body: { bodyHtml: codeInput }, footer: { buttons: [ { element: buttons_exports.makeStacksButton( "advanced-flagging-submit-code", "Submit", { primary: true } ) }, { element: buttons_exports.makeStacksButton( "advanced-flagging-dismiss-code-modal", "Cancel" ), hideOnClick: true } ] } } ); return authModal; } static showMSTokenPopupAndGet() { return new Promise((resolve) => { const popup = this.getMetasmokeTokenPopup(); StackExchange.helpers.showModal(popup); popup.querySelector(".s-btn__filled")?.addEventListener("click", () => { const input = popup.querySelector("input"); const token = input?.value; popup.remove(); if (!token) return; resolve(token); }); }); } static async codeGetter(metaSmokeOAuthUrl) { if (_MetaSmokeAPI.isDisabled) return; const authenticate = await StackExchange.helpers.showConfirmModal({ title: "Setting up metasmoke", bodyHtml: "If you do not wish to connect, press cancel and this popup won't show up again. To reset configuration, see the footer of Stack Overflow.", buttonLabel: "Authenticate!" }); if (!authenticate) { Store.set(Cached.Metasmoke.disabled, true); return; } window.open(metaSmokeOAuthUrl, "_blank"); await delay(100); return await this.showMSTokenPopupAndGet(); } static async getUserKey() { while (typeof StackExchange.helpers.showConfirmModal === "undefined") { await delay(100); } const { appKey } = _MetaSmokeAPI; const url = `https://metasmoke.erwaysoftware.com/oauth/request?key=${appKey}`; return await Store.getAndCache( Cached.Metasmoke.userKey, async () => { const code = await _MetaSmokeAPI.codeGetter(url); if (!code) return ""; const tokenUrl = `//metasmoke.erwaysoftware.com/oauth/token?key=${appKey}&code=${code}`; const tokenCall = await fetch(tokenUrl); const { token } = await tokenCall.json(); return token; } ); } }; // src/UserscriptTools/NattyApi.ts var dayMillis = 1e3 * 60 * 60 * 24; var nattyFeedbackUrl = "https://logs.sobotics.org/napi-1.1/api/stored/"; var nattyReportedMessage = "Post reported to Natty"; var NattyAPI = class _NattyAPI extends Reporter { constructor(id, questionDate, answerDate, deleted) { super("Natty", id); this.questionDate = questionDate; this.answerDate = answerDate; this.deleted = deleted; this.feedbackMessage = `@Natty feedback https://stackoverflow.com/a/${this.id}`; this.reportMessage = `@Natty report https://stackoverflow.com/a/${this.id}`; } raisedRedFlag = false; static nattyIds = []; chat = new ChatApi(); feedbackMessage; reportMessage; static getAllNattyIds(ids) { const postIds2 = (ids ?? page.getAllPostIds(false, false)).join(","); if (!Page.isStackOverflow || !postIds2) return Promise.resolve(); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: `${nattyFeedbackUrl}${postIds2}`, onload: ({ status, responseText }) => { if (status !== 200) reject(); const result = JSON.parse(responseText); this.nattyIds = result.items.map(({ name }) => Number(name)); resolve(); }, onerror: () => reject() }); }); } wasReported() { return _NattyAPI.nattyIds.includes(this.id); } canBeReported() { const answerAge = this.getDaysBetween(this.answerDate, /* @__PURE__ */ new Date()); const daysPostedAfterQuestion = this.getDaysBetween(this.questionDate, this.answerDate); return this.answerDate > this.questionDate && answerAge < 30 && daysPostedAfterQuestion > 30 && !this.deleted; } canSendFeedback(feedback) { return Page.isStackOverflow && (this.wasReported() || this.canBeReported() && feedback === "tp" && !this.deleted); } async sendFeedback(feedback) { if (this.wasReported()) { await this.chat.sendMessage(`${this.feedbackMessage} ${feedback}`); } else if (feedback === "tp") { await this.report(); } } getIcon() { return this.createBotIcon( this.wasReported() ? `//sentinel.sobotics.org/posts/aid/${this.id}` : "" ); } getProgressMessage(feedback) { return this.wasReported() || feedback !== "tp" ? super.getProgressMessage(feedback) : "Reporting post to Natty"; } async report() { if (!this.canBeReported()) return ""; if (StackExchange.options.user.isModerator || Page.isLqpReviewPage || this.raisedRedFlag) { const url = await this.chat.getFinalUrl(); const wsUtils = new WebsocketUtils(url, this.id, this.progress); const reportProgress = this.progress?.addSubItem("Sending report..."); try { await this.chat.sendMessage(this.reportMessage); reportProgress?.completed(); } catch (error) { wsUtils.closeWebsocket(); reportProgress?.failed(); throw error; } await wsUtils.waitForReport((event) => this.chat.reportReceived(event)); } else { await this.chat.sendMessage(this.reportMessage); } return nattyReportedMessage; } getDaysBetween(questionDate, answerDate) { return (answerDate.valueOf() - questionDate.valueOf()) / dayMillis; } }; // node_modules/@stackoverflow/stacks-icons/dist/icons.js var IconCheckmark = ''; var IconClear = ''; var IconEyeOff = ''; var IconFlag = ''; var IconPencil = ''; var IconPlus = ''; var IconTrash = ''; // src/UserscriptTools/Post.ts var Post = class _Post { constructor(element) { this.element = element; this.type = this.getType(); this.id = this.getId(); this.deleted = this.element.classList.contains("deleted-answer"); this.date = this.getCreationDate(); if (this.type === "Question") { _Post.qDate = this.date; } this.score = this.getScore(); this.opReputation = this.getOpReputation(); this.opName = this.getOpName(); [this.done, this.failed, this.flagged] = _Post.getActionIcons(); this.initReporters(); } static qDate = /* @__PURE__ */ new Date(); type; id; deleted; date; opReputation; opName; // not really related to the post, // but are unique and easy to access this way :) done; failed; flagged; progress = new Progress(); reporters = {}; autoflagging = false; score; static getActionIcons() { return [ [IconCheckmark, "fc-green-500"], [IconClear, "fc-red-500"], [IconFlag, "fc-red-500"] ].map(([svg, classname]) => _Post.getIcon(svg, classname)); } async flag(flagName, text) { const targetUrl = this.reporters.Guttenberg?.targetUrl; const url = `/flags/posts/${this.id}/add/${flagName}`; const data = { fkey: StackExchange.options.user.fkey, otherText: text || "", // plagiarism flag: fill "Link(s) to original content" // note wrt link: site will always be Stack Overflow, // post will always be an answer. customData: flagName === "PlagiarizedContent" /* Plagiarism */ ? JSON.stringify({ plagiarizedSource: `https:${targetUrl}` }) : "" }; if (Store.dryRun) { console.log(`Flag post as ${flagName} via`, url, data); return; } const flagRequest = await fetch(url, { method: "POST", body: getFormDataFromObject(data) }); this.autoflagging = true; const tooFast = /You may only flag a post every \d+ seconds?/; const responseText = await flagRequest.text(); if (tooFast.test(responseText)) { const rlCount = /\d+/.exec(responseText)?.[0] ?? 0; const pluralS = Number(rlCount) > 1 ? "s" : ""; console.error(responseText); throw new Error(`rate-limited for ${rlCount} second${pluralS}`); } const response = JSON.parse(responseText); if (!response.Success) { const { Message: message } = response; const fullMessage = `Failed to flag the post with outcome ${response.Outcome}: ${message}.`; console.error(fullMessage); if (message.includes("already flagged")) { throw new Error("post already flagged"); } else if (message.includes("limit reached")) { throw new Error("post flag limit reached"); } else { throw new Error(message); } } if (response.ResultChangedState) this.reload(); } downvote() { const button = this.element.querySelector(".js-vote-down-btn"); const hasDownvoted = button?.classList.contains("fc-theme-primary"); if (hasDownvoted) return; if (Store.dryRun) { console.log("Downvote post by clicking", button); return; } button?.click(); this.score = this.getScore(); } async deleteVote() { const fkey = StackExchange.options.user.fkey; const url = `/posts/${this.id}/vote/10`; if (Store.dryRun) { console.log("Delete vote via", url, "with", fkey); return; } const request = await fetch(url, { method: "POST", body: getFormDataFromObject({ fkey }) }); const response = await request.text(); let json; try { json = JSON.parse(response); } catch (error) { console.error(error, response); throw new Error("could not parse JSON"); } if (!json.Success) { console.error(json); throw new Error(json.Message.toLowerCase()); } if (json.Refresh) this.reload(); } async comment(text) { const data = { fkey: StackExchange.options.user.fkey, comment: text }; const url = `/posts/${this.id}/comments`; if (Store.dryRun) { console.log("Post comment via", url, data); return; } const request = await fetch(url, { method: "POST", body: getFormDataFromObject(data) }); const result = await request.text(); const commentUI = StackExchange.comments.uiForPost($(`#comments-${this.id}`)); commentUI.addShow(true, false); commentUI.showComments(result, null, false, true); $(document).trigger("comment", this.id); } upvoteSameComments(comment) { const alternative = Store.config.addAuthorName ? comment.split(", ").slice(1).join(", ") : `${this.opName}, ${comment}`; const stripped = _Post.getStrippedComment(comment).toLowerCase(); const strippedAlt = _Post.getStrippedComment(alternative).toLowerCase(); this.element.querySelectorAll(".comment-body .comment-copy").forEach((element) => { const text = element.innerText.toLowerCase(); if (text !== stripped && text !== strippedAlt) return; const parent = element.closest("li"); const button = parent?.querySelector( "a.js-comment-up.comment-up-off" // voting button ); if (Store.dryRun) { console.log("Upvote", element, "by clicking", button); return; } button?.click(); }); } watchForFlags() { const watchFlags = Store.config[Cached.Configuration.watchFlags]; if (!watchFlags || this.deleted) return; addXHRListener((xhr) => { const { status, responseURL } = xhr; const regex = new RegExp( `/flags/posts/${this.id}/popup` ); if (this.autoflagging || status !== 200 || !regex.test(responseURL)) return; const flagPopup = document.querySelector("#popup-flag-post"); const submit = flagPopup?.querySelector(".js-popup-submit"); if (!submit || !flagPopup || submit.textContent.trim().startsWith("Retract")) return; appendLabelAndBoxes(submit, this); submit.addEventListener("click", async (event) => { const checked = flagPopup.querySelector("input.s-radio:checked"); if (!checked) return; const flag = checked.value; const flagType = Store.flagTypes.find((item) => item.sendWhenFlagRaised && item.reportType === flag); if (!flagType) return; if (Store.dryRun) { console.log("Post", this.id, "manually flagged as", flag, flagType); } const natty = this.reporters.Natty; if (natty) { natty.raisedRedFlag = ["PostSpam", "PostOffensive"].includes(flag); } await addProgress(event, flagType, this); $(this.flagged).fadeIn(); }, { once: true }); }, this.id); } filterReporters(feedbacks) { return Object.values(this.reporters).filter((reporter) => { const { name, sanitisedName } = reporter; const selector = `#advanced-flagging-send-feedback-to-${sanitisedName.toLowerCase()}-${this.id}`; const input = document.querySelector(`${selector}-flag-review`) ?? document.querySelector(selector); const sendFeedback = input?.checked ?? true; const feedback = feedbacks[name]; return sendFeedback && feedback && reporter.canSendFeedback(feedback); }); } async sendFeedbacks({ feedbacks }) { let hasFailed = false; const allPromises = this.filterReporters(feedbacks).map((reporter) => { const feedback = feedbacks[reporter.name]; const text = reporter.getProgressMessage(feedback); const sendingFeedback = this.progress.addItem(`${text}...`); reporter.progress = sendingFeedback; return reporter.sendFeedback(feedback).then(() => sendingFeedback.completed()).catch((error) => { console.error(error); if (error instanceof Error) { sendingFeedback.failed(error.message); } hasFailed = true; }); }); await Promise.allSettled(allPromises); return !hasFailed; } addIcons() { const iconLocation = this.element.querySelector(".js-post-menu > div.d-flex") ?? this.element.querySelector("a.question-hyperlink, a.answer-hyperlink, .s-link"); const icons = Object.values(this.reporters).filter((reporter) => reporter.wasReported()).map((reporter) => reporter.getIcon()); iconLocation?.append(...icons); } canDelete(popover = false) { const selector = '.js-delete-post[title^="Vote to delete"]'; const deleteButton = this.element.querySelector(selector); const userRep = StackExchange.options.user.rep; return !this.deleted && // mods can delete no matter what (StackExchange.options.user.isModerator || // if the delete button is visible, then the user can vote to delete (Boolean(deleteButton) || userRep >= 2e4 && (popover ? this.score <= 0 : this.score < 0))); } // returns [bot name, checkbox config] getFeedbackBoxes(isFlagOrReview = false) { const newEntries = Object.entries(this.reporters).filter(([name, instance]) => { return instance.showOnPopover() && (!Page.isLqpReviewPage || (name !== "Smokey" || instance.wasReported())); }).map(([, instance]) => { const botName = instance.sanitisedName; const botNameId = `advanced-flagging-send-feedback-to-${botName}-${this.id}`; const iconHtml = instance.getIcon().outerHTML; const checkbox = { // on post page, the id is not unique! id: `${botNameId}${isFlagOrReview ? "-flag-review" : ""}`, labelConfig: { text: `${isFlagOrReview ? "" : "Feedback to"} ${iconHtml}`, classes: [isFlagOrReview ? "mb4" : "fs-body1"] }, selected: Store.config.default[botName] }; return [instance.name, checkbox]; }); return Object.fromEntries(newEntries); } static getIcon(element, classname) { const parsed = new DOMParser().parseFromString(element, "text/html"); const svg = parsed.querySelector("svg"); const wrapper = document.createElement("div"); wrapper.classList.add("flex--item"); wrapper.style.display = "none"; svg.classList.add(classname); wrapper.append(svg); return wrapper; } static getStrippedComment(text) { return text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1").replace(/\[([^\]]+)\][^(]*?/g, "$1").replace(/_([^_]+)_/g, "$1").replace(/\*\*([^*]+)\*\*/g, "$1").replace(/\*([^*]+)\*/g, "$1").replace(" - From Review", ""); } static markDeleted(post) { post.element.classList.add("deleted-answer", "py16"); const disabledLink = document.createElement("span"); disabledLink.classList.add("disabled-link"); disabledLink.textContent = "Comments disabled on deleted / locked posts / reviews"; post.element.querySelector(".js-add-link")?.replaceWith(disabledLink); const text = document.createElement("div"); const b = document.createElement("b"); b.textContent = "This post is hidden"; text.append(b, ". It was deleted."); const notice = notices_exports.makeStacksNotice({ type: "info", text, icon: [ "iconEyeOff", getIconPath(IconEyeOff)[0] ], classes: ["mb16"] }); post.element.querySelector(".js-post-body")?.prepend(notice); } getType() { return this.element.classList.contains("question") || this.element.id.startsWith("question") || this.element.querySelector(".question-hyperlink") ? "Question" : "Answer"; } getId() { const href = this.element.querySelector( ".answer-hyperlink, .question-hyperlink, .s-link" )?.href; const postId = ( // questions page: get value of data-questionid/data-answerid this.element.dataset.questionid ?? this.element.dataset.answerid ?? (this.type === "Answer" ? new URL(href || "").pathname.split("/").pop() : href?.split("/")[4]) ); return Number(postId); } getScore() { const voteElement = this.element.querySelector(".js-vote-count"); return Number(voteElement?.textContent.trim()) || 0; } getOpReputation() { const repDiv = [...this.element.querySelectorAll( ".user-info .reputation-score" )].pop(); if (!repDiv) return 0; let reputationText = repDiv.innerText.replace(/,/g, ""); if (!reputationText) return 0; if (reputationText.includes("k")) { reputationText = reputationText.replace(/\.\d/g, "").replace(/k/, ""); return Number(reputationText) * 1e3; } else { return Number(reputationText); } } getOpName() { const lastNameEl = [...this.element.querySelectorAll(".user-info .user-details a")].pop(); return lastNameEl?.textContent.trim() ?? ""; } getCreationDate() { const dateElements = this.element.querySelectorAll(".user-info .relativetime"); const authorDateElement = Array.from(dateElements).pop(); return new Date(authorDateElement?.title ?? ""); } reload() { this.deleted = true; const newPage = new Page(true); newPage.posts.forEach((post) => { post.element.style.opacity = "1"; const previous = post.element.previousElementSibling; if (previous?.matches(".realtime-post-deleted-notification")) previous.remove(); }); if (StackExchange.options.user.canSeeDeletedPosts) { if (this.type === "Question") { const postIds2 = page.getAllPostIds(true, false); void StackExchange.realtime.reloadPosts(postIds2); } else { void StackExchange.realtime.reloadPosts([this.id]); } } else { this.type === "Question" ? newPage.posts.forEach((post) => _Post.markDeleted(post)) : _Post.markDeleted(this); this.progress.updateLocation(); } } initReporters() { this.reporters.Smokey = new MetaSmokeAPI(this.id, this.type, this.deleted); if (this.type === "Answer" && Page.isStackOverflow) { this.reporters.Natty = new NattyAPI(this.id, _Post.qDate, this.date, this.deleted); this.reporters.Guttenberg = new CopyPastorAPI(this.id); } if (Page.isStackOverflow) { this.reporters["Generic Bot"] = new GenericBotAPI(this.id, this.deleted); } } }; // src/UserscriptTools/Page.ts var Page = class _Page { constructor(includeModified = false) { this.includeModified = includeModified; this.href = new URL(location.href); this.name = this.getName(); this.selector = this.getPostSelector(); this.posts = this.getPosts(); const question = document.querySelector(".question"); if (_Page.isLqpReviewPage && question) { const post = new Post(question); Post.qDate = post.date; } } static isStackOverflow = /^https:\/\/stackoverflow.com/.test(location.href); static isQuestionPage = /\/questions\/\d+.*/.test(location.href); static isLqpReviewPage = /\/review\/low-quality-posts(?:\/\d+)?(?:\/)?$/.test(location.href); static isStagingGroundPage = /\/staging-ground\/\d+/.test(location.href); name; posts = []; href; selector; getAllPostIds(includeQuestion, urlForm) { return this.posts.filter((post) => { if (!includeQuestion) return post.type !== "Question"; else return true; }).map(({ id, type }) => { const urlType = type === "Answer" ? "a" : "questions"; return urlForm ? `//${window.location.hostname}/${urlType}/${id}` : id; }); } getName() { const isNatoPage = this.href.pathname.startsWith("/tools/new-answers-old-questions"); const isFlagsPage = /\/users\/flag-summary\/\d+/.test(location.href); const isSearch = this.href.pathname.startsWith("/search"); if (isFlagsPage) return "Flags"; else if (isNatoPage) return "NATO"; else if (_Page.isQuestionPage) return "Question"; else if (isSearch) return "Search"; else if (_Page.isLqpReviewPage) return "Review"; else if (_Page.isStagingGroundPage) return "Staging Ground"; else return ""; } getPostSelector() { switch (this.name) { case "NATO": return ".default-view-post-table > tbody > tr"; case "Flags": return ".flagged-post"; case "Question": case "Staging Ground": return ".question, .answer"; case "Search": return ".js-search-results .s-post-summary"; case "Review": return "#answer .answer"; default: return ""; } } getPosts() { if (this.name === "") return []; return [...document.querySelectorAll(this.selector)].filter((el) => { return !el.querySelector(".advanced-flagging-link, .advanced-flagging-icon") || this.includeModified; }).map((el) => new Post(el)); } }; // src/FlagTypes.ts var deletedAnswers = "/help/deleted-answers"; var commentHelp = "/help/privileges/comment"; var reputationHelp = "/help/whats-reputation"; var voteUpHelp = "/help/privileges/vote-up"; var whyVote = "/help/why-vote"; var setBounties = "/help/privileges/set-bounties"; var flagPosts = "/help/privileges/flag-posts"; var flagCategories = [ { isDangerous: true, name: "Red flags", appliesTo: ["Answer", "Question"], id: 1, FlagTypes: [ { id: 1, displayName: "Spam", reportType: "PostSpam" /* Spam */, feedbacks: { Smokey: "tpu-", Natty: "tp", Guttenberg: "", "Generic Bot": "track" }, sendWhenFlagRaised: true }, { id: 2, displayName: "Rude or Abusive", reportType: "PostOffensive" /* Rude */, feedbacks: { Smokey: "tpu-", Natty: "tp", Guttenberg: "", "Generic Bot": "track" }, sendWhenFlagRaised: true } ] }, { isDangerous: true, name: "Guttenberg mod flags", appliesTo: ["Answer"], id: 2, FlagTypes: [ { id: 3, displayName: "Plagiarism", reportType: "PlagiarizedContent" /* Plagiarism */, flagText: "Possible plagiarism of the linked answer, as can be seen here $COPYPASTOR$", // don't send feedback to Smokey despite https://charcoal-se.org/smokey/Feedback-Guidance.html#plagiarism feedbacks: { Smokey: "", Natty: "", Guttenberg: "tp", "Generic Bot": "" }, sendWhenFlagRaised: false }, { id: 4, displayName: "Duplicate answer", reportType: "PostOther" /* ModFlag */, flagText: "The post is a repost of their other answer: $TARGET$, but as there are slight differences (see $COPYPASTOR$), an auto flag would not be raised.", comments: { low: "Please don't add the [same answer to multiple questions](//meta.stackexchange.com/q/104227). Answer the best one and flag the rest as duplicates, once you earn enough reputation. If it is not a duplicate, [edit] the answer and tailor the post to the question." }, feedbacks: { Smokey: "", Natty: "", Guttenberg: "tp", "Generic Bot": "" }, sendWhenFlagRaised: false }, { id: 5, displayName: "Bad attribution", reportType: "PlagiarizedContent" /* Plagiarism */, flagText: "This post is copied from $TARGET$, as can be seen here $COPYPASTOR$. The author only added a link to the other answer, which is [not the proper way of attribution](//stackoverflow.blog/2009/06/25/attribution-required).", feedbacks: { Smokey: "", Natty: "", Guttenberg: "tp", "Generic Bot": "" }, sendWhenFlagRaised: false } ] }, { isDangerous: false, name: "Answer-related", appliesTo: ["Answer"], id: 3, FlagTypes: [ { id: 6, displayName: "Link Only", reportType: "AnswerNotAnAnswer" /* NAA */, comments: { // comment by Yunnosch: https://chat.stackoverflow.com/transcript/message/57442309 low: `A link to a solution is welcome, but please ensure your answer is useful without it: You need to provide at least a technical summary of *how* the problem is solved, so that it can be reproduced even without the link. It is not enough to advertise *what* it achieves. Also please [add context around the link](//meta.stackexchange.com/a/8259) so your fellow users will have some idea what it is and why it is there. [Answers that are little more than a link may be deleted.](${deletedAnswers})` }, feedbacks: { Smokey: "naa-", Natty: "tp", Guttenberg: "", "Generic Bot": "track" }, sendWhenFlagRaised: true }, { id: 7, displayName: "Not an answer", reportType: "AnswerNotAnAnswer" /* NAA */, comments: { low: "This does not provide an answer to the question. You can [search for similar questions](/search), or refer to the related and linked questions on the right-hand side of the page to find an answer. If you have a related but different question, [ask a new question](/questions/ask), and include a link to this one to help provide context. See: [Ask questions, get answers, no distractions](/tour)", high: `This post doesn't look like an attempt to answer this question. Every post here is expected to be an explicit attempt to *answer* this question; if you have a critique or need a clarification of the question or another answer, you can [post a comment](${commentHelp}) (like this one) directly below it. Please remove this answer and create either a comment or a new question. See: [Ask questions, get answers, no distractions](/tour).` }, feedbacks: { Smokey: "naa-", Natty: "tp", Guttenberg: "", "Generic Bot": "track" }, sendWhenFlagRaised: true }, { id: 8, displayName: "Thanks", reportType: "AnswerNotAnAnswer" /* NAA */, comments: { low: `Please don't add _thanks_ as answers. They don't actually provide an answer to the question, and can be perceived as noise by its future visitors. Once you [earn](//meta.stackoverflow.com/q/146472) enough [reputation](${reputationHelp}), you will gain privileges to [upvote answers](${voteUpHelp}) you like. This way future visitors of the question will see a higher vote count on that answer, and the answerer will also be rewarded with reputation points. See [Why is voting important](${whyVote}).`, high: `Please don't add _thanks_ as answers. They don't actually provide an answer to the question, and can be perceived as noise by its future visitors. Instead, [upvote answers](${voteUpHelp}) you like. This way future visitors of the question will see a higher vote count on that answer, and the answerer will also be rewarded with reputation points. See [Why is voting important](${whyVote}).` }, feedbacks: { Smokey: "naa-", Natty: "tp", Guttenberg: "", "Generic Bot": "track" }, sendWhenFlagRaised: false }, { id: 9, displayName: "Me too", reportType: "AnswerNotAnAnswer" /* NAA */, comments: { low: `Please don't add *Me too* as answers. It doesn't actually provide an answer to the question. If you have a different but related question, then [ask](/questions/ask) it (reference this one if it will help provide context). If you are interested in this specific question, you can [upvote](${voteUpHelp}) it, leave a [comment](${commentHelp}), or start a [bounty](${setBounties}) once you have enough [reputation](${reputationHelp}).` }, feedbacks: { Smokey: "naa-", Natty: "tp", Guttenberg: "", "Generic Bot": "track" }, sendWhenFlagRaised: false }, { id: 10, displayName: "Library", reportType: "AnswerNotAnAnswer" /* NAA */, comments: { low: "Please don't just post some tool or library as an answer. At least demonstrate [how it solves the problem](//meta.stackoverflow.com/a/251605) in the answer itself." }, feedbacks: { Smokey: "naa-", Natty: "tp", Guttenberg: "", "Generic Bot": "track" }, sendWhenFlagRaised: false }, { id: 11, displayName: "Comment", reportType: "AnswerNotAnAnswer" /* NAA */, comments: { low: `This does not provide an answer to the question. Once you have sufficient [reputation](${reputationHelp}) you will be able to [comment on any post](${commentHelp}); instead, [provide answers that don't require clarification from the asker](//meta.stackexchange.com/q/214173).`, high: "This does not provide an answer to the question. Please write a comment instead." }, feedbacks: { Smokey: "naa-", Natty: "tp", Guttenberg: "", "Generic Bot": "track" }, sendWhenFlagRaised: false }, { id: 12, displayName: "Duplicate", reportType: "AnswerNotAnAnswer" /* NAA */, comments: { low: `Instead of posting an answer which merely links to another answer, please instead [flag the question](${flagPosts}) as a duplicate.` }, feedbacks: { Smokey: "naa-", Natty: "tp", Guttenberg: "", "Generic Bot": "track" }, sendWhenFlagRaised: false }, { id: 13, displayName: "Non English", reportType: "AnswerNotAnAnswer" /* NAA */, comments: { low: "Please write your answer in English, as Stack Overflow is an [English-only site](//meta.stackoverflow.com/a/297680)." }, feedbacks: { Smokey: "naa-", Natty: "tp", Guttenberg: "", "Generic Bot": "track" }, sendWhenFlagRaised: false }, { id: 14, displayName: "Should be an edit", reportType: "AnswerNotAnAnswer" /* NAA */, comments: { low: 'Please use the edit link on your question to add additional information. The "Post Answer" button should be used only for complete answers to the question.' }, feedbacks: { Smokey: "naa-", Natty: "tp", Guttenberg: "", "Generic Bot": "track" }, sendWhenFlagRaised: false } ] }, { isDangerous: false, name: "General", appliesTo: ["Answer", "Question"], id: 4, FlagTypes: [ { id: 15, displayName: "Looks Fine", reportType: "NoFlag" /* NoFlag */, feedbacks: { Smokey: "fp-", Natty: "fp", Guttenberg: "fp", "Generic Bot": "" }, sendWhenFlagRaised: false }, { id: 16, displayName: "Needs Editing", reportType: "NoFlag" /* NoFlag */, feedbacks: { Smokey: "fp-", Natty: "ne", Guttenberg: "fp", "Generic Bot": "" }, sendWhenFlagRaised: false }, { id: 17, displayName: "Vandalism", reportType: "NoFlag" /* NoFlag */, feedbacks: { Smokey: "tp-", Natty: "", Guttenberg: "fp", "Generic Bot": "" }, sendWhenFlagRaised: false } ] } ]; function getEmptyFlagType(id, belongsTo) { return { id, displayName: "Name", reportType: "NoFlag" /* NoFlag */, feedbacks: { Smokey: "", Natty: "", Guttenberg: "", "Generic Bot": "" }, sendWhenFlagRaised: false, downvote: false, enabled: true, belongsTo }; } // src/modals/config.ts function saveChanges() { document.querySelectorAll("#advanced-flagging-configuration-section-general input").forEach((element) => { const id = element.id; const key = id.split("-").pop(); const checked = element.checked; if (id.startsWith("advanced-flagging-default")) { Store.config.default[key] = checked; } else { Store.config[key] = checked; } }); Store.updateConfiguration(); displayStacksToast("Configuration saved", "success"); setTimeout(() => window.location.reload(), 500); } function resetConfig() { Store.unset(Cached.Configuration.key); displayStacksToast( "Configuration settings have been reset to defaults", "success" ); setTimeout(() => window.location.reload(), 500); } function buildConfigurationOverlay() { const modal = modals_exports.makeStacksModal( "advanced-flagging-configuration-modal", { title: { text: "Advanced Flagging configuration" }, body: { bodyHtml: getConfigModalBody() }, footer: { buttons: [ { element: buttons_exports.makeStacksButton( "advanced-flagging-configuration-modal-save", "Save changes", { primary: true, click: { handler: (event) => { event.preventDefault(); saveChanges(); } } } ) }, { element: buttons_exports.makeStacksButton( "advanced-flagging-configuration-modal-cancel", "Cancel" ), hideOnClick: true }, { element: buttons_exports.makeStacksButton( "advanced-flagging-configuration-modal-reset", "Reset", { type: ["danger"], click: { handler: resetConfig } } ) } ] }, fullscreen: true } ); modal.firstElementChild?.classList.add("w60"); document.body.append(modal); const resetButton = modal.querySelector(".s-btn__danger"); attachPopover( resetButton, "Resets config values to defaults. You will be prompted to reconfigure the script.", "right" ); } function getGeneralConfigItems() { const checkboxes = [ { text: "Open dropdown on hover", configValue: Cached.Configuration.openOnHover, description: "Opens the dropdown on hover and not on click" }, { text: "Watch for manual flags", configValue: Cached.Configuration.watchFlags, description: "Sends feedback when a flag is raised manually" }, { text: "Watch for queue responses", configValue: Cached.Configuration.watchQueues, description: "Sends feedback after a Looks OK or Recommend Deletion review in the Low Quality Answers queue" }, { text: "Add author's name before comments", configValue: Cached.Configuration.addAuthorName, description: "Adds the author's name before every comment to make it friendlier" }, { text: 'Enable "Leave comment" on all Stack Exchange sites', configValue: Cached.Configuration.allowComments, description: 'Shows the "Leave comment" checkbox and the answer-related dropdown options on every Stack Exchange site' }, { text: "Disable Advanced Flagging link", configValue: Cached.Configuration.linkDisabled }, { text: "Enable dry-run mode", configValue: Cached.Configuration.debug } ].map(({ text, configValue, description }) => { const selected = Store.config[configValue]; return { id: `advanced-flagging-${configValue}`, labelConfig: { text, description }, selected }; }); const botBoxes = ["Smokey", "Natty", "Generic Bot", "Guttenberg"].map((name) => { const reporter = new Reporter(name, 0); const sanitised = reporter.sanitisedName; const selected = Store.config.default[sanitised]; return { id: `advanced-flagging-default-${sanitised}`, labelConfig: { text: name }, selected }; }); const [defaultFeedback] = checkbox_exports.makeStacksCheckboxes( botBoxes, { horizontal: true, classes: ["fs-body2"] } ); const botDescription = document.createElement("div"); botDescription.classList.add("flex--item"); botDescription.innerText = "Send feedback by default to:"; defaultFeedback.prepend(botDescription); const optionBoxes = configBoxes.map(([name, sanitised]) => { const selected = Store.config.default[sanitised]; return { id: `advanced-flagging-default-${sanitised}`, labelConfig: { text: name }, selected }; }); const [defaultCheck] = checkbox_exports.makeStacksCheckboxes( optionBoxes, { horizontal: true, classes: ["fs-body2"] } ); const optionDescription = document.createElement("div"); optionDescription.classList.add("flex--item"); optionDescription.innerText = "Check the following by default:"; defaultCheck.prepend(optionDescription); const [fieldset] = checkbox_exports.makeStacksCheckboxes(checkboxes); fieldset.id = "advanced-flagging-configuration-section-general"; fieldset.append(defaultFeedback, defaultCheck); return fieldset; } function getAdminConfigItems() { const section = document.createElement("fieldset"); section.id = "advanced-flagging-configuration-section-admin"; section.classList.add("d-flex", "g8", "fd-column", "fs-body2"); const header = document.createElement("h2"); header.innerText = "Admin"; header.classList.add("flex--item"); const msInfoDiv = document.createElement("div"); msInfoDiv.classList.add("flex--item"); const clearMsInfo = document.createElement("a"); clearMsInfo.innerText = "Clear metasmoke configuration"; clearMsInfo.addEventListener("click", () => { MetaSmokeAPI.reset(); displayStacksToast( "Successfully cleared MS configuration.", "success", true ); }); const clearFkeyDiv = document.createElement("div"); clearFkeyDiv.classList.add("flex--item"); const clearChatFkey = document.createElement("a"); clearChatFkey.innerText = "Clear chat fkey"; clearChatFkey.addEventListener("click", () => { Store.unset(Cached.Fkey); displayStacksToast( "Successfully cleared chat fkey.", "success", true ); }); msInfoDiv.append(clearMsInfo); clearFkeyDiv.append(clearChatFkey); section.append(msInfoDiv, clearFkeyDiv); const chatFkey = Store.get(Cached.Fkey); const msAccessTokenText = MetaSmokeAPI.accessToken ? `token: ${MetaSmokeAPI.accessToken.substring(0, 32)}...` : "no access token found in storage"; const metasmokeTooltip = `This will remove your metasmoke access token (${msAccessTokenText})`; const fkeyClearTooltip = `This will clear the chat fkey. It will be regenerated the next time feedback is sent to Natty (${chatFkey ? `fkey: ${chatFkey}` : "fkey is not stored in cache"})`; attachPopover(clearMsInfo, metasmokeTooltip, "right"); attachPopover(clearChatFkey, fkeyClearTooltip, "right"); return section; } function getConfigModalBody() { const div = document.createElement("div"); const divider = document.createElement("hr"); divider.classList.add("my16"); const general = document.createElement("h2"); general.innerText = "General"; const admin = document.createElement("h2"); admin.innerText = "Admin"; div.append( general, getGeneralConfigItems(), divider, admin, getAdminConfigItems() ); return div; } // src/modals/comments/submit.ts function saveName(card, flagType) { const input = card.querySelector(".s-input__md"); flagType.displayName = input?.value || ""; } function saveTextareaContent(expandable, flagType) { const [flag, low, high] = [ "text-modflag", "comment-lowrep", "comment-highrep" ].map((id) => expandable.querySelector(`[id*="${id}"]`)).map((textarea) => textarea?.offsetParent ? textarea.value : ""); flagType.flagText = flag; if (low) { flagType.comments = { low, high }; } } function saveSwfr(expandable, flagType, flagId) { const swfrBox = expandable.querySelector('[id*="-send-when-flag-raised-"'); const sendFeedback = swfrBox?.checked || false; flagType.sendWhenFlagRaised = sendFeedback; const similar = Store.flagTypes.find((item) => item.sendWhenFlagRaised && item.reportType === flagType.reportType && item.id !== flagId); if (!similar || !sendFeedback) return; similar.sendWhenFlagRaised = false; const similarEl = document.querySelector( `[id*="-send-when-flag-raised-${similar.id}"]` ); if (similarEl) { similarEl.checked = false; } } function saveDownvote(expandable, flagType) { const downvote = expandable.querySelector('[id*="-downvote-post-"'); flagType.downvote = downvote?.checked || false; } function saveFeedbacks(expandable, flagType) { const feedbacks = [ "Smokey", "Natty", "Guttenberg", "Generic Bot" ].map((name) => { const selector = `[name*="-feedback-to-${name.replace(/\s/g, "-")}"]:checked`; const radio = expandable.querySelector(`.s-radio${selector}`); const feedback = radio?.dataset.feedback ?? ""; return [name, feedback]; }); flagType.feedbacks = Object.fromEntries(feedbacks); } function submitChanges(element) { const wrapper = element.closest(".s-card"); const expandable = wrapper?.querySelector(".s-expandable"); const flagId = Number(wrapper?.dataset.flagId); if (!flagId || !wrapper || !expandable) { displayStacksToast("Failed to save options", "danger", true); return; } const invalids = [...wrapper.querySelectorAll("textarea.is-invalid")].filter((textarea) => textarea.offsetParent !== null); if (invalids.length) { $(invalids).fadeOut(100).fadeIn(100); displayStacksToast("One or more of the textareas are invalid", "danger", true); return; } const flagType = getFlagTypeFromFlagId(flagId); if (!flagType) { displayStacksToast("Failed to save options", "danger", true); return; } const select = expandable.querySelector("select"); const newReportType = select?.value; if (isSpecialFlag(newReportType, false) && !select?.disabled) { displayStacksToast( "You cannot use this type of flag!", "danger", true ); return; } saveName(wrapper, flagType); saveTextareaContent(expandable, flagType); flagType.reportType = newReportType; saveSwfr(expandable, flagType, flagId); saveDownvote(expandable, flagType); saveFeedbacks(expandable, flagType); Store.updateFlagTypes(); const hideButton = element.nextElementSibling; hideButton.click(); displayStacksToast("Content saved successfully", "success", true); } // src/modals/comments/rows.ts var flagTypes = flagCategories.flatMap(({ FlagTypes }) => FlagTypes); var flagNames = [...new Set(flagTypes.map(({ reportType }) => reportType))]; function getCharSpan(textarea, contentType) { const content = textarea.value; const minCharacters = contentType === "flag" ? 10 : 15; const maxCharacters = contentType === "flag" ? 500 : 600; const charCount = content.length; const diff = Math.abs(charCount - maxCharacters); const pluralS = diff !== 1 ? "s" : ""; let spanText; if (charCount === 0) spanText = `Enter at least ${minCharacters} characters`; else if (charCount < minCharacters) spanText = `${minCharacters - charCount} more to go...`; else if (charCount > maxCharacters) spanText = `Too long by ${diff} character${pluralS}`; else spanText = `${diff} character${pluralS} left`; let classname; if (charCount > maxCharacters) classname = "fc-red-400"; else if (diff >= maxCharacters * 3 / 5) classname = "cool"; else if (diff >= maxCharacters * 2 / 5) classname = "warm"; else if (diff >= maxCharacters / 5) classname = "hot"; else classname = "supernova"; const isInvalid = classname === "fc-red-400" || /more|at least/.test(spanText); textarea.classList[isInvalid ? "add" : "remove"]("is-invalid"); const span = document.createElement("span"); span.classList.add("ml-auto", classname); span.innerText = spanText; return span; } function toggleTextarea(element, comment, type) { const wrapper = element.closest(".s-card")?.querySelector(`[id*="-comment-${comment}rep"]`)?.closest("div.flex--item"); if (!wrapper) return; const row = wrapper.parentElement?.parentElement; if (type === "In") { row.style.display = "block"; } $(wrapper)[`fade${type}`](400, () => { toggleHideIfNeeded(row); }); } function getCommentInputs({ id, comments }) { const container = document.createElement("div"); container.classList.add("d-flex", "ai-center", "g16"); const toggleContainer = document.createElement("div"); toggleContainer.classList.add("flex--item"); const toggle = toggle_exports.makeStacksToggle( `advanced-flagging-comments-toggle-${id}`, { text: "Leave comment" }, Boolean(comments?.low) ); toggleContainer.append(toggle); const [, checkbox] = checkbox_exports.makeStacksCheckboxes([ { id: `advanced-flagging-toggle-highrep-${id}`, labelConfig: { text: "Add a different comment for high reputation users" }, selected: Boolean(comments?.high), disabled: !comments?.low } ]); checkbox.classList.add("fs-body2", "pt1"); const toggleInput = toggle.querySelector("input"); const cbInput = checkbox.querySelector("input"); toggleInput.addEventListener("change", () => { const cbInput2 = checkbox.querySelector("input"); cbInput2.disabled = !toggleInput.checked; if (toggleInput.checked) { toggleTextarea(toggleInput, "low", "In"); if (cbInput2.checked) { toggleTextarea(toggleInput, "high", "In"); } checkbox.classList.remove("is-disabled"); } else { toggleTextarea(toggleInput, "low", "Out"); toggleTextarea(toggleInput, "high", "Out"); checkbox.classList.add("is-disabled"); } }); cbInput.addEventListener("change", () => { toggleTextarea( cbInput, "high", cbInput.checked ? "In" : "Out" ); const lowLabel = cbInput.closest(".s-card")?.querySelector('label[for*="-comment-lowrep-"]'); lowLabel.innerText = cbInput.checked ? "Comment text for low reputation users" : "Comment text"; }); container.append(toggleContainer, wrapInFlexItem(checkbox)); return container; } function getTextareas({ id, flagText, comments }) { const flag = textarea_exports.makeStacksTextarea( `advanced-flagging-text-modflag-${id}`, { value: flagText }, { text: "Flag text" } ); const lowRep = textarea_exports.makeStacksTextarea( `advanced-flagging-comment-lowrep-${id}`, { value: comments?.low }, // if there is a high rep comment, change the wording of the label { text: "Comment text" + (comments?.high ? " for low reputation users" : "") } ); const highRep = textarea_exports.makeStacksTextarea( `advanced-flagging-comment-highrep-${id}`, { value: comments?.high }, { text: "Comment text for high reputation users" } ); const wrappers = [flag, lowRep, highRep].map((element) => { const textarea = element.querySelector("textarea"); textarea.classList.add("fs-body2"); textarea.rows = 4; const contentType = textarea.id.includes("comment") ? "comment" : "flag"; const charsLeft = getCharSpan(textarea, contentType); textarea.insertAdjacentElement("afterend", charsLeft); textarea.addEventListener("keyup", function() { const newCharsLeft = getCharSpan(this, contentType); this.nextElementSibling?.replaceWith(newCharsLeft); }); const wrapper = wrapInFlexItem(element); wrapper.style.display = textarea.value ? "block" : "none"; return wrapper; }); const container = document.createElement("div"); container.classList.add("d-flex", "fd-column", "gy16"); container.append(...wrappers); return container; } function getFlagSelect(id, reportType) { const shouldDisable = isSpecialFlag(reportType, false); const options = flagNames.map((flagName) => { return { value: flagName, text: getHumanFromDisplayName(flagName) || "(none)", selected: flagName === reportType }; }); const select = select_exports.makeStacksSelect( `advanced-flagging-select-flag-${id}`, options, { disabled: shouldDisable } ); select.className = "d-flex ai-center"; const sSelect = select.querySelector(".s-select"); sSelect.style.right = "35px"; select.querySelector("select")?.classList.add("pl48"); const flagLabel = document.createElement("label"); flagLabel.classList.add("fw-bold", "ps-relative", "z-selected", "l12", "fs-body1", "flex--item"); flagLabel.innerText = "Flag:"; if (shouldDisable) { flagLabel.classList.add("o50"); } return [flagLabel, select]; } function getSelectRow({ id, sendWhenFlagRaised, downvote, reportType }) { const [label, select] = getFlagSelect(id, reportType); const [, feedback] = checkbox_exports.makeStacksCheckboxes([ { id: `advanced-flagging-send-when-flag-raised-${id}`, labelConfig: { text: "Send feedback from this flag type when this flag is raised" }, selected: sendWhenFlagRaised } ]); const [, downvoteBox] = checkbox_exports.makeStacksCheckboxes([ { id: `advanced-flagging-downvote-post-${id}`, labelConfig: { text: "Downvote post" }, selected: downvote } ]); const container = document.createElement("div"); container.classList.add("d-flex", "ai-center", "gx6"); container.append( label, select, wrapInFlexItem(downvoteBox) ); if (!isSpecialFlag(reportType)) { container.append(wrapInFlexItem(feedback)); } return container; } function getRadiosForBot(botName, currentFeedback, flagId) { const feedbacks = possibleFeedbacks[botName]; const idedName = botName.replace(/\s/g, "-"); const name = `advanced-flagging-${flagId}-feedback-to-${idedName}`; const config = feedbacks.map((feedback) => { return { id: `advanced-flagging-${idedName}-feedback-${feedback}-${flagId}`, labelConfig: { text: feedback || "(none)" }, selected: feedback === currentFeedback }; }); const [fieldset] = radio_exports.makeStacksRadios(config, name, { horizontal: true, classes: ["fs-body2"] }); fieldset.querySelectorAll("input").forEach((radio) => { const label = radio.nextElementSibling; const feedback = label.innerText || ""; radio.dataset.feedback = feedback === "(none)" ? "" : feedback; }); const description = document.createElement("div"); description.classList.add("flex--item"); description.innerText = `Feedback to ${botName}:`; fieldset.prepend(description); return fieldset; } function getRadioRow({ id, feedbacks }) { const container = document.createElement("div"); container.classList.add("d-flex", "fd-column", "gy4"); const feedbackRadios = Object.keys(possibleFeedbacks).map((item) => { const botName = item; return getRadiosForBot(botName, feedbacks[botName], id); }).map((checkbox) => wrapInFlexItem(checkbox)); container.append(...feedbackRadios); return container; } // src/modals/comments/main.ts function toggleHideIfNeeded(parent) { const children = parent.firstElementChild?.children; const shouldHide = [...children].every((element) => element.style.display === "none"); parent.style.display = shouldHide ? "none" : "block"; } function getExpandableContent(flagType) { const content = [ getCommentInputs(flagType), getTextareas(flagType), getSelectRow(flagType), getRadioRow(flagType) ].map((row) => { const flexItem = wrapInFlexItem(row); toggleHideIfNeeded(flexItem); return flexItem; }); return content; } function expandableToggled(edit) { const save = edit.previousElementSibling; const card = edit.closest(".s-card"); const expandable = card?.querySelector(".s-expandable"); if (!card || !save || !expandable) return; const isExpanded = expandable.classList.contains("is-expanded"); const flagId = Number(card.dataset.flagId); card.firstElementChild?.classList.toggle("jc-space-between"); if (isExpanded) { const name = card.querySelector("h3"); const input = input_exports.makeStacksInput( `advanced-flagging-flag-name-${flagId}`, { classes: ["s-input__md"], value: name?.innerText ?? "" } ); name?.replaceWith(input); } else { const input = card.querySelector( `#advanced-flagging-flag-name-${flagId}` ); const h3 = getH3(input?.value ?? ""); input?.parentElement?.replaceWith(h3); } const [svg, , text] = [...edit.childNodes]; svg.insertAdjacentHTML("afterend", isExpanded ? IconEyeOff : IconPencil); svg.remove(); text.textContent = isExpanded ? " Hide" : "Edit"; isExpanded ? $(save).fadeIn("fast") : $(save).fadeOut("fast"); } function getActionItems(flagId, enabled, expandableId) { const save = buttons_exports.makeStacksButton( `advanced-flagging-save-flagtype-${flagId}`, "Save", { primary: true, classes: ["flex--item"] } ); save.style.display = "none"; save.addEventListener("click", () => submitChanges(save)); const edit = buttons_exports.makeStacksButton( `advanced-flagging-edit-flagtype-${flagId}`, "Edit", { iconConfig: { name: "iconPencil", path: getIconPath(IconPencil), height: 18, width: 18 }, classes: ["flex--item"] } ); edit.dataset.controller = "s-expandable-control"; edit.setAttribute("aria-controls", expandableId); edit.addEventListener("s-expandable-control:hide", () => expandableToggled(edit)); edit.addEventListener("s-expandable-control:show", () => expandableToggled(edit)); const remove = buttons_exports.makeStacksButton( `advanced-flagging-remove-flagtype-${flagId}`, "Remove", { type: ["danger"], iconConfig: { name: "iconTrash", path: getIconPath(IconTrash), width: 18, height: 18 }, classes: ["flex--item"] } ); remove.addEventListener("click", () => { const wrapper = remove.closest(".s-card"); const flagId2 = Number(wrapper.dataset.flagId); const index = Store.flagTypes.findIndex(({ id }) => id === flagId2); Store.flagTypes.splice(index, 1); Store.updateFlagTypes(); $(wrapper).fadeOut("fast", () => { const category = wrapper.parentElement; wrapper.remove(); if (category?.childElementCount === 1) { $(category).fadeOut("fast", () => category.remove()); } }); displayStacksToast("Successfully removed flag type", "success", true); }); const toggle = toggle_exports.makeStacksToggle( `advanced-flagging-toggle-flagtype-${flagId}`, { text: "" }, enabled ).querySelector(".s-toggle-switch"); toggle.addEventListener("change", () => { const wrapper = toggle.closest(".s-card"); const flagId2 = Number(wrapper?.dataset.flagId); const current = getFlagTypeFromFlagId(flagId2); if (!current) { displayStacksToast("Failed to toggle flag type", "danger", true); return; } current.enabled = toggle.checked; Store.updateFlagTypes(); wrapper?.classList.toggle("s-card__muted"); displayStacksToast( `Successfully ${toggle.checked ? "en" : "dis"}abled flag type`, "success", true ); }); return [save, edit, remove, toggle]; } function getH3(displayName) { const h3 = document.createElement("h3"); h3.classList.add("mb0", "mr-auto", "fs-body3"); h3.innerText = displayName; return h3; } function createFlagTypeDiv(flagType) { const { id, displayName, enabled } = flagType; const card = document.createElement("div"); card.dataset.flagId = id.toString(); card.classList.add("s-card", "bs-sm", "py4"); if (!enabled) { card.classList.add("s-card__muted"); } const idedName = displayName.toLowerCase().replace(/\s/g, ""); const expandableId = `advanced-flagging-${id}-${idedName}`; const content = document.createElement("div"); content.classList.add("d-flex", "ai-center", "sm:fd-column", "sm:ai-start"); const h3 = getH3(displayName); const actions = document.createElement("div"); actions.classList.add("d-flex", "g8", "ai-center"); actions.append(...getActionItems(id, enabled, expandableId)); content.append(h3, actions); const expandableContent = getExpandableContent(flagType); const expandable = document.createElement("div"); expandable.classList.add("s-expandable"); expandable.id = expandableId; const expandableDiv = document.createElement("div"); expandableDiv.classList.add( "s-expandable--content", "d-flex", "fd-column", "g16", "py12" ); expandableDiv.append(...expandableContent); expandable.append(expandableDiv); card.append(content, expandable); return card; } function createCategoryDiv(category) { const container = document.createElement("div"); container.classList.add("flex--item"); const wrapper = document.createElement("div"); wrapper.classList.add("d-flex", "ai-center", "mb8"); const header = document.createElement("h2"); header.classList.add("flex--item", "fs-title", "mb0", "mr-auto", "fw-normal"); header.textContent = category.name ?? ""; const buttonContainer = document.createElement("div"); buttonContainer.classList.add("d-flex", "g8", "ai-center"); const addNew = buttons_exports.makeStacksButton( `advanced-flagging-add-new-${category.id}`, "New", { type: ["outlined"], iconConfig: { name: "iconPlus", path: getIconPath(IconPlus), height: 18, width: 18 } } ); addNew.addEventListener("click", () => { const id = Math.max(...Store.flagTypes.map(({ id: id2 }) => id2)); const flagType = getEmptyFlagType(id + 1, category.name ?? ""); Store.flagTypes.push(flagType); Store.updateFlagTypes(); const div = createFlagTypeDiv(flagType); div.style.display = "none"; container.append(div); $(div).fadeIn({ complete: () => { div.querySelector('[id^="advanced-flagging-edit-flagtype-"]')?.click(); div.querySelector('[id^="advanced-flagging-flag-name-"]')?.focus(); } }); }); const flagTypes2 = Store.flagTypes.filter(({ belongsTo }) => belongsTo === category.name); const enabled = flagTypes2.some(({ enabled: enabled2 }) => enabled2); const toggle = toggle_exports.makeStacksToggle( `advanced-flagging-toggle-category-${category.id}`, { text: "" }, enabled ).querySelector(".s-toggle-switch"); toggle.addEventListener("change", () => { container.querySelectorAll('input[id^="advanced-flagging-toggle-flagtype-"]').forEach((box) => { box.checked = toggle.checked; }); Store.flagTypes.filter(({ belongsTo }) => belongsTo === category.name).forEach((flag) => { flag.enabled = toggle.checked; const card = document.querySelector(`[data-flag-id="${flag.id}"]`); if (!card) return; card.classList[toggle.checked ? "remove" : "add"]("s-card__muted"); }); Store.updateFlagTypes(); displayStacksToast( `Successfully ${toggle.checked ? "en" : "dis"}abled all flag types from this category`, "success", true ); }); buttonContainer.append(addNew, toggle); wrapper.append(header, buttonContainer); container.append(wrapper); return container; } function getCommentsModalBody() { const container = document.createElement("div"); container.classList.add("d-flex", "fd-column", "g16"); const categories = Store.categories.filter(({ name }) => name).map((category) => { const { name } = category; const div = createCategoryDiv(category); const flagTypes2 = Store.flagTypes.filter(({ belongsTo: BelongsTo }) => BelongsTo === name).map((flagType) => createFlagTypeDiv(flagType)); div.append(...flagTypes2); return div; }).filter((element) => element.childElementCount > 1); container.append(...categories); return container; } function resetFlagTypes() { Store.unset(Cached.FlagTypes); cacheFlags(); displayStacksToast( "Comments and flags have been reset to defaults", "success" ); setTimeout(() => window.location.reload(), 500); } function setupCommentsAndFlagsModal() { const modal = modals_exports.makeStacksModal( "advanced-flagging-comments-modal", { title: { text: "Advanced Flagging: edit comments and flags" }, body: { bodyHtml: getCommentsModalBody() }, footer: { buttons: [ { element: buttons_exports.makeStacksButton( "advanced-flagging-comments-modal-done", "I'm done!", { primary: true } ), hideOnClick: true }, { element: buttons_exports.makeStacksButton( "advanced-flagging-comments-modal-cancel", "Cancel" ), hideOnClick: true }, { element: buttons_exports.makeStacksButton( "advanced-flagging-configuration-modal-reset", "Reset", { type: ["danger"], click: { handler: resetFlagTypes } } ) } ] }, fullscreen: true } ); modal.firstElementChild?.classList.add("w80", "sm:w100", "md:w100"); document.body.append(modal); } // src/Configuration.ts function isSpecialFlag(flagName, checkNoFlag = true) { const arrayOfFlags = [ "PostOther" /* ModFlag */, "PlagiarizedContent" /* Plagiarism */ ]; if (checkNoFlag) { arrayOfFlags.push("NoFlag" /* NoFlag */); } return arrayOfFlags.includes(flagName); } function wrapInFlexItem(element) { const flexItem = document.createElement("div"); flexItem.classList.add("flex--item"); flexItem.append(element); return flexItem; } function cacheFlags() { const flagTypesToCache = flagCategories.flatMap((category) => { return category.FlagTypes.map((flagType) => { return Object.assign(flagType, { belongsTo: category.name, downvote: !isSpecialFlag(flagType.reportType), enabled: true // all flags should be enabled by default }); }); }); Store.set(Cached.FlagTypes, flagTypesToCache); Store.flagTypes.push(...flagTypesToCache); } function cacheCategories() { const categories = flagCategories.map((category) => ({ isDangerous: category.isDangerous, name: category.name, appliesTo: category.appliesTo, id: category.id })); Store.set(Cached.FlagCategories, categories); Store.categories.push(...categories); } function setupDefaults() { if (!Store.flagTypes.length || !("downvote" in Store.flagTypes[0])) { cacheFlags(); } if (!Store.categories.length || !("id" in Store.categories[0])) { cacheCategories(); const linkOnly = getFlagTypeFromFlagId(6); const defaultComment = flagCategories[2].FlagTypes[0].comments?.low; if (linkOnly && defaultComment && linkOnly.comments?.low.includes("target page is unavailable")) { linkOnly.comments.low = defaultComment; Store.updateFlagTypes(); } } Store.flagTypes.forEach((cachedFlag) => { if (cachedFlag.id !== 3 && cachedFlag.id !== 5) return; cachedFlag.reportType = "PlagiarizedContent" /* Plagiarism */; }); Store.updateFlagTypes(); if (!(Cached.Configuration.allowComments in Store.config)) { Store.config.allowComments = false; Store.updateConfiguration(); } if ("defaultNoSmokey" in Store.config) { Store.config.default = {}; [ ["defaultNoComment", "comment"], ["defaultNoFlag", "flag"], ["defaultNoDownvote", "downvote"], ["defaultNoSmokey", "smokey"], ["defaultNoNatty", "natty"], ["defaultNoGuttenberg", "guttenberg"], ["defaultNoGenericBot", "genericbot"], ["defaultNoDelete", "delete"] ].forEach(([oldName, newName]) => { Store.config.default[newName] = !Store.config[oldName]; delete Store.config[oldName]; }); Store.updateConfiguration(); } Store.flagTypes.filter(({ reportType }) => reportType === "PostLowQuality").forEach((flagType) => { flagType.reportType = "AnswerNotAnAnswer" /* NAA */; }); Store.updateFlagTypes(); } function setupConfiguration() { setupDefaults(); buildConfigurationOverlay(); setupCommentsAndFlagsModal(); const configModal = document.querySelector("#advanced-flagging-configuration-modal"); const commentsModal = document.querySelector("#advanced-flagging-comments-modal"); const bottomBox = document.querySelector(".site-footer--copyright > ul.-list"); const configDiv = document.createElement("div"); configDiv.classList.add("ta-left", "pt6"); const configLink = document.createElement("a"); configLink.innerText = "Advanced Flagging configuration"; configLink.addEventListener("click", () => Stacks.showModal(configModal)); configDiv.append(configLink); const commentsDiv = configDiv.cloneNode(); const commentsLink = document.createElement("a"); commentsLink.innerText = "Advanced Flagging: edit comments and flags"; commentsLink.addEventListener("click", () => Stacks.showModal(commentsModal)); commentsDiv.append(commentsLink); bottomBox?.after(configDiv, commentsDiv); const propertyDoesNotExist = !Object.prototype.hasOwnProperty.call( Store.config, Cached.Configuration.addAuthorName ); if (!propertyDoesNotExist) return; displayStacksToast( "Please set up Advanced Flagging before continuing.", "info", true ); setTimeout(() => Stacks.showModal(configModal)); } // src/popover.ts var noneSpan = document.createElement("span"); noneSpan.classList.add("o50"); noneSpan.innerText = "(none)"; var Popover = class { constructor(post) { this.post = post; this.popover = this.makeMenu(); } popover; // do not vote to delete if reportType is one of these: excludedTypes = [ "PlagiarizedContent" /* Plagiarism */, "PostOther" /* ModFlag */, "NoFlag" /* NoFlag */, "PostSpam" /* Spam */, "PostOffensive" /* Rude */ ]; makeMenu() { const menu = menus_exports.makeMenu( { itemsType: "a", navItems: [ ...this.getReportLinks(), ...this.getOptionsRow(), { separatorType: "divider" }, ...this.getSendFeedbackToRow() ] } ); const arrow = document.createElement("div"); arrow.classList.add("s-popover--arrow", "s-popover--arrow__tc"); menu.prepend(arrow); setTimeout(() => increaseTooltipWidth(menu)); return menu; } // Section #1: Report links getReportLinks() { const { Guttenberg: copypastor } = this.post.reporters; const { copypastorId, repost, targetUrl } = copypastor ?? {}; const categories = Store.categories.filter((item) => item.appliesTo?.includes(this.post.type)).map((item) => ({ ...item, FlagTypes: [] })); Store.flagTypes.filter(({ reportType, id, belongsTo, enabled }) => { if (belongsTo === "Answer-related" && (Page.isStackOverflow || Store.config.allowComments)) return true; const isGuttenbergItem = isSpecialFlag(reportType, false); const showGutReport = Boolean(copypastorId) && (id === 4 ? repost : !repost); const showOnSo = ["Red flags", "General"].includes(belongsTo) || Page.isStackOverflow; return enabled && (isGuttenbergItem ? showGutReport : showOnSo); }).forEach((flagType) => { const { belongsTo } = flagType; const category = categories.find(({ name: Name }) => belongsTo === Name); category?.FlagTypes.push(flagType); }); return categories.filter((category) => category.FlagTypes.length).flatMap((category) => { const { isDangerous } = category; const mapped = category.FlagTypes.flatMap((flagType) => { const { displayName } = flagType; const flagText = copypastorId && targetUrl ? getFullFlag(flagType, targetUrl, copypastorId) : ""; const tooltipHtml = this.getTooltipHtml(flagType, flagText); const classes = isDangerous ? ["fc-red-500"] : ""; return { text: displayName, // unfortunately, danger: IsDangerous won't work // since SE uses s-anchors__muted blockLink: { selected: false }, // use this trick instead ...classes ? { classes } : {}, click: { handler: () => { void this.handleReportLinkClick(flagType, flagText); } }, popover: { html: tooltipHtml, position: "right-start" } }; }); return [...mapped, { separatorType: "divider" }]; }); } // Section #2: Leave comment, Flag, Downvote getOptionsRow() { const comments = this.post.element.querySelector(".comment-body"); return configBoxes.filter(([text]) => { if (text === "Leave comment") return Store.config.allowComments || Page.isStackOverflow; else if (text === "Delete") return this.post.canDelete(true); return true; }).map(([text, cacheKey]) => { const selected = Store.config.default[cacheKey] && (text === "Leave comment" ? !comments : true); const idified = text.toLowerCase().replace(" ", "-"); const id = `advanced-flagging-${idified}-checkbox-${this.post.id}`; return { checkbox: { id, labelConfig: { text, classes: ["pt1", "fs-body1"] }, selected } }; }); } // Section #3: Send feedback to X getSendFeedbackToRow() { return Object.entries(this.post.getFeedbackBoxes()).map(([name, checkbox]) => { return { checkbox, checkboxOptions: { classes: ["px6"] }, popover: { html: `Send feedback to ${name}`, position: "right-start" } }; }); } getTooltipHtml(flagType, flagText) { const { reportType, downvote } = flagType; const feedbackText = this.getFeedbackSpans(flagType).map((span) => span.outerHTML).join(", "); const feedbacks = document.createElement("span"); feedbacks.innerHTML = feedbackText; const tooltipFlagText = this.post.deleted ? "" : flagText; const commentText = this.getCommentText(flagType); const tooltipCommentText = (this.post.deleted ? "" : commentText) || ""; const reportTypeHuman = reportType === "NoFlag" /* NoFlag */ || !this.post.deleted ? getHumanFromDisplayName(reportType) : ""; const popoverParent = document.createElement("div"); Object.entries({ Flag: reportTypeHuman, Comment: tooltipCommentText, "Flag text": tooltipFlagText, Feedbacks: feedbacks }).filter(([, value]) => value).map(([boldText, value]) => createPopoverToOption(boldText, value)).filter(Boolean).forEach((element) => popoverParent.append(element)); const downvoteWrapper = document.createElement("li"); const downvoteOrNot = downvote ? "Downvotes" : "Does not downvote"; downvoteWrapper.innerHTML = `${downvoteOrNot} the post`; popoverParent.append(downvoteWrapper); if (this.post.canDelete() && !this.post.deleted && !this.excludedTypes.includes(reportType)) { const wrapper = document.createElement("li"); wrapper.innerHTML = "Votes to delete the post"; popoverParent.append(wrapper); } return popoverParent.innerHTML; } getFeedbackSpans(flagType) { const spans = Object.entries(flagType.feedbacks).filter(([, feedback]) => feedback).filter(([botName, feedback]) => { return Object.values(this.post.reporters).find(({ name }) => name === botName)?.canSendFeedback(feedback); }).map(([botName, feedback]) => { const feedbackSpan = document.createElement("span"); const strong = document.createElement("b"); feedbackSpan.append(strong); if (feedback === "track") { strong.innerText = "track"; feedbackSpan.append(" with Generic Bot"); return feedbackSpan; } const [ isGreen, isRed, isYellow ] = [/tp/, /fp/, /naa|ne/].map((regex) => regex.test(feedback)); let className = ""; if (isGreen) className = "success"; else if (isRed) className = "danger"; else if (isYellow) className = "warning"; const shouldReport = !Object.values(this.post.reporters).find(({ name }) => name === botName)?.wasReported(); strong.classList.add(`fc-${className}`); strong.innerHTML = shouldReport ? "report" : feedback; feedbackSpan.append(` to ${botName}`); return feedbackSpan; }).filter(String); return spans.length ? spans : [noneSpan]; } getCommentText({ comments }) { const { addAuthorName } = Store.config; const type = (this.post.opReputation || 0) > 50 ? "high" : "low"; let comment = comments?.[type] || comments?.low; if (comment) { const sitename = StackExchange.options.site.name || ""; const siteurl = window.location.hostname; const questionId = StackExchange.question.getQuestionId().toString(); comment = comment.replace(/%SITENAME%/g, sitename).replace(/%SITEURL%/g, siteurl).replace(/%OP%/g, this.post.opName).replace(/%QID%/g, questionId); } return (comment && addAuthorName ? `${this.post.opName}, ${comment[0].toLowerCase()}${comment.slice(1)}` : comment) || null; } async handleReportLinkClick(flagType, flagText) { const { reportType, displayName } = flagType; const dropdown = this.post.element.querySelector(".advanced-flagging-popover"); if (!dropdown) return; $(dropdown).fadeOut("fast"); const spinner = spinner_exports.makeSpinner({ size: "sm", classes: ["advanced-flagging-spinner"] }); const flex = document.createElement("div"); flex.classList.add("flex--item"); flex.append(spinner); dropdown.closest(".flex--item")?.after(flex); this.post.progress = new Progress(spinner); this.post.progress.attach(); const natty = this.post.reporters.Natty; if (natty) { natty.raisedRedFlag = reportType === "PostSpam" /* Spam */ || reportType === "PostOffensive" /* Rude */; } const success = await this.post.sendFeedbacks(flagType); const old = StackExchange.helpers.removeSpinner; StackExchange.helpers.removeSpinner = () => void 0; if (!this.post.deleted) { let comment = this.getCommentText(flagType); const leaveComment = dropdown.querySelector( '[id*="-leave-comment-checkbox-"]' )?.checked; if (!leaveComment && comment) { this.post.upvoteSameComments(comment); comment = null; } const [flag, downvote, del] = ["flag", "downvote", "delete"].map((type) => { return dropdown.querySelector( `[id*="-${type}-checkbox-"]` )?.checked || false; }); if (comment) { const cProgress = this.post.progress.addItem("Adding comment..."); try { await this.post.comment(comment); cProgress.completed(); } catch (error) { console.error(error); cProgress.failed(); } } if (downvote && flagType.downvote) { this.post.downvote(); } if (flag && reportType !== "NoFlag" /* NoFlag */ && (!StackExchange.options.user.isModerator || reportType === "PostSpam" /* Spam */ || reportType === "PostOffensive" /* Rude */)) { const humanFlag = getHumanFromDisplayName(reportType); const fProgress = this.post.progress.addItem(`Flagging ${humanFlag}...`); try { await this.post.flag(reportType, flagText); fProgress.completed(); $(this.post.flagged).fadeIn(); } catch (error) { console.error(error); fProgress.failed( error instanceof Error ? error.message : "see console for more details" ); } } this.post.progress.updateLocation(); if (del && this.post.canDelete() && !this.excludedTypes.includes(reportType)) { const dProgress = this.post.progress.addItem("Voting to delete..."); try { await this.post.deleteVote(); dProgress.completed(); } catch (error) { console.error(error); dProgress.failed( error instanceof Error ? error.message : "" ); } } this.post.progress.updateLocation(); } await delay(2e3); flex.remove(); this.post.progress.delete(); StackExchange.helpers.removeSpinner = old; if (reportType !== "NoFlag" /* NoFlag */) return; if (success) { attachPopover(this.post.done, `Performed action ${displayName}`); $(this.post.done).fadeIn(); } else { attachPopover(this.post.failed, `Failed to perform action ${displayName}`); $(this.post.failed).fadeIn(); } } }; function increaseTooltipWidth(menu) { [...menu.querySelectorAll("li")].filter((li) => li.firstElementChild?.classList.contains("s-block-link")).map((reportLink) => reportLink.nextElementSibling).forEach((tooltip) => { if (!tooltip) return; const textLength = tooltip.textContent.length; if (!textLength) return; tooltip.classList.add( textLength > 100 ? "wmn5" : "wmn2" ); }); } function createPopoverToOption(boldText, value) { if (!value) return; const wrapper = document.createElement("li"); const bold = document.createElement("strong"); bold.innerHTML = `${boldText}: `; wrapper.append(bold); if (Array.isArray(value)) { wrapper.append(...value); } else { wrapper.append(value || noneSpan); } return wrapper; } // src/review.ts var audit = false; async function runOnNewTask(xhr) { const regex = /\/review\/(next-task|task-reviewed\/)/; if (xhr.status !== 200 || !regex.test(xhr.responseURL) || !document.querySelector("#answer")) return; const response = JSON.parse(xhr.responseText); audit = response.isAudit; if (response.isAudit) return; const url = `//stackoverflow.com/a/${response.postId}`; await Promise.all([ MetaSmokeAPI.queryMetaSmokeInternal([url]), NattyAPI.getAllNattyIds([response.postId]), CopyPastorAPI.storeReportedPosts([url]) ]); const page2 = new Page(); const post = page2.posts[0]; while (!isDone) await delay(200); post.addIcons(); document.querySelector(".js-review-submit")?.addEventListener("click", async (event) => { const looksGood = document.querySelector( "#review-action-LooksGood" ); if (!looksGood?.checked) return; const flagType = Store.flagTypes.find(({ id }) => id === 15); if (!flagType) return; await addProgress(event, flagType); }, { once: true }); } function setupReview() { const watchReview = Store.config[Cached.Configuration.watchQueues]; if (!watchReview || !Page.isLqpReviewPage || !Page.isStackOverflow) return; addXHRListener(runOnNewTask); addXHRListener((xhr) => { const regex = /\/posts\/modal\/delete\/\d+/; if (xhr.status !== 200 || !regex.test(xhr.responseURL) || !document.querySelector("#answer") || audit) return; const submit = document.querySelector("form .js-modal-submit"); if (!submit) return; const [, checkbox] = checkbox_exports.makeStacksCheckboxes( [ { id: "advanced-flagging-flag-post", labelConfig: { text: "Flag post", classes: ["mt2"] }, selected: true } ] ); checkbox.classList.add("flex--item"); const post = new Page(true).posts[0]; submit.parentElement?.append(checkbox); appendLabelAndBoxes(submit, post); submit.addEventListener("click", async (event) => { const flagType = getFlagTypeFromFlagId(7); if (!flagType) return; await addProgress(event, flagType); }, { once: true }); }); } // src/AdvancedFlagging.ts function setupStyles() { GM_addStyle(` #popup-flag-post { max-width: 700px !important; } .advanced-flagging-popover { min-width: 10rem !important; } #advanced-flagging-comments-modal textarea { resize: vertical; } #advanced-flagging-snackbar { transform: translate(-50%, 0); /* correctly centre the element */ min-width: 19rem; } .advanced-flagging-link:focus { outline: none; } .advanced-flagging-link { outline-style: none !important; outline: none !important; } .advanced-flagging-link li > a { padding-block: 4px; } .advanced-flagging-link li > .s-check-control { padding-inline: 6px; gap: 4px; } #advanced-flagging-comments-modal > .s-modal--dialog, #advanced-flagging-configuration-modal > .s-modal--dialog { max-width: 90% !important; max-height: 95% !important; }`); } var popupWrapper = document.createElement("div"); popupWrapper.classList.add( "fc-white", "fs-body3", "ta-center", "z-modal", "ps-fixed", "l50" ); popupWrapper.id = "advanced-flagging-snackbar"; document.body.append(popupWrapper); function displayToaster(text, state) { const element = document.createElement("div"); element.classList.add("p12", `bg-${state}`); element.innerText = text; element.style.display = "none"; popupWrapper.append(element); $(element).fadeIn(); window.setTimeout(() => { $(element).fadeOut("slow", () => element.remove()); }, popupDelay); } function buildFlaggingDialog(post) { const dropdown = document.createElement("div"); dropdown.classList.add( "s-popover", "s-anchors", "s-anchors__default", "mt2", "c-default", "px0", "py4", "advanced-flagging-popover" ); const actionsMenu = new Popover(post).popover; dropdown.append(actionsMenu); return dropdown; } function setPopoverOpening(advancedFlaggingLink, dropdown) { const openOnHover = Store.config[Cached.Configuration.openOnHover]; advancedFlaggingLink.addEventListener(openOnHover ? "mouseover" : "click", (event) => { event.stopPropagation(); if (advancedFlaggingLink.isSameNode(event.target)) { $(dropdown).fadeIn("fast"); } }); if (openOnHover) { advancedFlaggingLink.addEventListener("mouseleave", (event) => { event.stopPropagation(); setTimeout(() => $(dropdown).fadeOut("fast"), 200); }); } else { window.addEventListener("click", () => $(dropdown).fadeOut("fast")); } } var page = new Page(); function setupPostPage() { if (Page.isLqpReviewPage) return; page = new Page(); if (page.name && page.name !== "Question" && page.name !== "Staging Ground") { page.posts.forEach((post) => post.addIcons()); return; } const linkDisabled = Store.config[Cached.Configuration.linkDisabled]; page.posts.forEach((post) => { const { id, done, failed, flagged, element } = post; post.watchForFlags(); if (linkDisabled) { post.addIcons(); return; } const advancedFlaggingLink = buttons_exports.makeStacksButton( `advanced-flagging-link-${id}`, "Advanced Flagging", { type: ["link"], classes: ["advanced-flagging-link"] } ); const iconLocation = element.querySelector(".js-post-menu")?.firstElementChild; const flexItem = document.createElement("div"); flexItem.classList.add("flex--item"); flexItem.append(advancedFlaggingLink); iconLocation?.append(flexItem); const dropDown = buildFlaggingDialog(post); advancedFlaggingLink.append(dropDown); iconLocation?.append(done, failed, flagged); post.addIcons(); setPopoverOpening(advancedFlaggingLink, dropDown); }); } var isDone = false; function Setup() { setupConfiguration(); void Promise.all([ MetaSmokeAPI.setup(), MetaSmokeAPI.queryMetaSmokeInternal(), CopyPastorAPI.getAllCopyPastorIds(), NattyAPI.getAllNattyIds() ]).then(() => { setupPostPage(); setupStyles(); addXHRListener(() => { setupPostPage(); setTimeout(setupPostPage, 55); setTimeout(setupPostPage, 200); }); isDone = true; }); } setupReview(); interceptXhr(); if (document.hasFocus()) { Setup(); } else { window.addEventListener("focus", () => Setup(), { once: true }); } })();