diff --git a/src/webpage/home.ts b/src/webpage/home.ts
new file mode 100644
index 0000000..7301cd9
--- /dev/null
+++ b/src/webpage/home.ts
@@ -0,0 +1,89 @@
+import { mobile } from "./login.js";
+console.log(mobile);
+const serverbox = document.getElementById("instancebox") as HTMLDivElement;
+
+fetch("/instances.json")
+ .then((_) => _.json())
+ .then(
+ (
+ json: {
+ name: string;
+ description?: string;
+ descriptionLong?: string;
+ image?: string;
+ url?: string;
+ display?: boolean;
+ online?: boolean;
+ uptime: { alltime: number; daytime: number; weektime: number };
+ urls: {
+ wellknown: string;
+ api: string;
+ cdn: string;
+ gateway: string;
+ login?: string;
+ };
+ }[]
+ ) => {
+ console.warn(json);
+ for (const instance of json) {
+ if (instance.display === false) {
+ continue;
+ }
+ const div = document.createElement("div");
+ div.classList.add("flexltr", "instance");
+ if (instance.image) {
+ const img = document.createElement("img");
+ img.src = instance.image;
+ div.append(img);
+ }
+ const statbox = document.createElement("div");
+ statbox.classList.add("flexttb");
+
+ {
+ const textbox = document.createElement("div");
+ textbox.classList.add("flexttb", "instatancetextbox");
+ const title = document.createElement("h2");
+ title.innerText = instance.name;
+ if (instance.online !== undefined) {
+ const status = document.createElement("span");
+ status.innerText = instance.online ? "Online" : "Offline";
+ status.classList.add("instanceStatus");
+ title.append(status);
+ }
+ textbox.append(title);
+ if (instance.description || instance.descriptionLong) {
+ const p = document.createElement("p");
+ if (instance.descriptionLong) {
+ p.innerText = instance.descriptionLong;
+ } else if (instance.description) {
+ p.innerText = instance.description;
+ }
+ textbox.append(p);
+ }
+ statbox.append(textbox);
+ }
+ if (instance.uptime) {
+ const stats = document.createElement("div");
+ stats.classList.add("flexltr");
+ const span = document.createElement("span");
+ span.innerText = `Uptime: All time: ${Math.round(
+ instance.uptime.alltime * 100
+ )}% This week: ${Math.round(
+ instance.uptime.weektime * 100
+ )}% Today: ${Math.round(instance.uptime.daytime * 100)}%`;
+ stats.append(span);
+ statbox.append(stats);
+ }
+ div.append(statbox);
+ div.onclick = (_) => {
+ if (instance.online) {
+ window.location.href =
+ "/register.html?instance=" + encodeURI(instance.name);
+ } else {
+ alert("Instance is offline, can't connect");
+ }
+ };
+ serverbox.append(div);
+ }
+ }
+ );
diff --git a/webpage/icons/announce.svg b/src/webpage/icons/announce.svg
similarity index 100%
rename from webpage/icons/announce.svg
rename to src/webpage/icons/announce.svg
diff --git a/webpage/icons/category.svg b/src/webpage/icons/category.svg
similarity index 100%
rename from webpage/icons/category.svg
rename to src/webpage/icons/category.svg
diff --git a/webpage/icons/channel.svg b/src/webpage/icons/channel.svg
similarity index 100%
rename from webpage/icons/channel.svg
rename to src/webpage/icons/channel.svg
diff --git a/webpage/icons/copy.svg b/src/webpage/icons/copy.svg
similarity index 100%
rename from webpage/icons/copy.svg
rename to src/webpage/icons/copy.svg
diff --git a/webpage/icons/delete.svg b/src/webpage/icons/delete.svg
similarity index 100%
rename from webpage/icons/delete.svg
rename to src/webpage/icons/delete.svg
diff --git a/webpage/icons/edit.svg b/src/webpage/icons/edit.svg
similarity index 100%
rename from webpage/icons/edit.svg
rename to src/webpage/icons/edit.svg
diff --git a/webpage/icons/explore.svg b/src/webpage/icons/explore.svg
similarity index 100%
rename from webpage/icons/explore.svg
rename to src/webpage/icons/explore.svg
diff --git a/webpage/icons/home.svg b/src/webpage/icons/home.svg
similarity index 100%
rename from webpage/icons/home.svg
rename to src/webpage/icons/home.svg
diff --git a/webpage/icons/reply.svg b/src/webpage/icons/reply.svg
similarity index 100%
rename from webpage/icons/reply.svg
rename to src/webpage/icons/reply.svg
diff --git a/webpage/icons/settings.svg b/src/webpage/icons/settings.svg
similarity index 100%
rename from webpage/icons/settings.svg
rename to src/webpage/icons/settings.svg
diff --git a/webpage/icons/voice.svg b/src/webpage/icons/voice.svg
similarity index 100%
rename from webpage/icons/voice.svg
rename to src/webpage/icons/voice.svg
diff --git a/webpage/index.html b/src/webpage/index.html
similarity index 88%
rename from webpage/index.html
rename to src/webpage/index.html
index 6b466fa..616a1ea 100644
--- a/webpage/index.html
+++ b/src/webpage/index.html
@@ -2,7 +2,8 @@
-
+
+
Jank Client
@@ -19,10 +20,10 @@
-

+
Jank Client is loading
This shouldn't take long
-
Switch Accounts
+
Switch Accounts
@@ -36,7 +37,7 @@
-
![]()
+
USERNAME
diff --git a/src/webpage/index.ts b/src/webpage/index.ts
new file mode 100644
index 0000000..18f2d40
--- /dev/null
+++ b/src/webpage/index.ts
@@ -0,0 +1,259 @@
+import { Localuser } from "./localuser.js";
+import { Contextmenu } from "./contextmenu.js";
+import { mobile, getBulkUsers, setTheme, Specialuser } from "./login.js";
+import { MarkDown } from "./markdown.js";
+import { Message } from "./message.js";
+import { File } from "./file.js";
+
+(async () => {
+ async function waitForLoad(): Promise
{
+ return new Promise((resolve) => {
+ document.addEventListener("DOMContentLoaded", (_) => resolve());
+ });
+ }
+
+ await waitForLoad();
+
+ const users = getBulkUsers();
+ if (!users.currentuser) {
+ window.location.href = "/login.html";
+ return;
+ }
+
+ function showAccountSwitcher(): void {
+ const table = document.createElement("div");
+ table.classList.add("accountSwitcher");
+
+ for (const user of Object.values(users.users)) {
+ const specialUser = user as Specialuser;
+ const userInfo = document.createElement("div");
+ userInfo.classList.add("flexltr", "switchtable");
+
+ const pfp = document.createElement("img");
+ pfp.src = specialUser.pfpsrc;
+ pfp.classList.add("pfp");
+ userInfo.append(pfp);
+
+ const userDiv = document.createElement("div");
+ userDiv.classList.add("userinfo");
+ userDiv.textContent = specialUser.username;
+ userDiv.append(document.createElement("br"));
+
+ const span = document.createElement("span");
+ span.textContent = specialUser.serverurls.wellknown
+ .replace("https://", "")
+ .replace("http://", "");
+ span.classList.add("serverURL");
+ userDiv.append(span);
+
+ userInfo.append(userDiv);
+ table.append(userInfo);
+
+ userInfo.addEventListener("click", () => {
+ thisUser.unload();
+ thisUser.swapped = true;
+ const loading = document.getElementById("loading") as HTMLDivElement;
+ loading.classList.remove("doneloading");
+ loading.classList.add("loading");
+
+ thisUser = new Localuser(specialUser);
+ users.currentuser = specialUser.uid;
+ localStorage.setItem("userinfos", JSON.stringify(users));
+
+ thisUser.initwebsocket().then(() => {
+ thisUser.loaduser();
+ thisUser.init();
+ loading.classList.add("doneloading");
+ loading.classList.remove("loading");
+ console.log("done loading");
+ });
+
+ userInfo.remove();
+ });
+ }
+
+ const switchAccountDiv = document.createElement("div");
+ switchAccountDiv.classList.add("switchtable");
+ switchAccountDiv.textContent = "Switch accounts ⇌";
+ switchAccountDiv.addEventListener("click", () => {
+ window.location.href = "/login.html";
+ });
+ table.append(switchAccountDiv);
+
+ if (Contextmenu.currentmenu) {
+ Contextmenu.currentmenu.remove();
+ }
+ Contextmenu.currentmenu = table;
+ document.body.append(table);
+ }
+
+ const userInfoElement = document.getElementById("userinfo") as HTMLDivElement;
+ userInfoElement.addEventListener("click", (event) => {
+ event.stopImmediatePropagation();
+ showAccountSwitcher();
+ });
+
+ const switchAccountsElement = document.getElementById(
+ "switchaccounts"
+ ) as HTMLDivElement;
+ switchAccountsElement.addEventListener("click", (event) => {
+ event.stopImmediatePropagation();
+ showAccountSwitcher();
+ });
+
+ let thisUser: Localuser;
+ try {
+ console.log(users.users, users.currentuser);
+ thisUser = new Localuser(users.users[users.currentuser]);
+ thisUser.initwebsocket().then(() => {
+ thisUser.loaduser();
+ thisUser.init();
+ const loading = document.getElementById("loading") as HTMLDivElement;
+ loading.classList.add("doneloading");
+ loading.classList.remove("loading");
+ console.log("done loading");
+ });
+ } catch (e) {
+ console.error(e);
+ (document.getElementById("load-desc") as HTMLSpanElement).textContent =
+ "Account unable to start";
+ thisUser = new Localuser(-1);
+ }
+
+ const menu = new Contextmenu("create rightclick");
+ menu.addbutton(
+ "Create channel",
+ () => {
+ if (thisUser.lookingguild) {
+ thisUser.lookingguild.createchannels();
+ }
+ },
+ null,
+ () => thisUser.isAdmin()
+ );
+
+ menu.addbutton(
+ "Create category",
+ () => {
+ if (thisUser.lookingguild) {
+ thisUser.lookingguild.createcategory();
+ }
+ },
+ null,
+ () => thisUser.isAdmin()
+ );
+
+ menu.bindContextmenu(
+ document.getElementById("channels") as HTMLDivElement,
+ 0,
+ 0
+ );
+
+ const pasteImageElement = document.getElementById(
+ "pasteimage"
+ ) as HTMLDivElement;
+ let replyingTo: Message | null = null;
+
+ async function handleEnter(event: KeyboardEvent): Promise {
+ const channel = thisUser.channelfocus;
+ if (!channel) return;
+
+ channel.typingstart();
+
+ if (event.key === "Enter" && !event.shiftKey) {
+ event.preventDefault();
+
+ if (channel.editing) {
+ channel.editing.edit(markdown.rawString);
+ channel.editing = null;
+ } else {
+ replyingTo = thisUser.channelfocus
+ ? thisUser.channelfocus.replyingto
+ : null;
+ if (replyingTo?.div) {
+ replyingTo.div.classList.remove("replying");
+ }
+ if (thisUser.channelfocus) {
+ thisUser.channelfocus.replyingto = null;
+ }
+ channel.sendMessage(markdown.rawString, {
+ attachments: images,
+ // @ts-ignore This is valid according to the API
+ embeds: [], // Add an empty array for the embeds property
+ replyingto: replyingTo,
+ });
+ if (thisUser.channelfocus) {
+ thisUser.channelfocus.makereplybox();
+ }
+ }
+
+ while (images.length) {
+ images.pop();
+ pasteImageElement.removeChild(imagesHtml.pop() as HTMLElement);
+ }
+
+ typebox.innerHTML = "";
+ }
+ }
+
+ interface CustomHTMLDivElement extends HTMLDivElement {
+ markdown: MarkDown;
+ }
+
+ const typebox = document.getElementById("typebox") as CustomHTMLDivElement;
+ const markdown = new MarkDown("", thisUser);
+ typebox.markdown = markdown;
+ typebox.addEventListener("keyup", handleEnter);
+ typebox.addEventListener("keydown", (event) => {
+ if (event.key === "Enter" && !event.shiftKey) event.preventDefault();
+ });
+ markdown.giveBox(typebox);
+
+ const images: Blob[] = [];
+ const imagesHtml: HTMLElement[] = [];
+
+ document.addEventListener("paste", async (e: ClipboardEvent) => {
+ if (!e.clipboardData) return;
+
+ for (const file of Array.from(e.clipboardData.files)) {
+ const fileInstance = File.initFromBlob(file);
+ e.preventDefault();
+ const html = fileInstance.upHTML(images, file);
+ pasteImageElement.appendChild(html);
+ images.push(file);
+ imagesHtml.push(html);
+ }
+ });
+
+ setTheme();
+
+ function userSettings(): void {
+ thisUser.showusersettings();
+ }
+
+ (document.getElementById("settings") as HTMLImageElement).onclick =
+ userSettings;
+
+ if (mobile) {
+ const channelWrapper = document.getElementById(
+ "channelw"
+ ) as HTMLDivElement;
+ channelWrapper.onclick = () => {
+ (
+ document.getElementById("channels")!.parentNode as HTMLElement
+ ).classList.add("collapse");
+ document.getElementById("servertd")!.classList.add("collapse");
+ document.getElementById("servers")!.classList.add("collapse");
+ };
+
+ const mobileBack = document.getElementById("mobileback") as HTMLDivElement;
+ mobileBack.textContent = "#";
+ mobileBack.onclick = () => {
+ (
+ document.getElementById("channels")!.parentNode as HTMLElement
+ ).classList.remove("collapse");
+ document.getElementById("servertd")!.classList.remove("collapse");
+ document.getElementById("servers")!.classList.remove("collapse");
+ };
+ }
+})();
diff --git a/src/webpage/infiniteScroller.ts b/src/webpage/infiniteScroller.ts
new file mode 100644
index 0000000..0020cb6
--- /dev/null
+++ b/src/webpage/infiniteScroller.ts
@@ -0,0 +1,323 @@
+class InfiniteScroller {
+ readonly getIDFromOffset: (
+ ID: string,
+ offset: number
+ ) => Promise;
+ readonly getHTMLFromID: (ID: string) => Promise;
+ readonly destroyFromID: (ID: string) => Promise;
+ readonly reachesBottom: () => void;
+ private readonly minDist = 2000;
+ private readonly fillDist = 3000;
+ private readonly maxDist = 6000;
+ HTMLElements: [HTMLElement, string][] = [];
+ div: HTMLDivElement | null = null;
+ timeout: NodeJS.Timeout | null = null;
+ beenloaded = false;
+ scrollBottom = 0;
+ scrollTop = 0;
+ needsupdate = true;
+ averageheight = 60;
+ watchtime = false;
+ changePromise: Promise | undefined;
+ scollDiv!: { scrollTop: number; scrollHeight: number; clientHeight: number };
+
+ constructor(
+ getIDFromOffset: InfiniteScroller["getIDFromOffset"],
+ getHTMLFromID: InfiniteScroller["getHTMLFromID"],
+ destroyFromID: InfiniteScroller["destroyFromID"],
+ reachesBottom: InfiniteScroller["reachesBottom"] = () => {}
+ ) {
+ this.getIDFromOffset = getIDFromOffset;
+ this.getHTMLFromID = getHTMLFromID;
+ this.destroyFromID = destroyFromID;
+ this.reachesBottom = reachesBottom;
+ }
+
+ async getDiv(initialId: string): Promise {
+ if (this.div) {
+ throw new Error("Div already exists, exiting.");
+ }
+
+ const scroll = document.createElement("div");
+ scroll.classList.add("flexttb", "scroller");
+ this.div = scroll;
+
+ this.div.addEventListener("scroll", () => {
+ this.checkscroll();
+ if (this.scrollBottom < 5) {
+ this.scrollBottom = 5;
+ }
+ if (this.timeout === null) {
+ this.timeout = setTimeout(this.updatestuff.bind(this), 300);
+ }
+ this.watchForChange();
+ });
+
+ let oldheight = 0;
+ new ResizeObserver(() => {
+ this.checkscroll();
+ const func = this.snapBottom();
+ this.updatestuff();
+ const change = oldheight - scroll.offsetHeight;
+ if (change > 0 && this.div) {
+ this.div.scrollTop += change;
+ }
+ oldheight = scroll.offsetHeight;
+ this.watchForChange();
+ func();
+ }).observe(scroll);
+
+ new ResizeObserver(this.watchForChange.bind(this)).observe(scroll);
+
+ await this.firstElement(initialId);
+ this.updatestuff();
+ await this.watchForChange().then(() => {
+ this.updatestuff();
+ this.beenloaded = true;
+ });
+
+ return scroll;
+ }
+
+ checkscroll(): void {
+ if (this.beenloaded && this.div && !document.body.contains(this.div)) {
+ console.warn("not in document");
+ this.div = null;
+ }
+ }
+
+ async updatestuff(): Promise {
+ this.timeout = null;
+ if (!this.div) return;
+
+ this.scrollBottom =
+ this.div.scrollHeight - this.div.scrollTop - this.div.clientHeight;
+ this.averageheight = this.div.scrollHeight / this.HTMLElements.length;
+ if (this.averageheight < 10) {
+ this.averageheight = 60;
+ }
+ this.scrollTop = this.div.scrollTop;
+
+ if (!this.scrollBottom && !(await this.watchForChange())) {
+ this.reachesBottom();
+ }
+ if (!this.scrollTop) {
+ await this.watchForChange();
+ }
+ this.needsupdate = false;
+ }
+
+ async firstElement(id: string): Promise {
+ if (!this.div) return;
+ const html = await this.getHTMLFromID(id);
+ this.div.appendChild(html);
+ this.HTMLElements.push([html, id]);
+ }
+
+ async addedBottom(): Promise {
+ await this.updatestuff();
+ const func = this.snapBottom();
+ await this.watchForChange();
+ func();
+ }
+
+ snapBottom(): () => void {
+ const scrollBottom = this.scrollBottom;
+ return () => {
+ if (this.div && scrollBottom < 4) {
+ this.div.scrollTop = this.div.scrollHeight;
+ }
+ };
+ }
+
+ private async watchForTop(
+ already = false,
+ fragment = new DocumentFragment()
+ ): Promise {
+ if (!this.div) return false;
+ try {
+ let again = false;
+ if (this.scrollTop < (already ? this.fillDist : this.minDist)) {
+ let nextid: string | undefined;
+ const firstelm = this.HTMLElements.at(0);
+ if (firstelm) {
+ const previd = firstelm[1];
+ nextid = await this.getIDFromOffset(previd, 1);
+ }
+
+ if (nextid) {
+ const html = await this.getHTMLFromID(nextid);
+ if (!html) {
+ this.destroyFromID(nextid);
+ return false;
+ }
+ again = true;
+ fragment.prepend(html);
+ this.HTMLElements.unshift([html, nextid]);
+ this.scrollTop += this.averageheight;
+ }
+ }
+ if (this.scrollTop > this.maxDist) {
+ const html = this.HTMLElements.shift();
+ if (html) {
+ again = true;
+ await this.destroyFromID(html[1]);
+ this.scrollTop -= this.averageheight;
+ }
+ }
+ if (again) {
+ await this.watchForTop(true, fragment);
+ }
+ return again;
+ } finally {
+ if (!already) {
+ if (this.div.scrollTop === 0) {
+ this.scrollTop = 1;
+ this.div.scrollTop = 10;
+ }
+ this.div.prepend(fragment, fragment);
+ }
+ }
+ }
+
+ async watchForBottom(
+ already = false,
+ fragment = new DocumentFragment()
+ ): Promise {
+ let func: Function | undefined;
+ if (!already) func = this.snapBottom();
+ if (!this.div) return false;
+ try {
+ let again = false;
+ const scrollBottom = this.scrollBottom;
+ if (scrollBottom < (already ? this.fillDist : this.minDist)) {
+ let nextid: string | undefined;
+ const lastelm = this.HTMLElements.at(-1);
+ if (lastelm) {
+ const previd = lastelm[1];
+ nextid = await this.getIDFromOffset(previd, -1);
+ }
+ if (nextid) {
+ again = true;
+ const html = await this.getHTMLFromID(nextid);
+ fragment.appendChild(html);
+ this.HTMLElements.push([html, nextid]);
+ this.scrollBottom += this.averageheight;
+ }
+ }
+ if (scrollBottom > this.maxDist) {
+ const html = this.HTMLElements.pop();
+ if (html) {
+ await this.destroyFromID(html[1]);
+ this.scrollBottom -= this.averageheight;
+ again = true;
+ }
+ }
+ if (again) {
+ await this.watchForBottom(true, fragment);
+ }
+ return again;
+ } finally {
+ if (!already) {
+ this.div.append(fragment);
+ if (func) {
+ func();
+ }
+ }
+ }
+ }
+
+ async watchForChange(): Promise {
+ if (this.changePromise) {
+ this.watchtime = true;
+ return await this.changePromise;
+ } else {
+ this.watchtime = false;
+ }
+
+ this.changePromise = new Promise(async (res) => {
+ try {
+ if (!this.div) {
+ res(false);
+ return false;
+ }
+ const out = (await Promise.allSettled([
+ this.watchForTop(),
+ this.watchForBottom(),
+ ])) as { value: boolean }[];
+ const changed = out[0].value || out[1].value;
+ if (this.timeout === null && changed) {
+ this.timeout = setTimeout(this.updatestuff.bind(this), 300);
+ }
+ res(Boolean(changed));
+ return Boolean(changed);
+ } catch (e) {
+ console.error(e);
+ res(false);
+ return false;
+ } finally {
+ setTimeout(() => {
+ this.changePromise = undefined;
+ if (this.watchtime) {
+ this.watchForChange();
+ }
+ }, 300);
+ }
+ });
+
+ return await this.changePromise;
+ }
+
+ async focus(id: string, flash = true): Promise {
+ let element: HTMLElement | undefined;
+ for (const thing of this.HTMLElements) {
+ if (thing[1] === id) {
+ element = thing[0];
+ }
+ }
+ if (element) {
+ if (flash) {
+ element.scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ });
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ element.classList.remove("jumped");
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ element.classList.add("jumped");
+ } else {
+ element.scrollIntoView();
+ }
+ } else {
+ for (const thing of this.HTMLElements) {
+ await this.destroyFromID(thing[1]);
+ }
+ this.HTMLElements = [];
+ await this.firstElement(id);
+ this.updatestuff();
+ await this.watchForChange();
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ await this.focus(id, true);
+ }
+ }
+
+ async delete(): Promise {
+ if (this.div) {
+ this.div.remove();
+ this.div = null;
+ }
+ try {
+ for (const thing of this.HTMLElements) {
+ await this.destroyFromID(thing[1]);
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ this.HTMLElements = [];
+ if (this.timeout) {
+ clearTimeout(this.timeout);
+ }
+ }
+}
+
+export { InfiniteScroller };
diff --git a/webpage/instances.json b/src/webpage/instances.json
similarity index 71%
rename from webpage/instances.json
rename to src/webpage/instances.json
index 43ad86d..328384c 100644
--- a/webpage/instances.json
+++ b/src/webpage/instances.json
@@ -1,9 +1,9 @@
[
{
- "name":"Spacebar",
- "description":"The official Spacebar instance.",
- "image":"https://raw.githubusercontent.com/spacebarchat/spacebarchat/master/branding/png/Spacebar__Icon-Discord.png",
- "url":"https://spacebar.chat"
+ "name": "Spacebar",
+ "description": "The official Spacebar instance.",
+ "image": "https://raw.githubusercontent.com/spacebarchat/spacebarchat/master/branding/png/Spacebar__Icon-Discord.png",
+ "url": "https://spacebar.chat"
},
{
"name": "Fastbar",
@@ -13,16 +13,16 @@
"language": "en",
"country": "US",
"display": true,
- "urls":{
+ "urls": {
"wellknown": "https://greysilly7.xyz",
"api": "https://spacebar.greysilly7.xyz/api/v9",
"cdn": "https://spacebar.greysilly7.xyz",
"gateway": "wss://spacebar.greysilly7.xyz"
},
- "contactInfo":{
+ "contactInfo": {
"dicord": "greysilly7",
"github": "https://github.com/greysilly7",
"email": "greysilly7@gmail.com"
}
}
-]
+]
\ No newline at end of file
diff --git a/webpage/invite.html b/src/webpage/invite.html
similarity index 88%
rename from webpage/invite.html
rename to src/webpage/invite.html
index 314a22e..b1c0299 100644
--- a/webpage/invite.html
+++ b/src/webpage/invite.html
@@ -4,7 +4,7 @@
Jank Client
-
+
diff --git a/src/webpage/invite.ts b/src/webpage/invite.ts
new file mode 100644
index 0000000..331fcc0
--- /dev/null
+++ b/src/webpage/invite.ts
@@ -0,0 +1,147 @@
+import { getBulkUsers, Specialuser, getapiurls } from "./login.js";
+
+(async () => {
+ const users = getBulkUsers();
+ const well = new URLSearchParams(window.location.search).get("instance");
+ const joinable: Specialuser[] = [];
+
+ for (const key in users.users) {
+ if (Object.prototype.hasOwnProperty.call(users.users, key)) {
+ const user: Specialuser = users.users[key];
+ if (well && user.serverurls.wellknown.includes(well)) {
+ joinable.push(user);
+ }
+ console.log(user);
+ }
+ }
+
+ let urls: { api: string; cdn: string } | undefined;
+
+ if (!joinable.length && well) {
+ const out = await getapiurls(well);
+ if (out) {
+ urls = out;
+ for (const key in users.users) {
+ if (Object.prototype.hasOwnProperty.call(users.users, key)) {
+ const user: Specialuser = users.users[key];
+ if (user.serverurls.api.includes(out.api)) {
+ joinable.push(user);
+ }
+ console.log(user);
+ }
+ }
+ } else {
+ throw new Error(
+ "Someone needs to handle the case where the servers don't exist"
+ );
+ }
+ } else {
+ urls = joinable[0].serverurls;
+ }
+
+ if (!joinable.length) {
+ document.getElementById("AcceptInvite")!.textContent =
+ "Create an account to accept the invite";
+ }
+
+ const code = window.location.pathname.split("/")[2];
+ let guildinfo: any;
+
+ fetch(`${urls!.api}/invites/${code}`, {
+ method: "GET",
+ })
+ .then((response) => response.json())
+ .then((json) => {
+ const guildjson = json.guild;
+ guildinfo = guildjson;
+ document.getElementById("invitename")!.textContent = guildjson.name;
+ document.getElementById(
+ "invitedescription"
+ )!.textContent = `${json.inviter.username} invited you to join ${guildjson.name}`;
+ if (guildjson.icon) {
+ const img = document.createElement("img");
+ img.src = `${urls!.cdn}/icons/${guildjson.id}/${guildjson.icon}.png`;
+ img.classList.add("inviteGuild");
+ document.getElementById("inviteimg")!.append(img);
+ } else {
+ const txt = guildjson.name
+ .replace(/'s /g, " ")
+ .replace(/\w+/g, (word: any[]) => word[0])
+ .replace(/\s/g, "");
+ const div = document.createElement("div");
+ div.textContent = txt;
+ div.classList.add("inviteGuild");
+ document.getElementById("inviteimg")!.append(div);
+ }
+ });
+
+ function showAccounts(): void {
+ const table = document.createElement("dialog");
+ for (const user of joinable) {
+ console.log(user.pfpsrc);
+
+ const userinfo = document.createElement("div");
+ userinfo.classList.add("flexltr", "switchtable");
+
+ const pfp = document.createElement("img");
+ pfp.src = user.pfpsrc;
+ pfp.classList.add("pfp");
+ userinfo.append(pfp);
+
+ const userDiv = document.createElement("div");
+ userDiv.classList.add("userinfo");
+ userDiv.textContent = user.username;
+ userDiv.append(document.createElement("br"));
+
+ const span = document.createElement("span");
+ span.textContent = user.serverurls.wellknown
+ .replace("https://", "")
+ .replace("http://", "");
+ span.classList.add("serverURL");
+ userDiv.append(span);
+
+ userinfo.append(userDiv);
+ table.append(userinfo);
+
+ userinfo.addEventListener("click", () => {
+ console.log(user);
+ fetch(`${urls!.api}/invites/${code}`, {
+ method: "POST",
+ headers: {
+ Authorization: user.token,
+ },
+ }).then(() => {
+ users.currentuser = user.uid;
+ localStorage.setItem("userinfos", JSON.stringify(users));
+ window.location.href = "/channels/" + guildinfo.id;
+ });
+ });
+ }
+
+ const td = document.createElement("div");
+ td.classList.add("switchtable");
+ td.textContent = "Login or create an account ⇌";
+ td.addEventListener("click", () => {
+ const l = new URLSearchParams("?");
+ l.set("goback", window.location.href);
+ l.set("instance", well!);
+ window.location.href = "/login?" + l.toString();
+ });
+
+ if (!joinable.length) {
+ const l = new URLSearchParams("?");
+ l.set("goback", window.location.href);
+ l.set("instance", well!);
+ window.location.href = "/login?" + l.toString();
+ }
+
+ table.append(td);
+ table.classList.add("accountSwitcher");
+ console.log(table);
+ document.body.append(table);
+ }
+
+ document
+ .getElementById("AcceptInvite")!
+ .addEventListener("click", showAccounts);
+})();
diff --git a/src/webpage/jsontypes.ts b/src/webpage/jsontypes.ts
new file mode 100644
index 0000000..1bbeffc
--- /dev/null
+++ b/src/webpage/jsontypes.ts
@@ -0,0 +1,501 @@
+type readyjson = {
+ op: 0;
+ t: "READY";
+ s: number;
+ d: {
+ v: number;
+ user: mainuserjson;
+ user_settings: {
+ index: number;
+ afk_timeout: number;
+ allow_accessibility_detection: boolean;
+ animate_emoji: boolean;
+ animate_stickers: number;
+ contact_sync_enabled: boolean;
+ convert_emoticons: boolean;
+ custom_status: string;
+ default_guilds_restricted: boolean;
+ detect_platform_accounts: boolean;
+ developer_mode: boolean;
+ disable_games_tab: boolean;
+ enable_tts_command: boolean;
+ explicit_content_filter: 0;
+ friend_discovery_flags: 0;
+ friend_source_flags: {
+ all: boolean;
+ }; //might be missing things here
+ gateway_connected: boolean;
+ gif_auto_play: boolean;
+ guild_folders: []; //need an example of this not empty
+ guild_positions: []; //need an example of this not empty
+ inline_attachment_media: boolean;
+ inline_embed_media: boolean;
+ locale: string;
+ message_display_compact: boolean;
+ native_phone_integration_enabled: boolean;
+ render_embeds: boolean;
+ render_reactions: boolean;
+ restricted_guilds: []; //need an example of this not empty
+ show_current_game: boolean;
+ status: string;
+ stream_notifications_enabled: boolean;
+ theme: string;
+ timezone_offset: number;
+ view_nsfw_guilds: boolean;
+ };
+ guilds: guildjson[];
+ relationships: {
+ id: string;
+ type: 0 | 1 | 2 | 3 | 4;
+ nickname: string | null;
+ user: userjson;
+ }[];
+ read_state: {
+ entries: {
+ id: string;
+ channel_id: string;
+ last_message_id: string;
+ last_pin_timestamp: string;
+ mention_count: number; //in theory, the server doesn't actually send this as far as I'm aware
+ }[];
+ partial: boolean;
+ version: number;
+ };
+ user_guild_settings: {
+ entries: {
+ channel_overrides: unknown[]; //will have to find example
+ message_notifications: number;
+ flags: number;
+ hide_muted_channels: boolean;
+ mobile_push: boolean;
+ mute_config: null;
+ mute_scheduled_events: boolean;
+ muted: boolean;
+ notify_highlights: number;
+ suppress_everyone: boolean;
+ suppress_roles: boolean;
+ version: number;
+ guild_id: string;
+ }[];
+ partial: boolean;
+ version: number;
+ };
+ private_channels: dirrectjson[];
+ session_id: string;
+ country_code: string;
+ users: userjson[];
+ merged_members: [memberjson][];
+ sessions: {
+ active: boolean;
+ activities: []; //will need to find example of this
+ client_info: {
+ version: number;
+ };
+ session_id: string;
+ status: string;
+ }[];
+ resume_gateway_url: string;
+ consents: {
+ personalization: {
+ consented: boolean;
+ };
+ };
+ experiments: []; //not sure if I need to do this :P
+ guild_join_requests: []; //need to get examples
+ connected_accounts: []; //need to get examples
+ guild_experiments: []; //need to get examples
+ geo_ordered_rtc_regions: []; //need to get examples
+ api_code_version: number;
+ friend_suggestion_count: number;
+ analytics_token: string;
+ tutorial: boolean;
+ session_type: string;
+ auth_session_id_hash: string;
+ notification_settings: {
+ flags: number;
+ };
+ };
+};
+type mainuserjson = userjson & {
+ flags: number;
+ mfa_enabled?: boolean;
+ email?: string;
+ phone?: string;
+ verified: boolean;
+ nsfw_allowed: boolean;
+ premium: boolean;
+ purchased_flags: number;
+ premium_usage_flags: number;
+ disabled: boolean;
+};
+type userjson = {
+ username: string;
+ discriminator: string;
+ id: string;
+ public_flags: number;
+ avatar: string | null;
+ accent_color: number;
+ banner?: string;
+ bio: string;
+ bot: boolean;
+ premium_since: string;
+ premium_type: number;
+ theme_colors: string;
+ pronouns: string;
+ badge_ids: string[];
+};
+type memberjson = {
+ index?: number;
+ id: string;
+ user: userjson | null;
+ guild_id: string;
+ guild: {
+ id: string;
+ } | null;
+ nick?: string;
+ roles: string[];
+ joined_at: string;
+ premium_since: string;
+ deaf: boolean;
+ mute: boolean;
+ pending: boolean;
+ last_message_id?: boolean; //What???
+};
+type emojijson = {
+ name: string;
+ id?: string;
+ animated?: boolean;
+};
+
+type guildjson = {
+ application_command_counts: { [key: string]: number };
+ channels: channeljson[];
+ data_mode: string;
+ emojis: emojijson[];
+ guild_scheduled_events: [];
+ id: string;
+ large: boolean;
+ lazy: boolean;
+ member_count: number;
+ premium_subscription_count: number;
+ properties: {
+ region: string | null;
+ name: string;
+ description: string;
+ icon: string;
+ splash: string;
+ banner: string;
+ features: string[];
+ preferred_locale: string;
+ owner_id: string;
+ application_id: string;
+ afk_channel_id: string;
+ afk_timeout: number;
+ member_count: number;
+ system_channel_id: string;
+ verification_level: number;
+ explicit_content_filter: number;
+ default_message_notifications: number;
+ mfa_level: number;
+ vanity_url_code: number;
+ premium_tier: number;
+ premium_progress_bar_enabled: boolean;
+ system_channel_flags: number;
+ discovery_splash: string;
+ rules_channel_id: string;
+ public_updates_channel_id: string;
+ max_video_channel_users: number;
+ max_members: number;
+ nsfw_level: number;
+ hub_type: null;
+ home_header: null;
+ id: string;
+ latest_onboarding_question_id: string;
+ max_stage_video_channel_users: number;
+ nsfw: boolean;
+ safety_alerts_channel_id: string;
+ };
+ roles: rolesjson[];
+ stage_instances: [];
+ stickers: [];
+ threads: [];
+ version: string;
+ guild_hashes: {};
+ joined_at: string;
+};
+type startTypingjson = {
+ d: {
+ channel_id: string;
+ guild_id?: string;
+ user_id: string;
+ timestamp: number;
+ member?: memberjson;
+ };
+};
+type channeljson = {
+ id: string;
+ created_at: string;
+ name: string;
+ icon: string;
+ type: number;
+ last_message_id: string;
+ guild_id: string;
+ parent_id: string;
+ last_pin_timestamp: string;
+ default_auto_archive_duration: number;
+ permission_overwrites: {
+ id: string;
+ allow: string;
+ deny: string;
+ }[];
+ video_quality_mode: null;
+ nsfw: boolean;
+ topic: string;
+ retention_policy_id: string;
+ flags: number;
+ default_thread_rate_limit_per_user: number;
+ position: number;
+};
+type rolesjson = {
+ id: string;
+ guild_id: string;
+ color: number;
+ hoist: boolean;
+ managed: boolean;
+ mentionable: boolean;
+ name: string;
+ permissions: string;
+ position: number;
+ icon: string;
+ unicode_emoji: string;
+ flags: number;
+};
+type dirrectjson = {
+ id: string;
+ flags: number;
+ last_message_id: string;
+ type: number;
+ recipients: userjson[];
+ is_spam: boolean;
+};
+type messagejson = {
+ id: string;
+ channel_id: string;
+ guild_id: string;
+ author: userjson;
+ member?: memberjson;
+ content: string;
+ timestamp: string;
+ edited_timestamp: string;
+ tts: boolean;
+ mention_everyone: boolean;
+ mentions: []; //need examples to fix
+ mention_roles: []; //need examples to fix
+ attachments: filejson[];
+ embeds: embedjson[];
+ reactions: {
+ count: number;
+ emoji: emojijson; //very likely needs expanding
+ me: boolean;
+ }[];
+ nonce: string;
+ pinned: boolean;
+ type: number;
+};
+type filejson = {
+ id: string;
+ filename: string;
+ content_type: string;
+ width?: number;
+ height?: number;
+ proxy_url: string | undefined;
+ url: string;
+ size: number;
+};
+type embedjson = {
+ type: string | null;
+ color?: number;
+ author: {
+ icon_url?: string;
+ name?: string;
+ url?: string;
+ title?: string;
+ };
+ title?: string;
+ url?: string;
+ description?: string;
+ fields?: {
+ name: string;
+ value: string;
+ inline: boolean;
+ }[];
+ footer?: {
+ icon_url?: string;
+ text?: string;
+ thumbnail?: string;
+ };
+ timestamp?: string;
+ thumbnail: {
+ proxy_url: string;
+ url: string;
+ width: number;
+ height: number;
+ };
+ provider: {
+ name: string;
+ };
+ video?: {
+ url: string;
+ width?: number | null;
+ height?: number | null;
+ proxy_url?: string;
+ };
+ invite?: {
+ url: string;
+ code: string;
+ };
+};
+type invitejson = {
+ code: string;
+ temporary: boolean;
+ uses: number;
+ max_use: number;
+ max_age: number;
+ created_at: string;
+ expires_at: string;
+ guild_id: string;
+ channel_id: string;
+ inviter_id: string;
+ target_user_id: string | null;
+ target_user_type: string | null;
+ vanity_url: string | null;
+ flags: number;
+ guild: guildjson["properties"];
+ channel: channeljson;
+ inviter: userjson;
+};
+type presencejson = {
+ status: string;
+ since: number | null;
+ activities: any[]; //bit more complicated but not now
+ afk: boolean;
+ user?: userjson;
+};
+type messageCreateJson = {
+ op: 0;
+ d: {
+ guild_id?: string;
+ channel_id?: string;
+ } & messagejson;
+ s: number;
+ t: "MESSAGE_CREATE";
+};
+type wsjson =
+ | {
+ op: 0;
+ d: any;
+ s: number;
+ t:
+ | "TYPING_START"
+ | "USER_UPDATE"
+ | "CHANNEL_UPDATE"
+ | "CHANNEL_CREATE"
+ | "CHANNEL_DELETE"
+ | "GUILD_DELETE"
+ | "GUILD_CREATE"
+ | "MESSAGE_REACTION_REMOVE_ALL"
+ | "MESSAGE_REACTION_REMOVE_EMOJI";
+ }
+ | {
+ op: 0;
+ t: "GUILD_MEMBERS_CHUNK";
+ d: memberChunk;
+ s: number;
+ }
+ | {
+ op: 0;
+ d: {
+ id: string;
+ guild_id?: string;
+ channel_id: string;
+ };
+ s: number;
+ t: "MESSAGE_DELETE";
+ }
+ | {
+ op: 0;
+ d: {
+ guild_id?: string;
+ channel_id: string;
+ } & messagejson;
+ s: number;
+ t: "MESSAGE_UPDATE";
+ }
+ | messageCreateJson
+ | readyjson
+ | {
+ op: 11;
+ s: undefined;
+ d: {};
+ }
+ | {
+ op: 10;
+ s: undefined;
+ d: {
+ heartbeat_interval: number;
+ };
+ }
+ | {
+ op: 0;
+ t: "MESSAGE_REACTION_ADD";
+ d: {
+ user_id: string;
+ channel_id: string;
+ message_id: string;
+ guild_id?: string;
+ emoji: emojijson;
+ member?: memberjson;
+ };
+ s: number;
+ }
+ | {
+ op: 0;
+ t: "MESSAGE_REACTION_REMOVE";
+ d: {
+ user_id: string;
+ channel_id: string;
+ message_id: string;
+ guild_id: string;
+ emoji: emojijson;
+ };
+ s: 3;
+ };
+type memberChunk = {
+ guild_id: string;
+ nonce: string;
+ members: memberjson[];
+ presences: presencejson[];
+ chunk_index: number;
+ chunk_count: number;
+ not_found: string[];
+};
+export {
+ readyjson,
+ dirrectjson,
+ startTypingjson,
+ channeljson,
+ guildjson,
+ rolesjson,
+ userjson,
+ memberjson,
+ mainuserjson,
+ messagejson,
+ filejson,
+ embedjson,
+ emojijson,
+ presencejson,
+ wsjson,
+ messageCreateJson,
+ memberChunk,
+ invitejson,
+};
diff --git a/src/webpage/localuser.ts b/src/webpage/localuser.ts
new file mode 100644
index 0000000..40caa46
--- /dev/null
+++ b/src/webpage/localuser.ts
@@ -0,0 +1,1824 @@
+import { Guild } from "./guild.js";
+import { Channel } from "./channel.js";
+import { Direct } from "./direct.js";
+import { Voice } from "./audio.js";
+import { User } from "./user.js";
+import { Dialog } from "./dialog.js";
+import { getapiurls, getBulkInfo, setTheme, Specialuser } from "./login.js";
+import {
+ channeljson,
+ guildjson,
+ memberjson,
+ messageCreateJson,
+ presencejson,
+ readyjson,
+ startTypingjson,
+ wsjson,
+} from "./jsontypes.js";
+import { Member } from "./member.js";
+import { FormError, Settings } from "./settings.js";
+import { MarkDown } from "./markdown.js";
+
+const wsCodesRetry = new Set([4000, 4003, 4005, 4007, 4008, 4009]);
+
+class Localuser {
+ badges: Map<
+ string,
+ { id: string; description: string; icon: string; link: string }
+ > = new Map();
+ lastSequence: number | null = null;
+ token!: string;
+ userinfo!: Specialuser;
+ serverurls!: Specialuser["serverurls"];
+ initialized!: boolean;
+ info!: Specialuser["serverurls"];
+ headers!: { "Content-type": string; Authorization: string };
+ userConnections!: Dialog;
+ devPortal!: Dialog;
+ ready!: readyjson;
+ guilds!: Guild[];
+ guildids: Map = new Map();
+ user!: User;
+ status!: string;
+ channelfocus: Channel | undefined;
+ lookingguild: Guild | undefined;
+ guildhtml: Map = new Map();
+ ws: WebSocket | undefined;
+ connectionSucceed = 0;
+ errorBackoff = 0;
+ channelids: Map = new Map();
+ readonly userMap: Map = new Map();
+ instancePing = {
+ name: "Unknown",
+ };
+ mfa_enabled!: boolean;
+ get perminfo() {
+ return this.userinfo.localuserStore;
+ }
+ set perminfo(e) {
+ this.userinfo.localuserStore = e;
+ }
+ constructor(userinfo: Specialuser | -1) {
+ if (userinfo === -1) {
+ return;
+ }
+ this.token = userinfo.token;
+ this.userinfo = userinfo;
+ this.perminfo.guilds ??= {};
+ this.serverurls = this.userinfo.serverurls;
+ this.initialized = false;
+ this.info = this.serverurls;
+ this.headers = {
+ "Content-type": "application/json; charset=UTF-8",
+ Authorization: this.userinfo.token,
+ };
+ }
+ gottenReady(ready: readyjson): void {
+ this.initialized = true;
+ this.ready = ready;
+ this.guilds = [];
+ this.guildids = new Map();
+ this.user = new User(ready.d.user, this);
+ this.user.setstatus("online");
+ this.mfa_enabled = ready.d.user.mfa_enabled as boolean;
+ this.userinfo.username = this.user.username;
+ this.userinfo.pfpsrc = this.user.getpfpsrc();
+ this.status = this.ready.d.user_settings.status;
+ this.channelfocus = undefined;
+ this.lookingguild = undefined;
+ this.guildhtml = new Map();
+ const members: { [key: string]: memberjson } = {};
+ for (const thing of ready.d.merged_members) {
+ members[thing[0].guild_id] = thing[0];
+ }
+
+ for (const thing of ready.d.guilds) {
+ const temp = new Guild(thing, this, members[thing.id]);
+ this.guilds.push(temp);
+ this.guildids.set(temp.id, temp);
+ }
+ {
+ const temp = new Direct(ready.d.private_channels, this);
+ this.guilds.push(temp);
+ this.guildids.set(temp.id, temp);
+ }
+ console.log(ready.d.user_guild_settings.entries);
+
+ for (const thing of ready.d.user_guild_settings.entries) {
+ (this.guildids.get(thing.guild_id) as Guild).notisetting(thing);
+ }
+
+ for (const thing of ready.d.read_state.entries) {
+ const channel = this.channelids.get(thing.channel_id);
+ if (!channel) {
+ continue;
+ }
+ channel.readStateInfo(thing);
+ }
+ for (const thing of ready.d.relationships) {
+ const user = new User(thing.user, this);
+ user.nickname = thing.nickname;
+ user.relationshipType = thing.type;
+ }
+
+ this.pingEndpoint();
+ this.userinfo.updateLocal();
+ }
+ outoffocus(): void {
+ const servers = document.getElementById("servers") as HTMLDivElement;
+ servers.innerHTML = "";
+ const channels = document.getElementById("channels") as HTMLDivElement;
+ channels.innerHTML = "";
+ if (this.channelfocus) {
+ this.channelfocus.infinite.delete();
+ }
+ this.lookingguild = undefined;
+ this.channelfocus = undefined;
+ }
+ unload(): void {
+ this.initialized = false;
+ this.outoffocus();
+ this.guilds = [];
+ this.guildids = new Map();
+ if (this.ws) {
+ this.ws.close(4001);
+ }
+ }
+ swapped = false;
+ async initwebsocket(): Promise {
+ let returny: () => void;
+ const ws = new WebSocket(
+ this.serverurls.gateway.toString() +
+ "?encoding=json&v=9" +
+ (DecompressionStream ? "&compress=zlib-stream" : "")
+ );
+ this.ws = ws;
+ let ds: DecompressionStream;
+ let w: WritableStreamDefaultWriter;
+ let r: ReadableStreamDefaultReader;
+ let arr: Uint8Array;
+ let build = "";
+ if (DecompressionStream) {
+ ds = new DecompressionStream("deflate");
+ w = ds.writable.getWriter();
+ r = ds.readable.getReader();
+ arr = new Uint8Array();
+ }
+ const promise = new Promise((res) => {
+ returny = res;
+ ws.addEventListener("open", (_event) => {
+ console.log("WebSocket connected");
+ ws.send(
+ JSON.stringify({
+ op: 2,
+ d: {
+ token: this.token,
+ capabilities: 16381,
+ properties: {
+ browser: "Jank Client",
+ client_build_number: 0, //might update this eventually lol
+ release_channel: "Custom",
+ browser_user_agent: navigator.userAgent,
+ },
+ compress: Boolean(DecompressionStream),
+ presence: {
+ status: "online",
+ since: null, //new Date().getTime()
+ activities: [],
+ afk: false,
+ },
+ },
+ })
+ );
+ });
+ const textdecode = new TextDecoder();
+ if (DecompressionStream) {
+ (async () => {
+ while (true) {
+ const read = await r.read();
+ const data = textdecode.decode(read.value);
+ build += data;
+ try {
+ const temp = JSON.parse(build);
+ build = "";
+ if (temp.op === 0 && temp.t === "READY") {
+ returny();
+ }
+ await this.handleEvent(temp);
+ } catch {}
+ }
+ })();
+ }
+ });
+
+ let order = new Promise((res) => res());
+
+ ws.addEventListener("message", async (event) => {
+ const temp2 = order;
+ order = new Promise(async (res) => {
+ await temp2;
+ let temp: { op: number; t: string };
+ try {
+ if (event.data instanceof Blob) {
+ const buff = await event.data.arrayBuffer();
+ const array = new Uint8Array(buff);
+
+ const temparr = new Uint8Array(array.length + arr.length);
+ temparr.set(arr, 0);
+ temparr.set(array, arr.length);
+ arr = temparr;
+
+ const len = array.length;
+ if (
+ !(
+ array[len - 1] === 255 &&
+ array[len - 2] === 255 &&
+ array[len - 3] === 0 &&
+ array[len - 4] === 0
+ )
+ ) {
+ return;
+ }
+ w.write(arr.buffer);
+ arr = new Uint8Array();
+ return; //had to move the while loop due to me being dumb
+ } else {
+ temp = JSON.parse(event.data);
+ }
+ if (temp.op === 0 && temp.t === "READY") {
+ returny();
+ }
+ await this.handleEvent(temp as readyjson);
+ } catch (e) {
+ console.error(e);
+ } finally {
+ res();
+ }
+ });
+ });
+
+ ws.addEventListener("close", async (event) => {
+ this.ws = undefined;
+ console.log("WebSocket closed with code " + event.code);
+
+ this.unload();
+ (document.getElementById("loading") as HTMLElement).classList.remove(
+ "doneloading"
+ );
+ (document.getElementById("loading") as HTMLElement).classList.add(
+ "loading"
+ );
+ this.fetchingmembers = new Map();
+ this.noncemap = new Map();
+ this.noncebuild = new Map();
+ if (
+ (event.code > 1000 && event.code < 1016) ||
+ wsCodesRetry.has(event.code)
+ ) {
+ if (
+ this.connectionSucceed !== 0 &&
+ Date.now() > this.connectionSucceed + 20000
+ )
+ this.errorBackoff = 0;
+ else this.errorBackoff++;
+ this.connectionSucceed = 0;
+
+ (document.getElementById("load-desc") as HTMLElement).innerHTML =
+ "Unable to connect to the Spacebar server, retrying in " +
+ Math.round(0.2 + this.errorBackoff * 2.8) +
+ " seconds...";
+ switch (
+ this.errorBackoff //try to recover from bad domain
+ ) {
+ case 3:
+ const newurls = await getapiurls(this.info.wellknown);
+ if (newurls) {
+ this.info = newurls;
+ this.serverurls = newurls;
+ this.userinfo.json.serverurls = this.info;
+ this.userinfo.updateLocal();
+ break;
+ }
+ break;
+
+ case 4: {
+ const newurls = await getapiurls(
+ new URL(this.info.wellknown).origin
+ );
+ if (newurls) {
+ this.info = newurls;
+ this.serverurls = newurls;
+ this.userinfo.json.serverurls = this.info;
+ this.userinfo.updateLocal();
+ break;
+ }
+ break;
+ }
+ case 5: {
+ const breakappart = new URL(this.info.wellknown).origin.split(".");
+ const url =
+ "https://" + breakappart.at(-2) + "." + breakappart.at(-1);
+ const newurls = await getapiurls(url);
+ if (newurls) {
+ this.info = newurls;
+ this.serverurls = newurls;
+ this.userinfo.json.serverurls = this.info;
+ this.userinfo.updateLocal();
+ }
+ break;
+ }
+ }
+ setTimeout(() => {
+ if (this.swapped) return;
+ (document.getElementById("load-desc") as HTMLElement).textContent =
+ "Retrying...";
+ this.initwebsocket().then(() => {
+ this.loaduser();
+ this.init();
+ const loading = document.getElementById("loading") as HTMLElement;
+ loading.classList.add("doneloading");
+ loading.classList.remove("loading");
+ console.log("done loading");
+ });
+ }, 200 + this.errorBackoff * 2800);
+ } else
+ (document.getElementById("load-desc") as HTMLElement).textContent =
+ "Unable to connect to the Spacebar server. Please try logging out and back in.";
+ });
+
+ await promise;
+ }
+ async handleEvent(temp: wsjson) {
+ console.debug(temp);
+ if (temp.s) this.lastSequence = temp.s;
+ if (temp.op == 0) {
+ switch (temp.t) {
+ case "MESSAGE_CREATE":
+ if (this.initialized) {
+ this.messageCreate(temp);
+ }
+ break;
+ case "MESSAGE_DELETE": {
+ temp.d.guild_id ??= "@me";
+ const channel = this.channelids.get(temp.d.channel_id);
+ if (!channel) break;
+ const message = channel.messages.get(temp.d.id);
+ if (!message) break;
+ message.deleteEvent();
+ break;
+ }
+ case "READY":
+ this.gottenReady(temp as readyjson);
+ break;
+ case "MESSAGE_UPDATE": {
+ temp.d.guild_id ??= "@me";
+ const channel = this.channelids.get(temp.d.channel_id);
+ if (!channel) break;
+ const message = channel.messages.get(temp.d.id);
+ if (!message) break;
+ message.giveData(temp.d);
+ break;
+ }
+ case "TYPING_START":
+ if (this.initialized) {
+ this.typingStart(temp);
+ }
+ break;
+ case "USER_UPDATE":
+ if (this.initialized) {
+ const users = this.userMap.get(temp.d.id);
+ if (users) {
+ users.userupdate(temp.d);
+ }
+ }
+ break;
+ case "CHANNEL_UPDATE":
+ if (this.initialized) {
+ this.updateChannel(temp.d);
+ }
+ break;
+ case "CHANNEL_CREATE":
+ if (this.initialized) {
+ this.createChannel(temp.d);
+ }
+ break;
+ case "CHANNEL_DELETE":
+ if (this.initialized) {
+ this.delChannel(temp.d);
+ }
+ break;
+ case "GUILD_DELETE": {
+ const guildy = this.guildids.get(temp.d.id);
+ if (guildy) {
+ this.guildids.delete(temp.d.id);
+ this.guilds.splice(this.guilds.indexOf(guildy), 1);
+ guildy.html.remove();
+ }
+ break;
+ }
+ case "GUILD_CREATE": {
+ const guildy = new Guild(temp.d, this, this.user);
+ this.guilds.push(guildy);
+ this.guildids.set(guildy.id, guildy);
+ (document.getElementById("servers") as HTMLDivElement).insertBefore(
+ guildy.generateGuildIcon(),
+ document.getElementById("bottomseparator")
+ );
+ break;
+ }
+ case "MESSAGE_REACTION_ADD":
+ {
+ temp.d.guild_id ??= "@me";
+ const guild = this.guildids.get(temp.d.guild_id);
+ if (!guild) break;
+ const channel = this.channelids.get(temp.d.channel_id);
+ if (!channel) break;
+ const message = channel.messages.get(temp.d.message_id);
+ if (!message) break;
+ let thing: Member | { id: string };
+ if (temp.d.member) {
+ thing = (await Member.new(temp.d.member, guild)) as Member;
+ } else {
+ thing = { id: temp.d.user_id };
+ }
+ message.reactionAdd(temp.d.emoji, thing);
+ }
+ break;
+ case "MESSAGE_REACTION_REMOVE":
+ {
+ temp.d.guild_id ??= "@me";
+ const channel = this.channelids.get(temp.d.channel_id);
+ if (!channel) break;
+ const message = channel.messages.get(temp.d.message_id);
+ if (!message) break;
+ message.reactionRemove(temp.d.emoji, temp.d.user_id);
+ }
+ break;
+ case "MESSAGE_REACTION_REMOVE_ALL":
+ {
+ temp.d.guild_id ??= "@me";
+ const channel = this.channelids.get(temp.d.channel_id);
+ if (!channel) break;
+ const message = channel.messages.get(temp.d.message_id);
+ if (!message) break;
+ message.reactionRemoveAll();
+ }
+ break;
+ case "MESSAGE_REACTION_REMOVE_EMOJI":
+ {
+ temp.d.guild_id ??= "@me";
+ const channel = this.channelids.get(temp.d.channel_id);
+ if (!channel) break;
+ const message = channel.messages.get(temp.d.message_id);
+ if (!message) break;
+ message.reactionRemoveEmoji(temp.d.emoji);
+ }
+ break;
+ case "GUILD_MEMBERS_CHUNK":
+ this.gotChunk(temp.d);
+ break;
+ }
+ } else if (temp.op === 10) {
+ if (!this.ws) return;
+ console.log("heartbeat down");
+ this.heartbeat_interval = temp.d.heartbeat_interval;
+ this.ws.send(JSON.stringify({ op: 1, d: this.lastSequence }));
+ } else if (temp.op === 11) {
+ setTimeout((_: any) => {
+ if (!this.ws) return;
+ if (this.connectionSucceed === 0) this.connectionSucceed = Date.now();
+ this.ws.send(JSON.stringify({ op: 1, d: this.lastSequence }));
+ }, this.heartbeat_interval);
+ }
+ }
+ heartbeat_interval: number = 0;
+ updateChannel(json: channeljson): void {
+ const guild = this.guildids.get(json.guild_id);
+ if (guild) {
+ guild.updateChannel(json);
+ if (json.guild_id === this.lookingguild?.id) {
+ this.loadGuild(json.guild_id);
+ }
+ }
+ }
+ createChannel(json: channeljson): undefined | Channel {
+ json.guild_id ??= "@me";
+ const guild = this.guildids.get(json.guild_id);
+ if (!guild) return;
+ const channel = guild.createChannelpac(json);
+ if (json.guild_id === this.lookingguild?.id) {
+ this.loadGuild(json.guild_id);
+ }
+ if (channel.id === this.gotoid) {
+ guild.loadGuild();
+ guild.loadChannel(channel.id);
+ this.gotoid = undefined;
+ }
+ return channel; // Add this line to return the 'channel' variable
+ }
+ gotoid: string | undefined;
+ async goToChannel(id: string) {
+ const channel = this.channelids.get(id);
+ if (channel) {
+ const guild = channel.guild;
+ guild.loadGuild();
+ guild.loadChannel(id);
+ } else {
+ this.gotoid = id;
+ }
+ }
+ delChannel(json: channeljson): void {
+ let guild_id = json.guild_id;
+ guild_id ??= "@me";
+ const guild = this.guildids.get(guild_id);
+ if (guild) {
+ guild.delChannel(json);
+ }
+
+ if (json.guild_id === this.lookingguild?.id) {
+ this.loadGuild(json.guild_id);
+ }
+ }
+ init(): void {
+ const location = window.location.href.split("/");
+ this.buildservers();
+ if (location[3] === "channels") {
+ const guild = this.loadGuild(location[4]);
+ if (!guild) {
+ return;
+ }
+ guild.loadChannel(location[5]);
+ this.channelfocus = this.channelids.get(location[5]);
+ }
+ }
+ loaduser(): void {
+ (document.getElementById("username") as HTMLSpanElement).textContent =
+ this.user.username;
+ (document.getElementById("userpfp") as HTMLImageElement).src =
+ this.user.getpfpsrc();
+ (document.getElementById("status") as HTMLSpanElement).textContent =
+ this.status;
+ }
+ isAdmin(): boolean {
+ if (this.lookingguild) {
+ return this.lookingguild.isAdmin();
+ } else {
+ return false;
+ }
+ }
+ loadGuild(id: string): Guild | undefined {
+ let guild = this.guildids.get(id);
+ if (!guild) {
+ guild = this.guildids.get("@me");
+ }
+ if (this.lookingguild === guild) {
+ return guild;
+ }
+ if (this.channelfocus) {
+ this.channelfocus.infinite.delete();
+ this.channelfocus = undefined;
+ }
+ if (this.lookingguild) {
+ this.lookingguild.html.classList.remove("serveropen");
+ }
+
+ if (!guild) return;
+ if (guild.html) {
+ guild.html.classList.add("serveropen");
+ }
+ this.lookingguild = guild;
+ (document.getElementById("serverName") as HTMLElement).textContent =
+ guild.properties.name;
+ //console.log(this.guildids,id)
+ const channels = document.getElementById("channels") as HTMLDivElement;
+ channels.innerHTML = "";
+ const html = guild.getHTML();
+ channels.appendChild(html);
+ return guild;
+ }
+ buildservers(): void {
+ const serverlist = document.getElementById("servers") as HTMLDivElement; //
+ const outdiv = document.createElement("div");
+ const home: any = document.createElement("span");
+ const div = document.createElement("div");
+ div.classList.add("home", "servericon");
+
+ home.classList.add("svgtheme", "svgicon", "svg-home");
+ home["all"] = this.guildids.get("@me");
+ (this.guildids.get("@me") as Guild).html = outdiv;
+ const unread = document.createElement("div");
+ unread.classList.add("unread");
+ outdiv.append(unread);
+ outdiv.append(div);
+ div.appendChild(home);
+
+ outdiv.classList.add("servernoti");
+ serverlist.append(outdiv);
+ home.onclick = function () {
+ this["all"].loadGuild();
+ this["all"].loadChannel();
+ };
+ const sentdms = document.createElement("div");
+ sentdms.classList.add("sentdms");
+ serverlist.append(sentdms);
+ sentdms.id = "sentdms";
+
+ const br = document.createElement("hr");
+ br.classList.add("lightbr");
+ serverlist.appendChild(br);
+ for (const thing of this.guilds) {
+ if (thing instanceof Direct) {
+ (thing as Direct).unreaddms();
+ continue;
+ }
+ const divy = thing.generateGuildIcon();
+ serverlist.append(divy);
+ }
+ {
+ const br = document.createElement("hr");
+ br.classList.add("lightbr");
+ serverlist.appendChild(br);
+ br.id = "bottomseparator";
+
+ const div = document.createElement("div");
+ div.textContent = "+";
+ div.classList.add("home", "servericon");
+ serverlist.appendChild(div);
+ div.onclick = (_) => {
+ this.createGuild();
+ };
+ const guilddsdiv = document.createElement("div");
+ const guildDiscoveryContainer = document.createElement("span");
+ guildDiscoveryContainer.classList.add(
+ "svgtheme",
+ "svgicon",
+ "svg-explore"
+ );
+ guilddsdiv.classList.add("home", "servericon");
+ guilddsdiv.appendChild(guildDiscoveryContainer);
+ serverlist.appendChild(guilddsdiv);
+ guildDiscoveryContainer.addEventListener("click", () => {
+ this.guildDiscovery();
+ });
+ }
+ this.unreads();
+ }
+ createGuild() {
+ let inviteurl = "";
+ const error = document.createElement("span");
+ const fields: { name: string; icon: string | null } = {
+ name: "",
+ icon: null,
+ };
+ const full = new Dialog([
+ "tabs",
+ [
+ [
+ "Join using invite",
+ [
+ "vdiv",
+ [
+ "textbox",
+ "Invite Link/Code",
+ "",
+ function (this: HTMLInputElement) {
+ inviteurl = this.value;
+ },
+ ],
+ ["html", error],
+ [
+ "button",
+ "",
+ "Submit",
+ (_: any) => {
+ let parsed = "";
+ if (inviteurl.includes("/")) {
+ parsed =
+ inviteurl.split("/")[inviteurl.split("/").length - 1];
+ } else {
+ parsed = inviteurl;
+ }
+ fetch(this.info.api + "/invites/" + parsed, {
+ method: "POST",
+ headers: this.headers,
+ })
+ .then((r) => r.json())
+ .then((_) => {
+ if (_.message) {
+ error.textContent = _.message;
+ }
+ });
+ },
+ ],
+ ],
+ ],
+ [
+ "Create Guild",
+ [
+ "vdiv",
+ ["title", "Create a guild"],
+ [
+ "fileupload",
+ "Icon:",
+ function (event: Event) {
+ const target = event.target as HTMLInputElement;
+ if (!target.files) return;
+ const reader = new FileReader();
+ reader.readAsDataURL(target.files[0]);
+ reader.onload = () => {
+ fields.icon = reader.result as string;
+ };
+ },
+ ],
+ [
+ "textbox",
+ "Name:",
+ "",
+ function (this: HTMLInputElement, event: Event) {
+ const target = event.target as HTMLInputElement;
+ fields.name = target.value;
+ },
+ ],
+ [
+ "button",
+ "",
+ "submit",
+ () => {
+ this.makeGuild(fields).then((_) => {
+ if (_.message) {
+ alert(_.errors.name._errors[0].message);
+ } else {
+ full.hide();
+ }
+ });
+ },
+ ],
+ ],
+ ],
+ ],
+ ]);
+ full.show();
+ }
+ async makeGuild(fields: { name: string; icon: string | null }) {
+ return await (
+ await fetch(this.info.api + "/guilds", {
+ method: "POST",
+ headers: this.headers,
+ body: JSON.stringify(fields),
+ })
+ ).json();
+ }
+ async guildDiscovery() {
+ const content = document.createElement("div");
+ content.classList.add("guildy");
+ content.textContent = "Loading...";
+ const full = new Dialog(["html", content]);
+ full.show();
+
+ const res = await fetch(this.info.api + "/discoverable-guilds?limit=50", {
+ headers: this.headers,
+ });
+ const json = await res.json();
+
+ content.innerHTML = "";
+ const title = document.createElement("h2");
+ title.textContent = "Guild discovery (" + json.total + " entries)";
+ content.appendChild(title);
+
+ const guilds = document.createElement("div");
+ guilds.id = "discovery-guild-content";
+
+ json.guilds.forEach((guild: guildjson["properties"]) => {
+ const content = document.createElement("div");
+ content.classList.add("discovery-guild");
+
+ if (guild.banner) {
+ const banner = document.createElement("img");
+ banner.classList.add("banner");
+ banner.crossOrigin = "anonymous";
+ banner.src =
+ this.info.cdn +
+ "/icons/" +
+ guild.id +
+ "/" +
+ guild.banner +
+ ".png?size=256";
+ banner.alt = "";
+ content.appendChild(banner);
+ }
+
+ const nameContainer = document.createElement("div");
+ nameContainer.classList.add("flex");
+ const img = document.createElement("img");
+ img.classList.add("icon");
+ img.crossOrigin = "anonymous";
+ img.src =
+ this.info.cdn +
+ (guild.icon
+ ? "/icons/" + guild.id + "/" + guild.icon + ".png?size=48"
+ : "/embed/avatars/3.png");
+ img.alt = "";
+ nameContainer.appendChild(img);
+
+ const name = document.createElement("h3");
+ name.textContent = guild.name;
+ nameContainer.appendChild(name);
+ content.appendChild(nameContainer);
+ const desc = document.createElement("p");
+ desc.textContent = guild.description;
+ content.appendChild(desc);
+
+ content.addEventListener("click", async () => {
+ const joinRes = await fetch(
+ this.info.api + "/guilds/" + guild.id + "/members/@me",
+ {
+ method: "PUT",
+ headers: this.headers,
+ }
+ );
+ if (joinRes.ok) full.hide();
+ });
+ guilds.appendChild(content);
+ });
+ content.appendChild(guilds);
+ }
+ messageCreate(messagep: messageCreateJson): void {
+ messagep.d.guild_id ??= "@me";
+ const channel = this.channelids.get(messagep.d.channel_id);
+ if (channel) {
+ channel.messageCreate(messagep);
+ this.unreads();
+ }
+ }
+ unreads(): void {
+ for (const thing of this.guilds) {
+ if (thing.id === "@me") {
+ continue;
+ }
+ const html = this.guildhtml.get(thing.id);
+ thing.unreads(html);
+ }
+ }
+ async typingStart(typing: startTypingjson): Promise {
+ const channel = this.channelids.get(typing.d.channel_id);
+ if (!channel) return;
+ channel.typingStart(typing);
+ }
+ updatepfp(file: Blob): void {
+ const reader = new FileReader();
+ reader.readAsDataURL(file);
+ reader.onload = () => {
+ fetch(this.info.api + "/users/@me", {
+ method: "PATCH",
+ headers: this.headers,
+ body: JSON.stringify({
+ avatar: reader.result,
+ }),
+ });
+ };
+ }
+ updatebanner(file: Blob | null): void {
+ if (file) {
+ const reader = new FileReader();
+ reader.readAsDataURL(file);
+ reader.onload = () => {
+ fetch(this.info.api + "/users/@me", {
+ method: "PATCH",
+ headers: this.headers,
+ body: JSON.stringify({
+ banner: reader.result,
+ }),
+ });
+ };
+ } else {
+ fetch(this.info.api + "/users/@me", {
+ method: "PATCH",
+ headers: this.headers,
+ body: JSON.stringify({
+ banner: null,
+ }),
+ });
+ }
+ }
+ updateProfile(json: {
+ bio?: string;
+ pronouns?: string;
+ accent_color?: number;
+ }) {
+ fetch(this.info.api + "/users/@me/profile", {
+ method: "PATCH",
+ headers: this.headers,
+ body: JSON.stringify(json),
+ });
+ }
+ async showusersettings() {
+ const settings = new Settings("Settings");
+ {
+ const userOptions = settings.addButton("User Settings", { ltr: true });
+ const hypotheticalProfile = document.createElement("div");
+ let file: undefined | File | null;
+ let newpronouns: string | undefined;
+ let newbio: string | undefined;
+ const hypouser = this.user.clone();
+ let color: string;
+ async function regen() {
+ hypotheticalProfile.textContent = "";
+ const hypoprofile = await hypouser.buildprofile(-1, -1);
+
+ hypotheticalProfile.appendChild(hypoprofile);
+ }
+ regen();
+ const settingsLeft = userOptions.addOptions("");
+ const settingsRight = userOptions.addOptions("");
+ settingsRight.addHTMLArea(hypotheticalProfile);
+
+ const finput = settingsLeft.addFileInput(
+ "Upload pfp:",
+ (_) => {
+ if (file) {
+ this.updatepfp(file);
+ }
+ },
+ { clear: true }
+ );
+ finput.watchForChange((_) => {
+ if (!_) {
+ file = null;
+ hypouser.avatar = null;
+ hypouser.hypotheticalpfp = true;
+ regen();
+ return;
+ }
+ if (_.length) {
+ file = _[0];
+ const blob = URL.createObjectURL(file);
+ hypouser.avatar = blob;
+ hypouser.hypotheticalpfp = true;
+ regen();
+ }
+ });
+ let bfile: undefined | File | null;
+ const binput = settingsLeft.addFileInput(
+ "Upload banner:",
+ (_) => {
+ if (bfile !== undefined) {
+ this.updatebanner(bfile);
+ }
+ },
+ { clear: true }
+ );
+ binput.watchForChange((_) => {
+ if (!_) {
+ bfile = null;
+ hypouser.banner = undefined;
+ hypouser.hypotheticalbanner = true;
+ regen();
+ return;
+ }
+ if (_.length) {
+ bfile = _[0];
+ const blob = URL.createObjectURL(bfile);
+ hypouser.banner = blob;
+ hypouser.hypotheticalbanner = true;
+ regen();
+ }
+ });
+ let changed = false;
+ const pronounbox = settingsLeft.addTextInput(
+ "Pronouns",
+ (_) => {
+ if (newpronouns || newbio || changed) {
+ this.updateProfile({
+ pronouns: newpronouns,
+ bio: newbio,
+ accent_color: Number.parseInt("0x" + color.substr(1), 16),
+ });
+ }
+ },
+ { initText: this.user.pronouns }
+ );
+ pronounbox.watchForChange((_) => {
+ hypouser.pronouns = _;
+ newpronouns = _;
+ regen();
+ });
+ const bioBox = settingsLeft.addMDInput("Bio:", (_) => {}, {
+ initText: this.user.bio.rawString,
+ });
+ bioBox.watchForChange((_) => {
+ newbio = _;
+ hypouser.bio = new MarkDown(_, this);
+ regen();
+ });
+
+ if (this.user.accent_color) {
+ color = "#" + this.user.accent_color.toString(16);
+ } else {
+ color = "transparent";
+ }
+ const colorPicker = settingsLeft.addColorInput(
+ "Profile color",
+ (_) => {},
+ { initColor: color }
+ );
+ colorPicker.watchForChange((_) => {
+ console.log();
+ color = _;
+ hypouser.accent_color = Number.parseInt("0x" + _.substr(1), 16);
+ changed = true;
+ regen();
+ });
+ }
+ {
+ const tas = settings.addButton("Themes & sounds");
+ {
+ const themes = ["Dark", "WHITE", "Light"];
+ tas.addSelect(
+ "Theme:",
+ (_) => {
+ localStorage.setItem("theme", themes[_]);
+ setTheme();
+ },
+ themes,
+ {
+ defaultIndex: themes.indexOf(
+ localStorage.getItem("theme") as string
+ ),
+ }
+ );
+ }
+ {
+ const sounds = Voice.sounds;
+ tas
+ .addSelect(
+ "Notification sound:",
+ (_) => {
+ Voice.setNotificationSound(sounds[_]);
+ },
+ sounds,
+ { defaultIndex: sounds.indexOf(Voice.getNotificationSound()) }
+ )
+ .watchForChange((_) => {
+ Voice.noises(sounds[_]);
+ });
+ }
+
+ {
+ const userinfos = getBulkInfo();
+ tas.addColorInput(
+ "Accent color:",
+ (_) => {
+ userinfos.accent_color = _;
+ localStorage.setItem("userinfos", JSON.stringify(userinfos));
+ document.documentElement.style.setProperty(
+ "--accent-color",
+ userinfos.accent_color
+ );
+ },
+ { initColor: userinfos.accent_color }
+ );
+ }
+ }
+ {
+ const security = settings.addButton("Account Settings");
+ const genSecurity = () => {
+ security.removeAll();
+ if (this.mfa_enabled) {
+ security.addButtonInput("", "Disable 2FA", () => {
+ const form = security.addSubForm(
+ "2FA Disable",
+ (_: any) => {
+ if (_.message) {
+ switch (_.code) {
+ case 60008:
+ form.error("code", "Invalid code");
+ break;
+ }
+ } else {
+ this.mfa_enabled = false;
+ security.returnFromSub();
+ genSecurity();
+ }
+ },
+ {
+ fetchURL: this.info.api + "/users/@me/mfa/totp/disable",
+ headers: this.headers,
+ }
+ );
+ form.addTextInput("Code:", "code", { required: true });
+ });
+ } else {
+ security.addButtonInput("", "Enable 2FA", async () => {
+ let secret = "";
+ for (let i = 0; i < 18; i++) {
+ secret += "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"[
+ Math.floor(Math.random() * 32)
+ ];
+ }
+ const form = security.addSubForm(
+ "2FA Setup",
+ (_: any) => {
+ if (_.message) {
+ switch (_.code) {
+ case 60008:
+ form.error("code", "Invalid code");
+ break;
+ case 400:
+ form.error("password", "Incorrect password");
+ break;
+ }
+ } else {
+ genSecurity();
+ this.mfa_enabled = true;
+ security.returnFromSub();
+ }
+ },
+ {
+ fetchURL: this.info.api + "/users/@me/mfa/totp/enable/",
+ headers: this.headers,
+ }
+ );
+ form.addTitle(
+ "Copy this secret into your totp(time-based one time password) app"
+ );
+ form.addText(
+ `Your secret is: ${secret} and it's 6 digits, with a 30 second token period`
+ );
+ form.addTextInput("Account Password:", "password", {
+ required: true,
+ password: true,
+ });
+ form.addTextInput("Code:", "code", { required: true });
+ form.setValue("secret", secret);
+ });
+ }
+ security.addButtonInput("", "Change discriminator", () => {
+ const form = security.addSubForm(
+ "Change Discriminator",
+ (_) => {
+ security.returnFromSub();
+ },
+ {
+ fetchURL: this.info.api + "/users/@me/",
+ headers: this.headers,
+ method: "PATCH",
+ }
+ );
+ form.addTextInput("New discriminator:", "discriminator");
+ });
+ security.addButtonInput("", "Change email", () => {
+ const form = security.addSubForm(
+ "Change Email",
+ (_) => {
+ security.returnFromSub();
+ },
+ {
+ fetchURL: this.info.api + "/users/@me/",
+ headers: this.headers,
+ method: "PATCH",
+ }
+ );
+ form.addTextInput("Password:", "password", { password: true });
+ if (this.mfa_enabled) {
+ form.addTextInput("Code:", "code");
+ }
+ form.addTextInput("New email:", "email");
+ });
+ security.addButtonInput("", "Change username", () => {
+ const form = security.addSubForm(
+ "Change Username",
+ (_) => {
+ security.returnFromSub();
+ },
+ {
+ fetchURL: this.info.api + "/users/@me/",
+ headers: this.headers,
+ method: "PATCH",
+ }
+ );
+ form.addTextInput("Password:", "password", { password: true });
+ if (this.mfa_enabled) {
+ form.addTextInput("Code:", "code");
+ }
+ form.addTextInput("New username:", "username");
+ });
+ security.addButtonInput("", "Change password", () => {
+ const form = security.addSubForm(
+ "Change Password",
+ (_) => {
+ security.returnFromSub();
+ },
+ {
+ fetchURL: this.info.api + "/users/@me/",
+ headers: this.headers,
+ method: "PATCH",
+ }
+ );
+ form.addTextInput("Old password:", "password", { password: true });
+ if (this.mfa_enabled) {
+ form.addTextInput("Code:", "code");
+ }
+ let in1 = "";
+ let in2 = "";
+ form.addTextInput("New password:", "").watchForChange((text) => {
+ in1 = text;
+ });
+ const copy = form.addTextInput("New password again:", "");
+ copy.watchForChange((text) => {
+ in2 = text;
+ });
+ form.setValue("new_password", () => {
+ if (in1 === in2) {
+ return in1;
+ } else {
+ throw new FormError(copy, "Passwords don't match");
+ }
+ });
+ });
+ };
+ genSecurity();
+ }
+ {
+ const connections = settings.addButton("Connections");
+ const connectionContainer = document.createElement("div");
+ connectionContainer.id = "connection-container";
+
+ fetch(this.info.api + "/connections", {
+ headers: this.headers,
+ })
+ .then((r) => r.json())
+ .then((json) => {
+ Object.keys(json)
+ .sort((key) => (json[key].enabled ? -1 : 1))
+ .forEach((key) => {
+ const connection = json[key];
+
+ const container = document.createElement("div");
+ container.textContent =
+ key.charAt(0).toUpperCase() + key.slice(1);
+
+ if (connection.enabled) {
+ container.addEventListener("click", async () => {
+ const connectionRes = await fetch(
+ this.info.api + "/connections/" + key + "/authorize",
+ {
+ headers: this.headers,
+ }
+ );
+ const connectionJSON = await connectionRes.json();
+ window.open(
+ connectionJSON.url,
+ "_blank",
+ "noopener noreferrer"
+ );
+ });
+ } else {
+ container.classList.add("disabled");
+ container.title =
+ "This connection has been disabled server-side.";
+ }
+
+ connectionContainer.appendChild(container);
+ });
+ });
+ connections.addHTMLArea(connectionContainer);
+ }
+ {
+ const devPortal = settings.addButton("Developer Portal");
+
+ const teamsRes = await fetch(this.info.api + "/teams", {
+ headers: this.headers,
+ });
+ const teams = await teamsRes.json();
+
+ devPortal.addButtonInput("", "Create application", () => {
+ const form = devPortal.addSubForm(
+ "Create application",
+ (json: any) => {
+ if (json.message) form.error("name", json.message);
+ else {
+ devPortal.returnFromSub();
+ this.manageApplication(json.id);
+ }
+ },
+ {
+ fetchURL: this.info.api + "/applications",
+ headers: this.headers,
+ method: "POST",
+ }
+ );
+
+ form.addTextInput("Name", "name", { required: true });
+ form.addSelect(
+ "Team",
+ "team_id",
+ ["Personal", ...teams.map((team: { name: string }) => team.name)],
+ {
+ defaultIndex: 0,
+ }
+ );
+ });
+
+ const appListContainer = document.createElement("div");
+ appListContainer.id = "app-list-container";
+ fetch(this.info.api + "/applications", {
+ headers: this.headers,
+ })
+ .then((r) => r.json())
+ .then((json) => {
+ json.forEach(
+ (application: {
+ cover_image: any;
+ icon: any;
+ id: string | undefined;
+ name: string | number;
+ bot: any;
+ }) => {
+ const container = document.createElement("div");
+
+ if (application.cover_image || application.icon) {
+ const cover = document.createElement("img");
+ cover.crossOrigin = "anonymous";
+ cover.src =
+ this.info.cdn +
+ "/app-icons/" +
+ application.id +
+ "/" +
+ (application.cover_image || application.icon) +
+ ".png?size=256";
+ cover.alt = "";
+ cover.loading = "lazy";
+ container.appendChild(cover);
+ }
+
+ const name = document.createElement("h2");
+ name.textContent =
+ application.name + (application.bot ? " (Bot)" : "");
+ container.appendChild(name);
+
+ container.addEventListener("click", async () => {
+ this.manageApplication(application.id);
+ });
+ appListContainer.appendChild(container);
+ }
+ );
+ });
+ devPortal.addHTMLArea(appListContainer);
+ }
+ settings.show();
+ }
+ async manageApplication(appId = "") {
+ const res = await fetch(this.info.api + "/applications/" + appId, {
+ headers: this.headers,
+ });
+ const json = await res.json();
+
+ const fields: any = {};
+ const appDialog = new Dialog([
+ "vdiv",
+ ["title", "Editing " + json.name],
+ [
+ "vdiv",
+ [
+ "textbox",
+ "Application name:",
+ json.name,
+ (event: Event) => {
+ const target = event.target as HTMLInputElement;
+ fields.name = target.value;
+ },
+ ],
+ [
+ "mdbox",
+ "Description:",
+ json.description,
+ (event: Event) => {
+ const target = event.target as HTMLInputElement;
+ fields.description = target.value;
+ },
+ ],
+ [
+ "vdiv",
+ json.icon
+ ? [
+ "img",
+ this.info.cdn +
+ "/app-icons/" +
+ appId +
+ "/" +
+ json.icon +
+ ".png?size=128",
+ [128, 128],
+ ]
+ : ["text", "No icon"],
+ [
+ "fileupload",
+ "Application icon:",
+ (event) => {
+ const reader = new FileReader();
+ const files = (event.target as HTMLInputElement).files;
+ if (files) {
+ reader.readAsDataURL(files[0]);
+ reader.onload = () => {
+ fields.icon = reader.result;
+ };
+ }
+ },
+ ],
+ ],
+ ],
+ [
+ "hdiv",
+ [
+ "textbox",
+ "Privacy policy URL:",
+ json.privacy_policy_url || "",
+ (event: Event) => {
+ const target = event.target as HTMLInputElement;
+ fields.privacy_policy_url = target.value;
+ },
+ ],
+ [
+ "textbox",
+ "Terms of Service URL:",
+ json.terms_of_service_url || "",
+ (event: Event) => {
+ const target = event.target as HTMLInputElement;
+ fields.terms_of_service_url = target.value;
+ },
+ ],
+ ],
+ [
+ "hdiv",
+ [
+ "checkbox",
+ "Make bot publicly inviteable?",
+ json.bot_public,
+ (event: Event) => {
+ const target = event.target as HTMLInputElement;
+ fields.bot_public = target.checked;
+ },
+ ],
+ [
+ "checkbox",
+ "Require code grant to invite the bot?",
+ json.bot_require_code_grant,
+ (event: Event) => {
+ const target = event.target as HTMLInputElement;
+ fields.bot_require_code_grant = target.checked;
+ },
+ ],
+ ],
+ [
+ "hdiv",
+ [
+ "button",
+ "",
+ "Save changes",
+ async () => {
+ const updateRes = await fetch(
+ this.info.api + "/applications/" + appId,
+ {
+ method: "PATCH",
+ headers: this.headers,
+ body: JSON.stringify(fields),
+ }
+ );
+ if (updateRes.ok) appDialog.hide();
+ else {
+ const updateJSON = await updateRes.json();
+ alert("An error occurred: " + updateJSON.message);
+ }
+ },
+ ],
+ [
+ "button",
+ "",
+ (json.bot ? "Manage" : "Add") + " bot",
+ async () => {
+ if (!json.bot) {
+ if (
+ !confirm(
+ "Are you sure you want to add a bot to this application? There's no going back."
+ )
+ )
+ return;
+
+ const updateRes = await fetch(
+ this.info.api + "/applications/" + appId + "/bot",
+ {
+ method: "POST",
+ headers: this.headers,
+ }
+ );
+ const updateJSON = await updateRes.json();
+ alert("Bot token:\n" + updateJSON.token);
+ }
+
+ appDialog.hide();
+ this.manageBot(appId);
+ },
+ ],
+ ],
+ ]);
+ appDialog.show();
+ }
+ async manageBot(appId = "") {
+ const res = await fetch(this.info.api + "/applications/" + appId, {
+ headers: this.headers,
+ });
+ const json = await res.json();
+ if (!json.bot)
+ return alert(
+ "For some reason, this application doesn't have a bot (yet)."
+ );
+
+ const fields: any = {
+ username: json.bot.username,
+ avatar: json.bot.avatar
+ ? this.info.cdn +
+ "/app-icons/" +
+ appId +
+ "/" +
+ json.bot.avatar +
+ ".png?size=256"
+ : "",
+ };
+ const botDialog = new Dialog([
+ "vdiv",
+ ["title", "Editing bot: " + json.bot.username],
+ [
+ "hdiv",
+ [
+ "textbox",
+ "Bot username:",
+ json.bot.username,
+ (event: Event) => {
+ const target = event.target as HTMLInputElement;
+ fields.username = target.value;
+ },
+ ],
+ [
+ "vdiv",
+ fields.avatar
+ ? ["img", fields.avatar, [128, 128]]
+ : ["text", "No avatar"],
+ [
+ "fileupload",
+ "Bot avatar:",
+ (event) => {
+ const reader = new FileReader();
+ const files = (event.target as HTMLInputElement).files;
+ if (files) {
+ const file = files[0];
+ reader.readAsDataURL(file);
+ reader.onload = () => {
+ fields.avatar = reader.result;
+ };
+ }
+ },
+ ],
+ ],
+ ],
+ [
+ "hdiv",
+ [
+ "button",
+ "",
+ "Save changes",
+ async () => {
+ const updateRes = await fetch(
+ this.info.api + "/applications/" + appId + "/bot",
+ {
+ method: "PATCH",
+ headers: this.headers,
+ body: JSON.stringify(fields),
+ }
+ );
+ if (updateRes.ok) botDialog.hide();
+ else {
+ const updateJSON = await updateRes.json();
+ alert("An error occurred: " + updateJSON.message);
+ }
+ },
+ ],
+ [
+ "button",
+ "",
+ "Reset token",
+ async () => {
+ if (
+ !confirm(
+ "Are you sure you want to reset the bot token? Your bot will stop working until you update it."
+ )
+ )
+ return;
+
+ const updateRes = await fetch(
+ this.info.api + "/applications/" + appId + "/bot/reset",
+ {
+ method: "POST",
+ headers: this.headers,
+ }
+ );
+ const updateJSON = await updateRes.json();
+ alert("New token:\n" + updateJSON.token);
+ botDialog.hide();
+ },
+ ],
+ ],
+ ]);
+ botDialog.show();
+ }
+
+ //---------- resolving members code -----------
+ readonly waitingmembers: Map<
+ string,
+ Map void>
+ > = new Map();
+ readonly presences: Map = new Map();
+ async resolvemember(
+ id: string,
+ guildid: string
+ ): Promise {
+ if (guildid === "@me") {
+ return undefined;
+ }
+ const guild = this.guildids.get(guildid);
+ const borked = true;
+ if (borked && guild && guild.member_count > 250) {
+ //sorry puyo, I need to fix member resolving while it's broken on large guilds
+ try {
+ const req = await fetch(
+ this.info.api + "/guilds/" + guild.id + "/members/" + id,
+ {
+ headers: this.headers,
+ }
+ );
+ if (req.status !== 200) {
+ return undefined;
+ }
+ return await req.json();
+ } catch {
+ return undefined;
+ }
+ }
+ let guildmap = this.waitingmembers.get(guildid);
+ if (!guildmap) {
+ guildmap = new Map();
+ this.waitingmembers.set(guildid, guildmap);
+ }
+ const promise: Promise = new Promise((res) => {
+ guildmap.set(id, res);
+ this.getmembers();
+ });
+ return await promise;
+ }
+ fetchingmembers: Map = new Map();
+ noncemap: Map void> = new Map();
+ noncebuild: Map = new Map();
+ async gotChunk(chunk: {
+ chunk_index: number;
+ chunk_count: number;
+ nonce: string;
+ not_found?: string[];
+ members?: memberjson[];
+ presences: presencejson[];
+ }) {
+ for (const thing of chunk.presences) {
+ if (thing.user) {
+ this.presences.set(thing.user.id, thing);
+ }
+ }
+ chunk.members ??= [];
+ const arr = this.noncebuild.get(chunk.nonce);
+ if (!arr) return;
+ arr[0] = arr[0].concat(chunk.members);
+ if (chunk.not_found) {
+ arr[1] = chunk.not_found;
+ }
+ arr[2].push(chunk.chunk_index);
+ if (arr[2].length === chunk.chunk_count) {
+ this.noncebuild.delete(chunk.nonce);
+ const func = this.noncemap.get(chunk.nonce);
+ if (!func) return;
+ func([arr[0], arr[1]]);
+ this.noncemap.delete(chunk.nonce);
+ }
+ }
+ async getmembers() {
+ const promise = new Promise((res) => {
+ setTimeout(res, 10);
+ });
+ await promise; //allow for more to be sent at once :P
+ if (this.ws) {
+ this.waitingmembers.forEach(async (value, guildid) => {
+ const keys = value.keys();
+ if (this.fetchingmembers.has(guildid)) {
+ return;
+ }
+ const build: string[] = [];
+ for (const key of keys) {
+ build.push(key);
+ if (build.length === 100) {
+ break;
+ }
+ }
+ if (!build.length) {
+ this.waitingmembers.delete(guildid);
+ return;
+ }
+ const promise: Promise<[memberjson[], string[]]> = new Promise(
+ (res) => {
+ const nonce = "" + Math.floor(Math.random() * 100000000000);
+ this.noncemap.set(nonce, res);
+ this.noncebuild.set(nonce, [[], [], []]);
+ if (!this.ws) return;
+ this.ws.send(
+ JSON.stringify({
+ op: 8,
+ d: {
+ user_ids: build,
+ guild_id: guildid,
+ limit: 100,
+ nonce,
+ presences: true,
+ },
+ })
+ );
+ this.fetchingmembers.set(guildid, true);
+ }
+ );
+ const prom = await promise;
+ const data = prom[0];
+ for (const thing of data) {
+ if (value.has(thing.id)) {
+ const func = value.get(thing.id);
+ if (!func) {
+ value.delete(thing.id);
+ continue;
+ }
+ func(thing);
+ value.delete(thing.id);
+ }
+ }
+ for (const thing of prom[1]) {
+ if (value.has(thing)) {
+ const func = value.get(thing);
+ if (!func) {
+ value.delete(thing);
+ continue;
+ }
+ func(undefined);
+ value.delete(thing);
+ }
+ }
+ this.fetchingmembers.delete(guildid);
+ this.getmembers();
+ });
+ }
+ }
+ async pingEndpoint() {
+ const userInfo = getBulkInfo();
+ if (!userInfo.instances) userInfo.instances = {};
+ const wellknown = this.info.wellknown;
+ if (!userInfo.instances[wellknown]) {
+ const pingRes = await fetch(this.info.api + "/ping");
+ const pingJSON = await pingRes.json();
+ userInfo.instances[wellknown] = pingJSON;
+ localStorage.setItem("userinfos", JSON.stringify(userInfo));
+ }
+ this.instancePing = userInfo.instances[wellknown].instance;
+
+ this.pageTitle("Loading...");
+ }
+ pageTitle(channelName = "", guildName = "") {
+ (document.getElementById("channelname") as HTMLSpanElement).textContent =
+ channelName;
+ (
+ document.getElementsByTagName("title")[0] as HTMLTitleElement
+ ).textContent =
+ channelName +
+ (guildName ? " | " + guildName : "") +
+ " | " +
+ this.instancePing.name +
+ " | Jank Client";
+ }
+ async instanceStats() {
+ const res = await fetch(this.info.api + "/policies/stats", {
+ headers: this.headers,
+ });
+ const json = await res.json();
+
+ const dialog = new Dialog([
+ "vdiv",
+ ["title", "Instance stats: " + this.instancePing.name],
+ ["text", "Registered users: " + json.counts.user],
+ ["text", "Servers: " + json.counts.guild],
+ ["text", "Messages: " + json.counts.message],
+ ["text", "Members: " + json.counts.members],
+ ]);
+ dialog.show();
+ }
+}
+export { Localuser };
diff --git a/src/webpage/login.html b/src/webpage/login.html
new file mode 100644
index 0000000..16b2c3a
--- /dev/null
+++ b/src/webpage/login.html
@@ -0,0 +1,62 @@
+
+
+
+
+ Jank Client
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/webpage/login.ts b/src/webpage/login.ts
new file mode 100644
index 0000000..5e53c99
--- /dev/null
+++ b/src/webpage/login.ts
@@ -0,0 +1,625 @@
+import { Dialog } from "./dialog.js";
+
+const mobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
+
+function setTheme() {
+ let name = localStorage.getItem("theme");
+ if (!name) {
+ localStorage.setItem("theme", "Dark");
+ name = "Dark";
+ }
+ document.body.className = name + "-theme";
+}
+let instances:
+ | {
+ name: string;
+ description?: string;
+ descriptionLong?: string;
+ image?: string;
+ url?: string;
+ display?: boolean;
+ online?: boolean;
+ uptime: { alltime: number; daytime: number; weektime: number };
+ urls: {
+ wellknown: string;
+ api: string;
+ cdn: string;
+ gateway: string;
+ login?: string;
+ };
+ }[]
+ | null;
+
+setTheme();
+function getBulkUsers() {
+ const json = getBulkInfo();
+ for (const thing in json.users) {
+ json.users[thing] = new Specialuser(json.users[thing]);
+ }
+ return json;
+}
+function trimswitcher() {
+ const json = getBulkInfo();
+ const map = new Map();
+ for (const thing in json.users) {
+ const user = json.users[thing];
+ let wellknown = user.serverurls.wellknown;
+ if (wellknown.at(-1) !== "/") {
+ wellknown += "/";
+ }
+ wellknown += user.username;
+ if (map.has(wellknown)) {
+ const otheruser = map.get(wellknown);
+ if (otheruser[1].serverurls.wellknown.at(-1) === "/") {
+ delete json.users[otheruser[0]];
+ map.set(wellknown, [thing, user]);
+ } else {
+ delete json.users[thing];
+ }
+ } else {
+ map.set(wellknown, [thing, user]);
+ }
+ }
+ for (const thing in json.users) {
+ if (thing.at(-1) === "/") {
+ const user = json.users[thing];
+ delete json.users[thing];
+ json.users[thing.slice(0, -1)] = user;
+ }
+ }
+ localStorage.setItem("userinfos", JSON.stringify(json));
+ console.log(json);
+}
+
+function getBulkInfo() {
+ return JSON.parse(localStorage.getItem("userinfos")!);
+}
+function setDefaults() {
+ let userinfos = getBulkInfo();
+ if (!userinfos) {
+ localStorage.setItem(
+ "userinfos",
+ JSON.stringify({
+ currentuser: null,
+ users: {},
+ preferences: {
+ theme: "Dark",
+ notifications: false,
+ notisound: "three",
+ },
+ })
+ );
+ userinfos = getBulkInfo();
+ }
+ if (userinfos.users === undefined) {
+ userinfos.users = {};
+ }
+ if (userinfos.accent_color === undefined) {
+ userinfos.accent_color = "#242443";
+ }
+ document.documentElement.style.setProperty(
+ "--accent-color",
+ userinfos.accent_color
+ );
+ if (userinfos.preferences === undefined) {
+ userinfos.preferences = {
+ theme: "Dark",
+ notifications: false,
+ notisound: "three",
+ };
+ }
+ if (userinfos.preferences && userinfos.preferences.notisound === undefined) {
+ userinfos.preferences.notisound = "three";
+ }
+ localStorage.setItem("userinfos", JSON.stringify(userinfos));
+}
+setDefaults();
+class Specialuser {
+ serverurls: {
+ api: string;
+ cdn: string;
+ gateway: string;
+ wellknown: string;
+ login: string;
+ };
+ email: string;
+ token: string;
+ loggedin;
+ json;
+ constructor(json: any) {
+ if (json instanceof Specialuser) {
+ console.error("specialuser can't construct from another specialuser");
+ }
+ this.serverurls = json.serverurls;
+ let apistring = new URL(json.serverurls.api).toString();
+ apistring = apistring.replace(/\/(v\d+\/?)?$/, "") + "/v9";
+ this.serverurls.api = apistring;
+ this.serverurls.cdn = new URL(json.serverurls.cdn)
+ .toString()
+ .replace(/\/$/, "");
+ this.serverurls.gateway = new URL(json.serverurls.gateway)
+ .toString()
+ .replace(/\/$/, "");
+ this.serverurls.wellknown = new URL(json.serverurls.wellknown)
+ .toString()
+ .replace(/\/$/, "");
+ this.serverurls.login = new URL(json.serverurls.login)
+ .toString()
+ .replace(/\/$/, "");
+ this.email = json.email;
+ this.token = json.token;
+ this.loggedin = json.loggedin;
+ this.json = json;
+ this.json.localuserStore ??= {};
+ if (!this.serverurls || !this.email || !this.token) {
+ console.error(
+ "There are fundamentally missing pieces of info missing from this user"
+ );
+ }
+ }
+ set pfpsrc(e) {
+ this.json.pfpsrc = e;
+ this.updateLocal();
+ }
+ get pfpsrc() {
+ return this.json.pfpsrc;
+ }
+ set username(e) {
+ this.json.username = e;
+ this.updateLocal();
+ }
+ get username() {
+ return this.json.username;
+ }
+ set localuserStore(e) {
+ this.json.localuserStore = e;
+ this.updateLocal();
+ }
+ get localuserStore() {
+ return this.json.localuserStore;
+ }
+ get uid() {
+ return this.email + this.serverurls.wellknown;
+ }
+ toJSON() {
+ return this.json;
+ }
+ updateLocal() {
+ const info = getBulkInfo();
+ info.users[this.uid] = this.toJSON();
+ localStorage.setItem("userinfos", JSON.stringify(info));
+ }
+}
+function adduser(user: typeof Specialuser.prototype.json) {
+ user = new Specialuser(user);
+ const info = getBulkInfo();
+ info.users[user.uid] = user;
+ info.currentuser = user.uid;
+ localStorage.setItem("userinfos", JSON.stringify(info));
+ return user;
+}
+const instancein = document.getElementById("instancein") as HTMLInputElement;
+let timeout: string | number | NodeJS.Timeout | undefined;
+// let instanceinfo;
+const stringURLMap = new Map();
+
+const stringURLsMap = new Map<
+ string,
+ {
+ wellknown: string;
+ api: string;
+ cdn: string;
+ gateway: string;
+ login?: string;
+ }
+>();
+async function getapiurls(str: string): Promise<
+ | {
+ api: string;
+ cdn: string;
+ gateway: string;
+ wellknown: string;
+ login: string;
+ }
+ | false
+> {
+ if (!URL.canParse(str)) {
+ const val = stringURLMap.get(str);
+ if (val) {
+ str = val;
+ } else {
+ const val = stringURLsMap.get(str);
+ if (val) {
+ const responce = await fetch(
+ val.api + val.api.endsWith("/") ? "" : "/" + "ping"
+ );
+ if (responce.ok) {
+ if (val.login) {
+ return val as {
+ wellknown: string;
+ api: string;
+ cdn: string;
+ gateway: string;
+ login: string;
+ };
+ } else {
+ val.login = val.api;
+ return val as {
+ wellknown: string;
+ api: string;
+ cdn: string;
+ gateway: string;
+ login: string;
+ };
+ }
+ }
+ }
+ }
+ }
+ if (str.at(-1) !== "/") {
+ str += "/";
+ }
+ let api: string;
+ try {
+ const info = await fetch(`${str}/.well-known/spacebar`).then((x) =>
+ x.json()
+ );
+ api = info.api;
+ } catch {
+ return false;
+ }
+ const url = new URL(api);
+ try {
+ const info = await fetch(
+ `${api}${
+ url.pathname.includes("api") ? "" : "api"
+ }/policies/instance/domains`
+ ).then((x) => x.json());
+ return {
+ api: info.apiEndpoint,
+ gateway: info.gateway,
+ cdn: info.cdn,
+ wellknown: str,
+ login: url.toString(),
+ };
+ } catch {
+ const val = stringURLsMap.get(str);
+ if (val) {
+ const responce = await fetch(
+ val.api + val.api.endsWith("/") ? "" : "/" + "ping"
+ );
+ if (responce.ok) {
+ if (val.login) {
+ return val as {
+ wellknown: string;
+ api: string;
+ cdn: string;
+ gateway: string;
+ login: string;
+ };
+ } else {
+ val.login = val.api;
+ return val as {
+ wellknown: string;
+ api: string;
+ cdn: string;
+ gateway: string;
+ login: string;
+ };
+ }
+ }
+ }
+ return false;
+ }
+}
+async function checkInstance(instance?: string) {
+ const verify = document.getElementById("verify");
+ try {
+ verify!.textContent = "Checking Instance";
+ const instanceValue = instance || (instancein as HTMLInputElement).value;
+ const instanceinfo = (await getapiurls(instanceValue)) as {
+ wellknown: string;
+ api: string;
+ cdn: string;
+ gateway: string;
+ login: string;
+ value: string;
+ };
+ if (instanceinfo) {
+ instanceinfo.value = instanceValue;
+ localStorage.setItem("instanceinfo", JSON.stringify(instanceinfo));
+ verify!.textContent = "Instance is all good";
+ // @ts-ignore
+ if (checkInstance.alt) {
+ // @ts-ignore
+ checkInstance.alt();
+ }
+ setTimeout((_: any) => {
+ console.log(verify!.textContent);
+ verify!.textContent = "";
+ }, 3000);
+ } else {
+ verify!.textContent = "Invalid Instance, try again";
+ }
+ } catch {
+ console.log("catch");
+ verify!.textContent = "Invalid Instance, try again";
+ }
+}
+
+if (instancein) {
+ console.log(instancein);
+ instancein.addEventListener("keydown", (_) => {
+ const verify = document.getElementById("verify");
+ verify!.textContent = "Waiting to check Instance";
+ clearTimeout(timeout);
+ timeout = setTimeout(() => checkInstance(), 1000);
+ });
+ if (localStorage.getItem("instanceinfo")) {
+ const json = JSON.parse(localStorage.getItem("instanceinfo")!);
+ if (json.value) {
+ (instancein as HTMLInputElement).value = json.value;
+ } else {
+ (instancein as HTMLInputElement).value = json.wellknown;
+ }
+ } else {
+ checkInstance("https://spacebar.chat/");
+ }
+}
+
+async function login(username: string, password: string, captcha: string) {
+ if (captcha === "") {
+ captcha = "";
+ }
+ const options = {
+ method: "POST",
+ body: JSON.stringify({
+ login: username,
+ password,
+ undelete: false,
+ captcha_key: captcha,
+ }),
+ headers: {
+ "Content-type": "application/json; charset=UTF-8",
+ },
+ };
+ try {
+ const info = JSON.parse(localStorage.getItem("instanceinfo")!);
+ const api = info.login + (info.login.startsWith("/") ? "/" : "");
+ return await fetch(api + "/auth/login", options)
+ .then((response) => response.json())
+ .then((response) => {
+ console.log(response, response.message);
+ if (response.message === "Invalid Form Body") {
+ return response.errors.login._errors[0].message;
+ console.log("test");
+ }
+ //this.serverurls||!this.email||!this.token
+ console.log(response);
+
+ if (response.captcha_sitekey) {
+ const capt = document.getElementById("h-captcha");
+ if (!capt!.children.length) {
+ const capty = document.createElement("div");
+ capty.classList.add("h-captcha");
+
+ capty.setAttribute("data-sitekey", response.captcha_sitekey);
+ const script = document.createElement("script");
+ script.src = "https://js.hcaptcha.com/1/api.js";
+ capt!.append(script);
+ capt!.append(capty);
+ } else {
+ eval("hcaptcha.reset()");
+ }
+ } else {
+ console.log(response);
+ if (response.ticket) {
+ let onetimecode = "";
+ new Dialog([
+ "vdiv",
+ ["title", "2FA code:"],
+ [
+ "textbox",
+ "",
+ "",
+ function (this: HTMLInputElement) {
+ onetimecode = this.value;
+ },
+ ],
+ [
+ "button",
+ "",
+ "Submit",
+ function () {
+ fetch(api + "/auth/mfa/totp", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ code: onetimecode,
+ ticket: response.ticket,
+ }),
+ })
+ .then((r) => r.json())
+ .then((response) => {
+ if (response.message) {
+ alert(response.message);
+ } else {
+ console.warn(response);
+ if (!response.token) return;
+ adduser({
+ serverurls: JSON.parse(
+ localStorage.getItem("instanceinfo")!
+ ),
+ email: username,
+ token: response.token,
+ }).username = username;
+ const redir = new URLSearchParams(
+ window.location.search
+ ).get("goback");
+ if (redir) {
+ window.location.href = redir;
+ } else {
+ window.location.href = "/channels/@me";
+ }
+ }
+ });
+ },
+ ],
+ ]).show();
+ } else {
+ console.warn(response);
+ if (!response.token) return;
+ adduser({
+ serverurls: JSON.parse(localStorage.getItem("instanceinfo")!),
+ email: username,
+ token: response.token,
+ }).username = username;
+ const redir = new URLSearchParams(window.location.search).get(
+ "goback"
+ );
+ if (redir) {
+ window.location.href = redir;
+ } else {
+ window.location.href = "/channels/@me";
+ }
+ return "";
+ }
+ }
+ });
+ } catch (error) {
+ console.error("Error:", error);
+ }
+}
+
+async function check(e: SubmitEvent) {
+ e.preventDefault();
+ const target = e.target as HTMLFormElement;
+ const h = await login(
+ (target[1] as HTMLInputElement).value,
+ (target[2] as HTMLInputElement).value,
+ (target[3] as HTMLInputElement).value
+ );
+ const wrongElement = document.getElementById("wrong");
+ if (wrongElement) {
+ wrongElement.textContent = h;
+ }
+ console.log(h);
+}
+if (document.getElementById("form")) {
+ const form = document.getElementById("form");
+ if (form) {
+ form.addEventListener("submit", (e: SubmitEvent) => check(e));
+ }
+}
+//this currently does not work, and need to be implemented better at some time.
+/*
+if ("serviceWorker" in navigator){
+ navigator.serviceWorker.register("/service.js", {
+ scope: "/",
+ }).then((registration) => {
+ let serviceWorker:ServiceWorker;
+ if (registration.installing) {
+ serviceWorker = registration.installing;
+ console.log("installing");
+ } else if (registration.waiting) {
+ serviceWorker = registration.waiting;
+ console.log("waiting");
+ } else if (registration.active) {
+ serviceWorker = registration.active;
+ console.log("active");
+ }
+ if (serviceWorker) {
+ console.log(serviceWorker.state);
+ serviceWorker.addEventListener("statechange", (e) => {
+ console.log(serviceWorker.state);
+ });
+ }
+ })
+}
+*/
+const switchurl = document.getElementById("switch") as HTMLAreaElement;
+if (switchurl) {
+ switchurl.href += window.location.search;
+ const instance = new URLSearchParams(window.location.search).get("instance");
+ console.log(instance);
+ if (instance) {
+ instancein.value = instance;
+ checkInstance("");
+ }
+}
+export { checkInstance };
+trimswitcher();
+export {
+ mobile,
+ getBulkUsers,
+ getBulkInfo,
+ setTheme,
+ Specialuser,
+ getapiurls,
+ adduser,
+};
+
+const datalist = document.getElementById("instances");
+console.warn(datalist);
+export function getInstances() {
+ return instances;
+}
+
+fetch("/instances.json")
+ .then((_) => _.json())
+ .then(
+ (
+ json: {
+ name: string;
+ description?: string;
+ descriptionLong?: string;
+ image?: string;
+ url?: string;
+ display?: boolean;
+ online?: boolean;
+ uptime: { alltime: number; daytime: number; weektime: number };
+ urls: {
+ wellknown: string;
+ api: string;
+ cdn: string;
+ gateway: string;
+ login?: string;
+ };
+ }[]
+ ) => {
+ instances = json;
+ if (datalist) {
+ console.warn(json);
+ if (instancein && instancein.value === "") {
+ instancein.value = json[0].name;
+ }
+ for (const instance of json) {
+ if (instance.display === false) {
+ continue;
+ }
+ const option = document.createElement("option");
+ option.disabled = !instance.online;
+ option.value = instance.name;
+ if (instance.url) {
+ stringURLMap.set(option.value, instance.url);
+ if (instance.urls) {
+ stringURLsMap.set(instance.url, instance.urls);
+ }
+ } else if (instance.urls) {
+ stringURLsMap.set(option.value, instance.urls);
+ } else {
+ option.disabled = true;
+ }
+ if (instance.description) {
+ option.label = instance.description;
+ } else {
+ option.label = instance.name;
+ }
+ datalist.append(option);
+ }
+ checkInstance("");
+ }
+ }
+ );
diff --git a/webpage/logo.svg b/src/webpage/logo.svg
similarity index 100%
rename from webpage/logo.svg
rename to src/webpage/logo.svg
diff --git a/webpage/logo.webp b/src/webpage/logo.webp
similarity index 100%
rename from webpage/logo.webp
rename to src/webpage/logo.webp
diff --git a/src/webpage/manifest.json b/src/webpage/manifest.json
new file mode 100644
index 0000000..dca0dc2
--- /dev/null
+++ b/src/webpage/manifest.json
@@ -0,0 +1,12 @@
+{
+ "name": "Jank Client",
+ "icons": [
+ {
+ "src": "/logo.svg",
+ "sizes": "512x512"
+ }
+ ],
+ "start_url": "/channels/@me",
+ "display": "standalone",
+ "theme_color": "#05050a"
+}
\ No newline at end of file
diff --git a/src/webpage/markdown.ts b/src/webpage/markdown.ts
new file mode 100644
index 0000000..444ea24
--- /dev/null
+++ b/src/webpage/markdown.ts
@@ -0,0 +1,870 @@
+import { Channel } from "./channel.js";
+import { Dialog } from "./dialog.js";
+import { Emoji } from "./emoji.js";
+import { Guild } from "./guild.js";
+import { Localuser } from "./localuser.js";
+import { Member } from "./member.js";
+
+class MarkDown {
+ txt: string[];
+ keep: boolean;
+ stdsize: boolean;
+ owner: Localuser | Channel;
+ info: Localuser["info"];
+ constructor(
+ text: string | string[],
+ owner: MarkDown["owner"],
+ { keep = false, stdsize = false } = {}
+ ) {
+ if (typeof text === typeof "") {
+ this.txt = (text as string).split("");
+ } else {
+ this.txt = text as string[];
+ }
+ if (this.txt === undefined) {
+ this.txt = [];
+ }
+ this.info = owner.info;
+ this.keep = keep;
+ this.owner = owner;
+ this.stdsize = stdsize;
+ }
+ get localuser() {
+ if (this.owner instanceof Localuser) {
+ return this.owner;
+ } else {
+ return this.owner.localuser;
+ }
+ }
+ get rawString() {
+ return this.txt.join("");
+ }
+ get textContent() {
+ return this.makeHTML().textContent;
+ }
+ makeHTML({ keep = this.keep, stdsize = this.stdsize } = {}) {
+ return this.markdown(this.txt, { keep, stdsize });
+ }
+ markdown(text: string | string[], { keep = false, stdsize = false } = {}) {
+ let txt: string[];
+ if (typeof text === typeof "") {
+ txt = (text as string).split("");
+ } else {
+ txt = text as string[];
+ }
+ if (txt === undefined) {
+ txt = [];
+ }
+ const span = document.createElement("span");
+ let current = document.createElement("span");
+ function appendcurrent() {
+ if (current.innerHTML !== "") {
+ span.append(current);
+ current = document.createElement("span");
+ }
+ }
+ for (let i = 0; i < txt.length; i++) {
+ if (txt[i] === "\n" || i === 0) {
+ const first = i === 0;
+ if (first) {
+ i--;
+ }
+ let element: HTMLElement = document.createElement("span");
+ let keepys = "";
+
+ if (txt[i + 1] === "#") {
+ if (txt[i + 2] === "#") {
+ if (txt[i + 3] === "#" && txt[i + 4] === " ") {
+ element = document.createElement("h3");
+ keepys = "### ";
+ i += 5;
+ } else if (txt[i + 3] === " ") {
+ element = document.createElement("h2");
+ element.classList.add("h2md");
+ keepys = "## ";
+ i += 4;
+ }
+ } else if (txt[i + 2] === " ") {
+ element = document.createElement("h1");
+ keepys = "# ";
+ i += 3;
+ }
+ } else if (txt[i + 1] === ">" && txt[i + 2] === " ") {
+ element = document.createElement("div");
+ const line = document.createElement("div");
+ line.classList.add("quoteline");
+ element.append(line);
+ element.classList.add("quote");
+ keepys = "> ";
+ i += 3;
+ }
+ if (keepys) {
+ appendcurrent();
+ if (!first && !stdsize) {
+ span.appendChild(document.createElement("br"));
+ }
+ const build: string[] = [];
+ for (; txt[i] !== "\n" && txt[i] !== undefined; i++) {
+ build.push(txt[i]);
+ }
+ try {
+ if (stdsize) {
+ element = document.createElement("span");
+ }
+ if (keep) {
+ element.append(keepys);
+ //span.appendChild(document.createElement("br"));
+ }
+ element.appendChild(this.markdown(build, { keep, stdsize }));
+ span.append(element);
+ } finally {
+ i -= 1;
+ continue;
+ }
+ }
+ if (first) {
+ i++;
+ }
+ }
+ if (txt[i] === "\n") {
+ if (!stdsize) {
+ appendcurrent();
+ span.append(document.createElement("br"));
+ }
+ continue;
+ }
+ if (txt[i] === "`") {
+ let count = 1;
+ if (txt[i + 1] === "`") {
+ count++;
+ if (txt[i + 2] === "`") {
+ count++;
+ }
+ }
+ let build = "";
+ if (keep) {
+ build += "`".repeat(count);
+ }
+ let find = 0;
+ let j = i + count;
+ let init = true;
+ for (
+ ;
+ txt[j] !== undefined &&
+ (txt[j] !== "\n" || count === 3) &&
+ find !== count;
+ j++
+ ) {
+ if (txt[j] === "`") {
+ find++;
+ } else {
+ if (find !== 0) {
+ build += "`".repeat(find);
+ find = 0;
+ }
+ if (init && count === 3) {
+ if (txt[j] === " " || txt[j] === "\n") {
+ init = false;
+ }
+ if (keep) {
+ build += txt[j];
+ }
+ continue;
+ }
+ build += txt[j];
+ }
+ }
+ if (stdsize) {
+ build = build.replaceAll("\n", "");
+ }
+ if (find === count) {
+ appendcurrent();
+ i = j;
+ if (keep) {
+ build += "`".repeat(find);
+ }
+ if (count !== 3 && !stdsize) {
+ const samp = document.createElement("samp");
+ samp.textContent = build;
+ span.appendChild(samp);
+ } else {
+ const pre = document.createElement("pre");
+ if (build.at(-1) === "\n") {
+ build = build.substring(0, build.length - 1);
+ }
+ if (txt[i] === "\n") {
+ i++;
+ }
+ pre.textContent = build;
+ span.appendChild(pre);
+ }
+ i--;
+ continue;
+ }
+ }
+
+ if (txt[i] === "*") {
+ let count = 1;
+ if (txt[i + 1] === "*") {
+ count++;
+ if (txt[i + 2] === "*") {
+ count++;
+ }
+ }
+ let build: string[] = [];
+ let find = 0;
+ let j = i + count;
+ for (; txt[j] !== undefined && find !== count; j++) {
+ if (txt[j] === "*") {
+ find++;
+ } else {
+ build.push(txt[j]);
+ if (find !== 0) {
+ build = build.concat(new Array(find).fill("*"));
+ find = 0;
+ }
+ }
+ }
+ if (find === count && (count != 1 || txt[i + 1] !== " ")) {
+ appendcurrent();
+ i = j;
+
+ const stars = "*".repeat(count);
+ if (count === 1) {
+ const i = document.createElement("i");
+ if (keep) {
+ i.append(stars);
+ }
+ i.appendChild(this.markdown(build, { keep, stdsize }));
+ if (keep) {
+ i.append(stars);
+ }
+ span.appendChild(i);
+ } else if (count === 2) {
+ const b = document.createElement("b");
+ if (keep) {
+ b.append(stars);
+ }
+ b.appendChild(this.markdown(build, { keep, stdsize }));
+ if (keep) {
+ b.append(stars);
+ }
+ span.appendChild(b);
+ } else {
+ const b = document.createElement("b");
+ const i = document.createElement("i");
+ if (keep) {
+ b.append(stars);
+ }
+ b.appendChild(this.markdown(build, { keep, stdsize }));
+ if (keep) {
+ b.append(stars);
+ }
+ i.appendChild(b);
+ span.appendChild(i);
+ }
+ i--;
+ continue;
+ }
+ }
+
+ if (txt[i] === "_") {
+ let count = 1;
+ if (txt[i + 1] === "_") {
+ count++;
+ if (txt[i + 2] === "_") {
+ count++;
+ }
+ }
+ let build: string[] = [];
+ let find = 0;
+ let j = i + count;
+ for (; txt[j] !== undefined && find !== count; j++) {
+ if (txt[j] === "_") {
+ find++;
+ } else {
+ build.push(txt[j]);
+ if (find !== 0) {
+ build = build.concat(new Array(find).fill("_"));
+ find = 0;
+ }
+ }
+ }
+ if (
+ find === count &&
+ (count != 1 ||
+ txt[j + 1] === " " ||
+ txt[j + 1] === "\n" ||
+ txt[j + 1] === undefined)
+ ) {
+ appendcurrent();
+ i = j;
+ const underscores = "_".repeat(count);
+ if (count === 1) {
+ const i = document.createElement("i");
+ if (keep) {
+ i.append(underscores);
+ }
+ i.appendChild(this.markdown(build, { keep, stdsize }));
+ if (keep) {
+ i.append(underscores);
+ }
+ span.appendChild(i);
+ } else if (count === 2) {
+ const u = document.createElement("u");
+ if (keep) {
+ u.append(underscores);
+ }
+ u.appendChild(this.markdown(build, { keep, stdsize }));
+ if (keep) {
+ u.append(underscores);
+ }
+ span.appendChild(u);
+ } else {
+ const u = document.createElement("u");
+ const i = document.createElement("i");
+ if (keep) {
+ i.append(underscores);
+ }
+ i.appendChild(this.markdown(build, { keep, stdsize }));
+ if (keep) {
+ i.append(underscores);
+ }
+ u.appendChild(i);
+ span.appendChild(u);
+ }
+ i--;
+ continue;
+ }
+ }
+
+ if (txt[i] === "~" && txt[i + 1] === "~") {
+ const count = 2;
+ let build: string[] = [];
+ let find = 0;
+ let j = i + 2;
+ for (; txt[j] !== undefined && find !== count; j++) {
+ if (txt[j] === "~") {
+ find++;
+ } else {
+ build.push(txt[j]);
+ if (find !== 0) {
+ build = build.concat(new Array(find).fill("~"));
+ find = 0;
+ }
+ }
+ }
+ if (find === count) {
+ appendcurrent();
+ i = j - 1;
+ const tildes = "~~";
+ if (count === 2) {
+ const s = document.createElement("s");
+ if (keep) {
+ s.append(tildes);
+ }
+ s.appendChild(this.markdown(build, { keep, stdsize }));
+ if (keep) {
+ s.append(tildes);
+ }
+ span.appendChild(s);
+ }
+ continue;
+ }
+ }
+ if (txt[i] === "|" && txt[i + 1] === "|") {
+ const count = 2;
+ let build: string[] = [];
+ let find = 0;
+ let j = i + 2;
+ for (; txt[j] !== undefined && find !== count; j++) {
+ if (txt[j] === "|") {
+ find++;
+ } else {
+ build.push(txt[j]);
+ if (find !== 0) {
+ build = build.concat(new Array(find).fill("~"));
+ find = 0;
+ }
+ }
+ }
+ if (find === count) {
+ appendcurrent();
+ i = j - 1;
+ const pipes = "||";
+ if (count === 2) {
+ const j = document.createElement("j");
+ if (keep) {
+ j.append(pipes);
+ }
+ j.appendChild(this.markdown(build, { keep, stdsize }));
+ j.classList.add("spoiler");
+ j.onclick = MarkDown.unspoil;
+ if (keep) {
+ j.append(pipes);
+ }
+ span.appendChild(j);
+ }
+ continue;
+ }
+ }
+ if (
+ !keep &&
+ txt[i] === "h" &&
+ txt[i + 1] === "t" &&
+ txt[i + 2] === "t" &&
+ txt[i + 3] === "p"
+ ) {
+ let build = "http";
+ let j = i + 4;
+ const endchars = new Set(["\\", "<", ">", "|", "]", " "]);
+ for (; txt[j] !== undefined; j++) {
+ const char = txt[j];
+ if (endchars.has(char)) {
+ break;
+ }
+ build += char;
+ }
+ if (URL.canParse(build)) {
+ appendcurrent();
+ const a = document.createElement("a");
+ //a.href=build;
+ MarkDown.safeLink(a, build);
+ a.textContent = build;
+ a.target = "_blank";
+ i = j - 1;
+ span.appendChild(a);
+ continue;
+ }
+ }
+ if (txt[i] === "<" && (txt[i + 1] === "@" || txt[i + 1] === "#")) {
+ let id = "";
+ let j = i + 2;
+ const numbers = new Set([
+ "0",
+ "1",
+ "2",
+ "3",
+ "4",
+ "5",
+ "6",
+ "7",
+ "8",
+ "9",
+ ]);
+ for (; txt[j] !== undefined; j++) {
+ const char = txt[j];
+ if (!numbers.has(char)) {
+ break;
+ }
+ id += char;
+ }
+
+ if (txt[j] === ">") {
+ appendcurrent();
+ const mention = document.createElement("span");
+ mention.classList.add("mentionMD");
+ mention.contentEditable = "false";
+ const char = txt[i + 1];
+ i = j;
+ switch (char) {
+ case "@":
+ const user = this.localuser.userMap.get(id);
+ if (user) {
+ mention.textContent = `@${user.name}`;
+ let guild: null | Guild = null;
+ if (this.owner instanceof Channel) {
+ guild = this.owner.guild;
+ }
+ if (!keep) {
+ user.bind(mention, guild);
+ }
+ if (guild) {
+ Member.resolveMember(user, guild).then((member) => {
+ if (member) {
+ mention.textContent = `@${member.name}`;
+ }
+ });
+ }
+ } else {
+ mention.textContent = `@unknown`;
+ }
+ break;
+ case "#":
+ const channel = this.localuser.channelids.get(id);
+ if (channel) {
+ mention.textContent = `#${channel.name}`;
+ if (!keep) {
+ mention.onclick = (_) => {
+ this.localuser.goToChannel(id);
+ };
+ }
+ } else {
+ mention.textContent = `#unknown`;
+ }
+ break;
+ }
+ span.appendChild(mention);
+ mention.setAttribute("real", `<${char}${id}>`);
+ continue;
+ }
+ }
+ if (txt[i] === "<" && txt[i + 1] === "t" && txt[i + 2] === ":") {
+ let found = false;
+ const build = ["<", "t", ":"];
+ let j = i + 3;
+ for (; txt[j] !== void 0; j++) {
+ build.push(txt[j]);
+
+ if (txt[j] === ">") {
+ found = true;
+ break;
+ }
+ }
+
+ if (found) {
+ appendcurrent();
+ i = j;
+ const parts = build
+ .join("")
+ .match(/^$/) as RegExpMatchArray;
+ const dateInput = new Date(Number.parseInt(parts[1]) * 1000);
+ let time = "";
+ if (Number.isNaN(dateInput.getTime())) time = build.join("");
+ else {
+ if (parts[3] === "d")
+ time = dateInput.toLocaleString(void 0, {
+ day: "2-digit",
+ month: "2-digit",
+ year: "numeric",
+ });
+ else if (parts[3] === "D")
+ time = dateInput.toLocaleString(void 0, {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+ });
+ else if (!parts[3] || parts[3] === "f")
+ time =
+ dateInput.toLocaleString(void 0, {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+ }) +
+ " " +
+ dateInput.toLocaleString(void 0, {
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ else if (parts[3] === "F")
+ time =
+ dateInput.toLocaleString(void 0, {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+ weekday: "long",
+ }) +
+ " " +
+ dateInput.toLocaleString(void 0, {
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ else if (parts[3] === "t")
+ time = dateInput.toLocaleString(void 0, {
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ else if (parts[3] === "T")
+ time = dateInput.toLocaleString(void 0, {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
+ else if (parts[3] === "R")
+ time =
+ Math.round(
+ (Date.now() - Number.parseInt(parts[1]) * 1000) / 1000 / 60
+ ) + " minutes ago";
+ }
+
+ const timeElem = document.createElement("span");
+ timeElem.classList.add("markdown-timestamp");
+ timeElem.textContent = time;
+ span.appendChild(timeElem);
+ continue;
+ }
+ }
+
+ if (
+ txt[i] === "<" &&
+ (txt[i + 1] === ":" || (txt[i + 1] === "a" && txt[i + 2] === ":"))
+ ) {
+ let found = false;
+ const build = txt[i + 1] === "a" ? ["<", "a", ":"] : ["<", ":"];
+ let j = i + build.length;
+ for (; txt[j] !== void 0; j++) {
+ build.push(txt[j]);
+
+ if (txt[j] === ">") {
+ found = true;
+ break;
+ }
+ }
+
+ if (found) {
+ const buildjoin = build.join("");
+ const parts = buildjoin.match(/^<(a)?:\w+:(\d{10,30})>$/);
+ if (parts && parts[2]) {
+ appendcurrent();
+ i = j;
+ const isEmojiOnly = txt.join("").trim() === buildjoin.trim();
+ const owner =
+ this.owner instanceof Channel ? this.owner.guild : this.owner;
+ const emoji = new Emoji(
+ { name: buildjoin, id: parts[2], animated: Boolean(parts[1]) },
+ owner
+ );
+ span.appendChild(emoji.getHTML(isEmojiOnly));
+
+ continue;
+ }
+ }
+ }
+
+ if (txt[i] == "[" && !keep) {
+ let partsFound = 0;
+ let j = i + 1;
+ const build = ["["];
+ for (; txt[j] !== void 0; j++) {
+ build.push(txt[j]);
+
+ if (partsFound === 0 && txt[j] === "]") {
+ if (
+ txt[j + 1] === "(" &&
+ txt[j + 2] === "h" &&
+ txt[j + 3] === "t" &&
+ txt[j + 4] === "t" &&
+ txt[j + 5] === "p" &&
+ (txt[j + 6] === "s" || txt[j + 6] === ":")
+ ) {
+ partsFound++;
+ } else {
+ break;
+ }
+ } else if (partsFound === 1 && txt[j] === ")") {
+ partsFound++;
+ break;
+ }
+ }
+
+ if (partsFound === 2) {
+ appendcurrent();
+
+ const parts = build
+ .join("")
+ .match(/^\[(.+)\]\((https?:.+?)( ('|").+('|"))?\)$/);
+ if (parts) {
+ const linkElem = document.createElement("a");
+ if (URL.canParse(parts[2])) {
+ i = j;
+ MarkDown.safeLink(linkElem, parts[2]);
+ linkElem.textContent = parts[1];
+ linkElem.target = "_blank";
+ linkElem.rel = "noopener noreferrer";
+ linkElem.title =
+ (parts[3]
+ ? parts[3].substring(2, parts[3].length - 1) + "\n\n"
+ : "") + parts[2];
+ span.appendChild(linkElem);
+
+ continue;
+ }
+ }
+ }
+ }
+
+ current.textContent += txt[i];
+ }
+ appendcurrent();
+ return span;
+ }
+ static unspoil(e: any): void {
+ e.target.classList.remove("spoiler");
+ e.target.classList.add("unspoiled");
+ }
+ giveBox(box: HTMLDivElement) {
+ box.onkeydown = (_) => {
+ //console.log(_);
+ };
+ let prevcontent = "";
+ box.onkeyup = (_) => {
+ const content = MarkDown.gatherBoxText(box);
+ if (content !== prevcontent) {
+ prevcontent = content;
+ this.txt = content.split("");
+ this.boxupdate(box);
+ }
+ };
+ box.onpaste = (_) => {
+ if (!_.clipboardData) return;
+ console.log(_.clipboardData.types);
+ const data = _.clipboardData.getData("text");
+
+ document.execCommand("insertHTML", false, data);
+ _.preventDefault();
+ if (!box.onkeyup) return;
+ box.onkeyup(new KeyboardEvent("_"));
+ };
+ }
+ boxupdate(box: HTMLElement) {
+ const restore = saveCaretPosition(box);
+ box.innerHTML = "";
+ box.append(this.makeHTML({ keep: true }));
+ if (restore) {
+ restore();
+ }
+ }
+ static gatherBoxText(element: HTMLElement): string {
+ if (element.tagName.toLowerCase() === "img") {
+ return (element as HTMLImageElement).alt;
+ }
+ if (element.tagName.toLowerCase() === "br") {
+ return "\n";
+ }
+ if (element.hasAttribute("real")) {
+ return element.getAttribute("real") as string;
+ }
+ let build = "";
+ for (const thing of Array.from(element.childNodes)) {
+ if (thing instanceof Text) {
+ const text = thing.textContent;
+ build += text;
+ continue;
+ }
+ const text = this.gatherBoxText(thing as HTMLElement);
+ if (text) {
+ build += text;
+ }
+ }
+ return build;
+ }
+ static readonly trustedDomains = new Set([location.host]);
+ static safeLink(elm: HTMLElement, url: string) {
+ if (URL.canParse(url)) {
+ const Url = new URL(url);
+ if (
+ elm instanceof HTMLAnchorElement &&
+ this.trustedDomains.has(Url.host)
+ ) {
+ elm.href = url;
+ elm.target = "_blank";
+ return;
+ }
+ elm.onmouseup = (_) => {
+ if (_.button === 2) return;
+ console.log(":3");
+ function open() {
+ const proxy = window.open(url, "_blank");
+ if (proxy && _.button === 1) {
+ proxy.focus();
+ } else if (proxy) {
+ window.focus();
+ }
+ }
+ if (this.trustedDomains.has(Url.host)) {
+ open();
+ } else {
+ const full: Dialog = new Dialog([
+ "vdiv",
+ ["title", "You're leaving spacebar"],
+ [
+ "text",
+ "You're going to " +
+ Url.host +
+ " are you sure you want to go there?",
+ ],
+ [
+ "hdiv",
+ ["button", "", "Nevermind", (_: any) => full.hide()],
+ [
+ "button",
+ "",
+ "Go there",
+ (_: any) => {
+ open();
+ full.hide();
+ },
+ ],
+ [
+ "button",
+ "",
+ "Go there and trust in the future",
+ (_: any) => {
+ open();
+ full.hide();
+ this.trustedDomains.add(Url.host);
+ },
+ ],
+ ],
+ ]);
+ full.show();
+ }
+ };
+ } else {
+ throw Error(url + " is not a valid URL");
+ }
+ }
+ /*
+ static replace(base: HTMLElement, newelm: HTMLElement) {
+ const basechildren = base.children;
+ const newchildren = newelm.children;
+ for (const thing of Array.from(newchildren)) {
+ base.append(thing);
+ }
+ }
+ */
+}
+
+//solution from https://stackoverflow.com/questions/4576694/saving-and-restoring-caret-position-for-contenteditable-div
+let text = "";
+function saveCaretPosition(context: Node) {
+ const selection = window.getSelection();
+ if (!selection) return;
+ const range = selection.getRangeAt(0);
+ range.setStart(context, 0);
+ text = selection.toString();
+ let len = text.length + 1;
+ for (const str in text.split("\n")) {
+ if (str.length !== 0) {
+ len--;
+ }
+ }
+ len += +(text[text.length - 1] === "\n");
+
+ return function restore() {
+ if (!selection) return;
+ const pos = getTextNodeAtPosition(context, len);
+ selection.removeAllRanges();
+ const range = new Range();
+ range.setStart(pos.node, pos.position);
+ selection.addRange(range);
+ };
+}
+
+function getTextNodeAtPosition(root: Node, index: number) {
+ const NODE_TYPE = NodeFilter.SHOW_TEXT;
+ const treeWalker = document.createTreeWalker(root, NODE_TYPE, (elem) => {
+ if (!elem.textContent) return 0;
+ if (index > elem.textContent.length) {
+ index -= elem.textContent.length;
+ return NodeFilter.FILTER_REJECT;
+ }
+ return NodeFilter.FILTER_ACCEPT;
+ });
+ const c = treeWalker.nextNode();
+ return {
+ node: c ? c : root,
+ position: index,
+ };
+}
+export { MarkDown };
diff --git a/src/webpage/member.ts b/src/webpage/member.ts
new file mode 100644
index 0000000..d291f5e
--- /dev/null
+++ b/src/webpage/member.ts
@@ -0,0 +1,256 @@
+import { User } from "./user.js";
+import { Role } from "./role.js";
+import { Guild } from "./guild.js";
+import { SnowFlake } from "./snowflake.js";
+import { memberjson, presencejson } from "./jsontypes.js";
+import { Dialog } from "./dialog.js";
+
+class Member extends SnowFlake {
+ static already = {};
+ owner: Guild;
+ user: User;
+ roles: Role[] = [];
+ nick!: string;
+ [key: string]: any;
+
+ private constructor(memberjson: memberjson, owner: Guild) {
+ super(memberjson.id);
+ this.owner = owner;
+ if (this.localuser.userMap.has(memberjson.id)) {
+ this.user = this.localuser.userMap.get(memberjson.id) as User;
+ } else if (memberjson.user) {
+ this.user = new User(memberjson.user, owner.localuser);
+ } else {
+ throw new Error("Missing user object of this member");
+ }
+
+ for (const key of Object.keys(memberjson)) {
+ if (key === "guild" || key === "owner") {
+ continue;
+ }
+
+ if (key === "roles") {
+ for (const strrole of memberjson.roles) {
+ const role = this.guild.roleids.get(strrole);
+ if (!role) continue;
+ this.roles.push(role);
+ }
+ continue;
+ }
+ (this as any)[key] = (memberjson as any)[key];
+ }
+ if (this.localuser.userMap.has(this?.id)) {
+ this.user = this.localuser.userMap.get(this?.id) as User;
+ }
+ this.roles.sort((a, b) => {
+ return this.guild.roles.indexOf(a) - this.guild.roles.indexOf(b);
+ });
+ }
+ get guild() {
+ return this.owner;
+ }
+ get localuser() {
+ return this.guild.localuser;
+ }
+ get info() {
+ return this.owner.info;
+ }
+ static async new(
+ memberjson: memberjson,
+ owner: Guild
+ ): Promise {
+ let user: User;
+ if (owner.localuser.userMap.has(memberjson.id)) {
+ user = owner.localuser.userMap.get(memberjson.id) as User;
+ } else if (memberjson.user) {
+ user = new User(memberjson.user, owner.localuser);
+ } else {
+ throw new Error("missing user object of this member");
+ }
+ if (user.members.has(owner)) {
+ let memb = user.members.get(owner);
+ if (memb === undefined) {
+ memb = new Member(memberjson, owner);
+ user.members.set(owner, memb);
+ return memb;
+ } else if (memb instanceof Promise) {
+ return await memb; //I should do something else, though for now this is "good enough"
+ } else {
+ return memb;
+ }
+ } else {
+ const memb = new Member(memberjson, owner);
+ user.members.set(owner, memb);
+ return memb;
+ }
+ }
+ static async resolveMember(
+ user: User,
+ guild: Guild
+ ): Promise {
+ const maybe = user.members.get(guild);
+ if (!user.members.has(guild)) {
+ const membpromise = guild.localuser.resolvemember(user.id, guild.id);
+ const promise = new Promise(async (res) => {
+ const membjson = await membpromise;
+ if (membjson === undefined) {
+ return res(undefined);
+ } else {
+ const member = new Member(membjson, guild);
+ const map = guild.localuser.presences;
+ member.getPresence(map.get(member.id));
+ map.delete(member.id);
+ res(member);
+ return member;
+ }
+ });
+ user.members.set(guild, promise);
+ }
+ if (maybe instanceof Promise) {
+ return await maybe;
+ } else {
+ return maybe;
+ }
+ }
+ public getPresence(presence: presencejson | undefined) {
+ this.user.getPresence(presence);
+ }
+ /**
+ * @todo
+ */
+ highInfo() {
+ fetch(
+ this.info.api +
+ "/users/" +
+ this.id +
+ "/profile?with_mutual_guilds=true&with_mutual_friends_count=true&guild_id=" +
+ this.guild.id,
+ { headers: this.guild.headers }
+ );
+ }
+ hasRole(ID: string) {
+ console.log(this.roles, ID);
+ for (const thing of this.roles) {
+ if (thing.id === ID) {
+ return true;
+ }
+ }
+ return false;
+ }
+ getColor() {
+ for (const thing of this.roles) {
+ const color = thing.getColor();
+ if (color) {
+ return color;
+ }
+ }
+ return "";
+ }
+ isAdmin() {
+ for (const role of this.roles) {
+ if (role.permissions.getPermission("ADMINISTRATOR")) {
+ return true;
+ }
+ }
+ return this.guild.properties.owner_id === this.user.id;
+ }
+ bind(html: HTMLElement) {
+ if (html.tagName === "SPAN") {
+ if (!this) {
+ return;
+ }
+ /*
+ if(this.error){
+
+ }
+ */
+ html.style.color = this.getColor();
+ }
+
+ //this.profileclick(html);
+ }
+ profileclick(/* html: HTMLElement */) {
+ //to be implemented
+ }
+ get name() {
+ return this.nick || this.user.username;
+ }
+ kick() {
+ let reason = "";
+ const menu = new Dialog([
+ "vdiv",
+ ["title", "Kick " + this.name + " from " + this.guild.properties.name],
+ [
+ "textbox",
+ "Reason:",
+ "",
+ function (e: Event) {
+ reason = (e.target as HTMLInputElement).value;
+ },
+ ],
+ [
+ "button",
+ "",
+ "submit",
+ () => {
+ this.kickAPI(reason);
+ menu.hide();
+ },
+ ],
+ ]);
+ menu.show();
+ }
+ kickAPI(reason: string) {
+ const headers = structuredClone(this.guild.headers);
+ (headers as any)["x-audit-log-reason"] = reason;
+ fetch(`${this.info.api}/guilds/${this.guild.id}/members/${this.id}`, {
+ method: "DELETE",
+ headers,
+ });
+ }
+ ban() {
+ let reason = "";
+ const menu = new Dialog([
+ "vdiv",
+ ["title", "Ban " + this.name + " from " + this.guild.properties.name],
+ [
+ "textbox",
+ "Reason:",
+ "",
+ function (e: Event) {
+ reason = (e.target as HTMLInputElement).value;
+ },
+ ],
+ [
+ "button",
+ "",
+ "submit",
+ () => {
+ this.banAPI(reason);
+ menu.hide();
+ },
+ ],
+ ]);
+ menu.show();
+ }
+ banAPI(reason: string) {
+ const headers = structuredClone(this.guild.headers);
+ (headers as any)["x-audit-log-reason"] = reason;
+ fetch(`${this.info.api}/guilds/${this.guild.id}/bans/${this.id}`, {
+ method: "PUT",
+ headers,
+ });
+ }
+ hasPermission(name: string): boolean {
+ if (this.isAdmin()) {
+ return true;
+ }
+ for (const thing of this.roles) {
+ if (thing.permissions.getPermission(name)) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
+export { Member };
diff --git a/src/webpage/message.ts b/src/webpage/message.ts
new file mode 100644
index 0000000..486ea88
--- /dev/null
+++ b/src/webpage/message.ts
@@ -0,0 +1,769 @@
+import { Contextmenu } from "./contextmenu.js";
+import { User } from "./user.js";
+import { Member } from "./member.js";
+import { MarkDown } from "./markdown.js";
+import { Embed } from "./embed.js";
+import { Channel } from "./channel.js";
+import { Localuser } from "./localuser.js";
+import { Role } from "./role.js";
+import { File } from "./file.js";
+import { SnowFlake } from "./snowflake.js";
+import { memberjson, messagejson } from "./jsontypes.js";
+import { Emoji } from "./emoji.js";
+import { Dialog } from "./dialog.js";
+
+class Message extends SnowFlake {
+ static contextmenu = new Contextmenu("message menu");
+ owner: Channel;
+ headers: Localuser["headers"];
+ embeds!: Embed[];
+ author!: User;
+ mentions!: User[];
+ mention_roles!: Role[];
+ attachments!: File[]; //probably should be its own class tbh, should be Attachments[]
+ message_reference!: messagejson;
+ type!: number;
+ timestamp!: number;
+ content!: MarkDown;
+ static del: Promise;
+ static resolve: Function;
+ /*
+ weakdiv:WeakRef;
+ set div(e:HTMLDivElement){
+ if(!e){
+ this.weakdiv=null;
+ return;
+ }
+ this.weakdiv=new WeakRef(e);
+ }
+ get div(){
+ return this.weakdiv?.deref();
+ }
+ //*/
+ div:
+ | (HTMLDivElement & { pfpparent?: Message | undefined; txt?: HTMLElement })
+ | undefined;
+ member: Member | undefined;
+ reactions!: messagejson["reactions"];
+ static setup() {
+ this.del = new Promise((_) => {
+ this.resolve = _;
+ });
+ Message.setupcmenu();
+ }
+ static setupcmenu() {
+ Message.contextmenu.addbutton("Copy raw text", function (this: Message) {
+ navigator.clipboard.writeText(this.content.rawString);
+ });
+ Message.contextmenu.addbutton("Reply", function (this: Message) {
+ this.channel.setReplying(this);
+ });
+ Message.contextmenu.addbutton("Copy message id", function (this: Message) {
+ navigator.clipboard.writeText(this.id);
+ });
+ Message.contextmenu.addsubmenu(
+ "Add reaction",
+ function (this: Message, _, e: MouseEvent) {
+ Emoji.emojiPicker(e.x, e.y, this.localuser).then((_) => {
+ this.reactionToggle(_);
+ });
+ }
+ );
+ Message.contextmenu.addbutton(
+ "Edit",
+ function (this: Message) {
+ this.setEdit();
+ },
+ null,
+ function () {
+ return this.author.id === this.localuser.user.id;
+ }
+ );
+ Message.contextmenu.addbutton(
+ "Delete message",
+ function (this: Message) {
+ this.delete();
+ },
+ null,
+ function () {
+ return this.canDelete();
+ }
+ );
+ }
+ setEdit() {
+ this.channel.editing = this;
+ const markdown = (
+ document.getElementById("typebox") as HTMLDivElement & {
+ markdown: MarkDown;
+ }
+ )["markdown"] as MarkDown;
+ markdown.txt = this.content.rawString.split("");
+ markdown.boxupdate(document.getElementById("typebox") as HTMLDivElement);
+ }
+ constructor(messagejson: messagejson, owner: Channel) {
+ super(messagejson.id);
+ this.owner = owner;
+ this.headers = this.owner.headers;
+ this.giveData(messagejson);
+ this.owner.messages.set(this.id, this);
+ }
+ reactionToggle(emoji: string | Emoji) {
+ let remove = false;
+ for (const thing of this.reactions) {
+ if (thing.emoji.name === emoji) {
+ remove = thing.me;
+ break;
+ }
+ }
+ let reactiontxt: string;
+ if (emoji instanceof Emoji) {
+ reactiontxt = `${emoji.name}:${emoji.id}`;
+ } else {
+ reactiontxt = encodeURIComponent(emoji);
+ }
+ fetch(
+ `${this.info.api}/channels/${this.channel.id}/messages/${this.id}/reactions/${reactiontxt}/@me`,
+ {
+ method: remove ? "DELETE" : "PUT",
+ headers: this.headers,
+ }
+ );
+ }
+ giveData(messagejson: messagejson) {
+ const func = this.channel.infinite.snapBottom();
+ for (const thing of Object.keys(messagejson)) {
+ if (thing === "attachments") {
+ this.attachments = [];
+ for (const thing of messagejson.attachments) {
+ this.attachments.push(new File(thing, this));
+ }
+ continue;
+ } else if (thing === "content") {
+ this.content = new MarkDown(messagejson[thing], this.channel);
+ continue;
+ } else if (thing === "id") {
+ continue;
+ } else if (thing === "member") {
+ Member.new(messagejson.member as memberjson, this.guild).then((_) => {
+ this.member = _ as Member;
+ });
+ continue;
+ } else if (thing === "embeds") {
+ this.embeds = [];
+ for (const thing in messagejson.embeds) {
+ this.embeds[thing] = new Embed(messagejson.embeds[thing], this);
+ }
+ continue;
+ }
+ (this as any)[thing] = (messagejson as any)[thing];
+ }
+ if (messagejson.reactions?.length) {
+ console.log(messagejson.reactions, ":3");
+ }
+
+ this.author = new User(messagejson.author, this.localuser);
+ for (const thing in messagejson.mentions) {
+ this.mentions[thing] = new User(
+ messagejson.mentions[thing],
+ this.localuser
+ );
+ }
+ if (!this.member && this.guild.id !== "@me") {
+ this.author.resolvemember(this.guild).then((_) => {
+ this.member = _;
+ });
+ }
+ if (this.mentions.length || this.mention_roles.length) {
+ //currently mention_roles isn't implemented on the spacebar servers
+ console.log(this.mentions, this.mention_roles);
+ }
+ if (this.mentionsuser(this.localuser.user)) {
+ console.log(this);
+ }
+ if (this.div) {
+ this.generateMessage();
+ }
+ func();
+ }
+ canDelete() {
+ return (
+ this.channel.hasPermission("MANAGE_MESSAGES") ||
+ this.author === this.localuser.user
+ );
+ }
+ get channel() {
+ return this.owner;
+ }
+ get guild() {
+ return this.owner.guild;
+ }
+ get localuser() {
+ return this.owner.localuser;
+ }
+ get info() {
+ return this.owner.info;
+ }
+ messageevents(obj: HTMLDivElement) {
+ // const func = Message.contextmenu.bindContextmenu(obj, this, undefined);
+ this.div = obj;
+ obj.classList.add("messagediv");
+ }
+ deleteDiv() {
+ if (!this.div) return;
+ try {
+ this.div.remove();
+ this.div = undefined;
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ mentionsuser(userd: User | Member) {
+ if (userd instanceof User) {
+ return this.mentions.includes(userd);
+ } else if (userd instanceof Member) {
+ return this.mentions.includes(userd.user);
+ } else {
+ return;
+ }
+ }
+ getimages() {
+ const build: File[] = [];
+ for (const thing of this.attachments) {
+ if (thing.content_type.startsWith("image/")) {
+ build.push(thing);
+ }
+ }
+ return build;
+ }
+ async edit(content: string) {
+ return await fetch(
+ this.info.api + "/channels/" + this.channel.id + "/messages/" + this.id,
+ {
+ method: "PATCH",
+ headers: this.headers,
+ body: JSON.stringify({ content }),
+ }
+ );
+ }
+ delete() {
+ fetch(`${this.info.api}/channels/${this.channel.id}/messages/${this.id}`, {
+ headers: this.headers,
+ method: "DELETE",
+ });
+ }
+ deleteEvent() {
+ console.log("deleted");
+ if (this.div) {
+ this.div.remove();
+ this.div.innerHTML = "";
+ this.div = undefined;
+ }
+ const prev = this.channel.idToPrev.get(this.id);
+ const next = this.channel.idToNext.get(this.id);
+ this.channel.idToPrev.delete(this.id);
+ this.channel.idToNext.delete(this.id);
+ this.channel.messages.delete(this.id);
+ if (prev && next) {
+ this.channel.idToPrev.set(next, prev);
+ this.channel.idToNext.set(prev, next);
+ } else if (prev) {
+ this.channel.idToNext.delete(prev);
+ } else if (next) {
+ this.channel.idToPrev.delete(next);
+ }
+ if (prev) {
+ const prevmessage = this.channel.messages.get(prev);
+ if (prevmessage) {
+ prevmessage.generateMessage();
+ }
+ }
+ if (
+ this.channel.lastmessage === this ||
+ this.channel.lastmessageid === this.id
+ ) {
+ if (prev) {
+ this.channel.lastmessage = this.channel.messages.get(prev);
+ this.channel.lastmessageid = prev;
+ } else {
+ this.channel.lastmessage = undefined;
+ this.channel.lastmessageid = undefined;
+ }
+ }
+ if (this.channel.lastreadmessageid === this.id) {
+ if (prev) {
+ this.channel.lastreadmessageid = prev;
+ } else {
+ this.channel.lastreadmessageid = undefined;
+ }
+ }
+ console.log("deleted done");
+ }
+ reactdiv!: WeakRef;
+ blockedPropigate() {
+ const previd = this.channel.idToPrev.get(this.id);
+ if (!previd) {
+ this.generateMessage();
+ return;
+ }
+ const premessage = this.channel.messages.get(previd);
+ if (premessage?.author === this.author) {
+ premessage.blockedPropigate();
+ } else {
+ this.generateMessage();
+ }
+ }
+ generateMessage(premessage?: Message | undefined, ignoredblock = false) {
+ if (!this.div) return;
+ if (!premessage) {
+ premessage = this.channel.messages.get(
+ this.channel.idToPrev.get(this.id) as string
+ );
+ }
+ const div = this.div;
+ for (const user of this.mentions) {
+ if (user === this.localuser.user) {
+ div.classList.add("mentioned");
+ }
+ }
+ if (this === this.channel.replyingto) {
+ div.classList.add("replying");
+ }
+ div.innerHTML = "";
+ const build = document.createElement("div");
+
+ build.classList.add("flexltr", "message");
+ div.classList.remove("zeroheight");
+ if (this.author.relationshipType === 2) {
+ if (ignoredblock) {
+ if (premessage?.author !== this.author) {
+ const span = document.createElement("span");
+ span.textContent =
+ "You have this user blocked, click to hide these messages.";
+ div.append(span);
+ span.classList.add("blocked");
+ span.onclick = (_) => {
+ const scroll = this.channel.infinite.scrollTop;
+ let next: Message | undefined = this;
+ while (next?.author === this.author) {
+ next.generateMessage();
+ next = this.channel.messages.get(
+ this.channel.idToNext.get(next.id) as string
+ );
+ }
+ if (this.channel.infinite.scollDiv && scroll) {
+ this.channel.infinite.scollDiv.scrollTop = scroll;
+ }
+ };
+ }
+ } else {
+ div.classList.remove("topMessage");
+ if (premessage?.author === this.author) {
+ div.classList.add("zeroheight");
+ premessage.blockedPropigate();
+ div.appendChild(build);
+ return div;
+ } else {
+ build.classList.add("blocked", "topMessage");
+ const span = document.createElement("span");
+ let count = 1;
+ let next = this.channel.messages.get(
+ this.channel.idToNext.get(this.id) as string
+ );
+ while (next?.author === this.author) {
+ count++;
+ next = this.channel.messages.get(
+ this.channel.idToNext.get(next.id) as string
+ );
+ }
+ span.textContent = `You have this user blocked, click to see the ${count} blocked messages.`;
+ build.append(span);
+ span.onclick = (_) => {
+ const scroll = this.channel.infinite.scrollTop;
+ const func = this.channel.infinite.snapBottom();
+ let next: Message | undefined = this;
+ while (next?.author === this.author) {
+ next.generateMessage(undefined, true);
+ next = this.channel.messages.get(
+ this.channel.idToNext.get(next.id) as string
+ );
+ console.log("loopy");
+ }
+ if (this.channel.infinite.scollDiv && scroll) {
+ func();
+ this.channel.infinite.scollDiv.scrollTop = scroll;
+ }
+ };
+ div.appendChild(build);
+ return div;
+ }
+ }
+ }
+ if (this.message_reference) {
+ const replyline = document.createElement("div");
+ const line = document.createElement("hr");
+ const minipfp = document.createElement("img");
+ minipfp.classList.add("replypfp");
+ replyline.appendChild(line);
+ replyline.appendChild(minipfp);
+ const username = document.createElement("span");
+ replyline.appendChild(username);
+ const reply = document.createElement("div");
+ username.classList.add("username");
+ reply.classList.add("replytext");
+ replyline.appendChild(reply);
+ const line2 = document.createElement("hr");
+ replyline.appendChild(line2);
+ line2.classList.add("reply");
+ line.classList.add("startreply");
+ replyline.classList.add("replyflex");
+ // TODO: Fix this
+ this.channel.getmessage(this.message_reference.id).then((message) => {
+ if (message.author.relationshipType === 2) {
+ username.textContent = "Blocked user";
+ return;
+ }
+ const author = message.author;
+ reply.appendChild(message.content.makeHTML({ stdsize: true }));
+ minipfp.src = author.getpfpsrc();
+ author.bind(minipfp, this.guild);
+ username.textContent = author.username;
+ author.bind(username, this.guild);
+ });
+ reply.onclick = (_) => {
+ // TODO: FIX this
+ this.channel.infinite.focus(this.message_reference.id);
+ };
+ div.appendChild(replyline);
+ }
+ div.appendChild(build);
+ if ({ 0: true, 19: true }[this.type] || this.attachments.length !== 0) {
+ const pfpRow = document.createElement("div");
+ pfpRow.classList.add("flexltr");
+ let pfpparent, current;
+ if (premessage != null) {
+ pfpparent ??= premessage;
+ // @ts-ignore
+ // TODO: type this
+ let pfpparent2 = pfpparent.all;
+ pfpparent2 ??= pfpparent;
+ const old = new Date(pfpparent2.timestamp).getTime() / 1000;
+ const newt = new Date(this.timestamp).getTime() / 1000;
+ current = newt - old > 600;
+ }
+ const combine =
+ premessage?.author != this.author || current || this.message_reference;
+ if (combine) {
+ const pfp = this.author.buildpfp();
+ this.author.bind(pfp, this.guild, false);
+ pfpRow.appendChild(pfp);
+ } else {
+ div["pfpparent"] = pfpparent;
+ }
+ pfpRow.classList.add("pfprow");
+ build.appendChild(pfpRow);
+ const text = document.createElement("div");
+ text.classList.add("flexttb");
+ const texttxt = document.createElement("div");
+ texttxt.classList.add("commentrow", "flexttb");
+ text.appendChild(texttxt);
+ if (combine) {
+ const username = document.createElement("span");
+ username.classList.add("username");
+ this.author.bind(username, this.guild);
+ div.classList.add("topMessage");
+ username.textContent = this.author.username;
+ const userwrap = document.createElement("div");
+ userwrap.classList.add("flexltr");
+ userwrap.appendChild(username);
+ if (this.author.bot) {
+ const username = document.createElement("span");
+ username.classList.add("bot");
+ username.textContent = "BOT";
+ userwrap.appendChild(username);
+ }
+ const time = document.createElement("span");
+ time.textContent = " " + formatTime(new Date(this.timestamp));
+ time.classList.add("timestamp");
+ userwrap.appendChild(time);
+
+ texttxt.appendChild(userwrap);
+ } else {
+ div.classList.remove("topMessage");
+ }
+ const messaged = this.content.makeHTML();
+ (div as any)["txt"] = messaged;
+ const messagedwrap = document.createElement("div");
+ messagedwrap.classList.add("flexttb");
+ messagedwrap.appendChild(messaged);
+ texttxt.appendChild(messagedwrap);
+
+ build.appendChild(text);
+ if (this.attachments.length) {
+ console.log(this.attachments);
+ const attach = document.createElement("div");
+ attach.classList.add("flexltr");
+ for (const thing of this.attachments) {
+ attach.appendChild(thing.getHTML());
+ }
+ messagedwrap.appendChild(attach);
+ }
+ if (this.embeds.length) {
+ const embeds = document.createElement("div");
+ embeds.classList.add("flexltr");
+ for (const thing of this.embeds) {
+ embeds.appendChild(thing.generateHTML());
+ }
+ messagedwrap.appendChild(embeds);
+ }
+ //
+ } else if (this.type === 7) {
+ const text = document.createElement("div");
+ text.classList.add("flexttb");
+ const texttxt = document.createElement("div");
+ text.appendChild(texttxt);
+ build.appendChild(text);
+ texttxt.classList.add("flexltr");
+ const messaged = document.createElement("span");
+ div["txt"] = messaged;
+ messaged.textContent = "welcome: ";
+ texttxt.appendChild(messaged);
+
+ const username = document.createElement("span");
+ username.textContent = this.author.username;
+ //this.author.profileclick(username);
+ this.author.bind(username, this.guild);
+ texttxt.appendChild(username);
+ username.classList.add("username");
+
+ const time = document.createElement("span");
+ time.textContent = " " + formatTime(new Date(this.timestamp));
+ time.classList.add("timestamp");
+ texttxt.append(time);
+ div.classList.add("topMessage");
+ }
+ const reactions = document.createElement("div");
+ reactions.classList.add("flexltr", "reactiondiv");
+ this.reactdiv = new WeakRef(reactions);
+ this.updateReactions();
+ div.append(reactions);
+ this.bindButtonEvent();
+ return div;
+ }
+ bindButtonEvent() {
+ if (this.div) {
+ let buttons: HTMLDivElement | undefined;
+ this.div.onmouseenter = (_) => {
+ if (buttons) {
+ buttons.remove();
+ buttons = undefined;
+ }
+ if (this.div) {
+ buttons = document.createElement("div");
+ buttons.classList.add("messageButtons", "flexltr");
+ if (this.channel.hasPermission("SEND_MESSAGES")) {
+ const container = document.createElement("div");
+ const reply = document.createElement("span");
+ reply.classList.add("svgtheme", "svg-reply", "svgicon");
+ container.append(reply);
+ buttons.append(container);
+ container.onclick = (_) => {
+ this.channel.setReplying(this);
+ };
+ }
+ if (this.author === this.localuser.user) {
+ const container = document.createElement("div");
+ const edit = document.createElement("span");
+ edit.classList.add("svgtheme", "svg-edit", "svgicon");
+ container.append(edit);
+ buttons.append(container);
+ container.onclick = (_) => {
+ this.setEdit();
+ };
+ }
+ if (this.canDelete()) {
+ const container = document.createElement("div");
+ const reply = document.createElement("span");
+ reply.classList.add("svgtheme", "svg-delete", "svgicon");
+ container.append(reply);
+ buttons.append(container);
+ container.onclick = (_) => {
+ if (_.shiftKey) {
+ this.delete();
+ return;
+ }
+ const diaolog = new Dialog([
+ "hdiv",
+ ["title", "are you sure you want to delete this?"],
+ [
+ "button",
+ "",
+ "yes",
+ () => {
+ this.delete();
+ diaolog.hide();
+ },
+ ],
+ [
+ "button",
+ "",
+ "no",
+ () => {
+ diaolog.hide();
+ },
+ ],
+ ]);
+ diaolog.show();
+ };
+ }
+ if (buttons.childNodes.length !== 0) {
+ this.div.append(buttons);
+ }
+ }
+ };
+ this.div.onmouseleave = (_) => {
+ if (buttons) {
+ buttons.remove();
+ buttons = undefined;
+ }
+ };
+ }
+ }
+ updateReactions() {
+ const reactdiv = this.reactdiv.deref();
+ if (!reactdiv) return;
+ const func = this.channel.infinite.snapBottom();
+ reactdiv.innerHTML = "";
+ for (const thing of this.reactions) {
+ const reaction = document.createElement("div");
+ reaction.classList.add("reaction");
+ if (thing.me) {
+ reaction.classList.add("meReacted");
+ }
+ let emoji: HTMLElement;
+ if (thing.emoji.id || /\d{17,21}/.test(thing.emoji.name)) {
+ if (/\d{17,21}/.test(thing.emoji.name))
+ thing.emoji.id = thing.emoji.name; //Should stop being a thing once the server fixes this bug
+ const emo = new Emoji(
+ thing.emoji as { name: string; id: string; animated: boolean },
+ this.guild
+ );
+ emoji = emo.getHTML(false);
+ } else {
+ emoji = document.createElement("p");
+ emoji.textContent = thing.emoji.name;
+ }
+ const count = document.createElement("p");
+ count.textContent = "" + thing.count;
+ count.classList.add("reactionCount");
+ reaction.append(count);
+ reaction.append(emoji);
+ reactdiv.append(reaction);
+
+ reaction.onclick = (_) => {
+ this.reactionToggle(thing.emoji.name);
+ };
+ }
+ func();
+ }
+ reactionAdd(data: { name: string }, member: Member | { id: string }) {
+ for (const thing of this.reactions) {
+ if (thing.emoji.name === data.name) {
+ thing.count++;
+ if (member.id === this.localuser.user.id) {
+ thing.me = true;
+ this.updateReactions();
+ return;
+ }
+ }
+ }
+ this.reactions.push({
+ count: 1,
+ emoji: data,
+ me: member.id === this.localuser.user.id,
+ });
+ this.updateReactions();
+ }
+ reactionRemove(data: { name: string }, id: string) {
+ console.log("test");
+ for (const i in this.reactions) {
+ const thing = this.reactions[i];
+ console.log(thing, data);
+ if (thing.emoji.name === data.name) {
+ thing.count--;
+ if (thing.count === 0) {
+ this.reactions.splice(Number(i), 1);
+ this.updateReactions();
+ return;
+ }
+ if (id === this.localuser.user.id) {
+ thing.me = false;
+ this.updateReactions();
+ return;
+ }
+ }
+ }
+ }
+ reactionRemoveAll() {
+ this.reactions = [];
+ this.updateReactions();
+ }
+ reactionRemoveEmoji(emoji: Emoji) {
+ for (const i in this.reactions) {
+ const reaction = this.reactions[i];
+ if (
+ (reaction.emoji.id && reaction.emoji.id == emoji.id) ||
+ (!reaction.emoji.id && reaction.emoji.name == emoji.name)
+ ) {
+ this.reactions.splice(Number(i), 1);
+ this.updateReactions();
+ break;
+ }
+ }
+ }
+ buildhtml(premessage?: Message | undefined): HTMLElement {
+ if (this.div) {
+ console.error(`HTML for ${this.id} already exists, aborting`);
+ return this.div;
+ }
+ try {
+ const div = document.createElement("div");
+ this.div = div;
+ this.messageevents(div);
+ return this.generateMessage(premessage) as HTMLElement;
+ } catch (e) {
+ console.error(e);
+ }
+ return this.div as HTMLElement;
+ }
+}
+let now: string;
+let yesterdayStr: string;
+
+function formatTime(date: Date) {
+ updateTimes();
+ const datestring = date.toLocaleDateString();
+ const formatTime = (date: Date) =>
+ date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
+
+ if (datestring === now) {
+ return `Today at ${formatTime(date)}`;
+ } else if (datestring === yesterdayStr) {
+ return `Yesterday at ${formatTime(date)}`;
+ } else {
+ return `${date.toLocaleDateString()} at ${formatTime(date)}`;
+ }
+}
+let tomorrow = 0;
+updateTimes();
+function updateTimes() {
+ if (tomorrow < Date.now()) {
+ const d = new Date();
+ tomorrow = d.setHours(24, 0, 0, 0);
+ now = new Date().toLocaleDateString();
+ const yesterday = new Date(now);
+ yesterday.setDate(new Date().getDate() - 1);
+ yesterdayStr = yesterday.toLocaleDateString();
+ }
+}
+Message.setup();
+export { Message };
diff --git a/src/webpage/permissions.ts b/src/webpage/permissions.ts
new file mode 100644
index 0000000..308ffab
--- /dev/null
+++ b/src/webpage/permissions.ts
@@ -0,0 +1,347 @@
+class Permissions {
+ allow: bigint;
+ deny: bigint;
+ readonly hasDeny: boolean;
+ constructor(allow: string, deny: string = "") {
+ this.hasDeny = Boolean(deny);
+ try {
+ this.allow = BigInt(allow);
+ this.deny = BigInt(deny);
+ } catch {
+ this.allow = 0n;
+ this.deny = 0n;
+ console.error(
+ `Something really stupid happened with a permission with allow being ${allow} and deny being, ${deny}, execution will still happen, but something really stupid happened, please report if you know what caused this.`
+ );
+ }
+ }
+ getPermissionbit(b: number, big: bigint): boolean {
+ return Boolean((big >> BigInt(b)) & 1n);
+ }
+ setPermissionbit(b: number, state: boolean, big: bigint): bigint {
+ const bit = 1n << BigInt(b);
+ return (big & ~bit) | (BigInt(state) << BigInt(b)); //thanks to geotale for this code :3
+ }
+ static map: {
+ [key: number | string]:
+ | { name: string; readableName: string; description: string }
+ | number;
+ };
+ static info: { name: string; readableName: string; description: string }[];
+ static makeMap() {
+ Permissions.info = [
+ //for people in the future, do not reorder these, the creation of the map realize on the order
+ {
+ name: "CREATE_INSTANT_INVITE",
+ readableName: "Create invite",
+ description: "Allows the user to create invites for the guild",
+ },
+ {
+ name: "KICK_MEMBERS",
+ readableName: "Kick members",
+ description: "Allows the user to kick members from the guild",
+ },
+ {
+ name: "BAN_MEMBERS",
+ readableName: "Ban members",
+ description: "Allows the user to ban members from the guild",
+ },
+ {
+ name: "ADMINISTRATOR",
+ readableName: "Administrator",
+ description:
+ "Allows all permissions and bypasses channel permission overwrites. This is a dangerous permission!",
+ },
+ {
+ name: "MANAGE_CHANNELS",
+ readableName: "Manage channels",
+ description: "Allows the user to manage and edit channels",
+ },
+ {
+ name: "MANAGE_GUILD",
+ readableName: "Manage guild",
+ description: "Allows management and editing of the guild",
+ },
+ {
+ name: "ADD_REACTIONS",
+ readableName: "Add reactions",
+ description: "Allows user to add reactions to messages",
+ },
+ {
+ name: "VIEW_AUDIT_LOG",
+ readableName: "View audit log",
+ description: "Allows the user to view the audit log",
+ },
+ {
+ name: "PRIORITY_SPEAKER",
+ readableName: "Priority speaker",
+ description: "Allows for using priority speaker in a voice channel",
+ },
+ {
+ name: "STREAM",
+ readableName: "Video",
+ description: "Allows the user to stream",
+ },
+ {
+ name: "VIEW_CHANNEL",
+ readableName: "View channels",
+ description: "Allows the user to view the channel",
+ },
+ {
+ name: "SEND_MESSAGES",
+ readableName: "Send messages",
+ description: "Allows user to send messages",
+ },
+ {
+ name: "SEND_TTS_MESSAGES",
+ readableName: "Send text-to-speech messages",
+ description: "Allows the user to send text-to-speech messages",
+ },
+ {
+ name: "MANAGE_MESSAGES",
+ readableName: "Manage messages",
+ description: "Allows the user to delete messages that aren't their own",
+ },
+ {
+ name: "EMBED_LINKS",
+ readableName: "Embed links",
+ description: "Allow links sent by this user to auto-embed",
+ },
+ {
+ name: "ATTACH_FILES",
+ readableName: "Attach files",
+ description: "Allows the user to attach files",
+ },
+ {
+ name: "READ_MESSAGE_HISTORY",
+ readableName: "Read message history",
+ description: "Allows user to read the message history",
+ },
+ {
+ name: "MENTION_EVERYONE",
+ readableName: "Mention @everyone, @here and all roles",
+ description: "Allows the user to mention everyone",
+ },
+ {
+ name: "USE_EXTERNAL_EMOJIS",
+ readableName: "Use external emojis",
+ description: "Allows the user to use external emojis",
+ },
+ {
+ name: "VIEW_GUILD_INSIGHTS",
+ readableName: "View guild insights",
+ description: "Allows the user to see guild insights",
+ },
+ {
+ name: "CONNECT",
+ readableName: "Connect",
+ description: "Allows the user to connect to a voice channel",
+ },
+ {
+ name: "SPEAK",
+ readableName: "Speak",
+ description: "Allows the user to speak in a voice channel",
+ },
+ {
+ name: "MUTE_MEMBERS",
+ readableName: "Mute members",
+ description: "Allows user to mute other members",
+ },
+ {
+ name: "DEAFEN_MEMBERS",
+ readableName: "Deafen members",
+ description: "Allows user to deafen other members",
+ },
+ {
+ name: "MOVE_MEMBERS",
+ readableName: "Move members",
+ description: "Allows the user to move members between voice channels",
+ },
+ {
+ name: "USE_VAD",
+ readableName: "Use voice activity detection",
+ description:
+ "Allows users to speak in a voice channel by simply talking",
+ },
+ {
+ name: "CHANGE_NICKNAME",
+ readableName: "Change nickname",
+ description: "Allows the user to change their own nickname",
+ },
+ {
+ name: "MANAGE_NICKNAMES",
+ readableName: "Manage nicknames",
+ description: "Allows user to change nicknames of other members",
+ },
+ {
+ name: "MANAGE_ROLES",
+ readableName: "Manage roles",
+ description: "Allows user to edit and manage roles",
+ },
+ {
+ name: "MANAGE_WEBHOOKS",
+ readableName: "Manage webhooks",
+ description: "Allows management and editing of webhooks",
+ },
+ {
+ name: "MANAGE_GUILD_EXPRESSIONS",
+ readableName: "Manage expressions",
+ description: "Allows for managing emoji, stickers, and soundboards",
+ },
+ {
+ name: "USE_APPLICATION_COMMANDS",
+ readableName: "Use application commands",
+ description: "Allows the user to use application commands",
+ },
+ {
+ name: "REQUEST_TO_SPEAK",
+ readableName: "Request to speak",
+ description: "Allows user to request to speak in stage channel",
+ },
+ {
+ name: "MANAGE_EVENTS",
+ readableName: "Manage events",
+ description: "Allows user to edit and manage events",
+ },
+ {
+ name: "MANAGE_THREADS",
+ readableName: "Manage threads",
+ description:
+ "Allows the user to delete and archive threads and view all private threads",
+ },
+ {
+ name: "CREATE_PUBLIC_THREADS",
+ readableName: "Create public threads",
+ description: "Allows the user to create public threads",
+ },
+ {
+ name: "CREATE_PRIVATE_THREADS",
+ readableName: "Create private threads",
+ description: "Allows the user to create private threads",
+ },
+ {
+ name: "USE_EXTERNAL_STICKERS",
+ readableName: "Use external stickers",
+ description: "Allows user to use external stickers",
+ },
+ {
+ name: "SEND_MESSAGES_IN_THREADS",
+ readableName: "Send messages in threads",
+ description: "Allows the user to send messages in threads",
+ },
+ {
+ name: "USE_EMBEDDED_ACTIVITIES",
+ readableName: "Use activities",
+ description: "Allows the user to use embedded activities",
+ },
+ {
+ name: "MODERATE_MEMBERS",
+ readableName: "Timeout members",
+ description:
+ "Allows the user to time out other users to prevent them from sending or reacting to messages in chat and threads, and from speaking in voice and stage channels",
+ },
+ {
+ name: "VIEW_CREATOR_MONETIZATION_ANALYTICS",
+ readableName: "View creator monetization analytics",
+ description: "Allows for viewing role subscription insights",
+ },
+ {
+ name: "USE_SOUNDBOARD",
+ readableName: "Use soundboard",
+ description: "Allows for using soundboard in a voice channel",
+ },
+ {
+ name: "CREATE_GUILD_EXPRESSIONS",
+ readableName: "Create expressions",
+ description:
+ "Allows for creating emojis, stickers, and soundboard sounds, and editing and deleting those created by the current user.",
+ },
+ {
+ name: "CREATE_EVENTS",
+ readableName: "Create events",
+ description:
+ "Allows for creating scheduled events, and editing and deleting those created by the current user.",
+ },
+ {
+ name: "USE_EXTERNAL_SOUNDS",
+ readableName: "Use external sounds",
+ description:
+ "Allows the usage of custom soundboard sounds from other servers",
+ },
+ {
+ name: "SEND_VOICE_MESSAGES",
+ readableName: "Send voice messages",
+ description: "Allows sending voice messages",
+ },
+ {
+ name: "SEND_POLLS",
+ readableName: "Create polls",
+ description: "Allows sending polls",
+ },
+ {
+ name: "USE_EXTERNAL_APPS",
+ readableName: "Use external apps",
+ description:
+ "Allows user-installed apps to send public responses. " +
+ "When disabled, users will still be allowed to use their apps but the responses will be ephemeral. " +
+ "This only applies to apps not also installed to the server.",
+ },
+ ];
+ Permissions.map = {};
+ let i = 0;
+ for (const thing of Permissions.info) {
+ Permissions.map[i] = thing;
+ Permissions.map[thing.name] = i;
+ i++;
+ }
+ }
+ getPermission(name: string): number {
+ if (this.getPermissionbit(Permissions.map[name] as number, this.allow)) {
+ return 1;
+ } else if (
+ this.getPermissionbit(Permissions.map[name] as number, this.deny)
+ ) {
+ return -1;
+ } else {
+ return 0;
+ }
+ }
+ hasPermission(name: string): boolean {
+ if (this.deny) {
+ console.warn(
+ "This function may of been used in error, think about using getPermision instead"
+ );
+ }
+ if (this.getPermissionbit(Permissions.map[name] as number, this.allow))
+ return true;
+ if (name != "ADMINISTRATOR") return this.hasPermission("ADMINISTRATOR");
+ return false;
+ }
+ setPermission(name: string, setto: number): void {
+ const bit = Permissions.map[name] as number;
+ if (!bit) {
+ return console.error(
+ "Tried to set permission to " +
+ setto +
+ " for " +
+ name +
+ " but it doesn't exist"
+ );
+ }
+
+ if (setto === 0) {
+ this.deny = this.setPermissionbit(bit, false, this.deny);
+ this.allow = this.setPermissionbit(bit, false, this.allow);
+ } else if (setto === 1) {
+ this.deny = this.setPermissionbit(bit, false, this.deny);
+ this.allow = this.setPermissionbit(bit, true, this.allow);
+ } else if (setto === -1) {
+ this.deny = this.setPermissionbit(bit, true, this.deny);
+ this.allow = this.setPermissionbit(bit, false, this.allow);
+ } else {
+ console.error("invalid number entered:" + setto);
+ }
+ }
+}
+Permissions.makeMap();
+export { Permissions };
diff --git a/webpage/register.html b/src/webpage/register.html
similarity index 100%
rename from webpage/register.html
rename to src/webpage/register.html
diff --git a/src/webpage/register.ts b/src/webpage/register.ts
new file mode 100644
index 0000000..f36f46d
--- /dev/null
+++ b/src/webpage/register.ts
@@ -0,0 +1,152 @@
+import { checkInstance, adduser } from "./login.js";
+
+const registerElement = document.getElementById("register");
+if (registerElement) {
+ registerElement.addEventListener("submit", registertry);
+}
+
+async function registertry(e: Event) {
+ e.preventDefault();
+ const elements = (e.target as HTMLFormElement)
+ .elements as HTMLFormControlsCollection;
+ const email = (elements[1] as HTMLInputElement).value;
+ const username = (elements[2] as HTMLInputElement).value;
+ const password = (elements[3] as HTMLInputElement).value;
+ const confirmPassword = (elements[4] as HTMLInputElement).value;
+ const dateofbirth = (elements[5] as HTMLInputElement).value;
+ const consent = (elements[6] as HTMLInputElement).checked;
+ const captchaKey = (elements[7] as HTMLInputElement)?.value;
+
+ if (password !== confirmPassword) {
+ (document.getElementById("wrong") as HTMLElement).textContent =
+ "Passwords don't match";
+ return;
+ }
+
+ const instanceInfo = JSON.parse(localStorage.getItem("instanceinfo") ?? "{}");
+ const apiurl = new URL(instanceInfo.api);
+
+ try {
+ const response = await fetch(apiurl + "/auth/register", {
+ body: JSON.stringify({
+ date_of_birth: dateofbirth,
+ email,
+ username,
+ password,
+ consent,
+ captcha_key: captchaKey,
+ }),
+ headers: {
+ "content-type": "application/json",
+ },
+ method: "POST",
+ });
+
+ const data = await response.json();
+
+ if (data.captcha_sitekey) {
+ const capt = document.getElementById("h-captcha");
+ if (capt && !capt.children.length) {
+ const capty = document.createElement("div");
+ capty.classList.add("h-captcha");
+ capty.setAttribute("data-sitekey", data.captcha_sitekey);
+ const script = document.createElement("script");
+ script.src = "https://js.hcaptcha.com/1/api.js";
+ capt.append(script);
+ capt.append(capty);
+ } else {
+ eval("hcaptcha.reset()");
+ }
+ return;
+ }
+
+ if (!data.token) {
+ handleErrors(data.errors, elements);
+ } else {
+ adduser({
+ serverurls: instanceInfo,
+ email,
+ token: data.token,
+ }).username = username;
+ localStorage.setItem("token", data.token);
+ const redir = new URLSearchParams(window.location.search).get("goback");
+ window.location.href = redir ? redir : "/channels/@me";
+ }
+ } catch (error) {
+ console.error("Registration failed:", error);
+ }
+}
+
+function handleErrors(errors: any, elements: HTMLFormControlsCollection) {
+ if (errors.consent) {
+ showError(elements[6] as HTMLElement, errors.consent._errors[0].message);
+ } else if (errors.password) {
+ showError(
+ elements[3] as HTMLElement,
+ "Password: " + errors.password._errors[0].message
+ );
+ } else if (errors.username) {
+ showError(
+ elements[2] as HTMLElement,
+ "Username: " + errors.username._errors[0].message
+ );
+ } else if (errors.email) {
+ showError(
+ elements[1] as HTMLElement,
+ "Email: " + errors.email._errors[0].message
+ );
+ } else if (errors.date_of_birth) {
+ showError(
+ elements[5] as HTMLElement,
+ "Date of Birth: " + errors.date_of_birth._errors[0].message
+ );
+ } else {
+ (document.getElementById("wrong") as HTMLElement).textContent =
+ errors[Object.keys(errors)[0]]._errors[0].message;
+ }
+}
+
+function showError(element: HTMLElement, message: string) {
+ const parent = element.parentElement!;
+ let errorElement = parent.getElementsByClassName(
+ "suberror"
+ )[0] as HTMLElement;
+ if (!errorElement) {
+ const div = document.createElement("div");
+ div.classList.add("suberror", "suberrora");
+ parent.append(div);
+ errorElement = div;
+ } else {
+ errorElement.classList.remove("suberror");
+ setTimeout(() => {
+ errorElement.classList.add("suberror");
+ }, 100);
+ }
+ errorElement.textContent = message;
+}
+
+let TOSa = document.getElementById("TOSa") as HTMLAnchorElement | null;
+
+async function tosLogic() {
+ const instanceInfo = JSON.parse(localStorage.getItem("instanceinfo") ?? "{}");
+ const apiurl = new URL(instanceInfo.api);
+ const response = await fetch(apiurl.toString() + "/ping");
+ const data = await response.json();
+ const tosPage = data.instance.tosPage;
+
+ if (tosPage) {
+ document.getElementById("TOSbox")!.innerHTML =
+ 'I agree to the Terms of Service:';
+ TOSa = document.getElementById("TOSa") as HTMLAnchorElement;
+ TOSa.href = tosPage;
+ } else {
+ document.getElementById("TOSbox")!.textContent =
+ "This instance has no Terms of Service, accept ToS anyways:";
+ TOSa = null;
+ }
+ console.log(tosPage);
+}
+
+tosLogic();
+
+(checkInstance as any)["alt"] = tosLogic;
diff --git a/src/webpage/role.ts b/src/webpage/role.ts
new file mode 100644
index 0000000..a024aae
--- /dev/null
+++ b/src/webpage/role.ts
@@ -0,0 +1,180 @@
+import { Permissions } from "./permissions.js";
+import { Localuser } from "./localuser.js";
+import { Guild } from "./guild.js";
+import { SnowFlake } from "./snowflake.js";
+import { rolesjson } from "./jsontypes.js";
+class Role extends SnowFlake {
+ permissions: Permissions;
+ owner: Guild;
+ color!: number;
+ name!: string;
+ info: Guild["info"];
+ hoist!: boolean;
+ icon!: string;
+ mentionable!: boolean;
+ unicode_emoji!: string;
+ headers: Guild["headers"];
+ constructor(json: rolesjson, owner: Guild) {
+ super(json.id);
+ this.headers = owner.headers;
+ this.info = owner.info;
+ for (const thing of Object.keys(json)) {
+ if (thing === "id") {
+ continue;
+ }
+ (this as any)[thing] = (json as any)[thing];
+ }
+ this.permissions = new Permissions(json.permissions);
+ this.owner = owner;
+ }
+ get guild(): Guild {
+ return this.owner;
+ }
+ get localuser(): Localuser {
+ return this.guild.localuser;
+ }
+ getColor(): string | null {
+ if (this.color === 0) {
+ return null;
+ }
+ return `#${this.color.toString(16)}`;
+ }
+}
+export { Role };
+import { Options } from "./settings.js";
+class PermissionToggle implements OptionsElement {
+ readonly rolejson: {
+ name: string;
+ readableName: string;
+ description: string;
+ };
+ permissions: Permissions;
+ owner: Options;
+ value!: number;
+ constructor(
+ roleJSON: PermissionToggle["rolejson"],
+ permissions: Permissions,
+ owner: Options
+ ) {
+ this.rolejson = roleJSON;
+ this.permissions = permissions;
+ this.owner = owner;
+ }
+ watchForChange() {}
+ generateHTML(): HTMLElement {
+ const div = document.createElement("div");
+ div.classList.add("setting");
+ const name = document.createElement("span");
+ name.textContent = this.rolejson.readableName;
+ name.classList.add("settingsname");
+ div.append(name);
+
+ div.append(this.generateCheckbox());
+ const p = document.createElement("p");
+ p.textContent = this.rolejson.description;
+ div.appendChild(p);
+ return div;
+ }
+ generateCheckbox(): HTMLElement {
+ const div = document.createElement("div");
+ div.classList.add("tritoggle");
+ const state = this.permissions.getPermission(this.rolejson.name);
+
+ const on = document.createElement("input");
+ on.type = "radio";
+ on.name = this.rolejson.name;
+ div.append(on);
+ if (state === 1) {
+ on.checked = true;
+ }
+ on.onclick = (_) => {
+ this.permissions.setPermission(this.rolejson.name, 1);
+ this.owner.changed();
+ };
+
+ const no = document.createElement("input");
+ no.type = "radio";
+ no.name = this.rolejson.name;
+ div.append(no);
+ if (state === 0) {
+ no.checked = true;
+ }
+ no.onclick = (_) => {
+ this.permissions.setPermission(this.rolejson.name, 0);
+ this.owner.changed();
+ };
+ if (this.permissions.hasDeny) {
+ const off = document.createElement("input");
+ off.type = "radio";
+ off.name = this.rolejson.name;
+ div.append(off);
+ if (state === -1) {
+ off.checked = true;
+ }
+ off.onclick = (_) => {
+ this.permissions.setPermission(this.rolejson.name, -1);
+ this.owner.changed();
+ };
+ }
+ return div;
+ }
+ submit() {}
+}
+import { OptionsElement, Buttons } from "./settings.js";
+class RoleList extends Buttons {
+ readonly permissions: [Role, Permissions][];
+ permission: Permissions;
+ readonly guild: Guild;
+ readonly channel: boolean;
+ declare readonly buttons: [string, string][];
+ readonly options: Options;
+ onchange: Function;
+ curid!: string;
+ constructor(
+ permissions: [Role, Permissions][],
+ guild: Guild,
+ onchange: Function,
+ channel = false
+ ) {
+ super("Roles");
+ this.guild = guild;
+ this.permissions = permissions;
+ this.channel = channel;
+ this.onchange = onchange;
+ const options = new Options("", this);
+ if (channel) {
+ this.permission = new Permissions("0", "0");
+ } else {
+ this.permission = new Permissions("0");
+ }
+ for (const thing of Permissions.info) {
+ options.options.push(
+ new PermissionToggle(thing, this.permission, options)
+ );
+ }
+ for (const i of permissions) {
+ console.log(i);
+ this.buttons.push([i[0].name, i[0].id]);
+ }
+ this.options = options;
+ }
+ handleString(str: string): HTMLElement {
+ this.curid = str;
+ const arr = this.permissions.find((_) => _[0].id === str);
+ if (arr) {
+ const perm = arr[1];
+ this.permission.deny = perm.deny;
+ this.permission.allow = perm.allow;
+ const role = this.permissions.find((e) => e[0].id === str);
+ if (role) {
+ this.options.name = role[0].name;
+ this.options.haschanged = false;
+ }
+ }
+ return this.options.generateHTML();
+ }
+ save() {
+ this.onchange(this.curid, this.permission);
+ }
+}
+export { RoleList };
diff --git a/src/webpage/service.ts b/src/webpage/service.ts
new file mode 100644
index 0000000..67256c6
--- /dev/null
+++ b/src/webpage/service.ts
@@ -0,0 +1,96 @@
+function deleteoldcache() {
+ caches.delete("cache");
+ console.log("this ran :P");
+}
+
+async function putInCache(request: URL | RequestInfo, response: Response) {
+ console.log(request, response);
+ const cache = await caches.open("cache");
+ console.log("Grabbed");
+ try {
+ console.log(await cache.put(request, response));
+ } catch (error) {
+ console.error(error);
+ }
+}
+console.log("test");
+
+let lastcache: string;
+self.addEventListener("activate", async () => {
+ console.log("test2");
+ checkCache();
+});
+async function checkCache() {
+ if (checkedrecently) {
+ return;
+ }
+ const promise = await caches.match("/getupdates");
+ if (promise) {
+ lastcache = await promise.text();
+ }
+ console.log(lastcache);
+ fetch("/getupdates").then(async (data) => {
+ const text = await data.clone().text();
+ console.log(text, lastcache);
+ if (lastcache !== text) {
+ deleteoldcache();
+ putInCache("/getupdates", data.clone());
+ }
+ checkedrecently = true;
+ setTimeout((_: any) => {
+ checkedrecently = false;
+ }, 1000 * 60 * 30);
+ });
+}
+var checkedrecently = false;
+function samedomain(url: string | URL) {
+ return new URL(url).origin === self.origin;
+}
+function isindexhtml(url: string | URL) {
+ console.log(url);
+ if (new URL(url).pathname.startsWith("/channels")) {
+ return true;
+ }
+ return false;
+}
+async function getfile(event: {
+ request: { url: URL | RequestInfo; clone: () => string | URL | Request };
+}) {
+ checkCache();
+ if (!samedomain(event.request.url.toString())) {
+ return await fetch(event.request.clone());
+ }
+ const responseFromCache = await caches.match(event.request.url);
+ console.log(responseFromCache, caches);
+ if (responseFromCache) {
+ console.log("cache hit");
+ return responseFromCache;
+ }
+ if (isindexhtml(event.request.url.toString())) {
+ console.log("is index.html");
+ const responseFromCache = await caches.match("/index.html");
+ if (responseFromCache) {
+ console.log("cache hit");
+ return responseFromCache;
+ }
+ const responseFromNetwork = await fetch("/index.html");
+ await putInCache("/index.html", responseFromNetwork.clone());
+ return responseFromNetwork;
+ }
+ const responseFromNetwork = await fetch(event.request.clone());
+ console.log(event.request.clone());
+ await putInCache(event.request.clone(), responseFromNetwork.clone());
+ try {
+ return responseFromNetwork;
+ } catch (e) {
+ console.error(e);
+ return e;
+ }
+}
+self.addEventListener("fetch", (event: any) => {
+ try {
+ event.respondWith(getfile(event));
+ } catch (e) {
+ console.error(e);
+ }
+});
diff --git a/src/webpage/settings.ts b/src/webpage/settings.ts
new file mode 100644
index 0000000..83e66d2
--- /dev/null
+++ b/src/webpage/settings.ts
@@ -0,0 +1,1113 @@
+interface OptionsElement {
+ //
+ generateHTML(): HTMLElement;
+ submit: () => void;
+ readonly watchForChange: (func: (arg1: x) => void) => void;
+ value: x;
+}
+//future me stuff
+class Buttons implements OptionsElement {
+ readonly name: string;
+ readonly buttons: [string, Options | string][];
+ buttonList!: HTMLDivElement;
+ warndiv!: HTMLElement;
+ value: unknown;
+ constructor(name: string) {
+ this.buttons = [];
+ this.name = name;
+ }
+ add(name: string, thing?: Options | undefined) {
+ if (!thing) {
+ thing = new Options(name, this);
+ }
+ this.buttons.push([name, thing]);
+ return thing;
+ }
+ generateHTML() {
+ const buttonList = document.createElement("div");
+ buttonList.classList.add("Buttons");
+ buttonList.classList.add("flexltr");
+ this.buttonList = buttonList;
+ const htmlarea = document.createElement("div");
+ htmlarea.classList.add("flexgrow");
+ const buttonTable = document.createElement("div");
+ buttonTable.classList.add("flexttb", "settingbuttons");
+ for (const thing of this.buttons) {
+ const button = document.createElement("button");
+ button.classList.add("SettingsButton");
+ button.textContent = thing[0];
+ button.onclick = (_) => {
+ this.generateHTMLArea(thing[1], htmlarea);
+ if (this.warndiv) {
+ this.warndiv.remove();
+ }
+ };
+ buttonTable.append(button);
+ }
+ this.generateHTMLArea(this.buttons[0][1], htmlarea);
+ buttonList.append(buttonTable);
+ buttonList.append(htmlarea);
+ return buttonList;
+ }
+ handleString(str: string): HTMLElement {
+ const div = document.createElement("span");
+ div.textContent = str;
+ return div;
+ }
+ private generateHTMLArea(
+ buttonInfo: Options | string,
+ htmlarea: HTMLElement
+ ) {
+ let html: HTMLElement;
+ if (buttonInfo instanceof Options) {
+ buttonInfo.subOptions = undefined;
+ html = buttonInfo.generateHTML();
+ } else {
+ html = this.handleString(buttonInfo);
+ }
+ htmlarea.innerHTML = "";
+ htmlarea.append(html);
+ }
+ changed(html: HTMLElement) {
+ this.warndiv = html;
+ this.buttonList.append(html);
+ }
+ watchForChange() {}
+ save() {}
+ submit() {}
+}
+
+class TextInput implements OptionsElement {
+ readonly label: string;
+ readonly owner: Options;
+ readonly onSubmit: (str: string) => void;
+ value: string;
+ input!: WeakRef;
+ password: boolean;
+ constructor(
+ label: string,
+ onSubmit: (str: string) => void,
+ owner: Options,
+ { initText = "", password = false } = {}
+ ) {
+ this.label = label;
+ this.value = initText;
+ this.owner = owner;
+ this.onSubmit = onSubmit;
+ this.password = password;
+ }
+ generateHTML(): HTMLDivElement {
+ const div = document.createElement("div");
+ const span = document.createElement("span");
+ span.textContent = this.label;
+ div.append(span);
+ const input = document.createElement("input");
+ input.value = this.value;
+ input.type = this.password ? "password" : "text";
+ input.oninput = this.onChange.bind(this);
+ this.input = new WeakRef(input);
+ div.append(input);
+ return div;
+ }
+ private onChange() {
+ this.owner.changed();
+ const input = this.input.deref();
+ if (input) {
+ const value = input.value as string;
+ this.onchange(value);
+ this.value = value;
+ }
+ }
+ onchange: (str: string) => void = (_) => {};
+ watchForChange(func: (str: string) => void) {
+ this.onchange = func;
+ }
+ submit() {
+ this.onSubmit(this.value);
+ }
+}
+
+class SettingsText implements OptionsElement {
+ readonly onSubmit!: (str: string) => void;
+ value!: void;
+ readonly text: string;
+ constructor(text: string) {
+ this.text = text;
+ }
+ generateHTML(): HTMLSpanElement {
+ const span = document.createElement("span");
+ span.innerText = this.text;
+ return span;
+ }
+ watchForChange() {}
+ submit() {}
+}
+class SettingsTitle implements OptionsElement {
+ readonly onSubmit!: (str: string) => void;
+ value!: void;
+ readonly text: string;
+ constructor(text: string) {
+ this.text = text;
+ }
+ generateHTML(): HTMLSpanElement {
+ const span = document.createElement("h2");
+ span.innerText = this.text;
+ return span;
+ }
+ watchForChange() {}
+ submit() {}
+}
+class CheckboxInput implements OptionsElement {
+ readonly label: string;
+ readonly owner: Options;
+ readonly onSubmit: (str: boolean) => void;
+ value: boolean;
+ input!: WeakRef;
+ constructor(
+ label: string,
+ onSubmit: (str: boolean) => void,
+ owner: Options,
+ { initState = false } = {}
+ ) {
+ this.label = label;
+ this.value = initState;
+ this.owner = owner;
+ this.onSubmit = onSubmit;
+ }
+ generateHTML(): HTMLDivElement {
+ const div = document.createElement("div");
+ const span = document.createElement("span");
+ span.textContent = this.label;
+ div.append(span);
+ const input = document.createElement("input");
+ input.type = "checkbox";
+ input.checked = this.value;
+ input.oninput = this.onChange.bind(this);
+ this.input = new WeakRef(input);
+ div.append(input);
+ return div;
+ }
+ private onChange() {
+ this.owner.changed();
+ const input = this.input.deref();
+ if (input) {
+ const value = input.checked as boolean;
+ this.onchange(value);
+ this.value = value;
+ }
+ }
+ onchange: (str: boolean) => void = (_) => {};
+ watchForChange(func: (str: boolean) => void) {
+ this.onchange = func;
+ }
+ submit() {
+ this.onSubmit(this.value);
+ }
+}
+
+class ButtonInput implements OptionsElement {
+ readonly label: string;
+ readonly owner: Options;
+ readonly onClick: () => void;
+ textContent: string;
+ value!: void;
+ constructor(
+ label: string,
+ textContent: string,
+ onClick: () => void,
+ owner: Options,
+ {} = {}
+ ) {
+ this.label = label;
+ this.owner = owner;
+ this.onClick = onClick;
+ this.textContent = textContent;
+ }
+ generateHTML(): HTMLDivElement {
+ const div = document.createElement("div");
+ const span = document.createElement("span");
+ span.textContent = this.label;
+ div.append(span);
+ const button = document.createElement("button");
+ button.textContent = this.textContent;
+ button.onclick = this.onClickEvent.bind(this);
+ div.append(button);
+ return div;
+ }
+ private onClickEvent() {
+ this.onClick();
+ }
+ watchForChange() {}
+ submit() {}
+}
+
+class ColorInput implements OptionsElement {
+ readonly label: string;
+ readonly owner: Options;
+ readonly onSubmit: (str: string) => void;
+ colorContent: string;
+ input!: WeakRef;
+ value!: string;
+ constructor(
+ label: string,
+ onSubmit: (str: string) => void,
+ owner: Options,
+ { initColor = "" } = {}
+ ) {
+ this.label = label;
+ this.colorContent = initColor;
+ this.owner = owner;
+ this.onSubmit = onSubmit;
+ }
+ generateHTML(): HTMLDivElement {
+ const div = document.createElement("div");
+ const span = document.createElement("span");
+ span.textContent = this.label;
+ div.append(span);
+ const input = document.createElement("input");
+ input.value = this.colorContent;
+ input.type = "color";
+ input.oninput = this.onChange.bind(this);
+ this.input = new WeakRef(input);
+ div.append(input);
+ return div;
+ }
+ private onChange() {
+ this.owner.changed();
+ const input = this.input.deref();
+ if (input) {
+ const value = input.value as string;
+ this.value = value;
+ this.onchange(value);
+ this.colorContent = value;
+ }
+ }
+ onchange: (str: string) => void = (_) => {};
+ watchForChange(func: (str: string) => void) {
+ this.onchange = func;
+ }
+ submit() {
+ this.onSubmit(this.colorContent);
+ }
+}
+
+class SelectInput implements OptionsElement {
+ readonly label: string;
+ readonly owner: Options;
+ readonly onSubmit: (str: number) => void;
+ options: string[];
+ index: number;
+ select!: WeakRef;
+ get value() {
+ return this.index;
+ }
+ constructor(
+ label: string,
+ onSubmit: (str: number) => void,
+ options: string[],
+ owner: Options,
+ { defaultIndex = 0 } = {}
+ ) {
+ this.label = label;
+ this.index = defaultIndex;
+ this.owner = owner;
+ this.onSubmit = onSubmit;
+ this.options = options;
+ }
+ generateHTML(): HTMLDivElement {
+ const div = document.createElement("div");
+ const span = document.createElement("span");
+ span.textContent = this.label;
+ div.append(span);
+ const select = document.createElement("select");
+
+ select.onchange = this.onChange.bind(this);
+ for (const thing of this.options) {
+ const option = document.createElement("option");
+ option.textContent = thing;
+ select.appendChild(option);
+ }
+ this.select = new WeakRef(select);
+ select.selectedIndex = this.index;
+ div.append(select);
+ return div;
+ }
+ private onChange() {
+ this.owner.changed();
+ const select = this.select.deref();
+ if (select) {
+ const value = select.selectedIndex;
+ this.onchange(value);
+ this.index = value;
+ }
+ }
+ onchange: (str: number) => void = (_) => {};
+ watchForChange(func: (str: number) => void) {
+ this.onchange = func;
+ }
+ submit() {
+ this.onSubmit(this.index);
+ }
+}
+class MDInput implements OptionsElement {
+ readonly label: string;
+ readonly owner: Options;
+ readonly onSubmit: (str: string) => void;
+ value: string;
+ input!: WeakRef;
+ constructor(
+ label: string,
+ onSubmit: (str: string) => void,
+ owner: Options,
+ { initText = "" } = {}
+ ) {
+ this.label = label;
+ this.value = initText;
+ this.owner = owner;
+ this.onSubmit = onSubmit;
+ }
+ generateHTML(): HTMLDivElement {
+ const div = document.createElement("div");
+ const span = document.createElement("span");
+ span.textContent = this.label;
+ div.append(span);
+ div.append(document.createElement("br"));
+ const input = document.createElement("textarea");
+ input.value = this.value;
+ input.oninput = this.onChange.bind(this);
+ this.input = new WeakRef(input);
+ div.append(input);
+ return div;
+ }
+ onChange() {
+ this.owner.changed();
+ const input = this.input.deref();
+ if (input) {
+ const value = input.value as string;
+ this.onchange(value);
+ this.value = value;
+ }
+ }
+ onchange: (str: string) => void = (_) => {};
+ watchForChange(func: (str: string) => void) {
+ this.onchange = func;
+ }
+ submit() {
+ this.onSubmit(this.value);
+ }
+}
+class FileInput implements OptionsElement {
+ readonly label: string;
+ readonly owner: Options;
+ readonly onSubmit: (str: FileList | null) => void;
+ input!: WeakRef;
+ value!: FileList | null;
+ clear: boolean;
+ constructor(
+ label: string,
+ onSubmit: (str: FileList | null) => void,
+ owner: Options,
+ { clear = false } = {}
+ ) {
+ this.label = label;
+ this.owner = owner;
+ this.onSubmit = onSubmit;
+ this.clear = clear;
+ }
+ generateHTML(): HTMLDivElement {
+ const div = document.createElement("div");
+ const span = document.createElement("span");
+ span.textContent = this.label;
+ div.append(span);
+ const input = document.createElement("input");
+ input.type = "file";
+ input.oninput = this.onChange.bind(this);
+ this.input = new WeakRef(input);
+ div.append(input);
+ if (this.clear) {
+ const button = document.createElement("button");
+ button.textContent = "Clear";
+ button.onclick = (_) => {
+ if (this.onchange) {
+ this.onchange(null);
+ }
+ this.value = null;
+ this.owner.changed();
+ };
+ div.append(button);
+ }
+ return div;
+ }
+ onChange() {
+ this.owner.changed();
+ const input = this.input.deref();
+ if (input) {
+ this.value = input.files;
+ if (this.onchange) {
+ this.onchange(input.files);
+ }
+ }
+ }
+ onchange: ((str: FileList | null) => void) | null = null;
+ watchForChange(func: (str: FileList | null) => void) {
+ this.onchange = func;
+ }
+ submit() {
+ const input = this.input.deref();
+ if (input) {
+ this.onSubmit(input.files);
+ }
+ }
+}
+
+class HtmlArea implements OptionsElement {
+ submit: () => void;
+ html: (() => HTMLElement) | HTMLElement;
+ value!: void;
+ constructor(html: (() => HTMLElement) | HTMLElement, submit: () => void) {
+ this.submit = submit;
+ this.html = html;
+ }
+ generateHTML(): HTMLElement {
+ if (this.html instanceof Function) {
+ return this.html();
+ } else {
+ return this.html;
+ }
+ }
+ watchForChange() {}
+}
+class Options implements OptionsElement {
+ name: string;
+ haschanged = false;
+ readonly options: OptionsElement[];
+ readonly owner: Buttons | Options | Form;
+ readonly ltr: boolean;
+ value!: void;
+ readonly html: WeakMap, WeakRef> =
+ new WeakMap();
+ container: WeakRef = new WeakRef(
+ document.createElement("div")
+ );
+ constructor(
+ name: string,
+ owner: Buttons | Options | Form,
+ { ltr = false } = {}
+ ) {
+ this.name = name;
+ this.options = [];
+ this.owner = owner;
+ this.ltr = ltr;
+ }
+ removeAll() {
+ while (this.options.length) {
+ this.options.pop();
+ }
+ const container = this.container.deref();
+ if (container) {
+ container.innerHTML = "";
+ }
+ }
+ watchForChange() {}
+ addOptions(name: string, { ltr = false } = {}) {
+ const options = new Options(name, this, { ltr });
+ this.options.push(options);
+ this.generate(options);
+ return options;
+ }
+ subOptions: Options | Form | undefined;
+ addSubOptions(name: string, { ltr = false } = {}) {
+ const options = new Options(name, this, { ltr });
+ this.subOptions = options;
+ const container = this.container.deref();
+ if (container) {
+ this.generateContainter();
+ } else {
+ throw new Error(
+ "Tried to make a subOptions when the options weren't rendered"
+ );
+ }
+ return options;
+ }
+ addSubForm(
+ name: string,
+ onSubmit: (arg1: object) => void,
+ {
+ ltr = false,
+ submitText = "Submit",
+ fetchURL = "",
+ headers = {},
+ method = "POST",
+ traditionalSubmit = false,
+ } = {}
+ ) {
+ const options = new Form(name, this, onSubmit, {
+ ltr,
+ submitText,
+ fetchURL,
+ headers,
+ method,
+ traditionalSubmit,
+ });
+ this.subOptions = options;
+ const container = this.container.deref();
+ if (container) {
+ this.generateContainter();
+ } else {
+ throw new Error(
+ "Tried to make a subForm when the options weren't rendered"
+ );
+ }
+ return options;
+ }
+ returnFromSub() {
+ this.subOptions = undefined;
+ this.generateContainter();
+ }
+ addSelect(
+ label: string,
+ onSubmit: (str: number) => void,
+ selections: string[],
+ { defaultIndex = 0 } = {}
+ ) {
+ const select = new SelectInput(label, onSubmit, selections, this, {
+ defaultIndex,
+ });
+ this.options.push(select);
+ this.generate(select);
+ return select;
+ }
+ addFileInput(
+ label: string,
+ onSubmit: (files: FileList | null) => void,
+ { clear = false } = {}
+ ) {
+ const FI = new FileInput(label, onSubmit, this, { clear });
+ this.options.push(FI);
+ this.generate(FI);
+ return FI;
+ }
+ addTextInput(
+ label: string,
+ onSubmit: (str: string) => void,
+ { initText = "", password = false } = {}
+ ) {
+ const textInput = new TextInput(label, onSubmit, this, {
+ initText,
+ password,
+ });
+ this.options.push(textInput);
+ this.generate(textInput);
+ return textInput;
+ }
+ addColorInput(
+ label: string,
+ onSubmit: (str: string) => void,
+ { initColor = "" } = {}
+ ) {
+ const colorInput = new ColorInput(label, onSubmit, this, { initColor });
+ this.options.push(colorInput);
+ this.generate(colorInput);
+ return colorInput;
+ }
+ addMDInput(
+ label: string,
+ onSubmit: (str: string) => void,
+ { initText = "" } = {}
+ ) {
+ const mdInput = new MDInput(label, onSubmit, this, { initText });
+ this.options.push(mdInput);
+ this.generate(mdInput);
+ return mdInput;
+ }
+ addHTMLArea(
+ html: (() => HTMLElement) | HTMLElement,
+ submit: () => void = () => {}
+ ) {
+ const htmlarea = new HtmlArea(html, submit);
+ this.options.push(htmlarea);
+ this.generate(htmlarea);
+ return htmlarea;
+ }
+ addButtonInput(label: string, textContent: string, onSubmit: () => void) {
+ const button = new ButtonInput(label, textContent, onSubmit, this);
+ this.options.push(button);
+ this.generate(button);
+ return button;
+ }
+ addCheckboxInput(
+ label: string,
+ onSubmit: (str: boolean) => void,
+ { initState = false } = {}
+ ) {
+ const box = new CheckboxInput(label, onSubmit, this, { initState });
+ this.options.push(box);
+ this.generate(box);
+ return box;
+ }
+ addText(str: string) {
+ const text = new SettingsText(str);
+ this.options.push(text);
+ this.generate(text);
+ return text;
+ }
+ addTitle(str: string) {
+ const text = new SettingsTitle(str);
+ this.options.push(text);
+ this.generate(text);
+ return text;
+ }
+ addForm(
+ name: string,
+ onSubmit: (arg1: object) => void,
+ {
+ ltr = false,
+ submitText = "Submit",
+ fetchURL = "",
+ headers = {},
+ method = "POST",
+ traditionalSubmit = false,
+ } = {}
+ ) {
+ const options = new Form(name, this, onSubmit, {
+ ltr,
+ submitText,
+ fetchURL,
+ headers,
+ method,
+ traditionalSubmit,
+ });
+ this.options.push(options);
+ this.generate(options);
+ return options;
+ }
+ generate(elm: OptionsElement) {
+ const container = this.container.deref();
+ if (container) {
+ const div = document.createElement("div");
+ if (!(elm instanceof Options)) {
+ div.classList.add("optionElement");
+ }
+ const html = elm.generateHTML();
+ div.append(html);
+ this.html.set(elm, new WeakRef(div));
+ container.append(div);
+ }
+ }
+ title: WeakRef = new WeakRef(document.createElement("h2"));
+ generateHTML(): HTMLElement {
+ const div = document.createElement("div");
+ div.classList.add("titlediv");
+ const title = document.createElement("h2");
+ title.textContent = this.name;
+ div.append(title);
+ if (this.name !== "") title.classList.add("settingstitle");
+ this.title = new WeakRef(title);
+ const container = document.createElement("div");
+ this.container = new WeakRef(container);
+ container.classList.add(this.ltr ? "flexltr" : "flexttb", "flexspace");
+ this.generateContainter();
+ div.append(container);
+ return div;
+ }
+ generateContainter() {
+ const container = this.container.deref();
+ if (container) {
+ const title = this.title.deref();
+ if (title) title.innerHTML = "";
+ container.innerHTML = "";
+ if (this.subOptions) {
+ container.append(this.subOptions.generateHTML()); //more code needed, though this is enough for now
+ if (title) {
+ const name = document.createElement("span");
+ name.innerText = this.name;
+ name.classList.add("clickable");
+ name.onclick = () => {
+ this.returnFromSub();
+ };
+ title.append(name, " > ", this.subOptions.name);
+ }
+ } else {
+ for (const thing of this.options) {
+ this.generate(thing);
+ }
+ if (title) {
+ title.innerText = this.name;
+ }
+ }
+ if (title && title.innerText !== "") {
+ title.classList.add("settingstitle");
+ } else if (title) {
+ title.classList.remove("settingstitle");
+ }
+ } else {
+ console.warn("tried to generate container, but it did not exist");
+ }
+ }
+ changed() {
+ if (this.owner instanceof Options || this.owner instanceof Form) {
+ this.owner.changed();
+ return;
+ }
+ if (!this.haschanged) {
+ const div = document.createElement("div");
+ div.classList.add("flexltr", "savediv");
+ const span = document.createElement("span");
+ div.append(span);
+ span.textContent = "Careful, you have unsaved changes";
+ const button = document.createElement("button");
+ button.textContent = "Save changes";
+ div.append(button);
+ this.haschanged = true;
+ this.owner.changed(div);
+
+ button.onclick = (_) => {
+ if (this.owner instanceof Buttons) {
+ this.owner.save();
+ }
+ div.remove();
+ this.submit();
+ };
+ }
+ }
+ submit() {
+ this.haschanged = false;
+ for (const thing of this.options) {
+ thing.submit();
+ }
+ }
+}
+class FormError extends Error {
+ elem: OptionsElement;
+ message: string;
+ constructor(elem: OptionsElement, message: string) {
+ super(message);
+ this.message = message;
+ this.elem = elem;
+ }
+}
+export { FormError };
+class Form implements OptionsElement