mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-08-28 17:21:05 +00:00
feat: final bug fixes
This commit is contained in:
@@ -48,7 +48,7 @@
|
||||
tabindex={editMode ? -1 : 0}
|
||||
aria-label={item.label}
|
||||
aria-current={active ? "page" : undefined}
|
||||
in:fly={{ x: drillEnter ? 24 : 0, duration: drillEnter ? 180 : 0 }}
|
||||
in:fly={drillEnter ? { x: 24, duration: 180 } : undefined}
|
||||
onclick={(e) => {
|
||||
// Keep SEQTA's #menu handlers from seeing custom-list clicks — that fights
|
||||
// our drill UI and can freeze the tab (Goals / Folios / etc.).
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { SettingsState } from "@/types/storage";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
import { waitForSeqtaMenu } from "@/seqta/utils/waitForSeqtaShell";
|
||||
import Sidebar from "./Sidebar.svelte";
|
||||
import { getNativeMenuList } from "./parseNativeMenu";
|
||||
import {
|
||||
@@ -28,30 +29,62 @@ let syncTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let hashListenerAttached = false;
|
||||
let sidebarCaptureAttached = false;
|
||||
let earlyPrepareStarted = false;
|
||||
let catchupTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let catchupTimers: ReturnType<typeof setTimeout>[] = [];
|
||||
let nativeMenuListenerAttached = false;
|
||||
let lastMenuFingerprint = "";
|
||||
|
||||
const settingsListeners: Array<{
|
||||
key: keyof SettingsState;
|
||||
listener: ChangeListener;
|
||||
}> = [];
|
||||
|
||||
function syncFromMenu() {
|
||||
if (menuEl) sidebarState.syncFromNative(menuEl);
|
||||
/** Stable fingerprint of native menu keys so unchanged DOM does not re-sync. */
|
||||
function menuFingerprint(menu: HTMLElement): string {
|
||||
const list = getNativeMenuList(menu);
|
||||
if (!list) return "";
|
||||
const keys: string[] = [];
|
||||
for (const node of list.children) {
|
||||
const el = node as HTMLElement;
|
||||
if (el.id === ROOT_ID) continue;
|
||||
const key = el.dataset.key ?? "";
|
||||
const path = el.dataset.path ?? "";
|
||||
const colour = el.dataset.colour ?? "";
|
||||
keys.push(`${key}|${path}|${colour}`);
|
||||
}
|
||||
return `${keys.length}:${keys.join(",")}`;
|
||||
}
|
||||
|
||||
function syncFromMenu(force = false) {
|
||||
if (!menuEl) return;
|
||||
const next = menuFingerprint(menuEl);
|
||||
if (!force && next === lastMenuFingerprint) return;
|
||||
lastMenuFingerprint = next;
|
||||
sidebarState.syncFromNative(menuEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sparse catch-up after mount: plugins inject menu items shortly after first paint.
|
||||
* MutationObserver + scheduleSync cover ongoing changes; this only covers a short window.
|
||||
*/
|
||||
function startCatchupSync() {
|
||||
if (catchupTimer) clearInterval(catchupTimer);
|
||||
let attempts = 0;
|
||||
catchupTimer = setInterval(() => {
|
||||
attempts += 1;
|
||||
syncFromMenu();
|
||||
// Plugins (Analytics, Overview, icons) inject shortly after first paint.
|
||||
if (attempts >= 60) {
|
||||
clearInterval(catchupTimer!);
|
||||
catchupTimer = null;
|
||||
}
|
||||
}, 50);
|
||||
for (const t of catchupTimers) clearTimeout(t);
|
||||
catchupTimers = [];
|
||||
// Immediate, then a few delayed passes (~1s total) instead of 50ms×60.
|
||||
const delays = [0, 200, 500, 1000];
|
||||
for (const ms of delays) {
|
||||
catchupTimers.push(
|
||||
setTimeout(() => {
|
||||
syncFromMenu();
|
||||
}, ms),
|
||||
);
|
||||
}
|
||||
if (typeof requestIdleCallback === "function") {
|
||||
requestIdleCallback(() => syncFromMenu(), { timeout: 1500 });
|
||||
}
|
||||
}
|
||||
|
||||
function onNativeMenuUpdated() {
|
||||
syncFromMenu(true);
|
||||
}
|
||||
|
||||
function scheduleSync() {
|
||||
@@ -201,7 +234,12 @@ export async function mountCustomSidebar(): Promise<boolean> {
|
||||
|
||||
document.documentElement.classList.add(PENDING_CLASS);
|
||||
|
||||
const menu = (await waitForElm("#menu", true, 50, 200)) as HTMLElement | null;
|
||||
let menu: HTMLElement | null = null;
|
||||
try {
|
||||
menu = (await waitForSeqtaMenu(50, 200)) as HTMLElement;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!menu) return false;
|
||||
|
||||
// Prefer a populated native list, but don't block forever during loading.
|
||||
@@ -270,7 +308,7 @@ export async function mountCustomSidebar(): Promise<boolean> {
|
||||
}
|
||||
|
||||
if (!nativeMenuListenerAttached) {
|
||||
window.addEventListener("bsplus-native-menu-updated", syncFromMenu);
|
||||
window.addEventListener("bsplus-native-menu-updated", onNativeMenuUpdated);
|
||||
nativeMenuListenerAttached = true;
|
||||
}
|
||||
|
||||
@@ -288,7 +326,7 @@ export async function mountCustomSidebar(): Promise<boolean> {
|
||||
] as const) {
|
||||
registerSetting(key, () => applySidebarLook(menuEl));
|
||||
}
|
||||
const resync = () => syncFromMenu();
|
||||
const resync = () => syncFromMenu(true);
|
||||
registerSetting("menuorder", resync);
|
||||
registerSetting("menuitems", resync);
|
||||
|
||||
@@ -302,8 +340,9 @@ export function unmountCustomSidebar() {
|
||||
menuObserver = null;
|
||||
if (syncTimer) clearTimeout(syncTimer);
|
||||
syncTimer = null;
|
||||
if (catchupTimer) clearInterval(catchupTimer);
|
||||
catchupTimer = null;
|
||||
for (const t of catchupTimers) clearTimeout(t);
|
||||
catchupTimers = [];
|
||||
lastMenuFingerprint = "";
|
||||
|
||||
clearSettingListeners();
|
||||
|
||||
@@ -318,7 +357,7 @@ export function unmountCustomSidebar() {
|
||||
}
|
||||
|
||||
if (nativeMenuListenerAttached) {
|
||||
window.removeEventListener("bsplus-native-menu-updated", syncFromMenu);
|
||||
window.removeEventListener("bsplus-native-menu-updated", onNativeMenuUpdated);
|
||||
nativeMenuListenerAttached = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mount, unmount } from "svelte";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
import { waitForSeqtaTitle } from "@/seqta/utils/waitForSeqtaShell";
|
||||
import TitleBar from "./TitleBar.svelte";
|
||||
import { titleBarState } from "./titleBarState.svelte";
|
||||
|
||||
@@ -105,7 +105,7 @@ export async function mountCustomTitleBar(): Promise<boolean> {
|
||||
|
||||
let title: HTMLElement;
|
||||
try {
|
||||
title = (await waitForElm("#title", true, 50, 200)) as HTMLElement;
|
||||
title = (await waitForSeqtaTitle(50, 200)) as HTMLElement;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -311,10 +311,10 @@ export async function loadEngageHomePage(): Promise<void> {
|
||||
const engageHomeBody = stringToHTML(/* html */ `
|
||||
<div class="home-root" id="engage-home-root">
|
||||
<div class="home-container" id="engage-home-container">
|
||||
<div class="border shortcut-container">
|
||||
<div class="border shortcuts" id="shortcuts"></div>
|
||||
<div class="bsplus-rounded shortcut-container">
|
||||
<div class="bsplus-rounded shortcuts" id="shortcuts"></div>
|
||||
</div>
|
||||
<div class="border timetable-container">
|
||||
<div class="bsplus-rounded timetable-container">
|
||||
<div class="home-subtitle">
|
||||
<div class="engage-timetable-title-cluster">
|
||||
<h2 id="engage-home-lesson-subtitle">Today's Lessons</h2>
|
||||
@@ -331,7 +331,7 @@ export async function loadEngageHomePage(): Promise<void> {
|
||||
</div>
|
||||
<div class="day-container loading" id="engage-day-container"></div>
|
||||
</div>
|
||||
<div class="border notices-container">
|
||||
<div class="bsplus-rounded notices-container">
|
||||
<div style="display: flex; justify-content: space-between">
|
||||
<h2 class="home-subtitle">Notices</h2>
|
||||
<input type="date" id="engage-notices-date" />
|
||||
|
||||
@@ -62,10 +62,10 @@ export async function loadHomePage() {
|
||||
|
||||
const skeletonStructure = stringToHTML(/* html */ `
|
||||
<div class="home-container" id="home-container">
|
||||
<div class="border shortcut-container">
|
||||
<div class="border shortcuts" id="shortcuts"></div>
|
||||
<div class="bsplus-rounded shortcut-container">
|
||||
<div class="bsplus-rounded shortcuts" id="shortcuts"></div>
|
||||
</div>
|
||||
<div class="border timetable-container">
|
||||
<div class="bsplus-rounded timetable-container">
|
||||
<div class="home-subtitle">
|
||||
<h2 id="home-lesson-subtitle">Today's Lessons</h2>
|
||||
<div class="timetable-arrows">
|
||||
@@ -80,7 +80,7 @@ export async function loadHomePage() {
|
||||
<div class="day-container loading" id="day-container">
|
||||
</div>
|
||||
</div>
|
||||
<div class="border upcoming-container">
|
||||
<div class="bsplus-rounded upcoming-container">
|
||||
<div class="upcoming-title">
|
||||
<h2 class="home-subtitle">Upcoming Assessments</h2>
|
||||
<div class="upcoming-filters" id="upcoming-filters"></div>
|
||||
@@ -88,7 +88,7 @@ export async function loadHomePage() {
|
||||
<div class="upcoming-items loading" id="upcoming-items">
|
||||
</div>
|
||||
</div>
|
||||
<div class="border notices-container">
|
||||
<div class="bsplus-rounded notices-container">
|
||||
<div style="display: flex; justify-content: space-between">
|
||||
<h2 class="home-subtitle">Notices</h2>
|
||||
<input type="date" />
|
||||
|
||||
@@ -24,20 +24,58 @@ export interface ThemeOfTheMonthEntry {
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
const TOTM_FETCH_TIMEOUT_MS = 800;
|
||||
const TOTM_CACHE_KEY = "bsplus_theme_of_the_month_cache";
|
||||
|
||||
function isValidTotmEntry(data: unknown): data is ThemeOfTheMonthEntry {
|
||||
return (
|
||||
!!data &&
|
||||
typeof data === "object" &&
|
||||
typeof (data as ThemeOfTheMonthEntry).id === "string" &&
|
||||
!!(data as ThemeOfTheMonthEntry).id
|
||||
);
|
||||
}
|
||||
|
||||
async function readCachedThemeOfTheMonth(): Promise<ThemeOfTheMonthEntry | null> {
|
||||
try {
|
||||
const stored = await browser.storage.local.get(TOTM_CACHE_KEY);
|
||||
const cached = stored[TOTM_CACHE_KEY];
|
||||
return isValidTotmEntry(cached) ? cached : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeCachedThemeOfTheMonth(
|
||||
entry: ThemeOfTheMonthEntry,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await browser.storage.local.set({ [TOTM_CACHE_KEY]: entry });
|
||||
} catch {
|
||||
/* ignore cache write failures */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches Theme of the Month with a hard timeout so a hung API cannot stall
|
||||
* the startup popup queue. Falls back to last-good cached entry on timeout/error.
|
||||
*/
|
||||
export async function fetchThemeOfTheMonth(): Promise<ThemeOfTheMonthEntry | null> {
|
||||
try {
|
||||
const res = await fetch(`${getApiBase()}/api/theme-of-the-month/current`, {
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(TOTM_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
if (!res.ok) return readCachedThemeOfTheMonth();
|
||||
const text = await res.text();
|
||||
if (!text) return null;
|
||||
if (!text) return readCachedThemeOfTheMonth();
|
||||
const data = JSON.parse(text);
|
||||
if (!data || typeof data !== "object" || !data.id) return null;
|
||||
return data as ThemeOfTheMonthEntry;
|
||||
if (!isValidTotmEntry(data)) return readCachedThemeOfTheMonth();
|
||||
void writeCachedThemeOfTheMonth(data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.warn("[ThemeOfTheMonth] Failed to fetch current entry:", err);
|
||||
return null;
|
||||
return readCachedThemeOfTheMonth();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import browser from "webextension-polyfill";
|
||||
import { fetchThemeOfTheMonth } from "./OpenThemeOfTheMonthPopup";
|
||||
|
||||
jest.mock("../DevApiBase", () => ({
|
||||
getApiBase: () => "https://example.test",
|
||||
}));
|
||||
|
||||
const CACHE_KEY = "bsplus_theme_of_the_month_cache";
|
||||
|
||||
const sampleEntry = {
|
||||
id: "totm-1",
|
||||
month: "2026-07",
|
||||
title: "July Theme",
|
||||
description: "desc",
|
||||
cover_image: null,
|
||||
theme_id: null,
|
||||
theme: null,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
};
|
||||
|
||||
describe("fetchThemeOfTheMonth", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("caches a successful response", async () => {
|
||||
globalThis.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => JSON.stringify(sampleEntry),
|
||||
}) as typeof fetch;
|
||||
|
||||
const entry = await fetchThemeOfTheMonth();
|
||||
expect(entry?.id).toBe("totm-1");
|
||||
expect(browser.storage.local.set).toHaveBeenCalledWith({
|
||||
[CACHE_KEY]: sampleEntry,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to cache when fetch fails or times out", async () => {
|
||||
await browser.storage.local.set({ [CACHE_KEY]: sampleEntry });
|
||||
globalThis.fetch = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new DOMException("Aborted", "AbortError"));
|
||||
|
||||
const entry = await fetchThemeOfTheMonth();
|
||||
expect(entry?.id).toBe("totm-1");
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,7 @@ export async function SendNewsPage() {
|
||||
const html = stringToHTML(/* html */ `
|
||||
<div class="home-root">
|
||||
<div class="home-container" id="news-container">
|
||||
<h1 class="border">Latest Headlines in ${displayCountry}</h1>
|
||||
<h1 class="bsplus-rounded">Latest Headlines in ${displayCountry}</h1>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
import {
|
||||
resetSeqtaShellWaiters,
|
||||
waitForSeqtaMenu,
|
||||
waitForSeqtaTitle,
|
||||
} from "./waitForSeqtaShell";
|
||||
|
||||
jest.mock("@/seqta/utils/waitForElm", () => ({
|
||||
waitForElm: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("waitForSeqtaShell", () => {
|
||||
beforeEach(() => {
|
||||
resetSeqtaShellWaiters();
|
||||
document.body.innerHTML = "";
|
||||
jest.mocked(waitForElm).mockReset();
|
||||
});
|
||||
|
||||
it("returns existing #title without calling waitForElm", async () => {
|
||||
const el = document.createElement("div");
|
||||
el.id = "title";
|
||||
document.body.append(el);
|
||||
|
||||
await expect(waitForSeqtaTitle()).resolves.toBe(el);
|
||||
expect(waitForElm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shares one waitForElm promise for concurrent #menu waiters", async () => {
|
||||
let resolveWait!: (el: Element) => void;
|
||||
const pending = new Promise<Element>((resolve) => {
|
||||
resolveWait = resolve;
|
||||
});
|
||||
jest.mocked(waitForElm).mockReturnValue(pending);
|
||||
|
||||
const a = waitForSeqtaMenu();
|
||||
const b = waitForSeqtaMenu();
|
||||
expect(waitForElm).toHaveBeenCalledTimes(1);
|
||||
|
||||
const menu = document.createElement("div");
|
||||
menu.id = "menu";
|
||||
resolveWait(menu);
|
||||
|
||||
await expect(Promise.all([a, b])).resolves.toEqual([menu, menu]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
|
||||
/**
|
||||
* Shared readiness promises for SEQTA chrome elements that titlebar, search,
|
||||
* and sidebar all wait on. Avoids overlapping waitForElm observers/polls.
|
||||
*/
|
||||
|
||||
type ShellKey = "title" | "menu";
|
||||
|
||||
const inflight = new Map<ShellKey, Promise<Element>>();
|
||||
|
||||
function sharedWait(
|
||||
key: ShellKey,
|
||||
selector: string,
|
||||
interval: number,
|
||||
maxIterations: number,
|
||||
): Promise<Element> {
|
||||
const existing = inflight.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const promise = waitForElm(selector, true, interval, maxIterations).then(
|
||||
(el) => el,
|
||||
(err) => {
|
||||
inflight.delete(key);
|
||||
throw err;
|
||||
},
|
||||
);
|
||||
inflight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Resolves once `#title` exists (shared across titlebar + global search). */
|
||||
export function waitForSeqtaTitle(
|
||||
interval = 50,
|
||||
maxIterations = 200,
|
||||
): Promise<Element> {
|
||||
const immediate = document.querySelector("#title");
|
||||
if (immediate) return Promise.resolve(immediate);
|
||||
return sharedWait("title", "#title", interval, maxIterations);
|
||||
}
|
||||
|
||||
/** Resolves once `#menu` exists (shared across sidebar + background layers). */
|
||||
export function waitForSeqtaMenu(
|
||||
interval = 50,
|
||||
maxIterations = 200,
|
||||
): Promise<Element> {
|
||||
const immediate = document.querySelector("#menu");
|
||||
if (immediate) return Promise.resolve(immediate);
|
||||
return sharedWait("menu", "#menu", interval, maxIterations);
|
||||
}
|
||||
|
||||
/** Test/helpers: clear shared caches between mounts. */
|
||||
export function resetSeqtaShellWaiters(): void {
|
||||
inflight.clear();
|
||||
}
|
||||
Reference in New Issue
Block a user