diff --git a/src/webpage/localuser.ts b/src/webpage/localuser.ts index f6d3257..c24766a 100644 --- a/src/webpage/localuser.ts +++ b/src/webpage/localuser.ts @@ -3353,6 +3353,16 @@ class Localuser { SW.traceInit(); }; + const box7 = devSettings.addCheckboxInput(I18n.devSettings.reportSystem(), () => {}, { + initState: getDeveloperSettings().reportSystem, + }); + box7.onchange = (e) => { + const settings = getDeveloperSettings(); + settings.reportSystem = e; + setDeveloperSettings(settings); + SW.traceInit(); + }; + devSettings.addButtonInput("", I18n.devSettings.clearWellKnowns(), async () => { const currentUserInfos = JSON.parse(localStorage.getItem("userinfos")!); for (const user of Object.keys(currentUserInfos.users)) { diff --git a/src/webpage/message.ts b/src/webpage/message.ts index 6a4fa52..9a68a02 100644 --- a/src/webpage/message.ts +++ b/src/webpage/message.ts @@ -17,6 +17,8 @@ import {Dialog} from "./settings.js"; import {Sticker} from "./sticker.js"; import {Components} from "./interactions/compontents.js"; import {ImagesDisplay} from "./disimg"; +import {ReportMenu} from "./reporting/report.js"; +import {getDeveloperSettings} from "./utils/storage/devSettings.js"; class Message extends SnowFlake { static contextmenu = new Contextmenu("message menu"); stickers!: Sticker[]; @@ -216,6 +218,20 @@ class Message extends SnowFlake { color: "red", }, ); + Message.contextmenu.addButton( + () => I18n.message.report(), + async function () { + const menu = await ReportMenu.makeReport("message", this.localuser, {message: this}); + menu?.spawnMenu(); + }, + { + visible: function () { + const settings = getDeveloperSettings(); + return this.author.id !== this.localuser.user.id && settings.reportSystem; + }, + color: "red", + }, + ); Message.contextmenu.addSeperator(); Message.contextmenu.addButton( diff --git a/src/webpage/reporting/report.ts b/src/webpage/reporting/report.ts new file mode 100644 index 0000000..3c9d2d3 --- /dev/null +++ b/src/webpage/reporting/report.ts @@ -0,0 +1,473 @@ +import {I18n} from "../i18n.js"; +import {Localuser} from "../localuser.js"; +import {MarkDown} from "../markdown.js"; +import {Member} from "../member.js"; +import {Message} from "../message.js"; +import {removeAni} from "../utils/utils.js"; +import { + buttonTypes, + report, + reportElements, + reportMessagePut, + reportNode, + reportPut, + reportTypes, +} from "./types.js"; +interface InfoMap { + message?: Message; + failMessage?: string; +} +export class ReportMenu { + variant: string; + name: reportTypes; + owner: Localuser; + postback_url: URL; + rootNodeId: number; + successNodeId: number; + failNodeId: number; + reportNodes: Record; + get localuser() { + return this.owner; + } + get info() { + return this.localuser.info; + } + nodes: ReportNode[]; + options: string[] = []; + infoMap: InfoMap; + node?: ReportNode; + constructor(json: report, localuser: Localuser, infoMap: InfoMap) { + if (json.version !== "1.0") throw new Error("uh oh"); + this.name = json.name; + this.variant = json.variant; + this.owner = localuser; + this.postback_url = new URL(json.postback_url, this.info.api); + this.rootNodeId = json.root_node_id; + this.successNodeId = json.success_node_id; + this.failNodeId = json.fail_node_id; + this.reportNodes = {}; + for (const [id, nodejson] of Object.entries(json.nodes)) { + this.reportNodes[id] = new ReportNode(nodejson, this); + } + const first = this.reportNodes[this.rootNodeId]; + if (!first) throw new Error("unable to find first node"); + this.nodes = []; + this.infoMap = infoMap; + } + div?: HTMLDivElement; + async spawnMenu() { + const background = document.createElement("div"); + background.classList.add("background"); + background.onkeydown = (e) => { + if (e.key == "Escape") { + removeAni(background); + } + }; + background.onclick = () => { + removeAni(background); + }; + + const div = document.createElement("div"); + div.classList.add("flexttb", "reportMenu"); + background.append(div); + this.div = div; + div.onclick = (e) => { + e.stopImmediatePropagation(); + }; + const first = this.reportNodes[this.rootNodeId]; + first.render(); + + document.body.append(background); + } + static async makeReport(type: reportTypes, localuser: Localuser, infoMap: InfoMap = {}) { + const res = await fetch(localuser.info.api + "/reporting/menu/" + type, { + headers: localuser.headers, + }); + if (!res.ok) return; + const json = (await res.json()) as report; + return new ReportMenu(json, localuser, infoMap); + } + async submit(takeToScreen = true) { + const obj: Omit = { + version: "1.0", + variant: this.variant, + language: I18n.lang, + breadcrumbs: [...this.nodes.map((_) => _.id), this.node?.id as number], + elements: this.gatherElements(), + }; + let realBody: any; + switch (this.name) { + case "message": { + const message = this.infoMap.message; + if (!message) throw new Error("Message expected"); + const m: reportMessagePut = { + ...obj, + name: "message", + message_id: message.id, + channel_id: message.channel.id, + }; + realBody = m; + } + } + const res = await fetch(this.postback_url, { + method: "POST", + headers: this.localuser.headers, + body: JSON.stringify(realBody), + }); + if (res.ok) { + if (takeToScreen) { + const suc = this.reportNodes[this.successNodeId]; + if (!suc) throw new Error("unable to find suc node"); + suc.render(); + } + } else { + const json = await res.json(); + this.errorNode(json?.message); + } + } + errorNode(message?: string) { + const error = this.reportNodes[this.failNodeId]; + this.infoMap.failMessage = message; + if (!error) throw new Error("unable to find suc node"); + error.render(); + } + gatherElements() { + let elms: Record = {}; + for (const node of this.nodes) { + elms = { + ...node.gatherElements(), + ...elms, + }; + } + return elms; + } +} +class ReportNode { + owner: ReportMenu; + key: string; + header: string; + subheader: string | null; + info: string | null; + buttonType?: buttonTypes; + buttonTarget?: number; + elements: ReportElement[]; + reportType: string | null; + children: [string, number][]; + isMultiSelectRequired: boolean; + isAutoSubmit: boolean; + id: number; + constructor(json: reportNode, owner: ReportMenu) { + this.owner = owner; + this.header = json.header; + this.subheader = json.subheader; + this.key = json.key; + this.buttonType = json.button?.type; + this.buttonTarget = json.button?.target || undefined; + this.elements = json.elements.map((_) => new ReportElement(_, this)); + this.reportType = json.report_type; + this.children = json.children; + this.isMultiSelectRequired = json.is_multi_select_required; + this.isAutoSubmit = json.is_auto_submit; + this.id = json.id; + this.info = json.info; + } + div?: HTMLDivElement; + render() { + if (this.owner.node) { + if (this.owner.node.div) removeAni(this.owner.node.div); + } + this.owner.node = this; + const div = document.createElement("div"); + div.classList.add("flexttb"); + this.div = div; + const title = document.createElement("h2"); + title.textContent = this.header; + div.append(title); + + if (this.subheader) { + const sub = document.createElement("h3"); + sub.textContent = this.subheader; + div.append(sub); + } + div.append(document.createElement("hr")); + if (this.info) { + const info = document.createElement("span"); + info.textContent = this.info; + div.append(info); + } + div.append(...this.elements.map((e) => e.makeHTML())); + const children = document.createElement("div"); + children.classList.add("reportChildren", "flexttb"); + children.append( + ...this.children.map(([name, id]) => { + const button = document.createElement("button"); + button.textContent = name; + button.onclick = () => { + this.jumpPannel(id, true, name); + }; + return button; + }), + ); + + const buttonDiv = document.createElement("div"); + buttonDiv.classList.add("flexltr", "reportButtonDiv"); + console.log(this.buttonType); + switch (this.buttonType) { + case "next": { + const next = document.createElement("button"); + next.textContent = I18n.report.next(); + buttonDiv.append(next); + next.onclick = () => { + if (this.isMultiSelectRequired) { + for (const elm of this.elements) { + if (elm.notFilled()) { + return; + } + } + } + if (this.buttonTarget !== undefined) this.jumpPannel(this.buttonTarget, true); + }; + break; + } + case "submit": { + const submit = document.createElement("button"); + submit.textContent = I18n.report.submit(); + buttonDiv.append(submit); + submit.onclick = () => { + this.owner.submit(); + }; + break; + } + case "cancel": { + const cancel = document.createElement("button"); + cancel.textContent = I18n.report.cancel(); + buttonDiv.prepend(cancel); + cancel.onclick = () => { + const div = this.owner.div?.parentElement; + if (div) removeAni(div); + }; + break; + } + + default: + console.log(this.buttonType); + } + if ( + this.buttonType !== "cancel" && + this.buttonType !== "done" && + !this.elements.find((e) => e.json.type === "skip") + ) { + const back = document.createElement("button"); + back.textContent = I18n.report.back(); + buttonDiv.prepend(back); + back.onclick = () => { + const pop = this.owner.nodes.pop(); + this.owner.options.pop(); + if (pop) { + this.jumpPannel(pop.id); + } + }; + } + + div.append(children, buttonDiv); + if (this.isAutoSubmit) this.owner.submit(false); + + this.owner.div?.append(div); + } + gatherElements() { + const elms: Record = {}; + for (const thing of this.elements) { + if (thing.options) elms[thing.json.name] = thing.options; + } + return elms; + } + + jumpPannel(id: number, push = false, option = "") { + if (push) { + this.owner.nodes.push(this); + this.owner.options.push(option); + } + const next = this.owner.reportNodes[id]; + if (!next) throw new Error("node doesn't exist"); + next.render(); + } + getSelectStr() { + let comb: string[] = []; + for (const elm of this.elements) { + if (elm.optionReal.length) { + comb.push(...elm.optionReal); + } + } + console.log(comb, this); + return new Intl.ListFormat(I18n.lang).format(comb); + } +} +class ReportElement { + json: reportElements; + owner: ReportNode; + options: string[] = []; + optionReal: string[] = []; + constructor(json: reportElements, owner: ReportNode) { + this.json = json; + this.owner = owner; + } + makeHTML() { + const div = document.createElement("div"); + div.classList.add("reportOption", "flexttb"); + const json = this.json; + if (json.skip_if_unlocalized && !json.is_localized) return div; + const map = this.owner.owner.infoMap; + switch (json.type) { + case "external_link": { + const data = json.data; + if (data.is_header_hidden) break; + const a = document.createElement("a"); + MarkDown.safeLink(a, data.url); + a.textContent = data.link_text; + div.append(a); + if (data.link_description) { + const span = document.createElement("span"); + span.textContent = data.link_description; + div.append(span); + } + break; + } + case "message_preview": { + const m = this.owner.owner.infoMap.message; + if (m) { + div.append(m.buildhtml(undefined, true)); + } else { + div.append("You should never see this"); + } + break; + } + case "breadcrumbs": { + const title = document.createElement("span"); + title.textContent = I18n.report.summary(); + div.append(title); + const ul = document.createElement("ul"); + + for (let i = 0; i < this.owner.owner.options.length; i++) { + const str = this.owner.owner.options[i]; + if (str) { + const li = document.createElement("li"); + li.textContent = str; + ul.append(li); + } + const op = this.owner.owner.nodes[i]; + if (op) { + const str = op.getSelectStr(); + if (str) { + const li = document.createElement("li"); + li.textContent = str; + ul.append(li); + } + } + } + + div.append(ul); + break; + } + case "ignore_users": + case "share_with_parents": { + //TODO implement when spacebar implements these + break; + } + case "block_users": { + const user = map.message?.author; + if (!user) break; + const button = document.createElement("button"); + button.textContent = I18n.report.blockUser(); + div.append(button); + button.onclick = () => { + user.block(); + }; + break; + } + case "mute_users": { + const message = map.message; + if (!message) break; + if (!message.guild.member.hasPermission("MUTE_MEMBERS")) break; + (async () => { + const member = await Member.resolveMember(message.author, message.guild); + if (!member) return; + + const button = document.createElement("button"); + button.textContent = I18n.report.timeout(); + div.append(button); + button.onclick = () => { + member.timeout(); + }; + })(); + + break; + } + case "app_preview": { + //TODO figure out what this is supposed to be + break; + } + case "skip": { + break; + } + case "checkbox": { + div.classList.add("friendGroupSelect"); + for (const [subName, name, desc] of json.data) { + const elm = document.createElement("div"); + elm.classList.add("flexltr", "checkCard"); + const check = document.createElement("input"); + check.type = "checkbox"; + check.checked = this.options.includes(subName); + check.onclick = (e) => e.stopImmediatePropagation(); + elm.onclick = (e) => { + e.stopImmediatePropagation(); + check.click(); + }; + check.onchange = () => { + if (check.checked) { + this.options.push(subName); + } else { + this.options = this.options.filter((_) => _ !== subName); + } + this.optionReal = this.options.map( + (_) => json.data.find((e) => e[0] === _)?.[1] as string, + ); + }; + + const names = document.createElement("div"); + names.classList.add("flexttb"); + const nameElm = document.createElement("span"); + nameElm.textContent = name; + names.append(name); + if (desc) { + const descElm = document.createElement("span"); + descElm.textContent = desc; + names.append(desc); + } + elm.append(names, check); + div.append(elm); + } + break; + } + case "fail": { + if (!this.owner.owner.infoMap.failMessage) break; + const span = document.createElement("span"); + span.textContent = this.owner.owner.infoMap.failMessage; + div.append(span); + break; + } + default: + console.log(json); + div.textContent = this.json.type; + } + + return div; + } + notFilled() { + switch (this.json.type) { + case "checkbox": + return !this.options.length; + default: + return false; + } + } +} diff --git a/src/webpage/reporting/types.ts b/src/webpage/reporting/types.ts index a4b92c8..2ca40dc 100644 --- a/src/webpage/reporting/types.ts +++ b/src/webpage/reporting/types.ts @@ -1,4 +1,4 @@ -type reportTypes = +export type reportTypes = | "message" | "user" | "guild_discovery" @@ -10,7 +10,7 @@ type reportTypes = | "application" | "widget" | "guild_directory_entry"; -type buttonTypes = +export type buttonTypes = | "submit" // This button shows a warning about making false reports along with the submit and back button | "done" //This button just shows a Done button without a back button | "cancel" //This button may provide a back button, but does not provide a button to move forward @@ -39,7 +39,6 @@ type singleElementTypes = | "guild_directory_entry_preview" // Shows the user a preview of the dirrectory the user is reporting | "guild_scheduled_event_preview" //Shows the user a preview of the event the user is reporting | "channel_preview" //shows the user a preview of the reported channel - | "app_preview" //show the user of the reported | "widget_preview" //show the user a preview of the reported widget | "breadcrumbs" // I think this is a defunct element that visually does not do anything, but was at one point a signal to include the breadcrumbs of how the user got there. | "fail" // used in the failed menus @@ -152,12 +151,15 @@ interface reportText extends reportElementBase { is_localized: boolean; }; } -type reportElements = +export type reportElements = | singleElementReport | externalLinkReport | selectReport - | selfHarmHelp; -interface report { + | selfHarmHelp + | reportText + | freeTextReport + | dropdownReport; +export interface report { name: reportTypes; variant: string; version: string; @@ -177,49 +179,50 @@ interface report { */ language?: string; nodes: { - [key: string]: { - id: number; - /** - * this key is likely only used for translation reasons, and is not used during the reporting process - */ - key: string; - header: string; - subheader: string | null; - /** - * information about a certain section displayed in a special box - */ - info: null | string; - /** - * the button at the bottom of the report box - */ - button: { - type: buttonTypes; - target: null | number; - } | null; - /** - * these are the elements that aren't the buttons to just continue along and may provide information to the user, or present the user with more options - */ - elements: reportElements[]; - /** - * this says what type of thing the user is reporting, though isn't actually used in the process of reporting - */ - report_type: null | string; - /** - * These are the options with the strings leading to the numbers which represent the nodes to go to - */ - children: [string, number][]; - /** - * This is true if the checkbox element is required to have at least one element selected to continue - */ - is_multi_select_required: boolean; - /** - * Used if this screen automatically submits the report without additional input - */ - is_auto_submit: boolean; - }; + [key: string]: reportNode; }; } -interface reportPut { +export interface reportNode { + id: number; + /** + * this key is likely only used for translation reasons, and is not used during the reporting process + */ + key: string; + header: string; + subheader: string | null; + /** + * information about a certain section displayed in a special box + */ + info: null | string; + /** + * the button at the bottom of the report box + */ + button: { + type: buttonTypes; + target: null | number; + } | null; + /** + * these are the elements that aren't the buttons to just continue along and may provide information to the user, or present the user with more options + */ + elements: reportElements[]; + /** + * this says what type of thing the user is reporting, though isn't actually used in the process of reporting + */ + report_type: null | string; + /** + * These are the options with the strings leading to the numbers which represent the nodes to go to + */ + children: [string, number][]; + /** + * This is true if the checkbox element is required to have at least one element selected to continue + */ + is_multi_select_required: boolean; + /** + * Used if this screen automatically submits the report without additional input + */ + is_auto_submit: boolean; +} +export interface reportPut { version: string; variant: string; language: string; @@ -237,7 +240,7 @@ interface reportPut { name: reportTypes; } -interface reportMessagePut extends reportPut { +export interface reportMessagePut extends reportPut { channel_id: string; message_id: string; name: "message"; diff --git a/src/webpage/style.css b/src/webpage/style.css index 53fbb5d..c4f9fae 100644 --- a/src/webpage/style.css +++ b/src/webpage/style.css @@ -3604,6 +3604,51 @@ fieldset input[type="radio"] { border-radius: 3px 3px 1px 1px; } } +.reportMenu { + background: var(--primary-bg); + padding: 16px; + border-radius: 4px; + box-shadow: 1px 2px 5px 2px black; + width: 70%; + height: 70%; + overflow-y: auto; + > div { + > * { + margin-bottom: 4px; + } + min-height: 100%; + } +} +.removeElm { + > .reportMenu { + animation-duration: 0.2s; + animation-name: fade-in-back; + animation-fill-mode: forwards; + animation-timing-function: ease-out; + animation-name: fade-in-back-out; + } +} +.checkCard { + padding: 2px; + background: #00000036; + margin: 2px; + border-radius: 4px; + cursor: pointer; +} +.reportButtonDiv { + flex-shrink: 0; + button { + margin-right: 6px; + } +} +.reportChildren { + margin-top: auto; + height: fit-content; + + button { + margin-bottom: 6px; + } +} .settingbuttons { flex: none; width: 192px; diff --git a/src/webpage/utils/storage/devSettings.ts b/src/webpage/utils/storage/devSettings.ts index c2e2470..8a0c62f 100644 --- a/src/webpage/utils/storage/devSettings.ts +++ b/src/webpage/utils/storage/devSettings.ts @@ -5,6 +5,7 @@ export class DeveloperSettings { interceptApiTraces: boolean = false; cacheSourceMaps: boolean = false; logBannedFields: boolean = false; + reportSystem = false; constructor(data: Partial = {}) { Object.assign(this, data); @@ -12,9 +13,7 @@ export class DeveloperSettings { } export function getDeveloperSettings(): DeveloperSettings { - return new DeveloperSettings( - JSON.parse(localStorage.getItem("developerSettings") || "{}"), - ); + return new DeveloperSettings(JSON.parse(localStorage.getItem("developerSettings") || "{}")); } export function setDeveloperSettings(settings: DeveloperSettings): void { @@ -73,4 +72,4 @@ async function migrateOldDeveloperSettings(): Promise { } } await migrateOldDeveloperSettings(); -//endregion \ No newline at end of file +//endregion diff --git a/src/webpage/utils/utils.ts b/src/webpage/utils/utils.ts index 70a1800..b0fefbb 100644 --- a/src/webpage/utils/utils.ts +++ b/src/webpage/utils/utils.ts @@ -1022,7 +1022,8 @@ export class SW { this.worker.postMessage({data: mode, code: "setMode"}); } - if (mode === ServiceWorkerMode.Unregistered) this.registration.unregister().then(r => console.log("Service worker unregistered:", r)); + if (mode === ServiceWorkerMode.Unregistered) + this.registration.unregister().then((r) => console.log("Service worker unregistered:", r)); } static forceClear() { diff --git a/translations/en.json b/translations/en.json index 1d6a2bb..58723a9 100644 --- a/translations/en.json +++ b/translations/en.json @@ -158,6 +158,7 @@ "clearWellKnowns": "Re-fetch .well-known records", "description": "These are for developers of Spacebar/Fermi, if you don't know what you're doing, please don't mess with these settings.", "gatewayComp": "Disable Gateway compression", + "reportSystem":"Enable experimental reporting system", "logGateway": "Log received gateway events (log level info):", "name": "Developer Settings", "traces": "Expose traces:" @@ -556,6 +557,7 @@ "andMore": "$1, and more!", "attached": "Sent an attachment", "delete": "Delete message", + "report":"Report message", "deleted": "Deleted message", "edit": "Edit message", "edited": "(edited)", @@ -568,6 +570,15 @@ "retry": "Resend errored message", "viewrest": "View rest" }, + "report":{ + "back":"Back", + "next":"Next", + "cancel":"Cancel", + "submit":"Submit", + "blockUser":"Block user", + "timeout":"Timeout Member", + "summary":"Report Summary:" + }, "messageLink": "Message in # $1", "muteDuration": "Unmute in:", "nevermind": "Nevermind",