Merge pull request #341 from MathMan05/polls

add poll support
This commit is contained in:
Mathium05
2026-06-20 14:35:15 -05:00
committed by GitHub
30 changed files with 938 additions and 34 deletions
+1
View File
@@ -147,6 +147,7 @@
</body>
<script src="/index.js" type="module"></script>
<!-- <script src="/service.js" --->
<!-- <script src="/typeChecker/chekerIndex.js" --->
<!-- <script src="/audio/worklet/worklet.js" --->
<!-- <script src="/utils/dirrWorker.js" --->
</html>
+11 -6
View File
@@ -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};
+10 -3
View File
@@ -235,15 +235,17 @@ type contextCluster<X, Y> = [Contextmenu<X, Y>, X, Y];
class LayeredEvent extends CustomEvent<unknown> {
menus: contextCluster<unknown, unknown>[];
primary?: contextCluster<unknown, unknown>;
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<x, y> {
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<menuPart<unknown, unknown>>();
@@ -386,6 +392,7 @@ class Contextmenu<x, y> {
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<x, y> {
}
event.stopImmediatePropagation();
event.preventDefault();
const layered = new LayeredEvent(event, []);
const layered = new LayeredEvent(event, [], side);
obj.dispatchEvent(layered);
};
obj.addEventListener("layered", (layered) => {
+34 -2
View File
@@ -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;
@@ -423,7 +435,18 @@ if (window.location.pathname.startsWith("/channels")) {
e.preventDefault();
e.stopImmediatePropagation();
};
(document.getElementById("upload") as HTMLElement).onclick = () => {
const umenu = new Contextmenu<void, void>("upload");
umenu.addButton(
I18n.makePoll(),
() => {
thisUser.makePoll();
},
{
//TODO re-enable this once polls is merged
visible: () => false, //!!thisUser.channelfocus?.hasPermission("SEND_POLLS"),
},
);
umenu.addButton(I18n.upload(), () => {
const input = document.createElement("input");
input.type = "file";
input.click();
@@ -441,7 +464,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) => {
+33
View File
@@ -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 {
@@ -1039,6 +1071,7 @@ type wsjson =
}
| messageCreateJson
| readyjson
| pollUpdateJson
| {
op: 11;
s: undefined;
+84
View File
@@ -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";
@@ -721,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;
@@ -778,6 +784,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);
@@ -4532,7 +4546,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<string, (u: pollUpdateJson) => 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;
+26 -16
View File
@@ -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 {
+236 -2
View File
@@ -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<HTMLDivElement>;
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;
+50
View File
@@ -187,6 +187,9 @@ body {
min-height: 0;
display: flex;
}
.pollRes {
padding-left: 50px;
}
.instDiv {
img {
width: 32px;
@@ -278,6 +281,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;
@@ -2757,6 +2793,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;
@@ -3090,6 +3129,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;
}
+41
View File
@@ -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;
+10
View File
@@ -0,0 +1,10 @@
import {TypeChecker} from "../basetype";
export class Any extends TypeChecker {
constructor() {
super();
}
check() {
return;
}
}
+26
View File
@@ -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);
}
}
}
}
+10
View File
@@ -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`);
}
}
+10
View File
@@ -0,0 +1,10 @@
import {TypeChecker} from "../basetype";
export class Never extends TypeChecker {
constructor() {
super();
}
check(): void {
throw new Error(`never`);
}
}
+10
View File
@@ -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`);
}
}
@@ -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);
}
}
@@ -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`);
}
}
@@ -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<string, unknown>)[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]}`);
}
}
}
}
@@ -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);
}
}
+21
View File
@@ -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;
}
}
@@ -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}`);
}
}
+45
View File
@@ -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}`);
}
}
}
@@ -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)),
});
@@ -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)),
});
@@ -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)),
});
@@ -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)),
});
@@ -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),
});
@@ -0,0 +1,3 @@
import {StringChecker} from "../checkers/string";
export const snowflake = new StringChecker(/\d*/);
+2 -2
View File
@@ -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
+29 -3
View File
@@ -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:",
@@ -223,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": {
@@ -555,7 +556,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!",
@@ -623,6 +624,7 @@
"andMore": "$1, and more!",
"attached": "sent an attachment",
"delete": "Delete message",
"endPoll":"End poll",
"report":"Report message",
"deleted": "Deleted message",
"edit": "Edit message",
@@ -637,7 +639,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",
@@ -823,6 +826,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",