chore: merge main and release 3.7.3 bugfix bundle

Resolve conflicts with deep-reform (PR #452) while keeping bugfix branch
changes: sidebar visibility, verbose logging, device login name, and
notification archive. Bump to 3.7.3 with What's New release notes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 20:12:58 +09:30
93 changed files with 2628 additions and 1368 deletions
+31 -14
View File
@@ -3,11 +3,10 @@ import {
closeExtensionPopup,
SettingsClicked,
} from "../Closers/closeExtensionPopup";
import renderSvelte from "@/interface/main";
import { SettingsResizer } from "@/seqta/ui/SettingsResizer";
import Settings from "@/interface/pages/settings.svelte";
let isSettingsRendered = false;
let settingsLoadPromise: Promise<void> | null = null;
function extensionOutsideClickHandler(extensionPopup: HTMLElement) {
return (event: MouseEvent) => {
@@ -38,21 +37,39 @@ export function addExtensionSettings() {
(extensionContainer ?? document.body).addEventListener("click", handler, false);
}
export function renderSettingsIfNeeded() {
async function loadSettingsUi(extensionPopup: HTMLElement): Promise<void> {
if (isSettingsRendered) return;
const [{ default: renderSvelte }, { default: Settings }] = await Promise.all([
import("@/interface/main"),
import("@/interface/pages/settings.svelte"),
]);
const shadow = extensionPopup.attachShadow({ mode: "open" });
const mount = () => renderSvelte(Settings, shadow);
if ("requestIdleCallback" in window) {
requestIdleCallback(mount);
} else {
mount();
}
isSettingsRendered = true;
}
export async function renderSettingsIfNeeded(): Promise<void> {
if (isSettingsRendered) return;
const extensionPopup = document.getElementById("ExtensionPopup");
if (!extensionPopup) return;
try {
const shadow = extensionPopup.attachShadow({ mode: "open" });
if ('requestIdleCallback' in window) {
requestIdleCallback(() => renderSvelte(Settings, shadow));
} else {
renderSvelte(Settings, shadow);
}
isSettingsRendered = true;
} catch (err) {
console.error(err);
if (!settingsLoadPromise) {
settingsLoadPromise = loadSettingsUi(extensionPopup).catch((err) => {
settingsLoadPromise = null;
console.error(err);
throw err;
});
}
await settingsLoadPromise;
}
@@ -1,7 +1,7 @@
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { animate } from "motion";
import { settingsPopup } from "@/interface/hooks/SettingsPopup";
import { settingsPopup } from "@/seqta/utils/settingsPopup";
export let SettingsClicked = false;
+15 -17
View File
@@ -2,7 +2,6 @@ import browser from "webextension-polyfill";
import { getBsplusDeviceName } from "@/seqta/utils/bsplusDeviceName";
import { clearCloudPfpCache } from "@/seqta/utils/cloudPfpCache";
import { clearLastUploadedSnapshot } from "@/seqta/utils/cloudSettingsSync";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
const REDIRECT_URI = "https://accounts.betterseqta.org/auth/bsplus/callback";
@@ -88,11 +87,10 @@ class CloudAuthService {
}
/** Pull cloud settings backup after a fresh sign-in (matches manual “Download from cloud”). */
private triggerCloudSettingsDownloadAfterLogin(accessToken: string): void {
private triggerCloudSettingsDownloadAfterLogin(): void {
void browser.runtime
.sendMessage({
type: "cloudSettingsDownload",
token: accessToken,
})
.then((res: unknown) => {
const r = res as { success?: boolean; notFound?: boolean; error?: string } | undefined;
@@ -113,7 +111,6 @@ class CloudAuthService {
/** Persist an updated user object (e.g. after cloud profile picture sync). */
public async setUser(user: CloudUser | null): Promise<void> {
(settingsState as any).setKey(STORAGE_KEYS.user, user);
await browser.storage.local.set({ [STORAGE_KEYS.user]: user });
this._state = {
isLoggedIn: this._state.isLoggedIn,
@@ -123,11 +120,8 @@ class CloudAuthService {
}
private async getClientId(): Promise<string> {
let clientId = (settingsState as any)[STORAGE_KEYS.clientId] as string | undefined;
if (!clientId) {
const stored = await browser.storage.local.get(STORAGE_KEYS.clientId);
clientId = stored[STORAGE_KEYS.clientId] as string | undefined;
}
const stored = await browser.storage.local.get(STORAGE_KEYS.clientId);
let clientId = stored[STORAGE_KEYS.clientId] as string | undefined;
if (!clientId) {
const reserveResult = (await browser.runtime.sendMessage({
type: "cloudReserveClient",
@@ -137,7 +131,7 @@ class CloudAuthService {
throw new Error(reserveResult?.error ?? "Failed to reserve client");
}
clientId = reserveResult.client_id;
(settingsState as any).setKey(STORAGE_KEYS.clientId, clientId);
await browser.storage.local.set({ [STORAGE_KEYS.clientId]: clientId });
}
return clientId;
}
@@ -183,15 +177,17 @@ class CloudAuthService {
error?: string;
};
if (result?.access_token && result?.refresh_token) {
(settingsState as any).setKey(STORAGE_KEYS.accessToken, result.access_token);
(settingsState as any).setKey(STORAGE_KEYS.refreshToken, result.refresh_token);
(settingsState as any).setKey(STORAGE_KEYS.user, result.user ?? null);
await browser.storage.local.set({
[STORAGE_KEYS.accessToken]: result.access_token,
[STORAGE_KEYS.refreshToken]: result.refresh_token,
[STORAGE_KEYS.user]: result.user ?? null,
});
this._state = {
isLoggedIn: true,
user: result.user ?? null,
};
this.notify();
this.triggerCloudSettingsDownloadAfterLogin(result.access_token);
this.triggerCloudSettingsDownloadAfterLogin();
return { success: true };
}
return {
@@ -242,9 +238,11 @@ class CloudAuthService {
};
if (refreshResult?.access_token && refreshResult?.refresh_token) {
(settingsState as any).setKey(STORAGE_KEYS.accessToken, refreshResult.access_token);
(settingsState as any).setKey(STORAGE_KEYS.refreshToken, refreshResult.refresh_token);
(settingsState as any).setKey(STORAGE_KEYS.user, refreshResult.user ?? null);
await browser.storage.local.set({
[STORAGE_KEYS.accessToken]: refreshResult.access_token,
[STORAGE_KEYS.refreshToken]: refreshResult.refresh_token,
[STORAGE_KEYS.user]: refreshResult.user ?? null,
});
this._state = {
isLoggedIn: true,
user: refreshResult.user ?? null,
@@ -1,13 +1,28 @@
import stringToHTML from "../stringToHTML";
function isSafeShortcutHref(url: string): boolean {
if (typeof url !== "string" || !url.trim()) return false;
try {
const parsed = new URL(url, window.location.href);
return ["http:", "https:", "mailto:"].includes(parsed.protocol);
} catch {
return false;
}
}
export function CreateCustomShortcutDiv(element: any) {
// Creates the stucture and element information for each seperate shortcut
const container = document.getElementById("shortcuts");
if (!container) return;
var shortcut = document.createElement("a");
shortcut.setAttribute("href", element.url);
shortcut.setAttribute("target", "_blank");
if (isSafeShortcutHref(element.url)) {
shortcut.setAttribute("href", element.url);
shortcut.setAttribute("target", "_blank");
} else {
shortcut.setAttribute("href", "#");
shortcut.setAttribute("aria-disabled", "true");
}
var shortcutdiv = document.createElement("div");
shortcutdiv.classList.add("shortcut");
shortcutdiv.classList.add("customshortcut");
+1
View File
@@ -52,6 +52,7 @@ export function OpenAboutPage() {
</a>
<a class="socials" href="https://www.youtube.com/@BetterSEQTAPlus" style="background: none !important; margin: 0 5px; padding: 0; display: flex; align-items: center;">
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="50px" height="50px"><path d="M 44.898438 14.5 C 44.5 12.300781 42.601563 10.699219 40.398438 10.199219 C 37.101563 9.5 31 9 24.398438 9 C 17.800781 9 11.601563 9.5 8.300781 10.199219 C 6.101563 10.699219 4.199219 12.199219 3.800781 14.5 C 3.398438 17 3 20.5 3 25 C 3 29.5 3.398438 33 3.898438 35.5 C 4.300781 37.699219 6.199219 39.300781 8.398438 39.800781 C 11.898438 40.5 17.898438 41 24.5 41 C 31.101563 41 37.101563 40.5 40.601563 39.800781 C 42.800781 39.300781 44.699219 37.800781 45.101563 35.5 C 45.5 33 46 29.398438 46.101563 25 C 45.898438 20.5 45.398438 17 44.898438 14.5 Z M 19 32 L 19 18 L 31.199219 25 Z"/></svg>
</a>
<a class="socials" href="https://chromewebstore.google.com/detail/betterseqta+/afdgaoaclhkhemfkkkonemoapeinchel" style="background: none !important; margin: 0 5px; padding: 0; display: flex; align-items: center;">
<svg style="width:25px; height:25px; vertical-align: middle;" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,20L15.46,14H15.45C15.79,13.4 16,12.73 16,12C16,10.8 15.46,9.73 14.62,9H19.41C19.79,9.93 20,10.94 20,12A8,8 0 0,1 12,20M4,12C4,10.54 4.39,9.18 5.07,8L8.54,14H8.55C9.24,15.19 10.5,16 12,16C12.45,16 12.88,15.91 13.29,15.77L10.89,19.91C7,19.37 4,16.04 4,12M15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9A3,3 0 0,1 15,12M12,4C14.96,4 17.54,5.61 18.92,8H12C10.06,8 8.45,9.38 8.08,11.21L5.7,7.08C7.16,5.21 9.44,4 12,4M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
@@ -61,11 +61,12 @@ export function showBsCloudAutoSyncAnnouncement(onDismissed?: () => void) {
</div>
`).firstChild as HTMLElement;
settingsState.bsCloudAutoSyncAnnouncementShown = true;
openPopup({
header,
content: [imageContainer, text],
afterClose: onDismissed,
afterClose: () => {
settingsState.bsCloudAutoSyncAnnouncementShown = true;
onDismissed?.();
},
});
}
+207 -184
View File
@@ -7,14 +7,52 @@ import Sortable from "sortablejs";
export let MenuOptionsOpen = false;
function mergeMenuItemsFromDom(): Record<string, { toggle: boolean }> {
const menu = document.getElementById("menu");
function escapeHtmlAttr(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function syncDefaultMenuOrder(menu: HTMLElement) {
const childnodes = menu.firstChild!.childNodes;
if (settingsState.defaultmenuorder.length === 0) {
settingsState.defaultmenuorder = Array.from(childnodes).map(
(element) => (element as HTMLElement).dataset.key,
);
return;
}
if (settingsState.defaultmenuorder.length === childnodes.length) return;
for (let i = 0; i < childnodes.length; i++) {
const key = (childnodes[i] as HTMLElement).dataset.key;
if (key && settingsState.defaultmenuorder.indexOf(key) === -1) {
if (key === "analytics") {
settingsState.defaultmenuorder = insertKeyAfterInOrder(
settingsState.defaultmenuorder,
key,
"courses",
);
} else {
settingsState.defaultmenuorder = [
...settingsState.defaultmenuorder,
key,
];
}
}
}
}
function mergeMenuItemsFromDom(
menu: HTMLElement,
): Record<string, { toggle: boolean }> {
const merged = {
...(settingsState.menuitems as Record<string, { toggle: boolean }>),
};
if (!menu?.firstChild) return merged;
const children = menu.firstChild.childNodes;
const children = menu.firstChild!.childNodes;
for (let i = 0; i < children.length; i++) {
const key = (children[i] as HTMLElement).dataset.key;
if (key && !merged[key]) {
@@ -24,7 +62,110 @@ function mergeMenuItemsFromDom(): Record<string, { toggle: boolean }> {
return merged;
}
function storeMenuSettings(): void {
function createMenuEditControls(menu: HTMLElement, container: HTMLElement) {
const cover = document.createElement("div");
cover.classList.add("notMenuCover");
menu.style.zIndex = "20";
menu.style.setProperty("--menuHidden", "flex");
container.append(cover);
const menusettings = document.createElement("div");
menusettings.classList.add("editmenuoption-container");
const defaultbutton = document.createElement("div");
defaultbutton.classList.add("editmenuoption");
defaultbutton.innerText = "Restore Default";
defaultbutton.id = "restoredefaultoption";
const savebutton = document.createElement("div");
savebutton.classList.add("editmenuoption");
savebutton.innerText = "Save";
savebutton.id = "savemenuoption";
menusettings.appendChild(defaultbutton);
menusettings.appendChild(savebutton);
menu.appendChild(menusettings);
return { cover, menusettings, defaultbutton, savebutton };
}
function prepareMenuItemsForEdit(menu: HTMLElement) {
const listItems = menu.firstChild!.childNodes;
for (let i = 0; i < listItems.length; i++) {
const element = listItems[i] as HTMLElement;
element.classList.add("draggable");
if (element.classList.contains("hasChildren")) {
element.classList.remove("active");
(element.firstChild as HTMLElement).classList.remove("noscroll");
}
const menuKey = escapeHtmlAttr(element.dataset.key ?? "");
const menuItemToggle = stringToHTML(
`<div class="onoffswitch" style="margin: auto 0;"><input class="onoffswitch-checkbox notification menuitem" type="checkbox" id="${menuKey}"><label for="${menuKey}" class="onoffswitch-label"></label>`,
).firstChild;
element.append(menuItemToggle!);
if (element.dataset.betterseqta) continue;
const section = document.createElement("section");
cloneAttributes(section, element);
while (element.firstChild) section.appendChild(element.firstChild);
menu.firstChild!.insertBefore(section, element);
element.remove();
}
return listItems;
}
function syncMenuItemSettings(menu: HTMLElement) {
settingsState.menuitems = mergeMenuItemsFromDom(
menu,
) as SettingsState["menuitems"];
}
function applySavedMenuToggleStates() {
const menuItems = settingsState.menuitems as Record<
string,
{ toggle: boolean }
>;
const buttons = document.getElementsByClassName("menuitem");
for (let i = 0; i < buttons.length; i++) {
const id = buttons[i].id;
(buttons[i] as HTMLInputElement).checked = id
? (menuItems[id]?.toggle ?? true)
: true;
}
}
function createMenuSortable(): Sortable | undefined {
try {
const el = document.querySelector("#menu > ul");
if (!el) return undefined;
let sortable: Sortable | undefined;
sortable = Sortable.create(el as HTMLElement, {
draggable: ".draggable",
dataIdAttr: "data-key",
animation: 150,
easing: "cubic-bezier(.5,0,.5,1)",
onEnd: () => saveNewOrder(sortable),
});
return sortable;
} catch (err) {
console.error(err);
return undefined;
}
}
function changeMenuItemDisplay(element: HTMLInputElement) {
const row = element.closest("li, section") as HTMLElement | null;
if (!row) return;
if (!element.checked) {
row.style.display = "var(--menuHidden)";
return;
}
row.style.setProperty("display", "flex", "important");
}
function storeMenuSettings() {
const menuItems: Record<string, { toggle: boolean }> = {
...(settingsState.menuitems as Record<string, { toggle: boolean }>),
};
@@ -34,214 +175,96 @@ function storeMenuSettings(): void {
menuItems[input.id] = { toggle: input.checked };
}
}
settingsState.menuitems =
menuItems as SettingsState["menuitems"];
settingsState.menuitems = menuItems as SettingsState["menuitems"];
}
function restoreMenuItemsFromEditMode(
menu: HTMLElement,
listItems: NodeListOf<ChildNode>,
) {
for (let i = 0; i < listItems.length; i++) {
const element = listItems[i] as HTMLElement;
element.classList.remove("draggable");
element.setAttribute("draggable", "false");
if (element.dataset.betterseqta) continue;
const listItem = document.createElement("li");
cloneAttributes(listItem, element);
while (element.firstChild) listItem.appendChild(element.firstChild);
menu.firstChild!.insertBefore(listItem, element);
element.remove();
}
menu.querySelectorAll(".onoffswitch").forEach((toggle) => toggle.remove());
}
export function OpenMenuOptions() {
var container = document.getElementById("container");
var menu = document.getElementById("menu");
if (MenuOptionsOpen) return;
if (settingsState.defaultmenuorder.length == 0) {
let childnodes = menu!.firstChild!.childNodes;
let newdefaultmenuorder = [];
for (let i = 0; i < childnodes.length; i++) {
const element = childnodes[i];
newdefaultmenuorder.push((element as HTMLElement).dataset.key);
settingsState.defaultmenuorder = newdefaultmenuorder;
}
}
let childnodes = menu!.firstChild!.childNodes;
if (settingsState.defaultmenuorder.length != childnodes.length) {
for (let i = 0; i < childnodes.length; i++) {
const element = childnodes[i];
const key = (element as HTMLElement).dataset.key;
if (
key &&
settingsState.defaultmenuorder.indexOf(key) === -1
) {
if (key === "analytics") {
settingsState.defaultmenuorder = insertKeyAfterInOrder(
settingsState.defaultmenuorder,
key,
"courses",
);
} else {
settingsState.defaultmenuorder = [
...settingsState.defaultmenuorder,
key,
];
}
}
}
}
const container = document.getElementById("container");
const menu = document.getElementById("menu");
if (!container || !menu) return;
syncDefaultMenuOrder(menu);
MenuOptionsOpen = true;
menu!.classList.add("bsplus-sidebar-edit-mode");
menu.classList.add("bsplus-sidebar-edit-mode");
var cover = document.createElement("div");
cover.classList.add("notMenuCover");
menu!.style.zIndex = "20";
menu!.style.setProperty("--menuHidden", "flex");
container!.append(cover);
const { cover, menusettings, defaultbutton, savebutton } =
createMenuEditControls(menu, container);
const listItems = prepareMenuItemsForEdit(menu);
syncMenuItemSettings(menu);
applySavedMenuToggleStates();
var menusettings = document.createElement("div");
menusettings.classList.add("editmenuoption-container");
const menubuttons = document.getElementsByClassName("menuitem");
const sortable = createMenuSortable();
var defaultbutton = document.createElement("div");
defaultbutton.classList.add("editmenuoption");
defaultbutton.innerText = "Restore Default";
defaultbutton.id = "restoredefaultoption";
var savebutton = document.createElement("div");
savebutton.classList.add("editmenuoption");
savebutton.innerText = "Save";
savebutton.id = "savemenuoption";
menusettings.appendChild(defaultbutton);
menusettings.appendChild(savebutton);
menu!.appendChild(menusettings);
var ListItems = menu!.firstChild!.childNodes;
for (let i = 0; i < ListItems.length; i++) {
const element1 = ListItems[i];
const element = element1 as HTMLElement;
(element as HTMLElement).classList.add("draggable");
if ((element as HTMLElement).classList.contains("hasChildren")) {
(element as HTMLElement).classList.remove("active");
(element.firstChild as HTMLElement).classList.remove("noscroll");
}
let MenuItemToggle = stringToHTML(
`<div class="onoffswitch" style="margin: auto 0;"><input class="onoffswitch-checkbox notification menuitem" type="checkbox" id="${(element as HTMLElement).dataset.key}"><label for="${(element as HTMLElement).dataset.key}" class="onoffswitch-label"></label>`,
).firstChild;
(element as HTMLElement).append(MenuItemToggle!);
if (!element.dataset.betterseqta) {
const a = document.createElement("section");
a.innerHTML = element.innerHTML;
cloneAttributes(a, element);
menu!.firstChild!.insertBefore(a, element);
element.remove();
}
}
if (Object.keys(settingsState.menuitems).length == 0) {
settingsState.menuitems = mergeMenuItemsFromDom() as SettingsState["menuitems"];
} else {
settingsState.menuitems = mergeMenuItemsFromDom() as SettingsState["menuitems"];
}
let menuItems = settingsState.menuitems as Record<string, { toggle: boolean }>;
let buttons = document.getElementsByClassName("menuitem");
for (let i = 0; i < buttons.length; i++) {
const input = buttons[i] as HTMLInputElement;
const id = input.id;
input.checked =
id && menuItems[id] ? menuItems[id].toggle : true;
}
let sortable: Sortable | undefined;
try {
var el = document.querySelector("#menu > ul");
sortable = Sortable.create(el as HTMLElement, {
draggable: ".draggable",
dataIdAttr: "data-key",
animation: 150,
easing: "cubic-bezier(.5,0,.5,1)",
onEnd: function () {
saveNewOrder(sortable!);
},
});
} catch (err) {
console.error(err);
}
function changeDisplayProperty(input: HTMLInputElement) {
const row = input.closest("li, section") as HTMLElement | null;
if (!row) return;
if (!input.checked) {
row.style.display = "var(--menuHidden)";
} else {
row.style.setProperty("display", "flex", "important");
}
}
const menuInputs = document.querySelectorAll<HTMLInputElement>(".menuitem");
for (const input of menuInputs) {
input.addEventListener("change", () => {
for (let i = 0; i < menubuttons.length; i++) {
const element = menubuttons[i] as HTMLInputElement;
element.addEventListener("change", () => {
storeMenuSettings();
changeDisplayProperty(input);
changeMenuItemDisplay(element);
});
}
function closeAll() {
menusettings?.remove();
cover?.remove();
const closeAll = () => {
menusettings.remove();
cover.remove();
MenuOptionsOpen = false;
menu!.classList.remove("bsplus-sidebar-edit-mode");
menu!.style.setProperty("--menuHidden", "none");
for (let i = 0; i < ListItems.length; i++) {
const element1 = ListItems[i];
const element = element1 as HTMLElement;
element.classList.remove("draggable");
element.setAttribute("draggable", "false");
if (!element.dataset.betterseqta) {
const a = document.createElement("li");
a.innerHTML = element.innerHTML;
cloneAttributes(a, element);
menu!.firstChild!.insertBefore(a, element);
element.remove();
}
}
let switches = menu!.querySelectorAll(".onoffswitch");
for (let i = 0; i < switches.length; i++) {
switches[i].remove();
}
menu.classList.remove("bsplus-sidebar-edit-mode");
menu.style.setProperty("--menuHidden", "none");
restoreMenuItemsFromEditMode(menu, listItems);
applyMenuItemVisibility();
}
};
function saveAndClose() {
const saveAndClose = () => {
storeMenuSettings();
if (sortable) {
saveNewOrder(sortable);
}
applyMenuItemVisibility();
closeAll();
}
};
cover?.addEventListener("click", closeAll);
savebutton?.addEventListener("click", saveAndClose);
cover.addEventListener("click", closeAll);
savebutton.addEventListener("click", saveAndClose);
defaultbutton?.addEventListener("click", function () {
const options = settingsState.defaultmenuorder;
settingsState.menuorder = options;
defaultbutton.addEventListener("click", () => {
settingsState.menuorder = settingsState.defaultmenuorder;
ChangeMenuItemPositions(settingsState.defaultmenuorder);
ChangeMenuItemPositions(options);
const inputs = document.querySelectorAll<HTMLInputElement>(".menuitem");
const restored: Record<string, { toggle: boolean }> = {};
for (const input of inputs) {
input.checked = true;
const row = input.closest("li, section") as HTMLElement | null;
if (row) {
row.style.setProperty("display", "flex", "important");
}
if (input.id) {
restored[input.id] = { toggle: true };
for (let i = 0; i < menubuttons.length; i++) {
const element = menubuttons[i] as HTMLInputElement;
element.checked = true;
changeMenuItemDisplay(element);
if (element.id) {
restored[element.id] = { toggle: true };
}
}
settingsState.menuitems = restored as SettingsState["menuitems"];
if (sortable) {
saveNewOrder(sortable);
}
if (sortable) saveNewOrder(sortable);
});
}
@@ -105,6 +105,7 @@ export function OpenMinecraftServerPopup() {
</a>
<a class="socials" href="https://www.youtube.com/@BetterSEQTAPlus" style="background: none !important; margin: 0 5px; padding: 0; display: flex; align-items: center;">
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="50px" height="50px"><path d="M 44.898438 14.5 C 44.5 12.300781 42.601563 10.699219 40.398438 10.199219 C 37.101563 9.5 31 9 24.398438 9 C 17.800781 9 11.601563 9.5 8.300781 10.199219 C 6.101563 10.699219 4.199219 12.199219 3.800781 14.5 C 3.398438 17 3 20.5 3 25 C 3 29.5 3.398438 33 3.898438 35.5 C 4.300781 37.699219 6.199219 39.300781 8.398438 39.800781 C 11.898438 40.5 17.898438 41 24.5 41 C 31.101563 41 37.101563 40.5 40.601563 39.800781 C 42.800781 39.300781 44.699219 37.800781 45.101563 35.5 C 45.5 33 46 29.398438 46.101563 25 C 45.898438 20.5 45.398438 17 44.898438 14.5 Z M 19 32 L 19 18 L 31.199219 25 Z"/></svg>
</a>
<a class="socials" href="https://chromewebstore.google.com/detail/betterseqta+/afdgaoaclhkhemfkkkonemoapeinchel" style="background: none !important; margin: 0 5px; padding: 0; display: flex; align-items: center;">
<svg style="width:25px; height:25px; vertical-align: middle;" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,20L15.46,14H15.45C15.79,13.4 16,12.73 16,12C16,10.8 15.46,9.73 14.62,9H19.41C19.79,9.93 20,10.94 20,12A8,8 0 0,1 12,20M4,12C4,10.54 4.39,9.18 5.07,8L8.54,14H8.55C9.24,15.19 10.5,16 12,16C12.45,16 12.88,15.91 13.29,15.77L10.89,19.91C7,19.37 4,16.04 4,12M15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9A3,3 0 0,1 15,12M12,4C14.96,4 17.54,5.61 18.92,8H12C10.06,8 8.45,9.38 8.08,11.21L5.7,7.08C7.16,5.21 9.44,4 12,4M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
@@ -62,12 +62,13 @@ export function showPrivacyNotification(onDismissed?: () => void) {
attachPopupMediaFullscreenIfPresent(text, "img.aboutImg");
settingsState.privacyStatementLastUpdated = "2025-12-20";
settingsState.privacyStatementShown = true;
openPopup({
header,
content: [text],
afterClose: onDismissed,
afterClose: () => {
settingsState.privacyStatementLastUpdated = "2025-12-20";
settingsState.privacyStatementShown = true;
onDismissed?.();
},
});
}
@@ -31,12 +31,12 @@ export function OpenPrivacyStatement() {
<p>BetterSEQTA+ uses your browser's local storage to save your preferences and settings. This data remains on your device and is never transmitted anywhere. You can clear this data at any time through your browser's settings.</p>
<h3>Open Source</h3>
<p>BetterSEQTA+ is an open-source project. You can review our code on <a href="https://github.com/BetterSEQTA/BetterSEQTA-Plus" target="_blank" style="color: inherit; text-decoration: underline;">GitHub</a> to verify our privacy practices. We believe in transparency and encourage you to inspect the code yourself.</p>
<p>BetterSEQTA+ is an open-source project. You can review our code on <a href="https://github.com/BetterSEQTA/BetterSEQTA-Plus" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: underline;">GitHub</a> to verify our privacy practices. We believe in transparency and encourage you to inspect the code yourself.</p>
<h3>Our Commitment</h3>
<p>We are committed to providing the best features possible while respecting your privacy. We understand that schools and students have concerns about data privacy, and we want to assure you that BetterSEQTA+ is designed with privacy as a core principle.</p>
<p style="margin-top: 20px; font-weight: bold;">If you have any questions or concerns about our privacy practices, please reach out to us through our <a href="https://github.com/BetterSEQTA/BetterSEQTA-Plus" target="_blank" style="color: inherit; text-decoration: underline;">GitHub repository</a>.</p>
<p style="margin-top: 20px; font-weight: bold;">If you have any questions or concerns about our privacy practices, please reach out to us through our <a href="https://github.com/BetterSEQTA/BetterSEQTA-Plus" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: underline;">GitHub repository</a>.</p>
</div>
`).firstChild as HTMLElement;
@@ -4,13 +4,13 @@ import { settingsState } from "../listeners/SettingsState";
import { closePopup } from "./PopupManager";
import { getApiBase } from "../DevApiBase";
import { openThemeStoreWithHighlight } from "../openThemeStoreWithHighlight";
import { cloudAuth } from "../CloudAuth";
import type { Theme } from "@/interface/types/Theme";
import {
buildModalHeroSlides,
normalizeStoreTheme,
} from "@/interface/utils/themeStoreFlavours";
import { attachPopupMediaFullscreen } from "./attachPopupMediaFullscreen";
import { allowedPopupImageUrl } from "./allowedPopupImageUrl";
export interface ThemeOfTheMonthEntry {
id: string;
@@ -67,17 +67,15 @@ function heroUrlFromStoreTheme(theme: {
coverImage?: string | null;
}): string | null {
const url = (theme.marqueeImage || theme.coverImage || "").trim();
return url || null;
return allowedPopupImageUrl(url);
}
export async function fetchThemeStoreTheme(themeId: string): Promise<Theme | null> {
try {
const token = await cloudAuth.getStoredToken();
const res = (await browser.runtime.sendMessage({
type: "fetchThemeDetails",
themeId,
token: token ?? undefined,
})) as { success?: boolean; data?: { theme?: Record<string, unknown> } };
try {
const res = (await browser.runtime.sendMessage({
type: "fetchThemeDetails",
themeId,
})) as { success?: boolean; data?: { theme?: Record<string, unknown> } };
if (!res?.success || !res?.data?.theme) return null;
return normalizeStoreTheme(res.data.theme);
@@ -100,7 +98,12 @@ function buildPopupGallerySlides(
heroUrl: string | null,
): PopupGallerySlide[] {
if (storeTheme) {
return buildModalHeroSlides(storeTheme).filter((s) => s.imageUrl.trim());
return buildModalHeroSlides(storeTheme)
.map((s) => {
const imageUrl = allowedPopupImageUrl(s.imageUrl);
return imageUrl ? { imageUrl, caption: s.caption } : null;
})
.filter((s): s is PopupGallerySlide => s !== null);
}
if (heroUrl) {
return [{ imageUrl: heroUrl, caption: entry.title }];
@@ -642,7 +645,7 @@ export async function OpenThemeOfTheMonthPopup(
const storeTheme = linkedThemeId ? await fetchThemeStoreTheme(linkedThemeId) : null;
const heroUrl =
(storeTheme ? heroUrlFromStoreTheme(storeTheme) : null) ??
entry.cover_image?.trim() ??
allowedPopupImageUrl(entry.cover_image) ??
null;
const gallerySlides = buildPopupGallerySlides(entry, storeTheme, heroUrl);
const hasExpandableContent = gallerySlides.length > 0 || entry.description.trim().length > 0;
@@ -782,7 +785,7 @@ export async function OpenThemeOfTheMonthPopup(
card.querySelector(".themeOfTheMonthCardPrimary")?.addEventListener("click", () => {
settingsState.themeOfTheMonthDismissedMonth = entry.month;
dismissWithCleanup();
openThemeStoreWithHighlight(linkedThemeId!);
void openThemeStoreWithHighlight(linkedThemeId!);
});
const openDontShowConfirm = () => {
+19 -1
View File
@@ -42,6 +42,23 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
const text = stringToHTML(/* html */ `
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;">
<h1>3.7.3 Bugfix Bundle</h1>
<li>Fixed dropdown contrast and readability in settings and across SEQTA pages.</li>
<li>Fixed Analytics sidebar item not hiding when toggled off in Edit Sidebar.</li>
<li>Fixed timetable subject colour picker not reopening after closing (#221).</li>
<li>Fixed theme wallpaper images not applying on Firefox (#206).</li>
<li>Fixed missing subjects in upcoming assessments on the home page (#454).</li>
<li>Added home page assessment limits (subjects, per-subject count, include past).</li>
<li>Fixed assessment overview showing <code>Undefined%</code> for letter grades (#430).</li>
<li>Fixed notifications older than a year being removed; added local per-account archive (#443).</li>
<li>Fixed notices on the home screen sometimes failing to load (#388).</li>
<li>Fixed nightly/CI release builds (Windows runners, zip packaging, layerchart compile).</li>
<li>Fixed multi-target builds exiting early and masking Vite errors.</li>
<li>Improved background music autoplay with a tap-to-start hint when blocked.</li>
<li>Added verbose logging toggle under Developer settings.</li>
<li>Restored theme settings Edit/Done label and improved custom Select keyboard accessibility.</li>
<li>Cloud sign-in now sends a device name for account management.</li>
<h1>3.7.2 Analytics Fixes</h1>
<li>Fixed analytics page breaking on certain configurations.</li>
<li>Added safer DOM handling for analytics to prevent crashes.</li>
@@ -403,6 +420,7 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
</a>
<a class="socials" href="https://www.youtube.com/@BetterSEQTAPlus" style="background: none !important; margin: 0 5px; padding: 0; display: flex; align-items: center;">
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="50px" height="50px"><path d="M 44.898438 14.5 C 44.5 12.300781 42.601563 10.699219 40.398438 10.199219 C 37.101563 9.5 31 9 24.398438 9 C 17.800781 9 11.601563 9.5 8.300781 10.199219 C 6.101563 10.699219 4.199219 12.199219 3.800781 14.5 C 3.398438 17 3 20.5 3 25 C 3 29.5 3.398438 33 3.898438 35.5 C 4.300781 37.699219 6.199219 39.300781 8.398438 39.800781 C 11.898438 40.5 17.898438 41 24.5 41 C 31.101563 41 37.101563 40.5 40.601563 39.800781 C 42.800781 39.300781 44.699219 37.800781 45.101563 35.5 C 45.5 33 46 29.398438 46.101563 25 C 45.898438 20.5 45.398438 17 44.898438 14.5 Z M 19 32 L 19 18 L 31.199219 25 Z"/></svg>
</a>
<a class="socials" href="https://chromewebstore.google.com/detail/betterseqta+/afdgaoaclhkhemfkkkonemoapeinchel" style="background: none !important; margin: 0 5px; padding: 0; display: flex; align-items: center;">
<svg style="width:25px; height:25px; vertical-align: middle;" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,20L15.46,14H15.45C15.79,13.4 16,12.73 16,12C16,10.8 15.46,9.73 14.62,9H19.41C19.79,9.93 20,10.94 20,12A8,8 0 0,1 12,20M4,12C4,10.54 4.39,9.18 5.07,8L8.54,14H8.55C9.24,15.19 10.5,16 12,16C12.45,16 12.88,15.91 13.29,15.77L10.89,19.91C7,19.37 4,16.04 4,12M15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9A3,3 0 0,1 15,12M12,4C14.96,4 17.54,5.61 18.92,8H12C10.06,8 8.45,9.38 8.08,11.21L5.7,7.08C7.16,5.21 9.44,4 12,4M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
@@ -410,7 +428,7 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
</a>
</div>
<div>
<a href="https://ko-fi.com/sethburkart" target="_blank" style="background: none !important; margin:0;margin-left:6px;padding:0; display: flex; align-items: center;">
<a href="https://ko-fi.com/sethburkart" target="_blank" rel="noopener noreferrer" style="background: none !important; margin:0;margin-left:6px;padding:0; display: flex; align-items: center;">
<img height="25" style="border:0px; height:25px; margin-right: -6px;" src="${kofi}" border="0" alt="Buy Me a Coffee at ko-fi.com" />
</a>
</div>
+18 -2
View File
@@ -57,6 +57,15 @@ interface OpenPopupOptions {
containerClass?: string;
}
function chainAfterClose(next?: () => void) {
if (!next) return;
const previous = pendingAfterClose;
pendingAfterClose = () => {
next();
previous?.();
};
}
export function openPopup({
header,
content = [],
@@ -65,7 +74,12 @@ export function openPopup({
clearJustUpdated = false,
containerClass,
}: OpenPopupOptions = {}) {
pendingAfterClose = afterClose;
if (document.getElementById("whatsnewbk")) {
chainAfterClose(afterClose);
return;
}
chainAfterClose(afterClose);
const background = document.createElement("div");
background.id = "whatsnewbk";
@@ -87,7 +101,9 @@ export function openPopup({
container.append(closeButton);
background.append(container);
document.getElementById("container")!.append(background);
const appContainer = document.getElementById("container");
if (!appContainer) return;
appContainer.append(background);
if (settingsState.animations) {
(motionAnimate as any)(
@@ -0,0 +1,16 @@
/**
* Minimal allowlist for remote popup/API image (and media) URLs.
* Only https: URLs are accepted; everything else is rejected.
*/
export function allowedPopupImageUrl(url: string | null | undefined): string | null {
if (!url) return null;
const trimmed = url.trim();
if (!trimmed) return null;
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== "https:") return null;
return parsed.href;
} catch {
return null;
}
}
@@ -5,6 +5,7 @@
*/
import { settingsState } from "../listeners/SettingsState";
import { allowedPopupImageUrl } from "./allowedPopupImageUrl";
const FULLSCREENABLE_CLASS = "popup-media-fullscreenable";
const OVERLAY_VISIBLE_CLASS = "bsplus-popup-media-overlay-backdrop--visible";
@@ -56,13 +57,22 @@ function openMediaOverlayViewer(source: HTMLImageElement | HTMLVideoElement) {
nv.loop = v.loop;
nv.muted = v.muted;
nv.volume = v.volume;
let hasValidSource = false;
for (const s of v.querySelectorAll("source")) {
const src = allowedPopupImageUrl((s as HTMLSourceElement).src);
if (!src) continue;
hasValidSource = true;
const ns = document.createElement("source");
ns.src = (s as HTMLSourceElement).src;
ns.src = src;
const t = (s as HTMLSourceElement).type;
if (t) ns.type = t;
nv.appendChild(ns);
}
if (!hasValidSource) {
const directSrc = allowedPopupImageUrl(v.currentSrc || v.src);
if (!directSrc) return;
nv.src = directSrc;
}
nv.addEventListener(
"loadeddata",
() => {
@@ -79,9 +89,12 @@ function openMediaOverlayViewer(source: HTMLImageElement | HTMLVideoElement) {
nv.load();
media = nv;
} else {
const rawSrc = source.currentSrc || source.src;
const safeSrc = allowedPopupImageUrl(rawSrc);
if (!safeSrc) return;
const img = document.createElement("img");
img.classList.add("bsplus-popup-media-overlay-media");
img.src = source.currentSrc || source.src;
img.src = safeSrc;
img.alt = source.alt || "";
media = img;
}
+20 -10
View File
@@ -22,6 +22,16 @@ class ReactFiber {
return new ReactFiber(selector, options);
}
private getTargetOrigin(): string {
return window.location.origin;
}
private isTrustedMessage(event: MessageEvent): boolean {
return (
event.source === window && event.origin === this.getTargetOrigin()
);
}
private async sendMessage(action: string, payload: any = {}): Promise<any> {
return new Promise((resolve, _) => {
const messageId = this.messageIdCounter++;
@@ -34,7 +44,8 @@ class ReactFiber {
messageId,
};
const listener = (response: any) => {
const listener = (response: MessageEvent) => {
if (!this.isTrustedMessage(response)) return;
if (
response.data?.type === "reactFiberResponse" &&
response.data?.messageId === messageId
@@ -47,7 +58,7 @@ class ReactFiber {
}
};
window.addEventListener("message", listener);
window.postMessage(message, "*");
window.postMessage(message, this.getTargetOrigin());
});
}
@@ -57,15 +68,14 @@ class ReactFiber {
});
}
async setState(update: any | ((prevState: any) => any)): Promise<ReactFiber> {
const updateFnString =
typeof update === "function" ? update.toString() : null;
const updateObject = typeof update !== "function" ? update : null;
async setState(update: Record<string, unknown>): Promise<ReactFiber> {
if (typeof update !== "object" || update === null || Array.isArray(update)) {
throw new TypeError(
"ReactFiber.setState only accepts plain JSON-serializable objects",
);
}
await this.sendMessage("setState", {
updateFn: updateFnString,
updateObject,
});
await this.sendMessage("setState", { updateObject: update });
return this;
}
+1 -1
View File
@@ -107,7 +107,7 @@ export async function SendNewsPage() {
? article.description
: "No description available.";
description.innerHTML =
description.textContent =
articleDescription.length > 400
? articleDescription.substring(0, 400) + "..."
: articleDescription;
+64
View File
@@ -0,0 +1,64 @@
const ALLOWED_HOST_SUFFIXES = [
"betterseqta.org",
"accounts.betterseqta.org",
"raw.githubusercontent.com",
"github.com",
] as const;
function normalizeHost(hostname: string): string {
return hostname.toLowerCase().replace(/^\[|\]$/g, "");
}
function isNamedLocalHost(host: string): boolean {
return (
host === "localhost" ||
host.endsWith(".localhost") ||
host === "127.0.0.1" ||
host.startsWith("127.") ||
host === "::1" ||
host === "0.0.0.0"
);
}
function isPrivateIpv4(host: string): boolean {
const ipv4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (!ipv4) return false;
const [a, b] = [Number(ipv4[1]), Number(ipv4[2])];
if (a === 10) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 169 && b === 254) return true;
return a === 127 || a === 0;
}
function isPrivateIpv6(host: string): boolean {
if (!host.includes(":")) return false;
const h = host.split("%")[0];
return h.startsWith("fe80") || h.startsWith("fc") || h.startsWith("fd");
}
function isPrivateOrLocalHost(hostname: string): boolean {
const host = normalizeHost(hostname);
return isNamedLocalHost(host) || isPrivateIpv4(host) || isPrivateIpv6(host);
}
function isAllowedHost(hostname: string): boolean {
const host = hostname.toLowerCase();
return ALLOWED_HOST_SUFFIXES.some(
(suffix) => host === suffix || host.endsWith(`.${suffix}`),
);
}
/** HTTPS-only fetch allowlist for background `fetchFromUrl`. */
export function isAllowedFetchUrl(urlString: string): boolean {
let parsed: URL;
try {
parsed = new URL(urlString);
} catch {
return false;
}
if (parsed.protocol !== "https:") return false;
if (isPrivateOrLocalHost(parsed.hostname)) return false;
return isAllowedHost(parsed.hostname);
}
+13 -1
View File
@@ -73,14 +73,23 @@ const OMIT_FROM_UPLOAD_EXACT = new Set<string>([
...KEYS_OMITTED_FROM_CLOUD_UPLOAD,
...SENSITIVE_DEVICE_STORAGE_KEYS_EXACT,
...CLIENT_ONLY_CLOUD_KEYS_EXACT,
"devMode",
"devGhReleaseVersionOverride",
]);
const UNSAFE_STORAGE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
function isUnsafeStorageKey(key: string): boolean {
return UNSAFE_STORAGE_KEYS.has(key);
}
/** True if a storage key is part of the upload payload (and should trigger auto-upload when changed). */
export function isKeyIncludedInCloudUploadPayload(key: string): boolean {
return !shouldOmitKeyFromCloudPayload(key);
}
function shouldOmitKeyFromCloudPayload(key: string): boolean {
if (isUnsafeStorageKey(key)) return true;
if (OMIT_FROM_UPLOAD_EXACT.has(key)) return true;
for (const prefix of SENSITIVE_DEVICE_STORAGE_KEY_PREFIXES) {
if (key.startsWith(prefix)) return true;
@@ -115,6 +124,7 @@ function collectLocalKeysToPreserve(local: Record<string, unknown>): Record<stri
function stripExcludedKeysFromRemoteData(remote: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(remote)) {
if (isUnsafeStorageKey(k)) continue;
if (shouldOmitKeyFromCloudPayload(k)) continue;
out[k] = v;
}
@@ -336,5 +346,7 @@ export async function applyDownloadedEnvelope(envelope: unknown): Promise<void>
const migrated = migrateLegacyToPluginSettings(remoteFlat);
const remoteSanitized = stripExcludedKeysFromRemoteData(migrated);
await browser.storage.local.set(remoteSanitized);
const local = (await browser.storage.local.get()) as Record<string, unknown>;
const preserve = collectLocalKeysToPreserve(local);
await browser.storage.local.set({ ...remoteSanitized, ...preserve });
}
@@ -40,6 +40,8 @@ const OPTIONAL_UNSET_MEANS_DEFAULT_KEYS = [
"profile_picture_revision",
] as const;
let defaultsEnsured = false;
/**
* Flat default map in upload shape (plugin-format only; no legacy keys).
*/
@@ -79,6 +81,8 @@ function mergePluginSettingsDefaults(
* Never overwrites existing values. Missing plugin settings respect legacy keys.
*/
export async function ensureSyncableStorageDefaults(): Promise<void> {
if (defaultsEnsured) return;
const existing = await browser.storage.local.get();
const migratedFromExisting = migrateLegacyToPluginSettings({
...existing,
@@ -104,4 +108,6 @@ export async function ensureSyncableStorageDefaults(): Promise<void> {
if (Object.keys(patch).length > 0) {
await browser.storage.local.set(patch);
}
defaultsEnsured = true;
}
@@ -22,6 +22,8 @@ const handleNotificationClick = async (target: HTMLElement) => {
(item: any) => item.notificationID === parseInt(buttonId),
);
if (!matchingNotification?.message?.messageID) return;
await waitForElm('[class*="Viewer__Viewer___"] > div', true, 20);
// Select the specific direct message
+29 -2
View File
@@ -3,6 +3,7 @@ interface EventListenerOptions {
textContent?: string;
className?: string;
id?: string;
selector?: string;
customCheck?: (element: Element) => boolean;
once?: boolean;
parentElement?: Element;
@@ -20,6 +21,7 @@ class EventManager {
private listeners: Map<string, EventListener[]> = new Map();
private mutationObservers: Map<Element, MutationObserver> = new Map();
private pendingElements: Set<Element> = new Set();
private firedOnceIds: Set<string> = new Set();
private throttleTimeout: number = 5; // 5ms throttle
private throttleTimer: number | undefined;
private chunkSize: number = 50; // Process 50 elements per chunk
@@ -58,6 +60,7 @@ class EventManager {
}
private buildSelector(options: EventListenerOptions): string | null {
if (options.selector) return options.selector;
if (options.textContent || options.customCheck) return null;
let selector = options.elementType || "";
@@ -71,6 +74,23 @@ class EventManager {
return selector.trim() || null;
}
private getElementsToCheck(
element: Element,
options: EventListenerOptions,
): Element[] {
const selector = this.buildSelector(options);
if (!selector) return [element];
const targets = new Set<Element>();
if (element.matches(selector)) {
targets.add(element);
}
for (const match of element.querySelectorAll(selector)) {
targets.add(match);
}
return Array.from(targets);
}
private async scanExistingElements(
options: EventListenerOptions,
callback: (element: Element) => void,
@@ -174,10 +194,17 @@ class EventManager {
private async checkElement(element: Element): Promise<void> {
for (const [event, listeners] of this.listeners.entries()) {
for (const { id, options, callback } of listeners) {
if (this.matchesOptions(element, options)) {
callback(element);
if (options.once && this.firedOnceIds.has(id)) continue;
const targets = this.getElementsToCheck(element, options);
for (const target of targets) {
if (!this.matchesOptions(target, options)) continue;
callback(target);
if (options.once) {
this.firedOnceIds.add(id);
this.unregisterById(event, id);
break;
}
}
}
+72 -43
View File
@@ -6,15 +6,20 @@ import {
OpenMenuOptions,
} from "@/seqta/utils/Openers/OpenMenuOptions";
import { ThemeManager } from "@/plugins/built-in/themes/theme-manager";
import {
CloseThemeCreator,
OpenThemeCreator,
} from "@/plugins/built-in/themes/ThemeCreator";
import sendThemeUpdate from "@/seqta/utils/sendThemeUpdate";
import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent";
import type { ThemeManager } from "@/plugins/built-in/themes/theme-manager";
const themeManager = ThemeManager.getInstance();
let themeManagerPromise: Promise<ThemeManager> | null = null;
function getThemeManager(): Promise<ThemeManager> {
if (!themeManagerPromise) {
themeManagerPromise = import("@/plugins/built-in/themes/theme-manager").then(
({ ThemeManager }) => ThemeManager.getInstance(),
);
}
return themeManagerPromise;
}
export class MessageHandler {
constructor() {
@@ -34,6 +39,7 @@ export class MessageHandler {
case "UpdateThemePreview":
if (request?.save == true) {
const save = async () => {
const themeManager = await getThemeManager();
await themeManager.saveTheme({
...request.body,
userEdited: true,
@@ -44,65 +50,88 @@ export class MessageHandler {
sendResponse({ status: "success" });
sendThemeUpdate();
};
save();
void save();
} else {
themeManager.updatePreview(request.body);
sendResponse({ status: "success" });
void getThemeManager().then((themeManager) => {
themeManager.updatePreview(request.body);
sendResponse({ status: "success" });
});
}
return true;
case "GetTheme":
themeManager.getTheme(request.body.themeID).then((theme) => {
sendResponse(theme);
void getThemeManager().then((themeManager) => {
themeManager.getTheme(request.body.themeID).then((theme) => {
sendResponse(theme);
});
});
return true;
case "SetTheme":
themeManager.setTheme(request.body.themeID).then(() => {
sendResponse({ status: "success" });
});
break;
case "DisableTheme":
themeManager.disableTheme().then(() => {
sendResponse({ status: "success" });
});
break;
case "DeleteTheme":
themeManager.deleteTheme(request.body.themeID).then(() => {
sendResponse({ status: "success" });
});
break;
case "ListThemes":
themeManager.getAvailableThemes().then((themes) => {
sendResponse(themes);
void getThemeManager().then((themeManager) => {
themeManager.setTheme(request.body.themeID).then(() => {
sendResponse({ status: "success" });
});
});
return true;
case "OpenThemeCreator":
case "DisableTheme":
void getThemeManager().then((themeManager) => {
themeManager.disableTheme().then(() => {
sendResponse({ status: "success" });
});
});
return true;
case "DeleteTheme":
void getThemeManager().then((themeManager) => {
themeManager.deleteTheme(request.body.themeID).then(() => {
sendResponse({ status: "success" });
});
});
return true;
case "ListThemes":
void getThemeManager().then((themeManager) => {
themeManager.getAvailableThemes().then((themes) => {
sendResponse(themes);
});
});
return true;
case "OpenThemeCreator": {
const themeID = request?.body?.themeID;
OpenThemeCreator(themeID ? themeID : "");
void import("@/plugins/built-in/themes/ThemeCreator").then(
({ OpenThemeCreator }) => {
void OpenThemeCreator(themeID ? themeID : "");
},
);
closeExtensionPopup();
sendResponse({ status: "success" });
break;
}
case "ShareTheme":
themeManager.shareTheme(request.body.themeID).then((id) => {
sendResponse({ status: "success", id });
void getThemeManager().then((themeManager) => {
themeManager.shareTheme(request.body.themeID).then((id) => {
sendResponse({ status: "success", id });
});
});
return true;
case "CloseThemeCreator":
try {
CloseThemeCreator();
} catch (error) {
console.error("Error closing theme creator:", error);
sendResponse({ status: "error" });
}
sendResponse({ status: "success" });
break;
void import("@/plugins/built-in/themes/ThemeCreator").then(
({ CloseThemeCreator }) => {
try {
CloseThemeCreator();
sendResponse({ status: "success" });
} catch (error) {
console.error("Error closing theme creator:", error);
sendResponse({ status: "error" });
}
},
);
return true;
case "HideSensitive":
hideSensitiveContent();
+87 -10
View File
@@ -2,6 +2,37 @@ import browser from "webextension-polyfill";
import type { SettingsState } from "@/types/storage";
import type { Subscriber, Unsubscriber } from "svelte/store";
/** Auth/session keys live in `chrome.storage.local` only — not on the settingsState proxy. */
const EXCLUDED_FROM_SETTINGS_SURFACE = new Set([
"bsplus_token",
"bsplus_refresh_token",
"bsplus_client_id",
"bsplus_user",
"cloudAccessToken",
"cloudUsername",
]);
function isExcludedSettingsKey(key: string): boolean {
return EXCLUDED_FROM_SETTINGS_SURFACE.has(key);
}
const SAVE_DEBOUNCE_MS = 200;
function storageChangeIsNoop(oldValue: unknown, newValue: unknown): boolean {
if (oldValue === newValue) return true;
if (
oldValue === undefined ||
newValue === undefined ||
typeof oldValue !== "object" ||
typeof newValue !== "object" ||
oldValue === null ||
newValue === null
) {
return false;
}
return JSON.stringify(oldValue) === JSON.stringify(newValue);
}
type ChangeListener = (newValue: any, oldValue: any) => void;
type GlobalChangeListener = (newValue: any, oldValue: any, key: string) => void;
@@ -11,9 +42,11 @@ class StorageManager {
private listeners: Map<string, Set<ChangeListener>>;
private globalListeners: Set<GlobalChangeListener>;
private subscribers: Set<Subscriber<SettingsState>> = new Set();
private saveTimeout: NodeJS.Timeout | null = null;
private saveTimeout: ReturnType<typeof setTimeout> | null = null;
private pendingPatch: Record<string, unknown> = {};
private initialized = false;
private bootstrapping = false;
private suppressWrites = false;
private constructor() {
this.data = {} as SettingsState;
@@ -26,9 +59,16 @@ class StorageManager {
if (prop in target) {
return (target as any)[prop];
}
if (typeof prop === "string" && isExcludedSettingsKey(prop)) {
return undefined;
}
return Reflect.get(target.data, prop);
},
set: (target, prop: keyof SettingsState, value) => {
if (typeof prop === "string" && isExcludedSettingsKey(prop)) {
void browser.storage.local.set({ [prop]: value });
return true;
}
const oldValue = target.data[prop];
// Only save if the reference actually changed
@@ -95,6 +135,10 @@ class StorageManager {
key: K,
value: SettingsState[K],
): void {
if (typeof key === "string" && isExcludedSettingsKey(key)) {
void browser.storage.local.set({ [key]: value });
return;
}
const oldValue = this.data[key];
if (oldValue !== value) {
this.data[key] = value;
@@ -121,36 +165,64 @@ class StorageManager {
private async loadFromStorage(): Promise<void> {
const result = await browser.storage.local.get();
Object.entries(result).forEach(([key, value]) => {
if (isExcludedSettingsKey(key)) return;
Reflect.set(this.data, key, value);
});
}
public async saveToStorage(changedKeys?: string[]): Promise<void> {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
this.saveTimeout = null;
public setSuppressWrites(suppress: boolean): void {
this.suppressWrites = suppress;
if (!suppress) {
this.scheduleDebouncedSave();
}
const payload: Record<string, unknown> = {};
}
private queueStoragePatch(changedKeys?: string[]): void {
const keys =
changedKeys && changedKeys.length > 0
? changedKeys
: Object.keys(this.data);
for (const key of keys) {
if (isExcludedSettingsKey(key)) continue;
const value = (this.data as Record<string, unknown>)[key];
if (value !== undefined) {
payload[key] = value;
this.pendingPatch[key] = value;
}
}
}
if (Object.keys(payload).length === 0) return;
private scheduleDebouncedSave(): void {
if (this.bootstrapping || this.suppressWrites) return;
if (Object.keys(this.pendingPatch).length === 0) return;
await browser.storage.local.set(payload);
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
}
this.saveTimeout = setTimeout(() => {
void this.flushPendingPatch();
}, SAVE_DEBOUNCE_MS);
}
private async flushPendingPatch(): Promise<void> {
this.saveTimeout = null;
if (this.bootstrapping || this.suppressWrites) return;
const patch = { ...this.pendingPatch };
this.pendingPatch = {};
if (Object.keys(patch).length === 0) return;
await browser.storage.local.set(patch);
if (!this.bootstrapping) {
this.notifySubscribers();
}
}
public saveToStorage(changedKeys?: string[]): void {
this.queueStoragePatch(changedKeys);
this.scheduleDebouncedSave();
}
private async removeFromStorage(key: string): Promise<void> {
await browser.storage.local.remove(key);
}
@@ -162,7 +234,8 @@ class StorageManager {
const actualChanges: string[] = [];
for (const [key, { oldValue, newValue }] of Object.entries(changes)) {
if (JSON.stringify(oldValue) === JSON.stringify(newValue)) continue;
if (storageChangeIsNoop(oldValue, newValue)) continue;
if (isExcludedSettingsKey(key)) continue;
if (newValue !== undefined) {
(this.data as Record<string, unknown>)[key] = newValue;
@@ -264,3 +337,7 @@ class StorageManager {
export const settingsState = StorageManager.getInstance();
export const initializeSettingsState = async () =>
await StorageManager.initialize();
export function setSettingsStateSuppressWrites(suppress: boolean): void {
settingsState.setSuppressWrites(suppress);
}
+3 -17
View File
@@ -1,30 +1,15 @@
import { OpenStorePage } from "@/seqta/ui/renderStore";
/**
* Module-level handoff for "open the theme store and highlight this theme".
*
* The store page is mounted lazily inside a Shadow DOM the first time it
* opens, so a `CustomEvent` listener would have to be wired up before mount
* (causing a race). Using a shared cell keeps the producer (popup button) and
* consumer (store `onMount`) decoupled without that timing constraint.
*
* The store reads & clears this on mount via {@link consumePendingHighlightThemeId}.
*/
let pendingHighlightThemeId: string | null = null;
/** Read and clear the pending theme id (called by the store on mount). */
export function consumePendingHighlightThemeId(): string | null {
const id = pendingHighlightThemeId;
pendingHighlightThemeId = null;
return id;
}
/**
* Opens the theme store and asks it to focus / highlight the given theme.
* If the store is already mounted we dispatch a DOM event so it can react
* without remounting; otherwise the store consumes the pending id on mount.
*/
export function openThemeStoreWithHighlight(themeId: string): void {
export async function openThemeStoreWithHighlight(themeId: string): Promise<void> {
pendingHighlightThemeId = themeId;
const existing = document.getElementById("store");
@@ -35,5 +20,6 @@ export function openThemeStoreWithHighlight(themeId: string): void {
return;
}
OpenStorePage();
const { OpenStorePage } = await import("@/seqta/ui/renderStore");
await OpenStorePage();
}
+33
View File
@@ -0,0 +1,33 @@
type SettingsPopupCallback = () => void;
/**
* Singleton that notifies listeners when the in-page settings popup closes.
* Used by the colour picker and other overlays tied to ExtensionPopup.
*/
class SettingsPopup {
private static instance: SettingsPopup;
private listeners: Set<SettingsPopupCallback> = new Set();
private constructor() {}
public static getInstance(): SettingsPopup {
if (!SettingsPopup.instance) {
SettingsPopup.instance = new SettingsPopup();
}
return SettingsPopup.instance;
}
public addListener(callback: SettingsPopupCallback): void {
this.listeners.add(callback);
}
public removeListener(callback: SettingsPopupCallback): void {
this.listeners.delete(callback);
}
public triggerClose(): void {
this.listeners.forEach((callback) => callback());
}
}
export const settingsPopup = SettingsPopup.getInstance();
+1 -1
View File
@@ -17,7 +17,7 @@ export function setupSettingsButton() {
if (SettingsClicked) {
closeExtensionPopup(extensionPopup as HTMLElement);
} else {
renderSettingsIfNeeded();
await renderSettingsIfNeeded();
await delay(30);
+2 -3
View File
@@ -4,7 +4,7 @@ import DOMPurify from "dompurify";
* Converts an HTML string into a DOM element, with sanitization and optional styling.
*
* This function first sanitizes the input HTML string using DOMPurify to prevent XSS attacks.
* The sanitization process allows 'onclick' attributes and specific URI schemes.
* The sanitization process allows only safe URI schemes in links and media.
* Then, it parses the sanitized string into an HTML document and returns its body.
* Optionally, it can apply predefined CSS styles to the body element.
*
@@ -16,9 +16,8 @@ export default function stringToHTML(str: string, styles = false) {
const parser = new DOMParser();
str = DOMPurify.sanitize(str, {
ADD_ATTR: ["onclick"],
ALLOWED_URI_REGEXP:
/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|chrome-extension):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
/^(?:(?:https?|mailto|tel):|\/|#|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
});
const doc = parser.parseFromString(str, "text/html");
+41 -47
View File
@@ -1,12 +1,9 @@
import { eventManager } from "@/seqta/utils/listeners/EventManager";
import { delay } from "@/seqta/utils/delay";
/**
* Asynchronously waits for an element to be present in the DOM.
*
* This function can use either a polling mechanism (via `setTimeout`) or
* a `MutationObserver` (via `eventManager.register`) to detect the element.
* By default, it uses the `eventManager` which is more efficient.
* By default uses direct `querySelector` plus a targeted `MutationObserver`
* on `document.documentElement`. Polling via `setTimeout` is available as a
* fallback when `usePolling` is true.
*
* @param {string} selector The CSS selector for the target element.
* @param {boolean} [usePolling=false] If true, forces the use of `setTimeout` for polling.
@@ -24,9 +21,6 @@ export async function waitForElm(
if (usePolling) {
return new Promise((resolve, reject) => {
let iterations = 0;
if (maxIterations) {
iterations = 0;
}
const checkForElement = () => {
const element = document.querySelector(selector);
if (element) {
@@ -36,6 +30,7 @@ export async function waitForElm(
iterations++;
if (iterations >= maxIterations) {
reject(new Error("Element not found"));
return;
}
}
setTimeout(checkForElement, interval);
@@ -43,47 +38,46 @@ export async function waitForElm(
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", checkForElement);
document.addEventListener("DOMContentLoaded", checkForElement, {
once: true,
});
} else {
checkForElement();
}
});
} else {
return new Promise((resolve) => {
const registerObserver = () => {
const { unregister } = eventManager.register(
`${selector}`,
{
customCheck: (element) => element.matches(selector),
},
async (element) => {
resolve(element);
await delay(1);
unregister(); // Remove the listener once the element is found
},
);
return unregister;
};
let unregister = null;
if (document.readyState === "loading") {
// DOM is still loading, wait for it to be ready
document.addEventListener("DOMContentLoaded", () => {
unregister = registerObserver();
});
} else {
unregister = registerObserver();
}
const querySelector = () => document.querySelector(selector);
const element = querySelector();
if (element) {
if (unregister) unregister();
resolve(element);
return;
}
});
}
return new Promise((resolve) => {
const tryResolve = (): boolean => {
const element = document.querySelector(selector);
if (element) {
resolve(element);
return true;
}
return false;
};
const startObserver = () => {
if (tryResolve()) return;
const observer = new MutationObserver(() => {
if (tryResolve()) {
observer.disconnect();
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", startObserver, {
once: true,
});
} else {
startObserver();
}
});
}