From 67e55612951d4ce7c8d4a289cfa21ae6eff90b9a Mon Sep 17 00:00:00 2001 From: MathMan05 Date: Thu, 21 May 2026 16:12:34 -0500 Subject: [PATCH 1/5] add poll support --- src/webpage/channel.ts | 17 ++- src/webpage/contextmenu.ts | 13 +- src/webpage/index.ts | 17 ++- src/webpage/jsontypes.ts | 45 +++++++ src/webpage/localuser.ts | 79 ++++++++++++ src/webpage/markdown.ts | 42 ++++--- src/webpage/message.ts | 238 ++++++++++++++++++++++++++++++++++++- src/webpage/style.css | 50 ++++++++ translations/en.json | 28 ++++- 9 files changed, 499 insertions(+), 30 deletions(-) diff --git a/src/webpage/channel.ts b/src/webpage/channel.ts index adfa4ea..4e31812 100644 --- a/src/webpage/channel.ts +++ b/src/webpage/channel.ts @@ -9,6 +9,7 @@ import {InfiniteScroller} from "./infiniteScroller.js"; import {SnowFlake} from "./snowflake.js"; import { channeljson, + creatPollJSON, embedjson, filejson, memberjson, @@ -3619,12 +3620,14 @@ class Channel extends SnowFlake { embeds = [], sticker_ids = [], nonce = undefined, + poll = undefined, }: { - attachments: Blob[]; - embeds: embedjson[]; - replyingto: Message | null; - sticker_ids: string[]; + attachments?: Blob[]; + embeds?: embedjson[]; + replyingto?: Message | null; + sticker_ids?: string[]; nonce?: string; + poll?: creatPollJSON; }, onRes = (_e: "Ok" | "NotOk") => {}, ) { @@ -3634,7 +3637,8 @@ class Channel extends SnowFlake { content.trim() === "" && attachments.length === 0 && embeds.length == 0 && - sticker_ids.length === 0 + sticker_ids.length === 0 && + !poll ) { return; } @@ -3717,6 +3721,7 @@ class Channel extends SnowFlake { message_reference: undefined, sticker_ids, embeds, + poll, }; if (replyjson) { body.message_reference = replyjson; @@ -3758,6 +3763,7 @@ class Channel extends SnowFlake { message_reference: undefined, sticker_ids, embeds, + poll, }; if (replyjson) { body.message_reference = replyjson; @@ -3974,4 +3980,3 @@ class Channel extends SnowFlake { } Channel.setupcontextmenu(); export {Channel}; - diff --git a/src/webpage/contextmenu.ts b/src/webpage/contextmenu.ts index 957be17..979d1ea 100644 --- a/src/webpage/contextmenu.ts +++ b/src/webpage/contextmenu.ts @@ -235,15 +235,17 @@ type contextCluster = [Contextmenu, X, Y]; class LayeredEvent extends CustomEvent { menus: contextCluster[]; primary?: contextCluster; - constructor(mouse: MouseEvent, menus: LayeredEvent["menus"]) { + side: "top" | "bottom"; + constructor(mouse: MouseEvent, menus: LayeredEvent["menus"], side: "top" | "bottom") { super("layered", {bubbles: true}); + this.side = side; this.menus = menus; queueMicrotask(() => { console.log(this); const pop = this.primary || menus.pop(); if (!pop) return; const [menu, addinfo, other] = pop; - menu.makemenu(mouse.clientX, mouse.clientY, addinfo, other, undefined, menus); + menu.makemenu(mouse.clientX, mouse.clientY, addinfo, other, undefined, menus, this.side); }); } } @@ -340,7 +342,11 @@ class Contextmenu { other: y, keep: boolean | HTMLElement = false, layered: LayeredEvent["menus"] = [], + side: "top" | "bottom" = "top", ) { + if (side === "bottom") { + y = y - window.innerHeight; + } const div = document.createElement("div"); div.classList.add("contextmenu", "flexttb"); const processed = new WeakSet>(); @@ -386,6 +392,7 @@ class Contextmenu { touchDrag: (x: number, y: number) => unknown = () => {}, touchEnd: (x: number, y: number) => unknown = () => {}, click: "right" | "left" = "right", + side: "top" | "bottom" = "top", ) { const func = (event: MouseEvent) => { const selectedText = window.getSelection(); @@ -406,7 +413,7 @@ class Contextmenu { } event.stopImmediatePropagation(); event.preventDefault(); - const layered = new LayeredEvent(event, []); + const layered = new LayeredEvent(event, [], side); obj.dispatchEvent(layered); }; obj.addEventListener("layered", (layered) => { diff --git a/src/webpage/index.ts b/src/webpage/index.ts index 4be6d40..e2c438e 100644 --- a/src/webpage/index.ts +++ b/src/webpage/index.ts @@ -423,7 +423,11 @@ if (window.location.pathname.startsWith("/channels")) { e.preventDefault(); e.stopImmediatePropagation(); }; - (document.getElementById("upload") as HTMLElement).onclick = () => { + const umenu = new Contextmenu("upload"); + umenu.addButton(I18n.makePoll(), () => { + thisUser.makePoll(); + }); + umenu.addButton(I18n.upload(), () => { const input = document.createElement("input"); input.type = "file"; input.click(); @@ -441,7 +445,16 @@ if (window.location.pathname.startsWith("/channels")) { } } }; - }; + }); + umenu.bindContextmenu( + document.getElementById("upload")!, + undefined, + undefined, + undefined, + undefined, + "left", + "bottom", + ); const emojiTB = document.getElementById("emojiTB") as HTMLElement; emojiTB.onmousedown = (e) => e.stopImmediatePropagation(); emojiTB.onclick = (e) => { diff --git a/src/webpage/jsontypes.ts b/src/webpage/jsontypes.ts index 4949e78..3cc9b63 100644 --- a/src/webpage/jsontypes.ts +++ b/src/webpage/jsontypes.ts @@ -448,6 +448,37 @@ type emojijson = { animated?: boolean; emoji?: string; }; +interface pollMedia { + text: string; + emoji?: emojijson; +} +export interface creatPollJSON { + question: pollMedia; + answers: { + id?: number; + poll_media: pollMedia; + }[]; + duration: number; + allow_multiselect?: boolean; +} +export interface polljson { + question: pollMedia; + answers: { + answer_id?: number; + poll_media: pollMedia; + }[]; + expiry: string; + allow_multiselect?: boolean; + layout_type: 1; + results?: { + is_finalized: boolean; + answer_counts: { + id: number; + count: number; + me_voted: boolean; + }[]; + }; +} type emojipjson = emojijson & { available: boolean; guild_id: string; @@ -835,6 +866,7 @@ type messagejson = { sticker_items: stickerJson[]; message_reference?: string; referenced_message?: messagejson; + poll?: polljson; }; export interface threadMetadata { @@ -939,6 +971,18 @@ type messageCreateJson = { s: number; t: "MESSAGE_CREATE"; }; +export type pollUpdateJson = { + op: 0; + d: { + user_id: string; + channel_id: string; + message_id: string; + guild_id?: string; + answer_id: number; + }; + s: number; + t: "MESSAGE_POLL_VOTE_ADD" | "MESSAGE_POLL_VOTE_REMOVE"; +}; export interface relationJson { id: string; type: 0 | 1 | 2 | 3 | 4; @@ -1018,6 +1062,7 @@ type wsjson = } | messageCreateJson | readyjson + | pollUpdateJson | { op: 11; s: undefined; diff --git a/src/webpage/localuser.ts b/src/webpage/localuser.ts index 096be62..482225c 100644 --- a/src/webpage/localuser.ts +++ b/src/webpage/localuser.ts @@ -19,6 +19,7 @@ import { readyjson, startTypingjson, wsjson, + pollUpdateJson, } from "./jsontypes.js"; import {Member} from "./member.js"; import {Dialog, Form, FormError, Options, Settings} from "./settings.js"; @@ -773,6 +774,14 @@ class Localuser { this.conectionChange(); break; } + case "MESSAGE_POLL_VOTE_ADD": + case "MESSAGE_POLL_VOTE_REMOVE": { + const m = this.messages.get(temp.d.message_id); + m?.pollUpdate(temp); + const f = this.pollUpdateSubMap.get(temp.d.message_id); + f?.(temp); + break; + } case "MESSAGE_DELETE": { temp.d.guild_id ??= "@me"; const channel = this.channelids.get(temp.d.channel_id); @@ -4527,7 +4536,77 @@ class Localuser { const searchBox = document.getElementById("searchBox") as HTMLDivElement; searchBox.style.setProperty("--hint-text", JSON.stringify(I18n.search.search())); } + makePoll() { + const d = new Dialog(I18n.makePoll()); + const opt = d.options; + const q = opt.addTextInput(I18n.poll.question(), () => {}); + opt.addText(I18n.poll.answers()); + const ansField = document.createElement("div"); + ansField.classList.add("flexttb", "pollAnsM"); + const answers = ["", ""] as string[]; + const genAnswerField = () => { + ansField.textContent = ""; + for (let i = 0; i < answers.length; i++) { + const si = i; + const div = document.createElement("div"); + div.classList.add("flexltr"); + const input = document.createElement("input"); + input.type = "text"; + input.value = answers[i]; + input.onchange = () => { + answers[si] = input.value; + }; + + const del = document.createElement("span"); + del.classList.add("svg-delete", "svgicon"); + div.append(input, del); + del.onclick = () => { + answers.splice(si, 1); + genAnswerField(); + }; + ansField.append(div); + } + }; + genAnswerField(); + opt.addHTMLArea(ansField); + opt.addButtonInput("", I18n.poll.newAnswer(), () => { + answers.push(""); + genAnswerField(); + }); + const hours = [1, 4, 8, 24, 72, 168, 336] as const; + const h = opt.addSelect( + I18n.poll.duration(), + () => {}, + //@ts-ignore-error this is fine :P + hours.map((_) => I18n.poll.durCount[_ + ""]()), + { + defaultIndex: 3, + }, + ); + const c = opt.addCheckboxInput(I18n.poll.mult(), () => {}); + opt.addButtonInput("", I18n.submit(), () => { + const chan = this.channelfocus; + if (!chan) return; + chan.sendMessage("", { + poll: { + question: { + text: q.value, + }, + answers: answers.map((_) => ({poll_media: {text: _}})), + duration: hours[h.index] as number, + allow_multiselect: c.value, + }, + }); + d.hide(); + }); + d.show(); + } curSearch?: Symbol; + pollUpdateSubMap = new Map void>(); + subToPollUpdate(m: string, func: ((u: pollUpdateJson) => void) | null) { + if (func) this.pollUpdateSubMap.set(m, func); + else this.pollUpdateSubMap.delete(m); + } mSearch(query: string) { const searchy = Symbol("search"); this.curSearch = searchy; diff --git a/src/webpage/markdown.ts b/src/webpage/markdown.ts index e3dfb0f..f96df6a 100644 --- a/src/webpage/markdown.ts +++ b/src/webpage/markdown.ts @@ -968,7 +968,10 @@ class MarkDown { return span; } static relTime(date: Date, nextUpdate?: () => void): string { - const time = Date.now() - +date; + const r = Date.now() - +date; + if (isNaN(r)) return "NaN"; + const up = r < 0; + const time = Math.abs(r); let seconds = Math.round(time / 1000); const round = time % 1000; @@ -983,25 +986,32 @@ class MarkDown { const formatter = new Intl.RelativeTimeFormat(I18n.lang, {style: "short"}); if (years) { - if (nextUpdate) - setTimeout( - nextUpdate, - round + (seconds + (minutes + (hours + days * 24) * 60) * 60) * 1000, - ); - return formatter.format(-years, "year"); + if (nextUpdate) { + const ti = round + (seconds + (minutes + (hours + days * 24) * 60) * 60) * 1000; + setTimeout(nextUpdate, up ? 1000 * 60 * 60 * 24 * 365 - ti : ti); + } + return formatter.format(up ? years : -years, "year"); } else if (days) { - if (nextUpdate) - setTimeout(nextUpdate, round + (seconds + (minutes + hours * 60) * 60) * 1000); - return formatter.format(-days, "days"); + if (nextUpdate) { + const ti = round + (seconds + (minutes + hours * 60) * 60) * 1000; + setTimeout(nextUpdate, up ? 1000 * 60 * 60 * 24 - ti : ti); + } + return formatter.format(up ? days : -days, "days"); } else if (hours) { - if (nextUpdate) setTimeout(nextUpdate, round + (seconds + minutes * 60) * 1000); - return formatter.format(-hours, "hours"); + if (nextUpdate) { + const ti = round + (seconds + minutes * 60) * 1000; + setTimeout(nextUpdate, up ? 1000 * 60 * 60 - ti : ti); + } + return formatter.format(up ? hours : -hours, "hours"); } else if (minutes) { - if (nextUpdate) setTimeout(nextUpdate, round + seconds * 1000); - return formatter.format(-minutes, "minutes"); + if (nextUpdate) { + const ti = round + seconds * 1000; + setTimeout(nextUpdate, up ? 1000 * 60 - ti : ti); + } + return formatter.format(up ? minutes : -minutes, "minutes"); } else { - if (nextUpdate) setTimeout(nextUpdate, round); - return formatter.format(-seconds, "seconds"); + if (nextUpdate) setTimeout(nextUpdate, up ? 1000 - round : round); + return formatter.format(up ? seconds : -seconds, "seconds"); } } static unspoil(e: any): void { diff --git a/src/webpage/message.ts b/src/webpage/message.ts index 39d5928..d327ecc 100644 --- a/src/webpage/message.ts +++ b/src/webpage/message.ts @@ -14,6 +14,8 @@ import { interactionEvents, memberjson, messagejson, + polljson, + pollUpdateJson, userjson, } from "./jsontypes.js"; import {Emoji} from "./emoji.js"; @@ -68,6 +70,7 @@ class Message extends SnowFlake { }[] = []; pinned!: boolean; flags: number = 0; + poll?: polljson; getTimeStamp() { return new Date(this.timestamp).getTime(); } @@ -253,6 +256,22 @@ class Message extends SnowFlake { color: "red", }, ); + Message.contextmenu.addButton( + () => I18n.message.endPoll(), + function (this: Message) { + this.confirmDeletePoll(); + }, + { + visible: function () { + return ( + this.author.id === this.localuser.user.id && + !!this.poll && + !this.poll.results?.is_finalized + ); + }, + color: "red", + }, + ); Message.contextmenu.addButton( () => I18n.message.report(), async function () { @@ -642,6 +661,22 @@ class Message extends SnowFlake { } console.log("deleted done"); } + pollUpdate(update: pollUpdateJson) { + if (!this.poll) return; + if (!this.poll.results) this.poll.results = {is_finalized: false, answer_counts: []}; + let ans = this.poll.results.answer_counts.find((_) => _.id === update.d.answer_id); + if (!ans) { + ans = {id: update.d.answer_id, count: 0, me_voted: false}; + this.poll.results.answer_counts.push(ans); + } + if (update.t === "MESSAGE_POLL_VOTE_ADD") { + ans.count++; + if (update.d.user_id === this.localuser.user.id) ans.me_voted = true; + } else { + ans.count--; + if (update.d.user_id === this.localuser.user.id) ans.me_voted = false; + } + } reactdiv!: WeakRef; blockedPropigate() { const previd = this.channel.idToPrev.get(this.id); @@ -792,7 +827,8 @@ class Message extends SnowFlake { } } } - if (this.message_reference && this.type !== 6 && this.type !== 18) { + + if (this.message_reference && this.type !== 6 && this.type !== 18 && this.type !== 46) { const replyline = document.createElement("div"); const minipfp = document.createElement("img"); @@ -1137,6 +1173,76 @@ class Message extends SnowFlake { time.classList.add("timestamp"); text.append(time); div.classList.add("topMessage"); + } else if (this.type === 46) { + build.classList.remove("flexltr"); + build.classList.add("flexttb"); + const t = I18n.message.pollRes("$$$$", "||||"); + const content = document.createElement("div"); + content.classList.add("flexltr", "pollRes"); + const username = document.createElement("span"); + this.author.bind(username, this.guild); + username.classList.add("username"); + const [before, after] = t.split("$$$$"); + username.textContent = this.author.name; + const [b1, a1] = before.split("||||") as [string, undefined | string]; + const [b2, a2] = after.split("||||") as [string, undefined | string]; + const b1s = document.createElement("span"); + b1s.textContent = b1; + content.append(b1s); + const poll = document.createElement("span"); + poll.classList.add("pollText", "username"); + poll.onclick = () => { + this.channel.focus(this.message_reference?.message_id!, true); + }; + this.channel.getmessage(this.message_reference?.message_id!).then((_) => { + if (!_) return; + poll.textContent = _!.poll!.question!.text; + }); + + if (a1) { + content.append(poll); + const a1s = document.createElement("span"); + a1s.textContent = a1; + content.append(a1s); + } + content.append(username); + const b2s = document.createElement("span"); + b2s.textContent = b2; + content.append(b2s); + if (a2) { + content.append(poll); + const a2s = document.createElement("span"); + a2s.textContent = a2; + content.append(a2s); + } + build.append(content); + + const resBody = document.createElement("div"); + resBody.classList.add("embed", "flexltr", "pollresembed"); + const res = document.createElement("div"); + res.classList.add("flexttb"); + const m = new Map((this.embeds[0].json.fields ?? []).map((f) => [f.name, f.value] as const)); + if (m.has("victor_answer_text")) { + const ans = document.createElement("span"); + ans.textContent = m.get("victor_answer_text") + ""; + const winning = document.createElement("span"); + const per = Number(m.get("victor_answer_votes")) / Number(m.get("total_votes")); + winning.textContent = I18n.poll.winningAnswer(Math.round(per * 100) + ""); + res.append(ans, winning); + } else { + res.textContent = I18n.poll.tie(); + } + const view = document.createElement("button"); + view.textContent = I18n.poll.view(); + view.onclick = () => { + this.channel.focus(this.message_reference?.message_id!, true); + }; + resBody.append(res, view); + + build.append(resBody); + + this.bindButtonEvent(); + return div; } build.appendChild(text); const stickerArea = document.createElement("div"); @@ -1145,6 +1251,118 @@ class Message extends SnowFlake { stickerArea.append(sticker.getHTML()); } div.append(stickerArea); + + if (this.poll) { + const pollbody = document.createElement("div"); + pollbody.classList.add("flexttb", "pollBody"); + let voted = false; + const genPoll = () => { + if (!this.poll) return; + pollbody.textContent = ""; + const d = new Date(this.poll.expiry); + const expired = +d < Date.now() || this.poll.results?.is_finalized || false; + const nupdate = () => { + fetch(`${this.info.api}/channels/${this.channel.id}/polls/${this.id}/answers/@me`, { + method: "PUT", + headers: this.headers, + body: JSON.stringify({ + answer_ids: [...r.values()] + .filter((_) => _.me_voted) + .map(({id}) => id) + .filter((_) => _), + }), + }); + voted = !!r.values.length; + }; + if (!this.poll.results) this.poll.results = {is_finalized: false, answer_counts: []}; + const r = new Map((this.poll.results?.answer_counts ?? []).map((_) => [_.id, _] as const)); + const question = document.createElement("h3"); + question.textContent = this.poll.question.text; + pollbody.append(question); + let ccount = [...r.values()].reduce((e, l) => e + +l.me_voted, 0); + if (this.poll.allow_multiselect) voted = !!ccount; + for (const a of this.poll.answers) { + const aarea = document.createElement("div"); + aarea.classList.add("flexltr", "answerArea"); + const span = document.createElement("span"); + span.textContent = a.poll_media.text; + const check = document.createElement("input"); + check.type = "checkbox"; + check.disabled = expired; + check.checked = !!r.get(a.answer_id ?? -1)?.me_voted; + + aarea.append(span, check); + if (!expired) + check.onclick = (e) => { + e.stopImmediatePropagation(); + if (ccount && check.checked && !this.poll?.allow_multiselect) { + check.checked = false; + return; + } else ccount += check.checked ? 1 : -1; + if (this.poll?.allow_multiselect && ccount) voted = true; + let g = r.get(a.answer_id ?? -1); + if (!g) { + g = {count: 0, id: a.answer_id ?? -1, me_voted: false}; + this.poll?.results?.answer_counts.push(g); + r.set(a.answer_id ?? -1, g); + } + g.me_voted = check.checked; + if (this.poll?.allow_multiselect) nupdate(); + }; + if (voted || expired) { + const total = [...r.values()].reduce((e, l) => e + l.count, 0); + const count = document.createElement("span"); + count.classList.add("countpollspan"); + let c = r.get(a.answer_id ?? -1)?.count ?? 0; + if (isNaN(c)) c = 0; + let per = Math.round((c / total) * 100); + if (isNaN(per)) per = 0; + count.textContent = I18n.poll.count("" + c, per + ""); + aarea.append(count); + if (per) + aarea.style.background = `linear-gradient(to right, var(--green) ${per}%, var(--bg) ${100 - per}%)`; + } + aarea.onclick = () => check.click(); + pollbody.append(aarea); + } + + if (!this.poll.allow_multiselect) { + const submit = document.createElement("button"); + submit.textContent = I18n.submit(); + pollbody.append(submit); + if (expired) { + submit.disabled = true; + } else { + submit.onclick = () => { + nupdate(); + }; + } + } + + if (expired) { + } else { + const expiresAt = document.createElement("span"); + const updatePollTime = async () => { + if (document.contains(expiresAt)) + expiresAt.textContent = I18n.poll.expires(MarkDown.relTime(d, updatePollTime)); + }; + expiresAt.textContent = I18n.poll.expires(MarkDown.relTime(d)); + queueMicrotask(() => MarkDown.relTime(d, updatePollTime)); + pollbody.append(expiresAt); + } + }; + genPoll(); + this.localuser.subToPollUpdate(this.id, () => { + if (document.contains(div)) { + genPoll(); + } else { + this.localuser.subToPollUpdate(this.id, null); + } + }); + + div.append(pollbody); + } + if (!dupe) { if (this.components && this.components.components.length) { const cdiv = this.components.getHTML(); @@ -1304,7 +1522,7 @@ class Message extends SnowFlake { }); }; } - if (this.author === this.localuser.user) { + if (this.author === this.localuser.user && this.type !== 46) { const container = document.createElement("button"); const edit = document.createElement("span"); edit.classList.add("svg-edit", "svgicon"); @@ -1353,6 +1571,22 @@ class Message extends SnowFlake { }); diaolog.show(); } + confirmDeletePoll() { + const diaolog = new Dialog(""); + diaolog.options.addTitle(I18n.deleteConfirmPoll()); + const options = diaolog.options.addOptions("", {ltr: true}); + options.addButtonInput("", I18n.yes(), () => { + fetch(`${this.info.api}/channels/${this.channel.id}/polls/${this.id}/expire`, { + headers: this.headers, + method: "POST", + }); + diaolog.hide(); + }); + options.addButtonInput("", I18n.no(), () => { + diaolog.hide(); + }); + diaolog.show(); + } updateReactions() { const reactdiv = this.reactdiv.deref(); if (!reactdiv) return; diff --git a/src/webpage/style.css b/src/webpage/style.css index e0e7883..9d9ebb0 100644 --- a/src/webpage/style.css +++ b/src/webpage/style.css @@ -183,6 +183,9 @@ body { min-height: 0; display: flex; } +.pollRes { + padding-left: 50px; +} .amsCont { display: flex; flex-direction: row; @@ -257,6 +260,39 @@ body { display: flex; flex-direction: column; } +.pollBody { + background: #00000059; + padding: 10px; + border-radius: 4px; + margin-top: 2px; + margin-left: 52px; + h3 { + margin-bottom: 10px; + } +} +.answerArea { + --bg: #0000004a; + background: var(--bg); + margin-top: 3px; + padding: 12px; + cursor: pointer; + position: relative; + + input { + margin-left: auto; + cursor: pointer; + } +} +.pollAnsM { + .svg-delete { + width: 20px; + cursor: pointer; + margin-left: 6px; + } + .flexltr { + align-items: center; + } +} .reses { span { background: #0000007a; @@ -2733,6 +2769,9 @@ span.instanceStatus { position: relative; background-image: var(--userbg, linear-gradient(var(--primary-text), var(--primary-text))); } +.pollText { + margin: 0px 4px; +} .roleIcon { display: inline-block; padding: 1px; @@ -3057,6 +3096,17 @@ span .quote:last-of-type .quoteline { line-height: 20px; } } +.countpollspan { + position: absolute; + right: 40px; +} +.pollresembed { + margin-left: 50px; + margin-top: 6px; + button { + margin-left: auto; + } +} .linkembed { margin-top: 4px; } diff --git a/translations/en.json b/translations/en.json index f6138e7..4611454 100644 --- a/translations/en.json +++ b/translations/en.json @@ -213,6 +213,7 @@ "createAccount": "Create Account", "delete": "Delete", "deleteConfirm": "Are you sure you want to delete this?", + "deleteConfirmPoll":"Are you sure you want to end the poll early?", "devSettings": { "badUser": "Enable logging of bad user objects that send too much information:", "cache": "Enable Service Worker Caching map files:", @@ -621,6 +622,7 @@ "andMore": "$1, and more!", "attached": "sent an attachment", "delete": "Delete message", + "endPoll":"End poll", "report":"Report message", "deleted": "Deleted message", "edit": "Edit message", @@ -635,7 +637,8 @@ "reactions": "View reactions", "reactionsTitle": "Reactions", "retry": "Resend errored message", - "viewrest": "View rest" + "viewrest": "View rest", + "pollRes":"$1's poll $2 has closed." }, "report":{ "back":"Back", @@ -818,6 +821,29 @@ "roleFileIcon": "Role icon:", "roles": "Roles" }, + "upload":"Upload files", + "makePoll":"Make poll", + "poll":{ + "question":"Question:", + "answers":"Answers:", + "newAnswer":"New Answer", + "duration":"Duration:", + "durCount":{ + "1":"1 hour", + "4":"4 hours", + "8":"8 hours", + "24":"24 hours", + "72":"3 days", + "168":"7 days", + "336":"14 days" + }, + "mult":"Allow multiple answers:", + "expires":"Expires: $1", + "tie":"It was a tie!", + "winningAnswer":"Winning answer: $1%", + "view":"View poll", + "count":"$1 {{PLURAL:$1|vote|votes}} $2%" + }, "search": { "back": "Back", "new": "New", From 5935ccedd33b2f1baab6fba07868a0da6acf14ec Mon Sep 17 00:00:00 2001 From: MathMan05 Date: Thu, 21 May 2026 16:19:10 -0500 Subject: [PATCH 2/5] check perm --- src/webpage/index.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/webpage/index.ts b/src/webpage/index.ts index e2c438e..b9bf317 100644 --- a/src/webpage/index.ts +++ b/src/webpage/index.ts @@ -424,9 +424,15 @@ if (window.location.pathname.startsWith("/channels")) { e.stopImmediatePropagation(); }; const umenu = new Contextmenu("upload"); - umenu.addButton(I18n.makePoll(), () => { - thisUser.makePoll(); - }); + umenu.addButton( + I18n.makePoll(), + () => { + thisUser.makePoll(); + }, + { + visible: () => !!thisUser.channelfocus?.hasPermission("SEND_POLLS"), + }, + ); umenu.addButton(I18n.upload(), () => { const input = document.createElement("input"); input.type = "file"; From 98c07855da22da39b9c53c56d4b09175104d00e1 Mon Sep 17 00:00:00 2001 From: MathMan05 Date: Sun, 31 May 2026 00:09:57 -0500 Subject: [PATCH 3/5] what are these settings? --- src/webpage/voice.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/webpage/voice.ts b/src/webpage/voice.ts index 584b538..8f77c70 100644 --- a/src/webpage/voice.ts +++ b/src/webpage/voice.ts @@ -1096,8 +1096,8 @@ a=rtcp-mux\r`; const settings = e.track.getConstraints(); console.log("gotVideo?", media, settings); - settings.height = 300; - settings.width = 300; + //settings.height = 300; + //settings.width = 300; e.track.applyConstraints(settings); //e.track From 2fdc089f68b1c72f4a4b645fd5a6b1e6fb3f02a3 Mon Sep 17 00:00:00 2001 From: MathMan05 Date: Sat, 20 Jun 2026 13:59:17 -0500 Subject: [PATCH 4/5] init type updates --- src/webpage/app.html | 1 + src/webpage/index.ts | 12 ++++ src/webpage/localuser.ts | 5 ++ src/webpage/typeChecker/basetype.ts | 41 ++++++++++++ src/webpage/typeChecker/checkers/any.ts | 10 +++ src/webpage/typeChecker/checkers/array.ts | 26 +++++++ src/webpage/typeChecker/checkers/bool.ts | 10 +++ src/webpage/typeChecker/checkers/never.ts | 10 +++ src/webpage/typeChecker/checkers/null.ts | 10 +++ src/webpage/typeChecker/checkers/nullish.ts | 13 ++++ src/webpage/typeChecker/checkers/number.ts | 14 ++++ src/webpage/typeChecker/checkers/object.ts | 38 +++++++++++ src/webpage/typeChecker/checkers/optional.ts | 13 ++++ src/webpage/typeChecker/checkers/or.ts | 21 ++++++ src/webpage/typeChecker/checkers/string.ts | 17 +++++ src/webpage/typeChecker/chekerIndex.ts | 45 +++++++++++++ src/webpage/typeChecker/types/basicEvent.ts | 11 +++ .../typeChecker/types/events/hearbeat.ts | 10 +++ src/webpage/typeChecker/types/events/ready.ts | 51 ++++++++++++++ .../typeChecker/types/events/setInterval.ts | 12 ++++ src/webpage/typeChecker/types/objects/user.ts | 67 +++++++++++++++++++ src/webpage/typeChecker/types/snowflake.ts | 3 + translations/en.json | 4 +- 23 files changed, 442 insertions(+), 2 deletions(-) create mode 100644 src/webpage/typeChecker/basetype.ts create mode 100644 src/webpage/typeChecker/checkers/any.ts create mode 100644 src/webpage/typeChecker/checkers/array.ts create mode 100644 src/webpage/typeChecker/checkers/bool.ts create mode 100644 src/webpage/typeChecker/checkers/never.ts create mode 100644 src/webpage/typeChecker/checkers/null.ts create mode 100644 src/webpage/typeChecker/checkers/nullish.ts create mode 100644 src/webpage/typeChecker/checkers/number.ts create mode 100644 src/webpage/typeChecker/checkers/object.ts create mode 100644 src/webpage/typeChecker/checkers/optional.ts create mode 100644 src/webpage/typeChecker/checkers/or.ts create mode 100644 src/webpage/typeChecker/checkers/string.ts create mode 100644 src/webpage/typeChecker/chekerIndex.ts create mode 100644 src/webpage/typeChecker/types/basicEvent.ts create mode 100644 src/webpage/typeChecker/types/events/hearbeat.ts create mode 100644 src/webpage/typeChecker/types/events/ready.ts create mode 100644 src/webpage/typeChecker/types/events/setInterval.ts create mode 100644 src/webpage/typeChecker/types/objects/user.ts create mode 100644 src/webpage/typeChecker/types/snowflake.ts diff --git a/src/webpage/app.html b/src/webpage/app.html index 20e5160..68ddab3 100644 --- a/src/webpage/app.html +++ b/src/webpage/app.html @@ -147,6 +147,7 @@ + diff --git a/src/webpage/index.ts b/src/webpage/index.ts index b9bf317..fc57514 100644 --- a/src/webpage/index.ts +++ b/src/webpage/index.ts @@ -18,6 +18,7 @@ import "./oauth2/auth.js"; import "./audio/page.js"; import "./404.js"; import {Channel} from "./channel.js"; +import type * as C from "./typeChecker/chekerIndex.js"; if (window.location.pathname === "/app") { window.location.pathname = "/channels/@me"; @@ -25,6 +26,17 @@ if (window.location.pathname === "/app") { export interface CustomHTMLDivElement extends HTMLDivElement { markdown: MarkDown; } +declare global { + interface Window { + checker?: typeof C.Check; + } +} +if (localStorage.getItem("checkTypes")) { + const i = (await import( + "/typeChecker/chekerIndex.js" as "./typeChecker/chekerIndex.js" + )) as typeof C; + window.checker = i.Check; +} if (window.location.pathname.startsWith("/channels")) { let templateID = new URLSearchParams(window.location.search).get("templateID"); await I18n.done; diff --git a/src/webpage/localuser.ts b/src/webpage/localuser.ts index 482225c..01b23d8 100644 --- a/src/webpage/localuser.ts +++ b/src/webpage/localuser.ts @@ -722,6 +722,11 @@ class Localuser { } conectionChange = () => {}; async handleEvent(temp: wsjson) { + try { + window.checker?.checkEvent(temp); + } catch (e) { + console.error(e); + } if (temp.d._trace) this.handleTrace(temp.d._trace); if (getDeveloperSettings().gatewayLogging) console.debug(temp); if (temp.s) this.lastSequence = temp.s; diff --git a/src/webpage/typeChecker/basetype.ts b/src/webpage/typeChecker/basetype.ts new file mode 100644 index 0000000..189af85 --- /dev/null +++ b/src/webpage/typeChecker/basetype.ts @@ -0,0 +1,41 @@ +import {ArrayChecker} from "./checkers/array"; +import {BoolChecker} from "./checkers/bool"; +import {Never} from "./checkers/never"; +import {NumberChecker} from "./checkers/number"; +import {ObjectChecker} from "./checkers/object"; +import {StringChecker} from "./checkers/string"; + +export abstract class TypeChecker { + abstract check(obj: unknown): void; + static resolve(r: resolveable): TypeChecker { + if (r instanceof TypeChecker) return r; + if (typeof r === "string" || r instanceof RegExp) { + return new StringChecker(r); + } else if (r === String) { + return new StringChecker(); + } else if (r === Boolean) { + return new BoolChecker(); + } else if (r === Number) { + return new NumberChecker(); + } else if (typeof r === "number") { + return new NumberChecker(r); + } else if (r instanceof Array) { + return new ArrayChecker(r); + } else if (r instanceof Object) { + return new ObjectChecker(r as {[key: string]: resolveable}); + } else { + return new Never(); + } + } +} + +export type resolveable = + | typeof String + | string + | number + | typeof Number + | RegExp + | resolveable[] + | {[key: string]: resolveable} + | TypeChecker + | typeof Boolean; diff --git a/src/webpage/typeChecker/checkers/any.ts b/src/webpage/typeChecker/checkers/any.ts new file mode 100644 index 0000000..051c654 --- /dev/null +++ b/src/webpage/typeChecker/checkers/any.ts @@ -0,0 +1,10 @@ +import {TypeChecker} from "../basetype"; + +export class Any extends TypeChecker { + constructor() { + super(); + } + check() { + return; + } +} diff --git a/src/webpage/typeChecker/checkers/array.ts b/src/webpage/typeChecker/checkers/array.ts new file mode 100644 index 0000000..aa8dc88 --- /dev/null +++ b/src/webpage/typeChecker/checkers/array.ts @@ -0,0 +1,26 @@ +import {resolveable, TypeChecker} from "../basetype"; + +export class ArrayChecker extends TypeChecker { + arr: TypeChecker | TypeChecker[]; + constructor(arr: resolveable | resolveable[]) { + super(); + if (arr instanceof Array) { + this.arr = arr.map((_) => TypeChecker.resolve(_)); + } else { + this.arr = TypeChecker.resolve(arr); + } + } + check(obj: unknown) { + if (!(obj instanceof Array)) throw new Error(`${obj} is not of type array`); + if (this.arr instanceof Array) { + if (this.arr.length !== obj.length) throw new Error(`${obj} does not match array schema`); + for (let i = 0; i < obj.length; i++) { + this.arr[i].check(obj[i]); + } + } else { + for (const elm of obj) { + this.arr.check(elm); + } + } + } +} diff --git a/src/webpage/typeChecker/checkers/bool.ts b/src/webpage/typeChecker/checkers/bool.ts new file mode 100644 index 0000000..812fdf9 --- /dev/null +++ b/src/webpage/typeChecker/checkers/bool.ts @@ -0,0 +1,10 @@ +import {TypeChecker} from "../basetype"; + +export class BoolChecker extends TypeChecker { + constructor() { + super(); + } + check(obj: unknown): void { + if (typeof obj !== "boolean") throw new Error(`${obj} is not a bool`); + } +} diff --git a/src/webpage/typeChecker/checkers/never.ts b/src/webpage/typeChecker/checkers/never.ts new file mode 100644 index 0000000..bd03bd3 --- /dev/null +++ b/src/webpage/typeChecker/checkers/never.ts @@ -0,0 +1,10 @@ +import {TypeChecker} from "../basetype"; + +export class Never extends TypeChecker { + constructor() { + super(); + } + check(): void { + throw new Error(`never`); + } +} diff --git a/src/webpage/typeChecker/checkers/null.ts b/src/webpage/typeChecker/checkers/null.ts new file mode 100644 index 0000000..ec8eaf8 --- /dev/null +++ b/src/webpage/typeChecker/checkers/null.ts @@ -0,0 +1,10 @@ +import {TypeChecker} from "../basetype"; + +export class NullChecker extends TypeChecker { + constructor() { + super(); + } + check(obj: unknown): void { + if (obj !== null) throw new Error(`${obj} is not null`); + } +} diff --git a/src/webpage/typeChecker/checkers/nullish.ts b/src/webpage/typeChecker/checkers/nullish.ts new file mode 100644 index 0000000..426ea8c --- /dev/null +++ b/src/webpage/typeChecker/checkers/nullish.ts @@ -0,0 +1,13 @@ +import {resolveable, TypeChecker} from "../basetype"; + +export class Nullish extends TypeChecker { + checker: TypeChecker; + constructor(check: resolveable) { + super(); + this.checker = TypeChecker.resolve(check); + } + check(obj: unknown) { + if (obj === null) return; + this.checker.check(obj); + } +} diff --git a/src/webpage/typeChecker/checkers/number.ts b/src/webpage/typeChecker/checkers/number.ts new file mode 100644 index 0000000..75f915b --- /dev/null +++ b/src/webpage/typeChecker/checkers/number.ts @@ -0,0 +1,14 @@ +import {TypeChecker} from "../basetype"; + +export class NumberChecker extends TypeChecker { + num?: number; + constructor(num?: number) { + super(); + this.num = num; + } + check(obj: unknown): void { + if (this.num === undefined) { + if (typeof obj !== "number") throw new Error(`${obj} is not a number`); + } else if (this.num !== obj) throw new Error(`${obj} and ${this.num} do not match`); + } +} diff --git a/src/webpage/typeChecker/checkers/object.ts b/src/webpage/typeChecker/checkers/object.ts new file mode 100644 index 0000000..05982c0 --- /dev/null +++ b/src/webpage/typeChecker/checkers/object.ts @@ -0,0 +1,38 @@ +import {resolveable, TypeChecker} from "../basetype"; + +export class ObjectChecker extends TypeChecker { + obj: {[key: string]: TypeChecker} | [TypeChecker, TypeChecker] | [TypeChecker]; + constructor(obj: {[key: string]: resolveable} | [resolveable] | [resolveable, resolveable]) { + super(); + if (obj instanceof Array) { + this.obj = obj.map((_) => TypeChecker.resolve(_)) as + | [TypeChecker, TypeChecker] + | [TypeChecker]; + } else { + this.obj = Object.fromEntries( + Object.entries(obj).map(([key, value]) => [key, TypeChecker.resolve(value)]), + ); + } + } + check(obj: unknown) { + if (!(obj instanceof Object)) throw new Error(`${obj} is not of type object`); + if (this.obj instanceof Array) { + const valueCheck = this.obj[this.obj.length - 1]; + const keyCheck = this.obj.at(-2); + for (const [key, value] of Object.entries(obj)) { + keyCheck?.check(key); + valueCheck.check(value); + } + } else { + const thiskeys = Object.keys(this.obj); + for (const key of thiskeys) { + this.obj[key].check((obj as Record)[key]); + } + const objKeys = Object.keys(obj); + const extra = new Set(objKeys).difference(new Set(thiskeys)); + if (extra.size) { + throw new Error(`Object has extra keys ${[...extra]}`); + } + } + } +} diff --git a/src/webpage/typeChecker/checkers/optional.ts b/src/webpage/typeChecker/checkers/optional.ts new file mode 100644 index 0000000..2f4450d --- /dev/null +++ b/src/webpage/typeChecker/checkers/optional.ts @@ -0,0 +1,13 @@ +import {resolveable, TypeChecker} from "../basetype"; + +export class Optional extends TypeChecker { + checker: TypeChecker; + constructor(check: resolveable) { + super(); + this.checker = TypeChecker.resolve(check); + } + check(obj: unknown) { + if (obj === undefined) return; + this.checker.check(obj); + } +} diff --git a/src/webpage/typeChecker/checkers/or.ts b/src/webpage/typeChecker/checkers/or.ts new file mode 100644 index 0000000..15ed95f --- /dev/null +++ b/src/webpage/typeChecker/checkers/or.ts @@ -0,0 +1,21 @@ +import {resolveable, TypeChecker} from "../basetype"; + +export class OrChecker extends TypeChecker { + arr: TypeChecker[]; + constructor(arr: resolveable[]) { + super(); + this.arr = arr.map((_) => TypeChecker.resolve(_)); + } + check(obj: unknown) { + let err = new Error("or statement is missing things to or"); + for (const elm of this.arr) { + try { + elm.check(obj); + return; + } catch (e) { + err = e as Error; + } + } + throw err; + } +} diff --git a/src/webpage/typeChecker/checkers/string.ts b/src/webpage/typeChecker/checkers/string.ts new file mode 100644 index 0000000..12fcf92 --- /dev/null +++ b/src/webpage/typeChecker/checkers/string.ts @@ -0,0 +1,17 @@ +import {TypeChecker} from "../basetype"; + +export class StringChecker extends TypeChecker { + str?: string | RegExp; + constructor(str?: string | RegExp) { + super(); + this.str = str; + } + check(obj: unknown): void { + if (this.str === undefined) { + if (typeof obj !== "string") throw new Error(`${obj} is not a string`); + } else if (this.str instanceof RegExp) { + if (typeof obj !== "string") throw new Error(`${obj} is not a string`); + if (!obj.match(this.str)) throw new Error(`${obj} does not match ${this.str}`); + } else if (this.str !== obj) throw new Error(`${obj} does not match ${this.str}`); + } +} diff --git a/src/webpage/typeChecker/chekerIndex.ts b/src/webpage/typeChecker/chekerIndex.ts new file mode 100644 index 0000000..b8cc899 --- /dev/null +++ b/src/webpage/typeChecker/chekerIndex.ts @@ -0,0 +1,45 @@ +import {TypeChecker} from "./basetype"; +import {basicEvent} from "./types/basicEvent"; +import {heartbeat} from "./types/events/hearbeat"; +import {ready} from "./types/events/ready"; +import {initHeart} from "./types/events/setInterval"; +type basicgw = { + op: number; + d?: unknown; + s?: number; + t?: string; +}; +export class Check { + static apiMatch = [] as [RegExp, TypeChecker][]; + static checkAPI(path: string, obj: unknown) { + for (const [match, check] of this.apiMatch) { + if (path.match(match)) { + check.check(obj); + return; + } + } + console.warn(`could not check API path: ${path}`); + } + static checkDispatch(dis: basicgw) { + switch (dis.t) { + case "READY": + return ready.check(dis); + default: + console.warn(`could not check gateway dispatch: ${dis.t}`); + } + } + static checkEvent(obj: unknown) { + basicEvent.check(obj); + const o = obj as basicgw; + switch (o.op) { + case 0: + return this.checkDispatch(o); + case 10: + return initHeart.check(o); + case 11: + return heartbeat.check(o); + default: + console.warn(`could not check gateway: ${o.op}`); + } + } +} diff --git a/src/webpage/typeChecker/types/basicEvent.ts b/src/webpage/typeChecker/types/basicEvent.ts new file mode 100644 index 0000000..da044d4 --- /dev/null +++ b/src/webpage/typeChecker/types/basicEvent.ts @@ -0,0 +1,11 @@ +import {Any} from "../checkers/any"; +import {Nullish} from "../checkers/nullish"; +import {ObjectChecker} from "../checkers/object"; +import {Optional} from "../checkers/optional"; + +export const basicEvent = new ObjectChecker({ + op: Number, + d: new Nullish(new Optional(new Any())), + s: new Nullish(new Optional(Number)), + t: new Nullish(new Optional(String)), +}); diff --git a/src/webpage/typeChecker/types/events/hearbeat.ts b/src/webpage/typeChecker/types/events/hearbeat.ts new file mode 100644 index 0000000..9294fd4 --- /dev/null +++ b/src/webpage/typeChecker/types/events/hearbeat.ts @@ -0,0 +1,10 @@ +import {Nullish} from "../../checkers/nullish"; +import {ObjectChecker} from "../../checkers/object"; +import {Optional} from "../../checkers/optional"; + +export const heartbeat = new ObjectChecker({ + op: Number, + d: {}, + s: new Nullish(new Optional(Number)), + t: new Nullish(new Optional(String)), +}); diff --git a/src/webpage/typeChecker/types/events/ready.ts b/src/webpage/typeChecker/types/events/ready.ts new file mode 100644 index 0000000..13cd9a7 --- /dev/null +++ b/src/webpage/typeChecker/types/events/ready.ts @@ -0,0 +1,51 @@ +import {ArrayChecker} from "../../checkers/array"; +import {Nullish} from "../../checkers/nullish"; +import {ObjectChecker} from "../../checkers/object"; +import {Optional} from "../../checkers/optional"; +import {fulluser} from "../objects/user"; + +export const ready = new ObjectChecker({ + op: Number, + d: { + _trace: new Optional(new ArrayChecker(String)), + v: Number, + user: fulluser, + //TODO user_settings + user_settings_proto: String, + notification_settings: { + flags: Number, + }, + user_guild_settings: { + entries: new ArrayChecker({ + channel_overrides: new ArrayChecker({ + message_notifications: Number, + muted: Boolean, + mute_config: new Nullish({ + selected_time_window: Number, + end_time: Number, + }), + channel_id: String, + }), + message_notifications: Number, + flags: Number, + hide_muted_channels: Boolean, + mobile_push: Boolean, + mute_config: new Nullish({ + selected_time_window: Number, + end_time: Number, + }), + mute_scheduled_events: Boolean, + muted: Boolean, + notify_highlights: Number, + suppress_everyone: Boolean, + suppress_roles: Boolean, + version: Number, + guild_id: String, + }), + partial: Boolean, + version: Number, + }, + }, + s: new Nullish(new Optional(Number)), + t: new Nullish(new Optional(String)), +}); diff --git a/src/webpage/typeChecker/types/events/setInterval.ts b/src/webpage/typeChecker/types/events/setInterval.ts new file mode 100644 index 0000000..e8c15b9 --- /dev/null +++ b/src/webpage/typeChecker/types/events/setInterval.ts @@ -0,0 +1,12 @@ +import {Nullish} from "../../checkers/nullish"; +import {ObjectChecker} from "../../checkers/object"; +import {Optional} from "../../checkers/optional"; + +export const initHeart = new ObjectChecker({ + op: Number, + d: { + heartbeat_interval: Number, + }, + s: new Nullish(new Optional(Number)), + t: new Nullish(new Optional(String)), +}); diff --git a/src/webpage/typeChecker/types/objects/user.ts b/src/webpage/typeChecker/types/objects/user.ts new file mode 100644 index 0000000..4996170 --- /dev/null +++ b/src/webpage/typeChecker/types/objects/user.ts @@ -0,0 +1,67 @@ +import {Nullish} from "../../checkers/nullish"; +import {ObjectChecker} from "../../checkers/object"; +import {Optional} from "../../checkers/optional"; +import {snowflake} from "../snowflake"; +export const fulluser = new ObjectChecker({ + id: snowflake, + username: String, + discriminator: /\d\d\d\d/, + global_name: new Nullish(new Optional(String)), + avatar: new Nullish(String), + avatar_decoration_data: new Nullish( + new Optional({ + asset: String, + sku_id: String, + }), + ), + //TODO collectibles, display_name_styles, primary_guild, linked_users, premium_state + bot: new Optional(Boolean), + system: new Optional(Boolean), + mfa_enabled: Boolean, + nsfw_allowed: new Nullish(new Optional(Boolean)), + age_verification_status: new Optional(Number), + pronouns: new Optional(String), + bio: String, + banner: new Optional(new Nullish(String)), + accent_color: new Optional(new Nullish(Number)), + locale: new Optional(String), + verified: new Optional(Boolean), + email: new Nullish(String), + phone: new Optional(new Nullish(String)), + premium: Boolean, + premium_type: Number, + personal_connection_id: new Optional(snowflake), + flags: new Optional(Number), + public_flags: Number, + purchased_flags: new Optional(Number), + premium_usage_flags: new Optional(Number), + desktop: new Optional(Number), + mobile: new Optional(Number), + has_bounced_email: new Optional(Boolean), + authenticator_types: new Optional(new Array(Number)), + analytics_token: new Optional(String), +}); +export const partialuser = new ObjectChecker({ + id: snowflake, + username: String, + discriminator: /\d\d\d\d/, + global_name: new Nullish(new Optional(String)), + avatar: new Nullish(String), + avatar_decoration_data: new Nullish( + new Optional({ + asset: String, + sku_id: String, + }), + ), + //TODO collectibles, display_name_styles, primary_guild, linked_users + bot: new Optional(Boolean), + system: new Optional(Boolean), + mfa_enabled: Boolean, + nsfw_allowed: new Nullish(new Optional(Boolean)), + age_verification_status: new Optional(Number), + pronouns: new Optional(String), + bio: String, + banner: new Optional(new Nullish(String)), + accent_color: new Optional(new Nullish(Number)), + public_flags: new Optional(Number), +}); diff --git a/src/webpage/typeChecker/types/snowflake.ts b/src/webpage/typeChecker/types/snowflake.ts new file mode 100644 index 0000000..d125e01 --- /dev/null +++ b/src/webpage/typeChecker/types/snowflake.ts @@ -0,0 +1,3 @@ +import {StringChecker} from "../checkers/string"; + +export const snowflake = new StringChecker(/\d*/); diff --git a/translations/en.json b/translations/en.json index 4611454..e6aca65 100644 --- a/translations/en.json +++ b/translations/en.json @@ -224,7 +224,7 @@ "gatewayComp": "Disable Gateway compression:", "reportSystem":"Enable experimental reporting system:", "logGateway": "Log received gateway events (log level info):", - "name": "Developer Settings", + "name": "Developer settings", "traces": "Expose traces:" }, "deviceManage": { @@ -554,7 +554,7 @@ "themesAndSounds": "Themes & Sounds", "tokenDisplay": "Token: $1", "trace": "Traces", - "trusted": "Trusted Domains", + "trusted": "Trusted domains", "trustedDesc": "When you click on links sending you to these domain, you will ***not*** be prompted for permission to open like other links, only give this to domains you trust, such as 'https://fermi.chat'", "updateSettings": "Update settings", "updatesYay": "Updates found!", From 315b7996d003d4bc4d6e1cb41c1f483f5552b4b5 Mon Sep 17 00:00:00 2001 From: MathMan05 Date: Sat, 20 Jun 2026 14:30:03 -0500 Subject: [PATCH 5/5] diable polls --- src/webpage/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/webpage/index.ts b/src/webpage/index.ts index fc57514..bb49261 100644 --- a/src/webpage/index.ts +++ b/src/webpage/index.ts @@ -442,7 +442,8 @@ if (window.location.pathname.startsWith("/channels")) { thisUser.makePoll(); }, { - visible: () => !!thisUser.channelfocus?.hasPermission("SEND_POLLS"), + //TODO re-enable this once polls is merged + visible: () => false, //!!thisUser.channelfocus?.hasPermission("SEND_POLLS"), }, ); umenu.addButton(I18n.upload(), () => {