experimental reporting system intial

This commit is contained in:
MathMan05 committed 2026-01-15 16:50:11 -06:00
1 parent 73373b3fcd
commit aad6877cd4
8 files changed
+611 -53

No files matched your search

+10
View File
@@ -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)) {
+16
View File
@@ -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, void>("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(
+473
View File
@@ -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<string, ReportNode>;
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<reportPut, "name"> = {
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<string, string[]> = {};
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<string, string[]> = {};
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;
}
}
}
+51 -48
View File
@@ -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<singleElementTypes>
| 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";
+45
View File
@@ -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;
+3 -4
View File
@@ -5,6 +5,7 @@ export class DeveloperSettings {
interceptApiTraces: boolean = false;
cacheSourceMaps: boolean = false;
logBannedFields: boolean = false;
reportSystem = false;
constructor(data: Partial<DeveloperSettings> = {}) {
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<void> {
}
}
await migrateOldDeveloperSettings();
//endregion
//endregion
+2 -1
View File
@@ -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() {
+11
View File
@@ -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",