From 2452973373b38730cab5291ec99bf8e5665b444f Mon Sep 17 00:00:00 2001 From: MathMan05 Date: Wed, 15 Jul 2026 20:05:04 -0500 Subject: [PATCH 01/44] use safelink for badges --- src/webpage/user.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/webpage/user.ts b/src/webpage/user.ts index 1d6a179..bea904a 100644 --- a/src/webpage/user.ts +++ b/src/webpage/user.ts @@ -1272,7 +1272,7 @@ class User extends SnowFlake { const hover = new Hover(hovertxt); hover.addEvent(badge); if (badgejson.link && badge instanceof HTMLAnchorElement) { - badge.href = badgejson.link; + MarkDown.safeLink(badge, badgejson.link); } badgediv.append(badge); } From 033d931af301b6fca8adc7b59703cb3270152181 Mon Sep 17 00:00:00 2001 From: MathMan05 Date: Thu, 16 Jul 2026 13:52:42 -0500 Subject: [PATCH 02/44] check for http/https for video embeds --- src/webpage/embed.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/webpage/embed.ts b/src/webpage/embed.ts index 5ae090a..a5529bc 100644 --- a/src/webpage/embed.ts +++ b/src/webpage/embed.ts @@ -462,6 +462,7 @@ class Embed { img.onclick = async () => { if (this.json.video) { const url = new URL(this.json.video.url); + if (!(url.protocol === "http:" || url.protocol === "https:")) return; if (url.host !== "youtube.com") return; img.remove(); const iframe = document.createElement("iframe"); From f9748f531c420c21660d10a4dd039a9f743e2422 Mon Sep 17 00:00:00 2001 From: bunnisa Date: Thu, 16 Jul 2026 06:48:32 +0200 Subject: [PATCH 03/44] make goback redirs safe --- src/webpage/login.ts | 8 ++------ src/webpage/register.ts | 12 ++---------- src/webpage/utils/netUtils.ts | 15 +++++++++++++++ 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/webpage/login.ts b/src/webpage/login.ts index d2f9fa6..fd92c5f 100644 --- a/src/webpage/login.ts +++ b/src/webpage/login.ts @@ -2,7 +2,7 @@ import {InstanceInfo, adduser, Specialuser} from "./utils/utils.js"; import {I18n} from "./i18n.js"; import {Dialog, FormError} from "./settings.js"; import {makeRegister} from "./register.js"; -import {trimTrailingSlashes} from "./utils/netUtils"; +import {safeGoBackLink, trimTrailingSlashes} from "./utils/netUtils"; function generateRecArea(recover = document.getElementById("recover")) { if (!recover) return; recover.innerHTML = ""; @@ -81,11 +81,7 @@ export async function makeLogin( return; } const redir = new URLSearchParams(window.location.search).get("goback"); - if (redir && (!URL.canParse(redir) || new URL(redir).host === window.location.host)) { - window.location.href = redir; - } else { - window.location.href = "/channels/@me"; - } + window.location.href = safeGoBackLink(redir); } else { //@ts-ignore //TODO just type this to get rid of the ignore :P diff --git a/src/webpage/register.ts b/src/webpage/register.ts index c5a8e98..d9e8118 100644 --- a/src/webpage/register.ts +++ b/src/webpage/register.ts @@ -3,7 +3,7 @@ import {adduser, Specialuser} from "./utils/utils.js"; import {makeLogin} from "./login.js"; import {MarkDown} from "./markdown.js"; import {Dialog, FormError} from "./settings.js"; -import {trimTrailingSlashes} from "./utils/netUtils"; +import {safeGoBackLink, trimTrailingSlashes} from "./utils/netUtils"; export async function makeRegister( trasparentBg = false, instance = "", @@ -38,15 +38,7 @@ export async function makeRegister( return; } const redir = new URLSearchParams(window.location.search).get("goback"); - if ( - redir && - (!URL.canParse(redir) || new URL(redir).host === window.location.host) && - !invite - ) { - window.location.href = redir; - } else { - window.location.href = "/channels/@me"; - } + window.location.href = invite ? "/channels/@me" : safeGoBackLink(redir); } }, { diff --git a/src/webpage/utils/netUtils.ts b/src/webpage/utils/netUtils.ts index c6b05da..67663e1 100644 --- a/src/webpage/utils/netUtils.ts +++ b/src/webpage/utils/netUtils.ts @@ -6,3 +6,18 @@ export function trimTrailingSlashes(uri: string) { export function isLoopback(str: string) { return str.includes("localhost") || str.includes("127.0.0.1"); } + +export function safeGoBackLink(uri: string | null): string { + if (uri && URL.canParse(uri, window.location.origin)) { + const target = new URL(uri, window.location.origin); + + if ( + target.origin === window.location.origin && + (target.protocol === "http:" || target.protocol === "https:") + ) { + return target.pathname + target.search + target.hash; + } + } + + return "/channels/@me"; +} From 4419bc4bada44d3653457c28a6409debb8d0baff Mon Sep 17 00:00:00 2001 From: bunnisa Date: Thu, 16 Jul 2026 21:05:45 +0200 Subject: [PATCH 04/44] use toString() instead --- src/webpage/utils/netUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/webpage/utils/netUtils.ts b/src/webpage/utils/netUtils.ts index 67663e1..1d2afcf 100644 --- a/src/webpage/utils/netUtils.ts +++ b/src/webpage/utils/netUtils.ts @@ -15,7 +15,7 @@ export function safeGoBackLink(uri: string | null): string { target.origin === window.location.origin && (target.protocol === "http:" || target.protocol === "https:") ) { - return target.pathname + target.search + target.hash; + return target.toString(); } } From a7715b8a01978250b260b9fe9a989bc090be1389 Mon Sep 17 00:00:00 2001 From: MathMan05 Date: Fri, 17 Jul 2026 15:37:10 -0500 Subject: [PATCH 05/44] settings update --- src/webpage/localuser.ts | 1113 ++++++++++++++++++++------------------ src/webpage/role.ts | 16 +- src/webpage/settings.ts | 94 +++- src/webpage/style.css | 24 +- translations/en.json | 15 +- 5 files changed, 694 insertions(+), 568 deletions(-) diff --git a/src/webpage/localuser.ts b/src/webpage/localuser.ts index 5b0ded6..2142682 100644 --- a/src/webpage/localuser.ts +++ b/src/webpage/localuser.ts @@ -22,7 +22,7 @@ import { pollUpdateJson, } from "./jsontypes.js"; import {Member} from "./member.js"; -import {Dialog, Form, FormError, Options, Settings} from "./settings.js"; +import {buttonColor, Dialog, Form, FormError, Options, Settings} from "./settings.js"; import {getTextNodeAtPosition, MarkDown, saveCaretPosition} from "./markdown.js"; import {Bot} from "./bot.js"; import {Role} from "./role.js"; @@ -2501,9 +2501,18 @@ class Localuser { const prefs = await getPreferences(); const localSettings = getLocalSettings(); const settings = new Settings(I18n.localuser.settings()); + { + const donate = settings.addButton(I18n.donate.donate(), {initable: false}); + donate.addMDText(new MarkDown(I18n.donate.mdText(window.location.origin + "/donate"))); + donate.addText(I18n.donate.desc()); + } + settings.addButton(I18n.localuser.general(), { + head: true, + }); { const userOptions = settings.addButton(I18n.localuser.userSettings(), { ltr: true, + contained: true, }); const hypotheticalProfile = document.createElement("div"); let file: undefined | File | null; @@ -2623,10 +2632,289 @@ class Localuser { changed = true; regen(); }); + settingsLeft.addButtonInput("", I18n.localuser.connections(), () => { + const connections = userOptions.addSubOptions(I18n.localuser.connections()); + const connectionContainer = document.createElement("div"); + const actConDivCont = document.createElement("div"); + + connectionContainer.classList.add("connection-container"); + this.conectionChange = () => { + if (document.contains(settings.html)) { + remake(); + } else { + this.conectionChange = () => {}; + } + }; + const remake = () => { + connectionContainer.innerHTML = ""; + actConDivCont.innerHTML = ""; + const cons = fetch(this.info.api + "/users/@me/connections", { + headers: this.headers, + }); + + this.getConnections().then(async (json) => { + const actCons = (await (await cons).json()) as ConnectionJson[]; + const actConMap = new Map( + actCons.map((_) => [_.type, _] as const), + ); + const serverConnections = Object.keys(json).sort((key) => (json[key].enabled ? -1 : 1)); + + serverConnections + .filter((_) => !actConMap.has(_)) + .forEach((key) => { + const connection = json[key]; + + const container = document.createElement("div"); + if (connection.icon_url) { + const span = document.createElement("span"); + span.classList.add("conImg", "svgicon"); + span.style.setProperty("mask", `url("${connection.icon_url}")`); + //span.alt = key; + container.append(span); + } else { + 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"); + } + + connectionContainer.appendChild(container); + }); + serverConnections + .filter((_) => actConMap.has(_)) + .forEach((_) => { + const con = actConMap.get(_); + if (!con) return; + const connectionObj = json[_]; + + const actConDiv = document.createElement("div"); + actConDiv.classList.add("flexttb", "actConnectionDiv"); + const topRow = document.createElement("div"); + actConDiv.append(topRow); + topRow.classList.add("flexltr"); + if (connectionObj.icon_url) { + const span = document.createElement("span"); + span.classList.add("conImg", "svgicon"); + span.style.setProperty("mask", `url("${connectionObj.icon_url}")`); + //span.alt = key; + topRow.append(span); + } + + const nameDiv = document.createElement("div"); + nameDiv.classList.add("flexttb"); + + const name = document.createElement("span"); + name.textContent = con.name; + + const serviceName = document.createElement("span"); + serviceName.textContent = _; + + nameDiv.append(name, serviceName); + + topRow.append(nameDiv); + + const input = document.createElement("input"); + input.type = "checkbox"; + input.checked = !!con.visibility; + input.onchange = () => { + fetch(this.info.api + "/users/@me/connections/" + con.type + "/" + con.id, { + method: "PATCH", + body: JSON.stringify({ + visibility: input.checked, + }), + headers: this.headers, + }); + }; + + const dispRow = document.createElement("div"); + dispRow.classList.add("flexltr"); + actConDiv.append(dispRow); + + const dispText = document.createElement("span"); + dispText.textContent = I18n.connections.display(); + dispRow.append(dispText, input); + + const remove = document.createElement("button"); + remove.textContent = I18n.connections.delete(); + actConDiv.append(remove); + remove.onclick = () => { + const d = new Dialog(I18n.connections.sure()); + d.options.addText(I18n.connections.sureDesc()); + const row = d.options.addOptions("", {ltr: true}); + row.addButtonInput("", I18n.yes(), async () => { + await fetch( + this.info.api + "/users/@me/connections/" + con.type + "/" + con.id, + { + method: "DELETE", + headers: this.headers, + }, + ); + d.hide(); + }); + row.addButtonInput("", I18n.no(), () => { + d.hide(); + }); + d.show(); + }; + + actConDivCont.append(actConDiv); + }); + }); + }; + remake(); + connections.addHTMLArea(connectionContainer); + connections.addHR(); + connections.addHTMLArea(actConDivCont); + }); } + { + const security = settings.addButton(I18n.localuser.accountSettings(), {contained: true}); + + security.addSubButtonInput(I18n.localuser.changeDiscriminator(), (sub) => { + const form = sub.addForm( + "", + (_) => { + security.returnFromSub(); + }, + { + fetchURL: this.info.api + "/users/@me/", + headers: this.headers, + method: "PATCH", + }, + ); + form.addTextInput(I18n.localuser.newDiscriminator(), "discriminator"); + }); + security.addSubButtonInput(I18n.localuser.changeEmail(), (sub) => { + const form = sub.addForm( + "", + (_) => { + security.returnFromSub(); + }, + { + fetchURL: this.info.api + "/users/@me/", + headers: this.headers, + method: "PATCH", + }, + ); + form.addTextInput(I18n.localuser["password:"](), "password", { + password: true, + }); + if (this.mfa_enabled) { + form.addTextInput(I18n.localuser["2faCode:"](), "code"); + } + form.addTextInput(I18n.localuser["newEmail:"](), "email"); + }); + security.addSubButtonInput(I18n.localuser.changeUsername(), (sub) => { + const form = sub.addForm( + "", + (_) => { + security.returnFromSub(); + }, + { + fetchURL: this.info.api + "/users/@me/", + headers: this.headers, + method: "PATCH", + }, + ); + form.addTextInput(I18n.localuser["password:"](), "password", { + password: true, + }); + if (this.mfa_enabled) { + form.addTextInput(I18n.localuser["2faCode:"](), "code"); + } + form.addTextInput(I18n.localuser.newUsername(), "username"); + }); + security.addSubButtonInput(I18n.localuser.changePassword(), (sub) => { + const form = sub.addForm( + "", + (_) => { + security.returnFromSub(); + }, + { + fetchURL: this.info.api + "/users/@me/", + headers: this.headers, + method: "PATCH", + }, + ); + form.addTextInput(I18n.localuser["oldPassword:"](), "password", { + password: true, + }); + if (this.mfa_enabled) { + form.addTextInput(I18n.localuser["2faCode:"](), "code"); + } + let in1 = ""; + let in2 = ""; + form + .addTextInput(I18n.localuser["newPassword:"](), "", {password: true}) + .watchForChange((text) => { + in1 = text; + }); + const copy = form.addTextInput("New password again:", "", {password: true}); + copy.watchForChange((text) => { + in2 = text; + }); + form.setValue("new_password", () => { + if (in1 === in2) { + return in1; + } else { + throw new FormError(copy, I18n.localuser.PasswordsNoMatch()); + } + }); + }); + + { + security.addButtonInput("", I18n.logout.logout(), async () => { + if (await this.userinfo.logout()) window.location.href = "/"; + }); + } + } + { + const lang = settings.addButton(I18n.localuser.language(), {contained: true}); + const div = document.createElement("div"); + div.classList.add("flexttb"); + const langBuMap = new Map(); + let l = [...langmap]; + const langF = l.find((_) => _[0] === I18n.lang + ".json"); + l = l.filter((_) => _ !== langF); + if (langF) l.unshift(langF); + div.append( + ...l.map(([key, name]) => { + const button = document.createElement("button"); + button.classList.add("langButton"); + button.textContent = name; + langBuMap.set(key, button); + if (key === I18n.lang + ".json") { + button.classList.add("selected"); + } + button.onclick = () => { + const b = langBuMap.get(I18n.lang + ".json"); + if (b) b.classList.remove("selected"); + button.classList.add("selected"); + I18n.setLanguage(key.replace(".json", "")); + this.updateTranslations(); + }; + console.log(I18n.lang, key); + return button; + }), + ); + lang.addHTMLArea(div); + } + { const prefs = await getPreferences(); - const tas = settings.addButton(I18n.localuser.themesAndSounds()); + const tas = settings.addButton(I18n.localuser.themesAndSounds(), {contained: true}); { const themes = ["Dark", "WHITE", "Light", "Dark-Accent"]; tas.addSelect( @@ -2739,9 +3027,90 @@ class Localuser { }, ); } + } + { + const blog = settings.addButton(I18n.blog.blog(), {contained: true}); + blog.addCheckboxInput( + I18n.blog.blogUpdates(), + async (check) => { + prefs.showBlogUpdates = check; + await setPreferences(prefs); + }, + {initState: prefs.showBlogUpdates}, + ); + (async () => { + const posts = await this.getPosts(); + for (const post of posts.items) { + const div = document.createElement("div"); + div.classList.add("flexltr", "blogDiv"); + if (post.image) { + //TODO handle this case, no blog posts currently do this + } + const titleStuff = document.createElement("div"); + titleStuff.classList.add("flexttb"); + + const h2 = document.createElement("h2"); + h2.textContent = post.title; + + const p = document.createElement("p"); + p.textContent = post.content_html; + titleStuff.append(h2, p); + div.append(titleStuff); + blog.addHTMLArea(div); + MarkDown.safeLink(div, post.url); + } + })(); + } + + const installP = installPGet(); + if (installP) { + const c = settings.addButton(I18n.localuser.install(), {contained: true}); + c.addText(I18n.localuser.installDesc()); + c.addButtonInput("", I18n.localuser.installJank(), async () => { + //@ts-expect-error have to do this :3 + await installP.prompt(); + }); + } + + settings.addButton(I18n.accessibility.name(), {head: true}); + + { + const visuals = settings.addButton(I18n.accessibility.visuals(), {contained: true}); + visuals.addCheckboxInput( + I18n.accessibility.roleColors(), + (t) => { + console.log(t); + this.perminfo.user.disableColors = !t; + }, + {initState: !this.perminfo.user.disableColors}, + ); + visuals.addCheckboxInput( + I18n.accessibility.gradientColors(), + (t) => { + console.log(t); + this.perminfo.user.gradientColors = t; + }, + {initState: this.perminfo.user.gradientColors}, + ); + visuals.addCheckboxInput( + I18n.channel.allowIcons(), + (t) => { + console.log(t); + this.perminfo.user.disableIcons = !t; + }, + {initState: !this.perminfo.user.disableIcons}, + ); + + visuals.addCheckboxInput( + I18n.accessibility.decorations(), + (t) => { + this.perminfo.user.decorations = t; + }, + {initState: this.perminfo.user.decorations}, + ); { const cur = prefs.renderJoinAvatars; - tas.addCheckboxInput( + visuals.addCheckboxInput( I18n.renderJoinAvatars(), async (v) => { prefs.renderJoinAvatars = v; @@ -2752,44 +3121,33 @@ class Localuser { } } { - const update = settings.addButton(I18n.localuser.updateSettings()); - let index = ServiceWorkerModeValues.indexOf(localSettings.serviceWorkerMode); - if (index === -1) { - index = 2; - } - const sw = update.addSelect( - I18n.settings.updates.serviceWorkerMode.title(), - () => {}, - ServiceWorkerModeValues.map((e) => I18n.settings.updates.serviceWorkerMode[e]()), - { - defaultIndex: index, + const animations = settings.addButton(I18n.accessibility.animations(), {contained: true}); + animations.addSelect( + I18n.accessibility.playGif(), + async (i) => { + prefs.animateGifs = AnimateTristateValues[i]; + await setPreferences(prefs); }, + AnimateTristateValues.map((_) => I18n.accessibility.gifSettings[_]()), + {defaultIndex: AnimateTristateValues.indexOf(prefs.animateGifs)}, + ); + animations.addSelect( + I18n.accessibility.playIcon(), + async (i) => { + prefs.animateIcons = AnimateTristateValues[i]; + await setPreferences(prefs); + }, + AnimateTristateValues.map((_) => I18n.accessibility.gifSettings[_]()), + {defaultIndex: AnimateTristateValues.indexOf(prefs.animateIcons)}, ); - sw.onchange = (e) => { - SW.setMode(ServiceWorkerModeValues[e]); - }; - update.addButtonInput("", I18n.localuser.CheckUpdate(), async () => { - const update = await SW.checkUpdates(); - const text = update ? I18n.localuser.updatesYay() : I18n.localuser.noUpdates(); - const d = new Dialog(""); - d.options.addTitle(text); - if (update) { - d.options.addButtonInput("", I18n.localuser.refreshPage(), () => { - window.location.reload(); - }); - } - d.show(); - }); - update.addButtonInput("", I18n.localuser.clearCache(), () => { - SW.forceClear(); - }); } + settings.addButton(I18n.localuser.security(), {head: true}); { - const security = settings.addButton(I18n.localuser.accountSettings()); - const genSecurity = () => { - security.removeAll(); + const twofa = settings.addButton(I18n.localuser["2fa"](), {contained: true}); + const gen2FA = () => { + twofa.removeAll(); if (this.mfa_enabled) { - security.addSubButtonInput(I18n.localuser["2faDisable"](), (sub) => { + twofa.addSubButtonInput(I18n.localuser["2faDisable"](), (sub) => { const form = sub.addForm( "", (_: any) => { @@ -2801,8 +3159,8 @@ class Localuser { } } else { this.mfa_enabled = false; - security.returnFromSub(); - genSecurity(); + twofa.returnFromSub(); + gen2FA(); } }, { @@ -2813,7 +3171,7 @@ class Localuser { form.addTextInput(I18n.localuser["2faCode:"](), "code", {required: true}); }); } else { - security.addSubButtonInput(I18n.localuser["2faEnable"](), async (sub) => { + twofa.addSubButtonInput(I18n.localuser["2faEnable"](), async (sub) => { let secret = ""; for (let i = 0; i < 18; i++) { secret += "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"[Math.floor(Math.random() * 32)]; @@ -2831,9 +3189,9 @@ class Localuser { break; } } else { - genSecurity(); + gen2FA(); this.mfa_enabled = true; - security.returnFromSub(); + twofa.returnFromSub(); } }, { @@ -2852,7 +3210,7 @@ class Localuser { }); } { - security.addSubButtonInput(I18n.webauth.manage(), (keyMenu) => { + twofa.addSubButtonInput(I18n.webauth.manage(), (keyMenu) => { const addKey = (key: {name: string; id: string}) => { keyMenu.addButtonInput("", key.name, () => { const opt = keyMenu.addSubOptions(key.name); @@ -2939,314 +3297,113 @@ class Localuser { }); }); } - security.addSubButtonInput(I18n.localuser.changeDiscriminator(), (sub) => { - const form = sub.addForm( - "", - (_) => { - security.returnFromSub(); - }, - { - fetchURL: this.info.api + "/users/@me/", - headers: this.headers, - method: "PATCH", - }, - ); - form.addTextInput(I18n.localuser.newDiscriminator(), "discriminator"); - }); - security.addSubButtonInput(I18n.localuser.changeEmail(), (sub) => { - const form = sub.addForm( - "", - (_) => { - security.returnFromSub(); - }, - { - fetchURL: this.info.api + "/users/@me/", - headers: this.headers, - method: "PATCH", - }, - ); - form.addTextInput(I18n.localuser["password:"](), "password", { - password: true, - }); - if (this.mfa_enabled) { - form.addTextInput(I18n.localuser["2faCode:"](), "code"); + }; + gen2FA(); + } + { + const manageSessions = settings.addButton(I18n.deviceManage.title(), {contained: true}); + (async () => { + const json = (await ( + await fetch(this.info.api + "/auth/sessions?extended=true", {headers: this.headers}) + ).json()) as {user_sessions: expSessionJson[]}; + for (const session of json.user_sessions.sort( + (a, b) => +new Date(a.last_seen) - +new Date(b.last_seen), + )) { + const div = document.createElement("div"); + div.classList.add("flexltr", "sessionDiv"); + + const info = document.createElement("div"); + info.classList.add("flexttb"); + div.append(info); + + let line2 = ""; + const last = session.last_seen_location_info; + if (last) { + line2 += last.country_name; + if (last.region) line2 += ", " + last.region; + if (last.city) line2 += ", " + last.city; } - form.addTextInput(I18n.localuser["newEmail:"](), "email"); - }); - security.addSubButtonInput(I18n.localuser.changeUsername(), (sub) => { - const form = sub.addForm( - "", - (_) => { - security.returnFromSub(); - }, - { - fetchURL: this.info.api + "/users/@me/", - headers: this.headers, - method: "PATCH", - }, - ); - form.addTextInput(I18n.localuser["password:"](), "password", { - password: true, - }); - if (this.mfa_enabled) { - form.addTextInput(I18n.localuser["2faCode:"](), "code"); + if (line2) { + line2 += " • "; } - form.addTextInput(I18n.localuser.newUsername(), "username"); - }); - security.addSubButtonInput(I18n.localuser.changePassword(), (sub) => { - const form = sub.addForm( - "", - (_) => { - security.returnFromSub(); - }, - { - fetchURL: this.info.api + "/users/@me/", - headers: this.headers, - method: "PATCH", - }, - ); - form.addTextInput(I18n.localuser["oldPassword:"](), "password", { - password: true, - }); - if (this.mfa_enabled) { - form.addTextInput(I18n.localuser["2faCode:"](), "code"); + const format = new Intl.RelativeTimeFormat(I18n.lang, {style: "short"}); + const time = (Date.now() - +new Date(session.last_seen)) / 1000; + if (time < 60) { + line2 += format.format(-Math.floor(time), "seconds"); + } else if (time < 60 * 60) { + line2 += format.format(-Math.floor(time / 60), "minutes"); + } else if (time < 60 * 60 * 24) { + line2 += format.format(-Math.floor(time / 60 / 60), "hours"); + } else if (time < 60 * 60 * 24 * 7) { + line2 += format.format(-Math.floor(time / 60 / 60 / 24), "days"); + } else if (time < 60 * 60 * 24 * 365) { + line2 += format.format(-Math.floor(time / 60 / 60 / 24 / 7), "weeks"); + } else { + line2 += format.format(-Math.floor(time / 60 / 60 / 24 / 365), "years"); } - let in1 = ""; - let in2 = ""; - form - .addTextInput(I18n.localuser["newPassword:"](), "", {password: true}) - .watchForChange((text) => { - in1 = text; - }); - const copy = form.addTextInput("New password again:", "", {password: true}); - copy.watchForChange((text) => { - in2 = text; - }); - form.setValue("new_password", () => { - if (in1 === in2) { - return in1; - } else { - throw new FormError(copy, I18n.localuser.PasswordsNoMatch()); + const loc = document.createElement("span"); + loc.textContent = line2; + info.append(loc); + const r = manageSessions.addHTMLArea(div); + div.onclick = () => { + const sub = manageSessions.addSubOptions(I18n.deviceManage.manageDev()); + sub.addText(I18n.deviceManage.ip(session.last_seen_ip)); + sub.addText(I18n.deviceManage.last(session.approx_last_used_time)); + if (last) { + sub.addText(I18n.deviceManage.estimateWarn()); + sub.addText(I18n.deviceManage.continent(last.continent_name)); + sub.addText(I18n.deviceManage.country(last.country_name)); + if (last.region) sub.addText(I18n.deviceManage.region(last.region)); + if (last.city) sub.addText(I18n.deviceManage.city(last.city)); + if (last.postal) sub.addText(I18n.deviceManage.postal(last.postal)); + sub.addText(I18n.deviceManage.longitude(last.longitude + "")); + sub.addText(I18n.deviceManage.latitude(last.latitude + "")); } - }); - }); - - security.addSelect( - I18n.localuser.language(), - (e) => { - I18n.setLanguage(I18n.options()[e]); - this.updateTranslations(); - }, - [...langmap.values()], - {defaultIndex: I18n.options().indexOf(I18n.lang)}, - ); - - { - security.addButtonInput("", I18n.logout.logout(), async () => { - if (await this.userinfo.logout()) window.location.href = "/"; - }); - } - }; - genSecurity(); - } - { - const accessibility = settings.addButton(I18n.accessibility.name()); - accessibility.addCheckboxInput( - I18n.accessibility.roleColors(), - (t) => { - console.log(t); - this.perminfo.user.disableColors = !t; - }, - {initState: !this.perminfo.user.disableColors}, - ); - accessibility.addCheckboxInput( - I18n.accessibility.gradientColors(), - (t) => { - console.log(t); - this.perminfo.user.gradientColors = t; - }, - {initState: this.perminfo.user.gradientColors}, - ); - accessibility.addCheckboxInput( - I18n.channel.allowIcons(), - (t) => { - console.log(t); - this.perminfo.user.disableIcons = !t; - }, - {initState: !this.perminfo.user.disableIcons}, - ); - - accessibility.addCheckboxInput( - I18n.accessibility.decorations(), - (t) => { - this.perminfo.user.decorations = t; - }, - {initState: this.perminfo.user.decorations}, - ); - accessibility.addSelect( - I18n.accessibility.playGif(), - async (i) => { - prefs.animateGifs = AnimateTristateValues[i]; - await setPreferences(prefs); - }, - AnimateTristateValues.map((_) => I18n.accessibility.gifSettings[_]()), - {defaultIndex: AnimateTristateValues.indexOf(prefs.animateGifs)}, - ); - accessibility.addSelect( - I18n.accessibility.playIcon(), - async (i) => { - prefs.animateIcons = AnimateTristateValues[i]; - await setPreferences(prefs); - }, - AnimateTristateValues.map((_) => I18n.accessibility.gifSettings[_]()), - {defaultIndex: AnimateTristateValues.indexOf(prefs.animateIcons)}, - ); - } - { - const connections = settings.addButton(I18n.localuser.connections()); - const connectionContainer = document.createElement("div"); - const actConDivCont = document.createElement("div"); - - connectionContainer.classList.add("connection-container"); - this.conectionChange = () => { - if (document.contains(settings.html)) { - remake(); - } else { - this.conectionChange = () => {}; - } - }; - const remake = () => { - connectionContainer.innerHTML = ""; - actConDivCont.innerHTML = ""; - const cons = fetch(this.info.api + "/users/@me/connections", { - headers: this.headers, - }); - - this.getConnections().then(async (json) => { - const actCons = (await (await cons).json()) as ConnectionJson[]; - const actConMap = new Map( - actCons.map((_) => [_.type, _] as const), - ); - const serverConnections = Object.keys(json).sort((key) => (json[key].enabled ? -1 : 1)); - - serverConnections - .filter((_) => !actConMap.has(_)) - .forEach((key) => { - const connection = json[key]; - - const container = document.createElement("div"); - if (connection.icon_url) { - const span = document.createElement("span"); - span.classList.add("conImg", "svgicon"); - span.style.setProperty("mask", `url("${connection.icon_url}")`); - //span.alt = key; - container.append(span); - } else { - 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"); - } - - connectionContainer.appendChild(container); - }); - serverConnections - .filter((_) => actConMap.has(_)) - .forEach((_) => { - const con = actConMap.get(_); - if (!con) return; - const connectionObj = json[_]; - - const actConDiv = document.createElement("div"); - actConDiv.classList.add("flexttb", "actConnectionDiv"); - const topRow = document.createElement("div"); - actConDiv.append(topRow); - topRow.classList.add("flexltr"); - if (connectionObj.icon_url) { - const span = document.createElement("span"); - span.classList.add("conImg", "svgicon"); - span.style.setProperty("mask", `url("${connectionObj.icon_url}")`); - //span.alt = key; - topRow.append(span); - } - - const nameDiv = document.createElement("div"); - nameDiv.classList.add("flexttb"); - - const name = document.createElement("span"); - name.textContent = con.name; - - const serviceName = document.createElement("span"); - serviceName.textContent = _; - - nameDiv.append(name, serviceName); - - topRow.append(nameDiv); - - const input = document.createElement("input"); - input.type = "checkbox"; - input.checked = !!con.visibility; - input.onchange = () => { - fetch(this.info.api + "/users/@me/connections/" + con.type + "/" + con.id, { - method: "PATCH", - body: JSON.stringify({ - visibility: input.checked, - }), + if (session.id !== this.session_id) { + sub.addButtonInput("", I18n.deviceManage.logout(), () => { + div.remove(); + r.html = document.createElement("div"); + manageSessions.returnFromSub(); + fetch(this.info.api + "/auth/sessions/logout", { + method: "POST", headers: this.headers, + body: JSON.stringify({ + session_id_hashes: [session.id_hash], + }), }); - }; - - const dispRow = document.createElement("div"); - dispRow.classList.add("flexltr"); - actConDiv.append(dispRow); - - const dispText = document.createElement("span"); - dispText.textContent = I18n.connections.display(); - dispRow.append(dispText, input); - - const remove = document.createElement("button"); - remove.textContent = I18n.connections.delete(); - actConDiv.append(remove); - remove.onclick = () => { - const d = new Dialog(I18n.connections.sure()); - d.options.addText(I18n.connections.sureDesc()); - const row = d.options.addOptions("", {ltr: true}); - row.addButtonInput("", I18n.yes(), async () => { - await fetch(this.info.api + "/users/@me/connections/" + con.type + "/" + con.id, { - method: "DELETE", - headers: this.headers, - }); - d.hide(); - }); - row.addButtonInput("", I18n.no(), () => { - d.hide(); - }); - d.show(); - }; - - actConDivCont.append(actConDiv); - }); - }); - }; - remake(); - connections.addHTMLArea(connectionContainer); - connections.addHR(); - connections.addHTMLArea(actConDivCont); + }); + } else sub.addText(I18n.deviceManage.curSes()); + }; + } + })(); } { - const devPortal = settings.addButton(I18n.localuser.devPortal()); + const trusted = settings.addButton(I18n.localuser.trusted(), {contained: true}); + trusted.addMDText(new MarkDown(I18n.localuser.trustedDesc())); + for (const thing of MarkDown.trustedDomains) { + const div = document.createElement("div"); + div.classList.add("flexltr", "trustedDomain"); + + const name = document.createElement("span"); + name.textContent = thing; + + const remove = document.createElement("button"); + remove.textContent = I18n.remove(); + remove.onclick = () => { + MarkDown.saveTrusted(); + MarkDown.trustedDomains.delete(thing); + MarkDown.saveTrusted(true); + div.remove(); + }; + + div.append(name, remove); + trusted.addHTMLArea(div); + } + } + settings.addButton(I18n.localuser.advanced(), {head: true}); + + { + const devPortal = settings.addButton(I18n.localuser.devPortal(), {contained: true}); fetch(this.info.api + "/teams", { headers: this.headers, @@ -3330,128 +3487,11 @@ class Localuser { }); } - { - const manageSessions = settings.addButton(I18n.deviceManage.title()); - (async () => { - const json = (await ( - await fetch(this.info.api + "/auth/sessions?extended=true", {headers: this.headers}) - ).json()) as {user_sessions: expSessionJson[]}; - for (const session of json.user_sessions.sort( - (a, b) => +new Date(a.last_seen) - +new Date(b.last_seen), - )) { - const div = document.createElement("div"); - div.classList.add("flexltr", "sessionDiv"); - - const info = document.createElement("div"); - info.classList.add("flexttb"); - div.append(info); - - let line2 = ""; - const last = session.last_seen_location_info; - if (last) { - line2 += last.country_name; - if (last.region) line2 += ", " + last.region; - if (last.city) line2 += ", " + last.city; - } - if (line2) { - line2 += " • "; - } - const format = new Intl.RelativeTimeFormat(I18n.lang, {style: "short"}); - const time = (Date.now() - +new Date(session.last_seen)) / 1000; - if (time < 60) { - line2 += format.format(-Math.floor(time), "seconds"); - } else if (time < 60 * 60) { - line2 += format.format(-Math.floor(time / 60), "minutes"); - } else if (time < 60 * 60 * 24) { - line2 += format.format(-Math.floor(time / 60 / 60), "hours"); - } else if (time < 60 * 60 * 24 * 7) { - line2 += format.format(-Math.floor(time / 60 / 60 / 24), "days"); - } else if (time < 60 * 60 * 24 * 365) { - line2 += format.format(-Math.floor(time / 60 / 60 / 24 / 7), "weeks"); - } else { - line2 += format.format(-Math.floor(time / 60 / 60 / 24 / 365), "years"); - } - const loc = document.createElement("span"); - loc.textContent = line2; - info.append(loc); - const r = manageSessions.addHTMLArea(div); - div.onclick = () => { - const sub = manageSessions.addSubOptions(I18n.deviceManage.manageDev()); - sub.addText(I18n.deviceManage.ip(session.last_seen_ip)); - sub.addText(I18n.deviceManage.last(session.approx_last_used_time)); - if (last) { - sub.addText(I18n.deviceManage.estimateWarn()); - sub.addText(I18n.deviceManage.continent(last.continent_name)); - sub.addText(I18n.deviceManage.country(last.country_name)); - if (last.region) sub.addText(I18n.deviceManage.region(last.region)); - if (last.city) sub.addText(I18n.deviceManage.city(last.city)); - if (last.postal) sub.addText(I18n.deviceManage.postal(last.postal)); - sub.addText(I18n.deviceManage.longitude(last.longitude + "")); - sub.addText(I18n.deviceManage.latitude(last.latitude + "")); - } - if (session.id !== this.session_id) { - sub.addButtonInput("", I18n.deviceManage.logout(), () => { - div.remove(); - r.html = document.createElement("div"); - manageSessions.returnFromSub(); - fetch(this.info.api + "/auth/sessions/logout", { - method: "POST", - headers: this.headers, - body: JSON.stringify({ - session_id_hashes: [session.id_hash], - }), - }); - }); - } else sub.addText(I18n.deviceManage.curSes()); - }; - } - })(); - } - - { - const deleteAccount = settings.addButton(I18n.localuser.deleteAccount()).addForm( - "", - (e) => { - if ("message" in e) { - if (typeof e.message === "string") { - throw new FormError(password, e.message); - } - } else { - this.userinfo.remove(); - window.location.href = "/"; - } - }, - { - headers: this.headers, - method: "POST", - fetchURL: this.info.api + "/users/@me/delete/", - traditionalSubmit: false, - submitText: I18n.localuser.deleteAccountButton(), - }, - ); - const shrek = deleteAccount.addTextInput( - I18n.localuser.areYouSureDelete(I18n.localuser.sillyDeleteConfirmPhrase()), - "shrek", - ); - const password = deleteAccount.addTextInput(I18n.localuser["password:"](), "password", { - password: true, - }); - deleteAccount.addPreprocessor((obj) => { - if ("shrek" in obj) { - if (obj.shrek !== I18n.localuser.sillyDeleteConfirmPhrase()) { - throw new FormError(shrek, I18n.localuser.mustTypePhrase()); - } - delete obj.shrek; - } else { - throw new FormError(shrek, I18n.localuser.mustTypePhrase()); - } - }); - } if ( this.rights.hasPermission("OPERATOR") || this.rights.hasPermission("CREATE_REGISTRATION_TOKENS") ) { - const manageInstance = settings.addButton(I18n.localuser.manageInstance()); + const manageInstance = settings.addButton(I18n.localuser.manageInstance(), {contained: true}); if (this.rights.hasPermission("OPERATOR")) { manageInstance.addButtonInput("", I18n.manageInstance.stop(), () => { const menu = new Dialog(""); @@ -3580,7 +3620,7 @@ class Localuser { } } (async () => { - const jankInfo = settings.addButton(I18n.jankInfo()); + const jankInfo = settings.addButton(I18n.jankInfo(), {contained: true}); const img = document.createElement("img"); img.src = "/logo.svg"; jankInfo.addHTMLArea(img); @@ -3594,73 +3634,12 @@ class Localuser { ), ); })(); - const installP = installPGet(); - if (installP) { - const c = settings.addButton(I18n.localuser.install()); - c.addText(I18n.localuser.installDesc()); - c.addButtonInput("", I18n.localuser.installJank(), async () => { - //@ts-expect-error have to do this :3 - await installP.prompt(); + + { + const devSettings = settings.addButton(I18n.devSettings.name(), { + noSubmit: true, + contained: true, }); - } - { - const trusted = settings.addButton(I18n.localuser.trusted()); - trusted.addMDText(new MarkDown(I18n.localuser.trustedDesc())); - for (const thing of MarkDown.trustedDomains) { - const div = document.createElement("div"); - div.classList.add("flexltr", "trustedDomain"); - - const name = document.createElement("span"); - name.textContent = thing; - - const remove = document.createElement("button"); - remove.textContent = I18n.remove(); - remove.onclick = () => { - MarkDown.saveTrusted(); - MarkDown.trustedDomains.delete(thing); - MarkDown.saveTrusted(true); - div.remove(); - }; - - div.append(name, remove); - trusted.addHTMLArea(div); - } - } - { - const blog = settings.addButton(I18n.blog.blog()); - blog.addCheckboxInput( - I18n.blog.blogUpdates(), - async (check) => { - prefs.showBlogUpdates = check; - await setPreferences(prefs); - }, - {initState: prefs.showBlogUpdates}, - ); - (async () => { - const posts = await this.getPosts(); - for (const post of posts.items) { - const div = document.createElement("div"); - div.classList.add("flexltr", "blogDiv"); - if (post.image) { - //TODO handle this case, no blog posts currently do this - } - const titleStuff = document.createElement("div"); - titleStuff.classList.add("flexttb"); - - const h2 = document.createElement("h2"); - h2.textContent = post.title; - - const p = document.createElement("p"); - p.textContent = post.content_html; - titleStuff.append(h2, p); - div.append(titleStuff); - blog.addHTMLArea(div); - MarkDown.safeLink(div, post.url); - } - })(); - } - { - const devSettings = settings.addButton(I18n.devSettings.name(), {noSubmit: true}); devSettings.addText(I18n.devSettings.description()); devSettings.addHR(); const box1 = devSettings.addCheckboxInput(I18n.devSettings.logGateway(), () => {}, { @@ -3757,6 +3736,7 @@ class Localuser { if (this.trace.length && getDeveloperSettings().showTraces) { const traces = settings.addButton(I18n.localuser.trace(), { noSubmit: true, + contained: true, }); const traceArr = this.trace; @@ -3853,7 +3833,40 @@ class Localuser { updateInfo(); } { - const instanceInfo = settings.addButton(I18n.instanceInfo.name()); + const update = settings.addButton(I18n.localuser.updateSettings(), {contained: true}); + let index = ServiceWorkerModeValues.indexOf(localSettings.serviceWorkerMode); + if (index === -1) { + index = 2; + } + const sw = update.addSelect( + I18n.settings.updates.serviceWorkerMode.title(), + () => {}, + ServiceWorkerModeValues.map((e) => I18n.settings.updates.serviceWorkerMode[e]()), + { + defaultIndex: index, + }, + ); + sw.onchange = (e) => { + SW.setMode(ServiceWorkerModeValues[e]); + }; + update.addButtonInput("", I18n.localuser.CheckUpdate(), async () => { + const update = await SW.checkUpdates(); + const text = update ? I18n.localuser.updatesYay() : I18n.localuser.noUpdates(); + const d = new Dialog(""); + d.options.addTitle(text); + if (update) { + d.options.addButtonInput("", I18n.localuser.refreshPage(), () => { + window.location.reload(); + }); + } + d.show(); + }); + update.addButtonInput("", I18n.localuser.clearCache(), () => { + SW.forceClear(); + }); + } + { + const instanceInfo = settings.addButton(I18n.instanceInfo.name(), {contained: true}); fetch(this.info.api + "/policies/instance", { headers: this.headers, }) @@ -3891,11 +3904,49 @@ class Localuser { }); }); } + settings.addButton(I18n.localuser.danger(), {head: true}); { - const donate = settings.addButton(I18n.donate.donate()); - donate.addMDText(new MarkDown(I18n.donate.mdText(window.location.origin + "/donate"))); - donate.addText(I18n.donate.desc()); + const deleteAccount = settings + .addButton(I18n.localuser.deleteAccount(), {contained: true, color: buttonColor.RED}) + .addForm( + "", + (e) => { + if ("message" in e) { + if (typeof e.message === "string") { + throw new FormError(password, e.message); + } + } else { + this.userinfo.remove(); + window.location.href = "/"; + } + }, + { + headers: this.headers, + method: "POST", + fetchURL: this.info.api + "/users/@me/delete/", + traditionalSubmit: false, + submitText: I18n.localuser.deleteAccountButton(), + }, + ); + const shrek = deleteAccount.addTextInput( + I18n.localuser.areYouSureDelete(I18n.localuser.sillyDeleteConfirmPhrase()), + "shrek", + ); + const password = deleteAccount.addTextInput(I18n.localuser["password:"](), "password", { + password: true, + }); + deleteAccount.addPreprocessor((obj) => { + if ("shrek" in obj) { + if (obj.shrek !== I18n.localuser.sillyDeleteConfirmPhrase()) { + throw new FormError(shrek, I18n.localuser.mustTypePhrase()); + } + delete obj.shrek; + } else { + throw new FormError(shrek, I18n.localuser.mustTypePhrase()); + } + }); } + settings.show(); } readonly botTokens: Map = new Map(); diff --git a/src/webpage/role.ts b/src/webpage/role.ts index f9f65f4..37cf5d8 100644 --- a/src/webpage/role.ts +++ b/src/webpage/role.ts @@ -223,7 +223,7 @@ class RoleList extends Buttons { permission: Permissions; readonly guild: Guild; readonly channel: false | Channel; - declare buttons: [string, string][]; + declare buttons: {name: string; inner: string}[]; readonly options: Options; onchange: (id: string, perms: Permissions) => void; curid?: string; @@ -264,7 +264,7 @@ class RoleList extends Buttons { }); } for (const i of permissions) { - this.buttons.push([i[0].name, i[0].id]); + this.buttons.push({name: i[0].name, inner: i[0].id}); } this.options = options; guild.roleUpdate = this.groleUpdate.bind(this); @@ -516,7 +516,7 @@ class RoleList extends Buttons { return b.position - a.position; }); for (const i of this.permissions) { - this.buttons.push([i[0].name, i[0].id]); + this.buttons.push({name: i[0].name, inner: i[0].id}); } if (!this.buttonList) return; const elms = Array.from(this.buttonList.children); @@ -641,16 +641,16 @@ class RoleList extends Buttons { roleRow.append(add); buttonTable.append(roleRow); - for (const thing of this.buttons) { + for (const {name, inner} of this.buttons) { const button = document.createElement("button"); - this.buttonMap.set(thing[0], button); + this.buttonMap.set(name, button); button.classList.add("SettingsButton"); const span = document.createElement("span"); - span.textContent = thing[0]; + span.textContent = name; button.append(span); span.classList.add("roleButtonStyle"); - const role = this.guild.roleids.get(thing[1]) || this.guild.localuser.userMap.get(thing[1]); + const role = this.guild.roleids.get(inner) || this.guild.localuser.userMap.get(inner); if (role) { if (role instanceof Role) { if (role.getColor()) button.style.setProperty("--user-bg", `var(--role-${role.id})`); @@ -675,7 +675,7 @@ class RoleList extends Buttons { } button.onclick = (_) => { html.classList.remove("mobileHidden"); - this.generateHTMLArea(thing[1], html); + this.generateHTMLArea(inner, html); if (this.warndiv) { this.warndiv.remove(); } diff --git a/src/webpage/settings.ts b/src/webpage/settings.ts index 0e5ea9c..3f3e428 100644 --- a/src/webpage/settings.ts +++ b/src/webpage/settings.ts @@ -18,10 +18,22 @@ interface OptionsElement { readonly watchForChange: (func: (arg1: x) => void) => void; value: x; } +export const enum buttonColor { + WHITE, + RED, +} +interface buttonType { + name: string; + inner: Options | string; + head?: boolean; + contained?: boolean; + color?: buttonColor; + initable?: boolean; +} //future me stuff export class Buttons implements OptionsElement { readonly name: string; - readonly buttons: [string, Options | string][]; + readonly buttons: buttonType[]; readonly buttonMap = new Map(); buttonList!: HTMLDivElement; warndiv!: HTMLElement; @@ -34,16 +46,30 @@ export class Buttons implements OptionsElement { this.name = name; this.titles = titles; } - add(name: string, thing?: Options | undefined) { - if (!thing) { - thing = new Options(this.titles ? name : "", this); + add( + name: string, + inner?: Options, + contained?: boolean, + head?: boolean, + color?: buttonColor, + initable?: boolean, + ) { + if (!inner) { + inner = new Options(this.titles ? name : "", this); } - const button = [name, thing] as [string, string | Options]; + const button = { + name, + inner, + contained, + head, + color, + initable, + }; this.buttons.push(button); const htmlarea = this.htmlarea.deref(); const buttonTable = this.buttonTable.deref(); if (buttonTable && htmlarea) buttonTable.append(this.makeButtonHTML(button, htmlarea)); - return thing; + return inner; } htmlarea = new WeakRef(document.createElement("div")); buttonTable = new WeakRef(document.createElement("div")); @@ -55,8 +81,9 @@ export class Buttons implements OptionsElement { const htmlarea = document.createElement("div"); htmlarea.classList.add("flexgrow", "settingsHTMLArea"); const buttonTable = this.generateButtons(htmlarea); - if (this.buttons[0]) { - this.generateHTMLArea(this.buttons[0][1], htmlarea); + const button = this.buttons.find((_) => !_.head && _.initable !== false); + if (button) { + this.generateHTMLArea(button.inner, htmlarea); if (!hideButtons) htmlarea.classList.add("mobileHidden"); } if (!hideButtons) buttonList.append(buttonTable); @@ -65,18 +92,26 @@ export class Buttons implements OptionsElement { this.buttonTable = new WeakRef(buttonTable); return buttonList; } - makeButtonHTML(buttond: [string, string | Options], optionsArea: HTMLElement) { - const button = document.createElement("button"); - this.buttonMap.set(buttond[1], button); - button.classList.add("SettingsButton"); - button.textContent = buttond[0]; - button.onclick = (_) => { - this.generateHTMLArea(buttond[1], optionsArea); - optionsArea.classList.remove("mobileHidden"); - if (this.warndiv) { - this.warndiv.remove(); - } - }; + makeButtonHTML({name, inner, contained, head, color}: buttonType, optionsArea: HTMLElement) { + const button = document.createElement(head ? "span" : "button"); + this.buttonMap.set(inner, button); + button.classList.add( + ...[head ? "headSetting" : "SettingsButton", contained ? "settingsIndent" : ""].filter( + (_) => _, + ), + ); + button.textContent = name; + if (!head) + button.onclick = (_) => { + this.generateHTMLArea(inner, optionsArea); + optionsArea.classList.remove("mobileHidden"); + if (this.warndiv) { + this.warndiv.remove(); + } + }; + if (color === buttonColor.RED) { + button.classList.add("settingRed"); + } return button; } generateButtons(optionsArea: HTMLElement) { @@ -85,8 +120,8 @@ export class Buttons implements OptionsElement { if (this.top) { buttonTable.classList.add("flexltr"); } - for (const thing of this.buttons) { - buttonTable.append(this.makeButtonHTML(thing, optionsArea)); + for (const button of this.buttons) { + buttonTable.append(this.makeButtonHTML(button, optionsArea)); } return buttonTable; } @@ -2515,9 +2550,20 @@ class Settings extends Buttons { super(name); this.hideButtons = hideButtons; } - addButton(name: string, {ltr = false, optName = name, noSubmit = false} = {}): Options { + addButton( + name: string, + { + ltr = false, + optName = name, + noSubmit = false, + head = false, + contained = false, + color = buttonColor.WHITE, + initable = true, + } = {}, + ): Options { const options = new Options(optName, this, {ltr, noSubmit}); - this.add(name, options); + this.add(name, options, contained, head, color, initable); return options; } show() { diff --git a/src/webpage/style.css b/src/webpage/style.css index 5262732..4effa49 100644 --- a/src/webpage/style.css +++ b/src/webpage/style.css @@ -754,6 +754,14 @@ select { cursor: pointer; transition: background 0.1s ease-in-out; } +.langButton { + background: #0000004d; + padding: 10px; + margin-bottom: 8px; + &.selected { + background: #000000ad; + } +} .subButton { min-width: 350px; padding: 10px; @@ -4121,13 +4129,14 @@ fieldset input[type="radio"] { } .settingbuttons { flex: none; - width: 192px; + width: 210px; padding: 6px; background: var(--settings-panel-bg); border-right: 2px solid var(--shadow); border-left: 2px solid transparent; box-sizing: border-box; overflow-y: auto; + overflow-x: clip; } .roleButtonStyle { color: transparent; @@ -4145,6 +4154,7 @@ fieldset input[type="radio"] { border: none; transition: background 0.1s; position: relative; + padding-right: 0px; } .infosection { .activeSetting { @@ -4154,6 +4164,18 @@ fieldset input[type="radio"] { .activeSetting { background: var(--secondary-hover); } +.settingsIndent { + margin-left: 5px; + width: calc(100% - 10px); +} +.settingRed { + color: var(--red); +} +.headSetting { + padding: 5px; + display: block; + font-weight: bold; +} .SettingsButton:hover { background: var(--settings-panel-hover); color: var(--primary-text-prominent); diff --git a/translations/en.json b/translations/en.json index 43899f6..94343eb 100644 --- a/translations/en.json +++ b/translations/en.json @@ -64,6 +64,8 @@ "never": "Never" }, "name": "Accessibility", + "visuals":"Visuals", + "animations":"Animations", "playGif": "Play GIFs:", "playIcon": "Play animated icons:", "roleColors": "Disable role colors:", @@ -497,8 +499,12 @@ "leaving": "You're leaving Harmony", "loaded": "Loaded", "localuser": { + "security":"Security", + "advanced":"Advanced settings", + "danger":"Danger zone", "2faCode:": "Code:", "2faCodeGive": "Your TOTP secret is: $1. This will generate a 6 digits 2FA token with a 30 second expiration period.", + "2fa":"2FA settings", "2faDisable": "Disable 2FA", "2faEnable": "Enable 2FA", "CheckUpdate": "Check for updates", @@ -538,7 +544,7 @@ "install": "Install", "installDesc": "Installing Fermi will allow you to open it in its own window and act like its own app! You can also just continue to use Fermi in the web browser like you have been and it'll work the same.", "installJank": "Install Fermi", - "language": "Language:", + "language": "Language", "manageBot": "Manage bot", "manageInstance": "Manage instance", "mustTypePhrase": "To delete your account you must type the phrase", @@ -576,7 +582,8 @@ "trustedDesc": "When you click on links sending you to these domain, you will ***not*** be prompted for permission to open like other links, only give this to domains you trust, such as 'https://fermi.chat'", "updateSettings": "Update settings", "updatesYay": "Updates found!", - "userSettings": "Your public profile" + "userSettings": "Your public profile", + "general":"General" }, "login": { "allGood": "All good", @@ -727,7 +734,7 @@ "USE_SOUNDBOARD": "Allows role members to use a soundboard in voice channels.", "USE_EXTERNAL_SOUNDS": "Allows role members to use soundboard sounds from other guilds in voice channels.", "PRIORITY_SPEAKER": "Allows role members to be priority speaker in voice channels.", - "REQUEST_TO_SPEAK": "Allows role members to request to speak in stage channels.", + "REQUEST_TO_SPEAK": "Allows role members to request to speak in stage channels.", "USE_EMBEDDED_ACTIVITIES": "Allows role members to use embedded activities.", "USE_APPLICATION_COMMANDS": "Allows role members to use application commands.", "USE_EXTERNAL_APPS": "Allows role members to have application responses to show publicly in channel (When disabled, users will still be allowed to use their apps but responses will be visible only to themselves. This only applies to apps not also installed to the guild)." @@ -814,7 +821,7 @@ "instURL":"URL:" }, "remove": "Remove", - "renderJoinAvatars": "Display avatars for new members: ", + "renderJoinAvatars": " Display avatars in member welcome messages: ", "reply": "Reply", "replyingTo": "Replying to $1", "retrying": "Retrying...", From 15918371097bc0a1ca4f8d8a7aa49dbac5483d68 Mon Sep 17 00:00:00 2001 From: MathMan05 Date: Fri, 17 Jul 2026 18:56:42 -0500 Subject: [PATCH 06/44] better emoji reconition --- src/webpage/markdown.ts | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/webpage/markdown.ts b/src/webpage/markdown.ts index 699645a..2e5a69e 100644 --- a/src/webpage/markdown.ts +++ b/src/webpage/markdown.ts @@ -80,11 +80,25 @@ class MarkDown { } const span = document.createElement("span"); span.classList.add("md-emoji", "bigemojiUni"); - - const matched = str.match(/^((|([^\da-zA-Z <>])) *){1,3}$/u); - if (matched) { + const seg = new Intl.Segmenter("en-US", {granularity: "grapheme"}); + const matchReg = !!str.match(/^(((|([^\da-zA-Z <>]+)) *))$/gu); + const matched = matchReg + ? Array.from(str.matchAll(/((|([^\da-zA-Z <>]+)) *)/gu) || []).flatMap( + (match) => { + if (match[0].match(/^$/u)) { + return match[0]; + } else if (match[0].match(/[^\da-zA-Z <>]+$/gu)) { + return seg.segment(match[0]); + } else if (match[0].match(/^\s*$/)) { + console.log(match); + return []; + } else return [..."aaaa"]; + }, + ) + : [..."aaaa"]; + if (matched.length <= 3) { const map = [...str.matchAll(/|[^\da-zA-Z <>]+/gu).map(([_]) => _)]; - const seg = new Intl.Segmenter("en-US", {granularity: "grapheme"}); + const invalid = map.find((str) => { if (str.length > 10) return false; if (Array.from(seg.segment(str)).length !== 1) return true; From 128b6f92c417cbe292f592f8b008b7f8b20f0f13 Mon Sep 17 00:00:00 2001 From: MathMan05 Date: Fri, 17 Jul 2026 19:42:49 -0500 Subject: [PATCH 07/44] perf updates and perm selection updates --- src/webpage/app.html | 5 +-- src/webpage/message.ts | 9 +++-- src/webpage/role.ts | 46 ++++++++++++++--------- src/webpage/style.css | 84 +++++++++++++++++++++++++++++++----------- 4 files changed, 97 insertions(+), 47 deletions(-) diff --git a/src/webpage/app.html b/src/webpage/app.html index d8590f7..47b2abb 100644 --- a/src/webpage/app.html +++ b/src/webpage/app.html @@ -91,11 +91,8 @@ - - +
diff --git a/src/webpage/message.ts b/src/webpage/message.ts index 06b34be..ae4e991 100644 --- a/src/webpage/message.ts +++ b/src/webpage/message.ts @@ -831,6 +831,7 @@ class Message extends SnowFlake { } func(); }; + div.classList.add("blockedDiv"); div.appendChild(build); return div; } @@ -1394,7 +1395,7 @@ class Message extends SnowFlake { const reactions = document.createElement("div"); reactions.classList.add("flexltr", "reactiondiv"); this.reactdiv = new WeakRef(reactions); - this.updateReactions(); + this.updateReactions(false); div.append(reactions); } if (this.ephemeral) { @@ -1604,10 +1605,10 @@ class Message extends SnowFlake { }); diaolog.show(); } - updateReactions() { + updateReactions(needsSnap = true) { const reactdiv = this.reactdiv.deref(); if (!reactdiv) return; - const func = this.channel.infinite.snapBottom(); + const func = needsSnap && this.channel.infinite.snapBottom(); reactdiv.innerHTML = ""; for (const thing of this.reactions) { const reaction = document.createElement("div"); @@ -1667,7 +1668,7 @@ class Message extends SnowFlake { this.reactionToggle(new Emoji(thing.emoji, this.guild)); }; } - func(); + if (func) func(); } reactionAdd(data: {name: string; id?: string}, member: Member | {id: string}) { for (const thing of this.reactions) { diff --git a/src/webpage/role.ts b/src/webpage/role.ts index 37cf5d8..f11c89f 100644 --- a/src/webpage/role.ts +++ b/src/webpage/role.ts @@ -159,58 +159,70 @@ class PermissionToggle implements OptionsElement { watchForChange() {} generateHTML(): HTMLElement { const div = document.createElement("div"); - div.classList.add("setting"); + const descDiv = document.createElement("div"); + div.classList.add("setting", "flexltr", "roleSettings"); const name = document.createElement("span"); name.textContent = this.rolejson.readableName; name.classList.add("settingsname"); - div.append(name); + descDiv.append(name); - div.append(this.generateCheckbox()); + div.append(descDiv, this.generateCheckbox()); const p = document.createElement("p"); p.textContent = this.rolejson.description; - div.appendChild(p); + descDiv.appendChild(p); + return div; } generateCheckbox(): HTMLElement { - const rand = Math.random() + ""; const div = document.createElement("div"); div.classList.add("tritoggle"); const state = this.permissions.getPermission(this.rolejson.name); + let cur = document.createElement("span"); + function switchCur(newCur: HTMLSpanElement) { + cur.classList.remove("selected"); + cur = newCur; + cur.classList.add("selected"); + } - const on = document.createElement("input"); - on.type = "radio"; - on.name = this.rolejson.name + rand; + const on = document.createElement("span"); div.append(on); + on.classList.add("triOpt"); if (state === 1) { - on.checked = true; + cur = on; + on.classList.add("selected"); } on.onclick = (_) => { this.permissions.setPermission(this.rolejson.name, 1); this.owner.changed(); + switchCur(on); }; - const no = document.createElement("input"); - no.type = "radio"; - no.name = this.rolejson.name + rand; + const no = document.createElement("span"); + no.classList.add("triOpt"); + div.append(no); if (state === 0) { - no.checked = true; + cur = no; + on.classList.add("selected"); } no.onclick = (_) => { this.permissions.setPermission(this.rolejson.name, 0); this.owner.changed(); + switchCur(no); }; if (this.permissions.hasDeny) { - const off = document.createElement("input"); - off.type = "radio"; - off.name = this.rolejson.name + rand; + const off = document.createElement("span"); + div.append(off); + off.classList.add("triOpt"); if (state === -1) { - off.checked = true; + cur = off; + on.classList.add("selected"); } off.onclick = (_) => { this.permissions.setPermission(this.rolejson.name, -1); this.owner.changed(); + switchCur(off); }; } return div; diff --git a/src/webpage/style.css b/src/webpage/style.css index 4effa49..387c111 100644 --- a/src/webpage/style.css +++ b/src/webpage/style.css @@ -2764,7 +2764,7 @@ span.instanceStatus { .message.blocked { margin-bottom: 0; } -.messagediv:has(.message.blocked):hover { +.blockedDiv:hover { background: transparent; } .pfprow { @@ -3349,21 +3349,24 @@ img.bigembedimg { opacity: 0.5; } #memberlisttoggle { - display: none; + /* display: none; */ + &::before { + content: ""; + width: 24px; + height: 24px; + display: block; + } } -#memberlisttoggleicon { - display: block; - padding: 12px 0; - margin-left: 0; +#memberlisttoggle { cursor: pointer; - flex-grow: 0; + &::before { + background: var(--primary-text-soft); + } } -#memberlisttoggleicon span { - height: 16px; - width: 16px; -} -#page:has(#memberlisttoggle:checked) #memberlisttoggleicon span { - background: var(--primary-text-prominent); +#memberlisttoggle:checked { + &::before { + background: var(--primary-text-prominent); + } } /* Context Menu */ @@ -4416,6 +4419,10 @@ fieldset input[type="radio"] { color: var(--secondary-text); border-radius: 4px; } +.roleSettings { + display: flex; + align-items: center; +} .settingsname { font-size: 1.15rem; } @@ -4424,18 +4431,51 @@ fieldset input[type="radio"] { color: var(--secondary-text-soft); } .tritoggle { - display: inline; - margin-left: 4px; + display: flex; + margin-left: auto; + position: relative; + padding: 0px; + cursor: pointer; + &::after { + content: ""; + background: var(--secondary-bg); + transition: left 0.2s; + border-radius: 4px; + width: 18px; + height: 18px; + display: block; + top: 3px; + left: 2px; + position: absolute; + } + :first-child { + border-radius: 5px 0px 0px 5px; + } + :last-child { + border-radius: 0px 5px 5px 0px; + } + &:has(:first-child.selected)::after { + left: 3px; + } + &:has(:nth-child(2).selected)::after { + left: 27px; + } + &:has(:nth-child(3).selected)::after { + left: 51px; + } } -.tritoggle input { - margin: 0 2px; - accent-color: var(--grey); +.tritoggle > .triOpt { + margin: 0 0px; + background: var(--grey); + width: 24px; + height: 24px; + display: inline-block; } -.tritoggle input:first-child { - accent-color: var(--green); +.tritoggle > .triOpt:first-child { + background: var(--green); } -.tritoggle input:nth-child(3) { - accent-color: var(--red); +.tritoggle > .triOpt:nth-child(3) { + background: var(--red); } .nonimagecenter > .flexltr.savediv { padding: 16px !important; From 1af75902f7a6c3956d58e30dd3b6077f02444116 Mon Sep 17 00:00:00 2001 From: MathMan05 Date: Fri, 17 Jul 2026 19:50:47 -0500 Subject: [PATCH 08/44] screw up fix --- src/webpage/role.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/webpage/role.ts b/src/webpage/role.ts index f11c89f..af6362a 100644 --- a/src/webpage/role.ts +++ b/src/webpage/role.ts @@ -203,7 +203,7 @@ class PermissionToggle implements OptionsElement { div.append(no); if (state === 0) { cur = no; - on.classList.add("selected"); + no.classList.add("selected"); } no.onclick = (_) => { this.permissions.setPermission(this.rolejson.name, 0); @@ -217,7 +217,7 @@ class PermissionToggle implements OptionsElement { off.classList.add("triOpt"); if (state === -1) { cur = off; - on.classList.add("selected"); + off.classList.add("selected"); } off.onclick = (_) => { this.permissions.setPermission(this.rolejson.name, -1); From b140cafa3731c9609464996cad38d7253c49a32a Mon Sep 17 00:00:00 2001 From: MathMan05 Date: Fri, 17 Jul 2026 20:40:33 -0500 Subject: [PATCH 09/44] oops --- src/webpage/style.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/webpage/style.css b/src/webpage/style.css index 387c111..57f2673 100644 --- a/src/webpage/style.css +++ b/src/webpage/style.css @@ -4081,6 +4081,7 @@ fieldset input[type="radio"] { border-right: 0; border-radius: 0.04in; .SettingsButton { + padding-right: 12px; width: auto; border-radius: 3px 3px 1px 1px; } From 98ffac09dca02b00d949a69562b34eaf01591232 Mon Sep 17 00:00:00 2001 From: MathMan05 Date: Fri, 17 Jul 2026 20:43:54 -0500 Subject: [PATCH 10/44] hopefully fix video attachment size --- src/webpage/style.css | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/webpage/style.css b/src/webpage/style.css index 57f2673..0e2a47a 100644 --- a/src/webpage/style.css +++ b/src/webpage/style.css @@ -3148,6 +3148,10 @@ span .quote:last-of-type .quoteline { margin-top: 4px; gap: 4px; flex-wrap: wrap; + video { + max-height: 288px; + max-width: 288px; + } } .embed { max-width: 400px; @@ -4081,7 +4085,6 @@ fieldset input[type="radio"] { border-right: 0; border-radius: 0.04in; .SettingsButton { - padding-right: 12px; width: auto; border-radius: 3px 3px 1px 1px; } From ebce3f6bbb06d8b130e86b57c0c9baf8de9608cd Mon Sep 17 00:00:00 2001 From: MathMan05 Date: Fri, 17 Jul 2026 21:33:06 -0500 Subject: [PATCH 11/44] forgot to remove true --- src/webpage/channel.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/webpage/channel.ts b/src/webpage/channel.ts index 840283e..9fa4817 100644 --- a/src/webpage/channel.ts +++ b/src/webpage/channel.ts @@ -222,11 +222,7 @@ class Channel extends SnowFlake { }, { visible: function () { - return ( - this.hasPermission("MANAGE_CHANNELS") || - this.owner_id === this.localuser.user.id || - true - ); + return this.hasPermission("MANAGE_CHANNELS") || this.owner_id === this.localuser.user.id; }, icon: { css: "svg-settings", From 0368cf8f5d1f3708bfa6f0f527c9445e8d8263d7 Mon Sep 17 00:00:00 2001 From: MathMan05 Date: Fri, 17 Jul 2026 21:53:44 -0500 Subject: [PATCH 12/44] fix nsfw pannel bug --- src/webpage/channel.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/webpage/channel.ts b/src/webpage/channel.ts index 9fa4817..bdd5f4a 100644 --- a/src/webpage/channel.ts +++ b/src/webpage/channel.ts @@ -1547,6 +1547,7 @@ class Channel extends SnowFlake { const buttons = options.addOptions("", {ltr: true}); buttons.addButtonInput("", "Yes", () => { this.perminfo.nsfwOk = true; + this.localuser.focusChannel = undefined; this.getHTML(); }); buttons.addButtonInput("", "No", () => { From 1d7c3cba35b3e78295be9a7119308e12283776a2 Mon Sep 17 00:00:00 2001 From: MathMan05 Date: Fri, 17 Jul 2026 22:35:05 -0500 Subject: [PATCH 13/44] fix some bugs --- src/webpage/channel.ts | 10 ++++++++-- src/webpage/role.ts | 26 ++++++++++++++++---------- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/webpage/channel.ts b/src/webpage/channel.ts index bdd5f4a..1866e9b 100644 --- a/src/webpage/channel.ts +++ b/src/webpage/channel.ts @@ -56,12 +56,16 @@ class Channel extends SnowFlake { get lastreadmessageid() { return this.lastreadmessageidint; } - async getOverwrites(): Promise<[User | Role, Permissions][]> { + async getOverwrites(): Promise<[User | Role | {id: string}, Permissions][]> { return await Promise.all( [...this.permissionOverwriteMap].map(async ([id, p]) => { const role = this.guild.roleids.get(id); if (role) return [role, p] as const; - return [await this.localuser.getUser(id), p] as const; + try { + return [await this.localuser.getUser(id), p] as const; + } catch { + return [{id}, p] as const; + } }), ); } @@ -577,7 +581,9 @@ class Channel extends SnowFlake { const s1 = settings.addButton(I18n.channel.permissions(), {optName: ""}); (async () => { + console.log("Here"); const list = await this.getOverwrites(); + console.log("here 2"); s1.options.push( new RoleList(list, this.guild, this.updateRolePermissions.bind(this), this), diff --git a/src/webpage/role.ts b/src/webpage/role.ts index af6362a..8199d21 100644 --- a/src/webpage/role.ts +++ b/src/webpage/role.ts @@ -231,7 +231,7 @@ class PermissionToggle implements OptionsElement { } class RoleList extends Buttons { - permissions: [Role | User, Permissions][]; + permissions: [Role | User | {id: string}, Permissions][]; permission: Permissions; readonly guild: Guild; readonly channel: false | Channel; @@ -246,7 +246,7 @@ class RoleList extends Buttons { return this.guild.headers; } constructor( - permissions: [Role | User, Permissions][], + permissions: [Role | User | {id: string}, Permissions][], guild: Guild, onchange: (id: string, perms: Permissions) => void, channel: false | Channel, @@ -276,7 +276,10 @@ class RoleList extends Buttons { }); } for (const i of permissions) { - this.buttons.push({name: i[0].name, inner: i[0].id}); + this.buttons.push({ + name: "name" in i[0] ? i[0].name : I18n.userping.unknown(), + inner: i[0].id, + }); } this.options = options; guild.roleUpdate = this.groleUpdate.bind(this); @@ -474,13 +477,13 @@ class RoleList extends Buttons { return menu; } - deleteRole(role: Role | User) { - const dio = new Dialog(I18n.role.confirmDelete(role.name)); + deleteRole(role: Role | User | {id: string}) { + const dio = new Dialog(I18n.role.confirmDelete("name" in role ? role.name : role.id)); const opt = dio.options.addOptions("", {ltr: true}); opt.addButtonInput("", I18n.yes(), async () => { opt.removeAll(); opt.addText(I18n.role.deleting()); - await fetch(role.info.api + "/guilds/" + this.guild.id + "/roles/" + role.id, { + await fetch(this.info.api + "/guilds/" + this.guild.id + "/roles/" + role.id, { method: "DELETE", headers: this.guild.headers, }); @@ -523,12 +526,15 @@ class RoleList extends Buttons { redoButtons() { this.buttons = []; this.permissions.sort(([a], [b]) => { - if (b instanceof User) return 1; - if (a instanceof User) return -1; + if (b instanceof User || !(b instanceof Channel)) return 1; + if (a instanceof User || !(a instanceof Channel)) return -1; return b.position - a.position; }); for (const i of this.permissions) { - this.buttons.push({name: i[0].name, inner: i[0].id}); + this.buttons.push({ + name: "name" in i[0] ? i[0].name : I18n.userping.unknown(), + inner: i[0].id, + }); } if (!this.buttonList) return; const elms = Array.from(this.buttonList.children); @@ -712,7 +718,7 @@ class RoleList extends Buttons { 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.name = "name" in role[0] ? role[0].name : I18n.userping.unknown(); this.options.haschanged = false; } } From 5b0e9657afb16c9eab52f587e5f52e9da10348ee Mon Sep 17 00:00:00 2001 From: MathMan05 Date: Fri, 17 Jul 2026 23:52:45 -0500 Subject: [PATCH 14/44] black folder support --- src/webpage/localuser.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/webpage/localuser.ts b/src/webpage/localuser.ts index 2142682..544fd3e 100644 --- a/src/webpage/localuser.ts +++ b/src/webpage/localuser.ts @@ -2038,7 +2038,7 @@ class Localuser { folder.name = name.value; folder.color = +("0x" + (color.value || "#0").split("#")[1]); icon.style.setProperty("--folder-color", "#" + folder.color.toString(16).padStart(6, "0")); - if (!folder.color) icon.style.removeProperty("--folder-color"); + if (!folder.color && folder.color !== 0) icon.style.removeProperty("--folder-color"); await this.saveGuildOrder(); dio.hide(); }); @@ -2048,7 +2048,7 @@ class Localuser { menu.bindContextmenu(iconDiv); if (folder.color !== null && folder.color !== undefined) { icon.style.setProperty("--folder-color", "#" + folder.color.toString(16).padStart(6, "0")); - if (!folder.color) icon.style.removeProperty("--folder-color"); + if (!folder.color && folder.color !== 0) icon.style.removeProperty("--folder-color"); } iconDiv.append(icon); const divy = document.createElement("div"); From cb9c9794c695ac65b28286f511feded208b2e890 Mon Sep 17 00:00:00 2001 From: MathMan05 Date: Sat, 18 Jul 2026 14:07:02 -0500 Subject: [PATCH 15/44] update Harmony icon --- src/webpage/instances.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/webpage/instances.json b/src/webpage/instances.json index 65371b7..7099768 100644 --- a/src/webpage/instances.json +++ b/src/webpage/instances.json @@ -2,14 +2,14 @@ { "name": "Harmony", "description": "The official Harmony community!", - "image": "https://codeberg.org/MelodyChat/landing-page/raw/commit/9d69a5de98123a95cba77556ccaf83ad71162d63/src/assets/harmony.webp", + "image": "https://codeberg.org/MelodyChat/landing-page/raw/branch/main/assets/harmony.webp", "url": "https://api.harmony.melodychat.org/", "display":true }, { "name": "Harmony Staging", "description": "A staging instance for Harmony", - "image": "https://codeberg.org/MelodyChat/landing-page/raw/commit/9d69a5de98123a95cba77556ccaf83ad71162d63/src/assets/harmony.webp", + "image": "https://codeberg.org/MelodyChat/landing-page/raw/branch/main/assets/harmony.webp", "url": "https://stg-api.harmony.melodychat.org/", "display":false }, From 7aa1c13d17929baeec6cbff13a8798558772f583 Mon Sep 17 00:00:00 2001 From: MathMan05 Date: Sat, 18 Jul 2026 17:38:49 -0500 Subject: [PATCH 16/44] fix firefox bug and make a setting out of it --- src/webpage/localuser.ts | 23 ++++++++++++++++++++ src/webpage/style.css | 11 +++++++--- src/webpage/utils/storage/userPreferences.ts | 1 + translations/en.json | 1 + 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/webpage/localuser.ts b/src/webpage/localuser.ts index 544fd3e..23a3d84 100644 --- a/src/webpage/localuser.ts +++ b/src/webpage/localuser.ts @@ -457,6 +457,13 @@ class Localuser { } this.pingEndpoint(); + const prefs = await getPreferences(); + const ml = document.getElementById("memberlisttoggle")!; + if (prefs.checkMemberList) { + ml.classList = ""; + } else { + ml.classList = "svgicon svg-friends"; + } } readonly inrelation = new Set(); outoffocus(): void { @@ -3119,6 +3126,22 @@ class Localuser { {initState: cur}, ); } + visuals.addCheckboxInput( + I18n.checkBoxMemberList(), + (c) => { + prefs.checkMemberList = c; + const ml = document.getElementById("memberlisttoggle")!; + if (c) { + ml.classList = ""; + } else { + ml.classList = "svgicon svg-friends"; + } + setPreferences(prefs); + }, + { + initState: !!prefs.checkMemberList, + }, + ); } { const animations = settings.addButton(I18n.accessibility.animations(), {contained: true}); diff --git a/src/webpage/style.css b/src/webpage/style.css index 0e2a47a..bc5705d 100644 --- a/src/webpage/style.css +++ b/src/webpage/style.css @@ -3353,6 +3353,11 @@ img.bigembedimg { opacity: 0.5; } #memberlisttoggle { + width: 24px; + height: 24px; + cursor: pointer; +} +#memberlisttoggle.svgicon { /* display: none; */ &::before { content: ""; @@ -3361,13 +3366,13 @@ img.bigembedimg { display: block; } } -#memberlisttoggle { - cursor: pointer; +#memberlisttoggle.svgicon { + -moz-appearance: initial; &::before { background: var(--primary-text-soft); } } -#memberlisttoggle:checked { +#memberlisttoggle.svgicon:checked { &::before { background: var(--primary-text-prominent); } diff --git a/src/webpage/utils/storage/userPreferences.ts b/src/webpage/utils/storage/userPreferences.ts index d017300..ff1cfe0 100644 --- a/src/webpage/utils/storage/userPreferences.ts +++ b/src/webpage/utils/storage/userPreferences.ts @@ -34,6 +34,7 @@ export class UserPreferences { theme: ThemeOption = ThemeOption.Dark; accentColor: string = "#5865F2"; emojiFont?: string; + checkMemberList = false; constructor(init?: Partial) { Object.assign(this, init); diff --git a/translations/en.json b/translations/en.json index 94343eb..63ee1a9 100644 --- a/translations/en.json +++ b/translations/en.json @@ -822,6 +822,7 @@ }, "remove": "Remove", "renderJoinAvatars": " Display avatars in member welcome messages: ", + "checkBoxMemberList":"Use a checkbox for the member list: ", "reply": "Reply", "replyingTo": "Replying to $1", "retrying": "Retrying...", From 6b166a6a642f4826736346049ee765b300974564 Mon Sep 17 00:00:00 2001 From: MathMan05 Date: Sat, 18 Jul 2026 22:20:08 -0500 Subject: [PATCH 17/44] Gif providors update --- src/webpage/localuser.ts | 60 +++++++++++++++++++- src/webpage/utils/storage/userPreferences.ts | 1 + translations/en.json | 1 + 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/webpage/localuser.ts b/src/webpage/localuser.ts index 23a3d84..751a4a2 100644 --- a/src/webpage/localuser.ts +++ b/src/webpage/localuser.ts @@ -371,6 +371,7 @@ class Localuser { guildFolders: guildFolder[] = []; readonly unknownRead = new Map(); async gottenReady(ready: readyjson): Promise { + this.getGifProvidors(); await I18n.done; this.errorBackoff = 0; this.queryBlog(); @@ -2504,6 +2505,31 @@ class Localuser { headers: this.headers, }).then((r) => r.json() as Promise<{[key: string]: {enabled: boolean; icon_url?: string}}>); } + gifProvideors: {name: string; api_name: string}[] = []; + selectedGifProfidor?: {name: string; api_name: string}; + async getGifProvidors() { + const res = (await ( + await fetch(this.info.api + "/gifs", { + headers: this.headers, + }) + ).json()) as {name: string; api_name: string}[]; + if (res[0] && !("name" in res[0])) { + if (res.length === 3) { + this.gifProvideors = [ + {name: "Giphy", api_name: "giphy"}, + {name: "Klipy", api_name: "klipy"}, + ]; + } + } else { + this.gifProvideors = res.filter((_) => _.name !== "tenor"); + } + this.figureDefaultProvidor(); + } + async figureDefaultProvidor() { + const prefs = await getPreferences(); + this.selectedGifProfidor = + this.gifProvideors.find((_) => _.api_name == prefs.gifProvidor) || this.gifProvideors[0]; + } async showusersettings() { const prefs = await getPreferences(); const localSettings = getLocalSettings(); @@ -3034,6 +3060,23 @@ class Localuser { }, ); } + { + const ind = this.gifProvideors.findIndex((_) => this.selectedGifProfidor === _); + console.log(prefs.gifProvidor); + tas.addSelect( + I18n.gifProvidor(), + async (i) => { + prefs.gifProvidor = this.gifProvideors[i].api_name; + console.log(prefs.gifProvidor); + + await setPreferences(prefs); + Localuser.loadFont(); + this.figureDefaultProvidor(); + }, + this.gifProvideors.map((_) => _.name), + {defaultIndex: ind === -1 ? 0 : ind}, + ); + } } { const blog = settings.addButton(I18n.blog.blog(), {contained: true}); @@ -4195,7 +4238,12 @@ class Localuser { Contextmenu.declareMenu(menu); const trending = (await ( await fetch( - this.info.api + "/gifs/trending?" + new URLSearchParams([["locale", I18n.lang]]), + this.info.api + + "/gifs/trending?" + + new URLSearchParams([ + ["locale", I18n.lang], + ["provider", this.selectedGifProfidor?.api_name!], + ]), {headers: this.headers}, ) ).json()) as { @@ -4207,7 +4255,12 @@ class Localuser { }; await fetch( - this.info.api + "/gifs/trending-gifs?" + new URLSearchParams([["locale", I18n.lang]]), + this.info.api + + "/gifs/trending-gifs?" + + new URLSearchParams([ + ["locale", I18n.lang], + ["provider", this.selectedGifProfidor?.api_name!], + ]), {headers: this.headers}, ); const gifbox = document.createElement("div"); @@ -4283,6 +4336,7 @@ class Localuser { ["locale", I18n.lang], ["q", sValue], ["limit", "500"], + ["provider", this.selectedGifProfidor?.api_name!], ]), {headers: this.headers}, ) @@ -4307,7 +4361,7 @@ class Localuser { }; search.classList.add("searchGifBar"); //TODO fix this once we swap over - search.placeholder = I18n.searchGifs("Tenor"); + search.placeholder = I18n.searchGifs(this.selectedGifProfidor?.name!); const favs = this.favorites.favoriteGifs(); if (favs.length) { favs.forEach(async (_) => (_.src = await this.refreshIfNeeded(_.src))); diff --git a/src/webpage/utils/storage/userPreferences.ts b/src/webpage/utils/storage/userPreferences.ts index ff1cfe0..dcf5182 100644 --- a/src/webpage/utils/storage/userPreferences.ts +++ b/src/webpage/utils/storage/userPreferences.ts @@ -35,6 +35,7 @@ export class UserPreferences { accentColor: string = "#5865F2"; emojiFont?: string; checkMemberList = false; + gifProvidor?: string; constructor(init?: Partial) { Object.assign(this, init); diff --git a/translations/en.json b/translations/en.json index 63ee1a9..e8999e3 100644 --- a/translations/en.json +++ b/translations/en.json @@ -265,6 +265,7 @@ "upload": "Upload emojis" }, "emojiSelect": "Emoji:", + "gifProvidor":"Gif Provider:", "errorReconnect": "Unable to connect to the server, retrying in **$1** seconds...", "favoriteGifs": "Favorited GIFs", "folder": { From bc44ddb145b8961000cafa52e48ca6905ac97c8a Mon Sep 17 00:00:00 2001 From: MathMan05 Date: Sun, 19 Jul 2026 16:58:06 -0500 Subject: [PATCH 18/44] fix edit bug --- src/webpage/message.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/webpage/message.ts b/src/webpage/message.ts index ae4e991..0d67929 100644 --- a/src/webpage/message.ts +++ b/src/webpage/message.ts @@ -1009,7 +1009,7 @@ class Message extends SnowFlake { area.addEventListener("keyup", (event) => { if (this.localuser.keyup(event)) return; if (event.key === "Enter" && !event.shiftKey) { - this.edit(md.rawString); + this.edit(MarkDown.gatherBoxText(area)); this.channel.editing = null; this.generateMessage(); } From 8191c6bb3c5aeac64abfacd52350fb6dcd3192ac Mon Sep 17 00:00:00 2001 From: MathMan05 Date: Mon, 20 Jul 2026 00:16:37 -0500 Subject: [PATCH 19/44] syntax highlighting --- package-lock.json | 383 ++++++++++++++++++++++- package.json | 5 +- src/webpage/highlighter/clike/langs.json | 20 ++ src/webpage/highlighter/clike/lex.ts | 105 +++++++ src/webpage/highlighter/colors.ts | 8 + src/webpage/highlighter/highlight.ts | 132 ++++++++ src/webpage/highlighter/index.ts | 34 ++ src/webpage/highlighter/python/lex.ts | 71 +++++ src/webpage/highlighter/xml/lex.ts | 35 +++ src/webpage/markdown.ts | 4 + src/webpage/style.css | 19 ++ tsconfig.json | 4 +- 12 files changed, 806 insertions(+), 14 deletions(-) create mode 100644 src/webpage/highlighter/clike/langs.json create mode 100644 src/webpage/highlighter/clike/lex.ts create mode 100644 src/webpage/highlighter/colors.ts create mode 100644 src/webpage/highlighter/highlight.ts create mode 100644 src/webpage/highlighter/index.ts create mode 100644 src/webpage/highlighter/python/lex.ts create mode 100644 src/webpage/highlighter/xml/lex.ts diff --git a/package-lock.json b/package-lock.json index d3dc8da..1507cbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "jankclient", - "version": "0.2.0", + "name": "fermi", + "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "jankclient", - "version": "0.2.0", + "name": "fermi", + "version": "0.0.1", "license": "GPL-3.0", "dependencies": { "@swc/core": "1.11.24" @@ -16,7 +16,7 @@ "@types/node": "^25.5.0", "@types/node-fetch": "^2.6.13", "prettier": "^3.7.4", - "typescript": "5.9.3" + "typescript": "^7.0.2" } }, "node_modules/@swc/core": { @@ -272,6 +272,346 @@ "form-data": "^4.0.4" } }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -551,17 +891,38 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "dev": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "tsc": "bin/tsc" }, "engines": { - "node": ">=14.17" + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, "node_modules/undici-types": { diff --git a/package.json b/package.json index 30da0a5..63566be 100644 --- a/package.json +++ b/package.json @@ -20,12 +20,15 @@ "@types/node": "^25.5.0", "@types/node-fetch": "^2.6.13", "prettier": "^3.7.4", - "typescript": "5.9.3" + "typescript": "^7.0.2" }, "prettier": { "useTabs": true, "printWidth": 100, "semi": true, "bracketSpacing": false + }, + "allowScripts": { + "@swc/core@1.11.24": true } } diff --git a/src/webpage/highlighter/clike/langs.json b/src/webpage/highlighter/clike/langs.json new file mode 100644 index 0000000..462bcb3 --- /dev/null +++ b/src/webpage/highlighter/clike/langs.json @@ -0,0 +1,20 @@ +[ +{ + "names":["js","ts","javascript"], + "keywords": ["break", "case", "catch", "class", "const", "continue", "debugger", "default", "delete", "do","else", "export", "extends", "finally", "for", "function", "if", "import", "in", "instanceof", "new", "return", "super", "switch", "this", "throw", "try", "typeof", "var", "void", "while", "with", "yield", "let", "await", "true", "false", "null", "undefined"], + "firstLineShabang": true, + "hashComments": false, + "doubleSlashComments": true, + "multilineSlashComments": true, + "JSLikeTemplateStrings": true +}, +{ + "names":["h","c"], + "keywords":["auto", "break", "case", "char", "continue", "do", "default", "const", "double", "else", "enum", "extern", "for", "if", "goto", "float", "int", "long", "register", "return", "signed", "static", "sizeof", "short", "struct", "switch", "typedef", "union", "void", "while", "volatile", "unsigned"], + "firstLineShabang": false, + "hashComments": true, + "doubleSlashComments": true, + "multilineSlashComments": false, + "JSLikeTemplateStrings": false +} +] diff --git a/src/webpage/highlighter/clike/lex.ts b/src/webpage/highlighter/clike/lex.ts new file mode 100644 index 0000000..5398c11 --- /dev/null +++ b/src/webpage/highlighter/clike/lex.ts @@ -0,0 +1,105 @@ +import {hcolors} from "../colors.js"; + +interface clikeConf { + names: string[]; + keywords: string[]; + firstLineShabang: boolean; + hashComments: boolean; + doubleSlashComments: boolean; + multilineSlashComments: boolean; + JSLikeTemplateStrings: boolean; +} +function regex(config: clikeConf): RegExp { + const conds = [] as string[]; + if (config.hashComments) { + conds.push("#.*"); + } + if (config.doubleSlashComments) { + conds.push("\\/\\/.*"); + } + if (config.multilineSlashComments) { + conds.push("\\/\\*([^*]|\\*([^\\/]|$))*(\\*\\/)?"); + } + if (config.multilineSlashComments) { + conds.push("\\/\\*([^*]|\\*([^\\/]|$))*(\\*\\/)?"); + } + if (config.JSLikeTemplateStrings) { + conds.push("`(\\\\.|[^`])*`?"); + } + conds.push("(.|\n)"); + return new RegExp( + '"(\\\\(.|\\n)|[^"\\n\\\\])*"?|\'(\\\\(.|\\n)|[^"\\n\\\\])*\'?|\\s+|[a-zA-Z][a-zA-Z0-9]*|[0-9][a-zA-Z0-9]*\\.?[a-zA-Z0-9]*|' + + conds.join("|"), + "gm", + ); +} +let j: undefined | clikeConf[] = undefined; +export async function canLex(lang: string) { + if (!j) { + j = await (await fetch("/highlighter/clike/langs.json")).json(); + } + const conf = j!.find((_) => _.names.includes(lang)); + if (conf) { + return (code: string) => { + return lex(code, conf); + }; + } + return undefined; +} +function* lex(code: string, config: clikeConf) { + const r = regex(config); + const keywords = new Set(config.keywords); + if (config.firstLineShabang) { + const m = code.match(/^#!.*\n?/gm); + if (m) { + code = code.replace(/^#!.*\n?/gm, ""); + yield { + type: hcolors.comment, + content: m[0], + }; + } + } + for (const [lex] of code.matchAll(r)) { + if (lex.startsWith("//") && config.doubleSlashComments) { + yield { + type: hcolors.comment, + content: lex, + }; + } else if (lex.startsWith("/*") && config.multilineSlashComments) { + yield { + type: hcolors.comment, + content: lex, + }; + } else if (lex.startsWith("#") && config.hashComments) { + yield { + type: hcolors.comment, + content: lex, + }; + } else if (lex.startsWith("`") && config.JSLikeTemplateStrings) { + yield { + type: hcolors.string, + content: lex, + }; + } else if (lex.match(/^("|')/)) { + yield { + type: hcolors.string, + content: lex, + }; + } else if (lex.match(/^[0-9]/)) { + yield { + type: hcolors.number, + content: lex, + }; + } else if (lex.match(/^[a-zA-Z]/)) { + yield { + type: keywords.has(lex) ? hcolors.keyword : hcolors.identifier, + content: lex, + }; + } else { + yield { + type: hcolors.symbol, + content: lex, + }; + } + } +} diff --git a/src/webpage/highlighter/colors.ts b/src/webpage/highlighter/colors.ts new file mode 100644 index 0000000..3c2a5a1 --- /dev/null +++ b/src/webpage/highlighter/colors.ts @@ -0,0 +1,8 @@ +export const enum hcolors { + keyword, + number, + string, + symbol, + comment, + identifier, +} diff --git a/src/webpage/highlighter/highlight.ts b/src/webpage/highlighter/highlight.ts new file mode 100644 index 0000000..655a3c1 --- /dev/null +++ b/src/webpage/highlighter/highlight.ts @@ -0,0 +1,132 @@ +import {hcolors} from "./colors"; +function ifCSSAllows() { + if (!("CSS" in globalThis && CSS.highlights && globalThis.Highlight)) return () => {}; + const strLen = (str: string) => { + return [...str].length; + }; + const highkeyword = new Highlight(); + CSS.highlights.set("highkeyword", highkeyword); + + const highnumber = new Highlight(); + CSS.highlights.set("highnumber", highnumber); + + const highstring = new Highlight(); + CSS.highlights.set("highstring", highstring); + + const highsymbol = new Highlight(); + CSS.highlights.set("highsymbol", highsymbol); + const highcomment = new Highlight(); + CSS.highlights.set("highcomment", highcomment); + + const highidentifier = new Highlight(); + CSS.highlights.set("highidentifier", highidentifier); + const f = new FinalizationRegistry((id) => { + eliMap.delete(id); + }); + const eliMap = new Map(); + function addToWeakRange(r: Range, elm: HTMLElement, h: Highlight) { + let prop = elm.getAttribute("UUID"); + if (!prop) { + prop = Math.random() + ""; + elm.setAttribute("UUID", prop); + f.register(elm, prop); + } + let r2: [Highlight, Range][] | undefined = eliMap.get(prop); + if (!r2) { + r2 = [] as [Highlight, Range][]; + eliMap.set(prop, r2); + } + r2.push([h, r]); + } + function clearHighlights(elm: HTMLElement) { + let prop = elm.getAttribute("UUID"); + if (!prop) return; + const m = eliMap.get(prop); + if (!m) return; + eliMap.delete(prop); + for (const [h, r] of m) { + h.delete(r); + } + } + + function highlight(r: Range, color: hcolors, elm: HTMLElement) { + switch (color) { + case hcolors.keyword: + highkeyword.add(r); + addToWeakRange(r, elm, highkeyword); + break; + case hcolors.number: + highnumber.add(r); + addToWeakRange(r, elm, highnumber); + break; + case hcolors.comment: + highcomment.add(r); + addToWeakRange(r, elm, highcomment); + break; + case hcolors.identifier: + highidentifier.add(r); + addToWeakRange(r, elm, highidentifier); + break; + case hcolors.string: + highstring.add(r); + addToWeakRange(r, elm, highstring); + break; + case hcolors.symbol: + highsymbol.add(r); + addToWeakRange(r, elm, highsymbol); + break; + } + } + return function highlightFromLex( + elm: HTMLElement, + lex: Iterable<{ + type: hcolors; + content: string; + }>, + skipStart: number, + ) { + clearHighlights(elm); + const nodes = Array.from(elm.childNodes) as Text[]; + let r = new Range(); + const lexed = Array.from(lex); + if (skipStart) { + lexed.unshift({type: hcolors.symbol, content: " ".repeat(skipStart)}); + } + if (lexed.length === 0) return; + let i = 1; + let lenToGo = strLen(lexed[0].content); + let t = lexed[0].type; + r.setStart(nodes[0], 0); + function getNextNode() { + if (!lexed[i]) { + const last = nodes.at(-1) as Text; + r.setEnd(last, strLen(last.textContent)); + highlight(r, t, elm); + return -1; + } else highlight(r, t, elm); + lenToGo = strLen(lexed[i].content); + t = lexed[i].type; + i++; + return 0; + } + + for (const node of nodes) { + let len = strLen(node.textContent); + let start = 0; + while (len) { + if (len < lenToGo) { + lenToGo -= len; + break; + } else { + start += lenToGo; + r.setEnd(node, start); + len -= lenToGo; + if (getNextNode() === -1) return; + r = new Range(); + r.setStart(node, start); + } + } + } + }; +} +export const highlightFromLex = ifCSSAllows(); diff --git a/src/webpage/highlighter/index.ts b/src/webpage/highlighter/index.ts new file mode 100644 index 0000000..f790e49 --- /dev/null +++ b/src/webpage/highlighter/index.ts @@ -0,0 +1,34 @@ +import {canLex} from "./clike/lex.js"; +import {highlightFromLex} from "./highlight.js"; +import {lex as pylex} from "./python/lex.js"; +import {lex as xmlex} from "./xml/lex.js"; +export async function highlight( + elm: HTMLElement, + lang: string, + skipStart: number = 0, + skipEnd: number = 0, +) { + await new Promise((res) => setTimeout(res)); + lang = lang.trim(); + let content = elm.textContent; + if (skipStart) { + content = content.slice(skipStart); + } + if (skipEnd) { + content = content.slice(0, content.length - skipEnd); + } + if (lang === "py" || lang === "python") { + highlightFromLex(elm, pylex(content), skipStart); + } else if ( + lang === "xml" || + lang === "html" || + lang === "htmlx" || + lang === "qml" || + lang === "gmx" + ) { + highlightFromLex(elm, xmlex(content), skipStart); + } else { + const clike = await canLex(lang); + if (clike) highlightFromLex(elm, clike(content), skipStart); + } +} diff --git a/src/webpage/highlighter/python/lex.ts b/src/webpage/highlighter/python/lex.ts new file mode 100644 index 0000000..f1449a7 --- /dev/null +++ b/src/webpage/highlighter/python/lex.ts @@ -0,0 +1,71 @@ +import {hcolors} from "../colors.js"; + +const r = + /"""((\\(.|\n)|[^"\\]|"(?!")|""(?!"))*)(""")?|"(\\(.|\n)|[^"\n\\])*"?|'(\\(.|\n)|[^"\n\\])*'?|\s+|#.*|[~()\[\]|:;`=+\-*\/\\&^%$.]|[a-zA-Z][a-zA-Z0-9]*|[0-9][a-zA-Z0-9]*\.?[a-zA-Z0-9]*|./gm; +const keywords = new Set([ + "False", + "None", + "True", + "and", + "as", + "assert", + "async", + "await", + "break", + "class", + "continue", + "def", + "del", + "elif", + "else", + "except", + "finally", + "for", + "from", + "global", + "if", + "import", + "in", + "is", + "lambda", + "nonlocal", + "not", + "or", + "pass", + "raise", + "return", + "try", + "while", + "with", + "yield", +]); +export function* lex(code: string) { + for (const [lex] of code.matchAll(r)) { + if (lex.startsWith("#")) { + yield { + type: hcolors.comment, + content: lex, + }; + } else if (lex.match(/^("|')/)) { + yield { + type: hcolors.string, + content: lex, + }; + } else if (lex.match(/^[0-9]/)) { + yield { + type: hcolors.number, + content: lex, + }; + } else if (lex.match(/^[a-zA-Z]/)) { + yield { + type: keywords.has(lex) ? hcolors.keyword : hcolors.identifier, + content: lex, + }; + } else { + yield { + type: hcolors.symbol, + content: lex, + }; + } + } +} diff --git a/src/webpage/highlighter/xml/lex.ts b/src/webpage/highlighter/xml/lex.ts new file mode 100644 index 0000000..cea7c03 --- /dev/null +++ b/src/webpage/highlighter/xml/lex.ts @@ -0,0 +1,35 @@ +import {hcolors} from "../colors.js"; + +const r = + /"(\\(.|\n)|[^"\n\\])*"?|)?|[a-zA-Z][a-zA-Z0-9]*\s*=|<[\s\/]*[^<> ]+|.|\n/gm; + +export function* lex(code: string) { + for (const [lex] of code.matchAll(r)) { + if (lex.startsWith("