refactor: further trim PR #458 debloat across patches, notices, Select, music, archive, search

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-19 08:58:31 +09:30
parent 4fda63dedd
commit 3bac45032d
21 changed files with 497 additions and 924 deletions
@@ -9,12 +9,14 @@ const LAYER_CLASSES = [
["bg", "bg3", ANIMATED_BG_MARKER],
] as const;
const layerSelector = `:scope > div.bg.${ANIMATED_BG_MARKER}`;
const bgSel = `.bg.${ANIMATED_BG_MARKER}`;
const scopeSel = `:scope > div${bgSel}`;
const BASE_SPEEDS = [3, 4, 5] as const;
export function updateAnimationSpeed(speed: number) {
document.querySelectorAll(`.bg.${ANIMATED_BG_MARKER}`).forEach((element, index) => {
const baseSpeed = index === 0 ? 3 : index === 1 ? 4 : 5;
(element as HTMLElement).style.animationDuration = `${baseSpeed / speed}s`;
document.querySelectorAll(bgSel).forEach((element, index) => {
const base = BASE_SPEEDS[index] ?? BASE_SPEEDS[2];
(element as HTMLElement).style.animationDuration = `${base / speed}s`;
});
}
@@ -23,12 +25,12 @@ export function ensureAnimatedBackgroundLayers(
menu: HTMLElement,
speed: number,
): void {
if (container.querySelectorAll(layerSelector).length >= 3) {
if (container.querySelectorAll(scopeSel).length >= 3) {
updateAnimationSpeed(speed);
return;
}
container.querySelectorAll(layerSelector).forEach((el) => el.remove());
container.querySelectorAll(scopeSel).forEach((el) => el.remove());
for (const classes of LAYER_CLASSES) {
const bk = document.createElement("div");
@@ -40,7 +42,7 @@ export function ensureAnimatedBackgroundLayers(
}
export function removeAnimatedBackgroundLayers(): void {
document.querySelectorAll(`div.bg.${ANIMATED_BG_MARKER}`).forEach((el) => el.remove());
document.querySelectorAll(`div${bgSel}`).forEach((el) => el.remove());
}
export async function syncAnimatedBackground(
@@ -29,7 +29,6 @@ class AnimatedBackgroundPluginClass extends BasePlugin<typeof settings> {
}
const instance = new AnimatedBackgroundPluginClass();
const resync = (api: PluginAPI<typeof settings>) => () => void syncAnimatedBackground(api);
const animatedBackgroundPlugin: Plugin<typeof settings> = {
id: "animated-background",
@@ -42,22 +41,20 @@ const animatedBackgroundPlugin: Plugin<typeof settings> = {
run: async (api) => {
await syncAnimatedBackground(api);
const resync = () => void syncAnimatedBackground(api);
const speedUnregister = api.settings.onChange("speed", updateAnimationSpeed);
const pageChangeUnregister = api.seqta.onPageChange(resync(api));
const pageshowHandler = (event: PageTransitionEvent) => {
if (event.persisted) void syncAnimatedBackground(api);
};
window.addEventListener("pageshow", pageshowHandler);
const pageChangeUnregister = api.seqta.onPageChange(resync);
window.addEventListener("pageshow", resync);
const containerObserver = new MutationObserver(resync(api));
const containerObserver = new MutationObserver(resync);
const container = document.getElementById("container");
if (container) containerObserver.observe(container, { childList: true });
return () => {
speedUnregister.unregister();
pageChangeUnregister.unregister();
window.removeEventListener("pageshow", pageshowHandler);
window.removeEventListener("pageshow", resync);
containerObserver.disconnect();
removeAnimatedBackgroundLayers();
};
+34 -70
View File
@@ -44,15 +44,9 @@ let objectUrl: string | null = null;
let gestureCleanup: (() => void) | null = null;
let resumeTimer: ReturnType<typeof setTimeout> | null = null;
let hintEl: HTMLElement | null = null;
let playing = false;
const clamp = (v: number) => Math.max(0, Math.min(1, v));
async function loadBlob(): Promise<Blob | null> {
const blob = await store.getItem<Blob>("audio-blob");
return blob instanceof Blob ? blob : null;
}
function clearHint(): void {
hintEl?.remove();
hintEl = null;
@@ -63,40 +57,18 @@ function disarmGesture(): void {
gestureCleanup = null;
}
function onPlayStarted(): void {
playing = true;
clearHint();
disarmGesture();
}
function stopAudio(): void {
audio?.pause();
audio?.remove();
audio = null;
if (objectUrl) URL.revokeObjectURL(objectUrl);
objectUrl = null;
playing = false;
}
function showHint(onActivate: () => void): void {
clearHint();
const hint = document.createElement("button");
hint.id = "bsplus-bg-music-hint";
hint.type = "button";
hint.className = "bsplus-bg-music-hint";
hint.textContent = "Tap to start background music";
hint.addEventListener("pointerdown", (e) => {
e.preventDefault();
onActivate();
});
document.body.append(hint);
hintEl = hint;
}
/** Prepare <audio> so play() can run synchronously inside a user-gesture handler. */
async function prepareAudio(vol: number): Promise<boolean> {
const blob = await loadBlob();
if (!blob) {
const blob = await store.getItem<Blob>("audio-blob");
if (!(blob instanceof Blob)) {
stopAudio();
clearHint();
return false;
@@ -114,25 +86,16 @@ async function prepareAudio(vol: number): Promise<boolean> {
return true;
}
/** Call synchronously from a user-gesture handler (no await before this). */
function playPrepared(vol: number): void {
if (!audio) return;
function attemptPlay(vol: number): Promise<boolean> {
if (!audio) return Promise.resolve(false);
audio.volume = clamp(vol);
void audio.play().then(onPlayStarted).catch(() => {
playing = false;
});
}
async function tryAutoplay(vol: number): Promise<boolean> {
if (!(await prepareAudio(vol)) || !audio) return false;
try {
await audio.play();
onPlayStarted();
return true;
} catch {
playing = false;
return false;
}
return audio
.play()
.then(() => {
disarmGesture();
return true;
})
.catch(() => false);
}
function armGesture(onGesture: () => void): void {
@@ -153,14 +116,19 @@ function armGesture(onGesture: () => void): void {
}
clearHint();
};
showHint(onGesture);
}
function clearResumeTimer(): void {
if (resumeTimer !== null) {
clearTimeout(resumeTimer);
resumeTimer = null;
}
clearHint();
const hint = document.createElement("button");
hint.id = "bsplus-bg-music-hint";
hint.type = "button";
hint.className = "bsplus-bg-music-hint";
hint.textContent = "Tap to start background music";
hint.addEventListener("pointerdown", (e) => {
e.preventDefault();
onGesture();
});
document.body.append(hint);
hintEl = hint;
}
const backgroundMusicPlugin: Plugin<typeof settings> = {
@@ -177,22 +145,20 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
await api.storage.loaded;
type BgSettings = { volume?: number; pauseOnHidden?: boolean };
const s = () => api.settings as BgSettings;
const vol = () => s().volume ?? 0.5;
const pauseOnHidden = () => s().pauseOnHidden ?? true;
const vol = () => (api.settings as BgSettings).volume ?? 0.5;
const pauseOnHidden = () => (api.settings as BgSettings).pauseOnHidden ?? true;
const gestureStart = () => {
if (audio) playPrepared(vol());
const gesturePlay = () => {
void attemptPlay(vol());
};
const ensurePlayback = async () => {
if (!(await prepareAudio(vol()))) return;
if (playing && audio && !audio.paused) {
clearHint();
if (audio && !audio.paused) {
disarmGesture();
return;
}
if (!(await tryAutoplay(vol()))) armGesture(gestureStart);
if (!(await attemptPlay(vol()))) armGesture(gesturePlay);
};
api.settings.onChange("volume" as never, (value: unknown) => {
@@ -214,9 +180,9 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
const onVisibility = () => {
if (document.visibilityState === "hidden") {
if (!pauseOnHidden() || !audio) return;
clearResumeTimer();
if (resumeTimer) clearTimeout(resumeTimer);
resumeTimer = null;
audio.pause();
playing = false;
return;
}
if (!audio) {
@@ -224,10 +190,10 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
return;
}
if (!pauseOnHidden()) return;
clearResumeTimer();
if (resumeTimer) clearTimeout(resumeTimer);
resumeTimer = setTimeout(() => {
resumeTimer = null;
void tryAutoplay(vol());
void attemptPlay(vol());
}, 200);
};
@@ -235,16 +201,14 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
const onStop = () => {
disarmGesture();
stopAudio();
clearHint();
};
const teardown = () => {
document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("pageshow", onUpdated);
window.removeEventListener("betterseqta-background-music-updated", onUpdated);
window.removeEventListener("betterseqta-background-music-stop", onStop);
clearResumeTimer();
if (resumeTimer) clearTimeout(resumeTimer);
disarmGesture();
clearHint();
stopAudio();
};
@@ -11,14 +11,4 @@
font: 600 0.8125rem/1.25 system-ui, sans-serif;
cursor: pointer;
box-shadow: 0 8px 24px rgb(0 0 0 / 0.35);
animation: bsplus-bg-music-hint-in 220ms ease-out;
}
.bsplus-bg-music-hint:hover {
background: color-mix(in srgb, var(--theme-secondary, #2a2a2a) 90%, var(--better-main, #22c55e) 10%);
}
@keyframes bsplus-bg-music-hint-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
@@ -260,11 +260,12 @@ export async function applyStoreDiff(
if (puts.length === 0 && removeKeys.length === 0) return;
try {
const db = await openDB();
let db = await openDB();
if (!db.objectStoreNames.contains(store)) {
await upgradeDB(store);
db = await openDB();
}
await runStoreDiffTransaction(await openDB(), store, puts, removeKeys);
await runStoreDiffTransaction(db, store, puts, removeKeys);
} catch (error) {
console.error(`Error in applyStoreDiff for store ${store}:`, error);
throw error;
@@ -1,6 +1,6 @@
import { applyStoreDiff, get, getAll, put, remove } from "./db";
import { jobs } from "./jobs";
import { decorateIndexItems, publishDynamicItemsUpdate } from "./renderComponents";
import { decorateIndexItems } from "./renderComponents";
import type { IndexItem, Job, JobContext } from "./types";
import { VectorWorkerManager } from "./worker/vectorWorkerManager";
import { loadDynamicItems } from "../utils/dynamicItems";
@@ -260,54 +260,25 @@ function dispatchVectorProgress(
completedJobs: number,
totalSteps: number,
): number {
let detailMessage = progress.message || "";
const { status, total, processed, message = "" } = progress;
let detail = message;
let completed = completedJobs;
if (
progress.status === "processing" &&
progress.total &&
progress.processed !== undefined
) {
detailMessage = `Vectorizing: ${progress.processed} / ${progress.total}`;
} else if (progress.status === "complete") {
detailMessage = "Vectorization complete";
completed++;
dispatchProgress(completed, totalSteps, false, "Indexing finished", detailMessage);
if (status === "processing" && total != null && processed != null) {
detail = `Vectorizing: ${processed} / ${total}`;
} else if (status === "started") {
detail = `Vectorization started for ${total} items`;
} else if (status === "complete") {
dispatchProgress(++completed, totalSteps, false, "Indexing finished", "Vectorization complete");
return completed;
} else if (progress.status === "error") {
dispatchProgress(
completed,
totalSteps,
false,
"Vectorization failed",
`Vectorization error: ${progress.message}`,
);
} else if (status === "error") {
dispatchProgress(completed, totalSteps, false, "Vectorization failed", `Vectorization error: ${message}`);
return completed;
} else if (progress.status === "cancelled") {
dispatchProgress(
completed,
totalSteps,
false,
"Vectorization cancelled",
`Vectorization cancelled: ${progress.message}`,
);
} else if (status === "cancelled") {
dispatchProgress(completed, totalSteps, false, "Vectorization cancelled", `Vectorization cancelled: ${message}`);
return completed;
} else if (progress.status === "started") {
detailMessage = `Vectorization started for ${progress.total} items`;
}
if (
progress.status !== "complete" &&
progress.status !== "error" &&
progress.status !== "cancelled"
) {
dispatchProgress(
completed,
totalSteps,
true,
"Vectorization in progress",
detailMessage,
);
} else {
dispatchProgress(completed, totalSteps, true, "Vectorization in progress", detail);
}
return completed;
@@ -322,10 +293,7 @@ export async function runIndexing(): Promise<void> {
}
await ensureSchemaCurrent();
if (isIndexingPaused()) {
return;
}
if (isIndexingPaused()) return;
if (!(await acquireLock())) {
verboseDebug(
@@ -1,5 +1,3 @@
import { verboseDebug } from '@/utils/verboseLog';
const EMBEDDIA_DB = "embeddiaDB";
const EMBEDDIA_STORE = "embeddiaObjectStore";
@@ -13,13 +11,9 @@ function openEmbeddiaDb(): Promise<IDBDatabase | null> {
export async function getVectorizedItemIds(): Promise<Set<string>> {
const db = await openEmbeddiaDb();
if (!db) {
verboseDebug("Could not open embeddiaDB, assuming no items are vectorized");
return new Set();
}
if (!db) return new Set();
if (!db.objectStoreNames.contains(EMBEDDIA_STORE)) {
verboseDebug("embeddiaObjectStore not found, assuming no items are vectorized");
db.close();
return new Set();
}
@@ -39,7 +33,6 @@ export async function getVectorizedItemIds(): Promise<Set<string>> {
if (typeof key === "string") vectorizedIds.add(key);
}
verboseDebug(`Found ${vectorizedIds.size} already vectorized items in embeddia DB`);
db.close();
return vectorizedIds;
} catch (error) {
@@ -28,15 +28,23 @@ function shouldDedupeAsSameCourseSPA(item: IndexItem): boolean {
return false;
}
function programmeMetaclassIds(
item: IndexItem,
): { programme?: number; metaclass?: number } {
const md = item.metadata ?? {};
return {
programme: toFiniteNumber(
md.programme ?? md.programmeId ?? md.programmeID,
),
metaclass: toFiniteNumber(
md.metaclass ?? md.metaclassId ?? md.metaclassID ?? md.subjectId,
),
};
}
export function courseDestinationKey(item: IndexItem): string | undefined {
if (!shouldDedupeAsSameCourseSPA(item)) return undefined;
const md = item.metadata ?? {};
const programme = toFiniteNumber(
md.programme ?? md.programmeId ?? md.programmeID,
);
const metaclass = toFiniteNumber(
md.metaclass ?? md.metaclassId ?? md.metaclassID ?? md.subjectId,
);
const { programme, metaclass } = programmeMetaclassIds(item);
if (programme === undefined || metaclass === undefined) return undefined;
return `course:${programme}:${metaclass}`;
}
@@ -74,13 +82,7 @@ function isPassiveLike(item: IndexItem): boolean {
}
function hasProgrammeMetaclass(item: IndexItem): boolean {
const md = item.metadata ?? {};
const programme = toFiniteNumber(
md.programme ?? md.programmeId ?? md.programmeID,
);
const metaclass = toFiniteNumber(
md.metaclass ?? md.metaclassId ?? md.metaclassID ?? md.subjectId,
);
const { programme, metaclass } = programmeMetaclassIds(item);
return programme !== undefined && metaclass !== undefined;
}
@@ -166,44 +168,29 @@ function dynamicSearchKey(row: CombinedResult): string | undefined {
return searchDedupeKey(row.item as IndexItem);
}
function mergeCombinedDuplicates(
a: CombinedResult,
b: CombinedResult,
key: string,
): CombinedResult {
const aItem = a.item as IndexItem;
const bItem = b.item as IndexItem;
const winnerItem = pickBetterSearchDuplicate(aItem, bItem, key);
const envelope = winnerItem.id === aItem.id ? a : b;
return {
...envelope,
score: Math.max(a.score, b.score),
id: winnerItem.id,
item: winnerItem,
};
}
export function dedupeCombinedResultsByCourseNav(
results: CombinedResult[],
): CombinedResult[] {
const best = new Map<string, CombinedResult>();
for (const r of results) {
const key = dynamicSearchKey(r);
if (!key) continue;
const prev = best.get(key);
if (!prev) {
best.set(key, r);
continue;
}
const aItem = prev.item as IndexItem;
const bItem = r.item as IndexItem;
const winnerItem = pickBetterSearchDuplicate(aItem, bItem, key);
const envelope = winnerItem.id === aItem.id ? prev : r;
best.set(key, {
...envelope,
score: Math.max(prev.score, r.score),
id: winnerItem.id,
item: winnerItem,
});
}
const seenCanon = new Set<string>();
const out: CombinedResult[] = [];
for (const r of results) {
const key = dynamicSearchKey(r);
if (!key) {
out.push(r);
continue;
}
if (seenCanon.has(key)) continue;
seenCanon.add(key);
out.push(best.get(key)!);
}
return out;
return dedupeByCanonicalKey(
results,
dynamicSearchKey,
mergeCombinedDuplicates,
);
}
@@ -63,7 +63,7 @@ const gradeAnalyticsPlugin: Plugin<{}> = {
});
menuObserver.observe(menuList, { childList: true });
const onClick = (e: Event) => {
analyticsItem.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (
MenuOptionsOpen ||
@@ -75,12 +75,10 @@ const gradeAnalyticsPlugin: Plugin<{}> = {
e.preventDefault();
window.history.pushState({}, "", "/#?page=/analytics");
void loadAnalyticsPage();
};
analyticsItem.addEventListener("click", onClick);
});
return () => {
menuObserver.disconnect();
analyticsItem.removeEventListener("click", onClick);
analyticsItem.remove();
};
},
@@ -1,27 +1,17 @@
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements";
type RawNotification = Record<string, unknown>;
export interface ArchivedNotification {
notificationID: number;
type: string;
timestamp: string;
title: string;
subtitle: string;
messageID?: number;
assessmentID?: number;
programmeID?: number;
metaclassID?: number;
subjectCode?: string;
firstSavedAt: string;
lastSeenAt: string;
raw: RawNotification;
}
export type ArchiveMap = Record<string, ArchivedNotification>;
export type ArchivesByUser = Record<string, ArchiveMap>;
type RawNotification = Record<string, unknown>;
export async function resolveNotificationUserKey(): Promise<string | null> {
try {
const info = await getUserInfo();
@@ -45,165 +35,58 @@ export async function fetchAllNotifications(): Promise<RawNotification[]> {
hash: "#?page=/notifications",
}),
});
if (!res.ok) return [];
const json = (await res.json()) as {
notifications?: RawNotification[];
payload?: { notifications?: RawNotification[] };
};
const list = json.notifications ?? json.payload?.notifications;
return Array.isArray(list) ? list : [];
}
function readString(value: unknown): string {
if (value == null) return "";
return String(value).trim();
}
export function normalizeArchivedNotification(
raw: RawNotification,
now = new Date().toISOString(),
): ArchivedNotification | null {
const notificationID = Number(raw.notificationID);
if (!notificationID || Number.isNaN(notificationID)) return null;
const type = readString(raw.type) || "unknown";
const timestamp = readString(raw.timestamp) || now;
if (type === "message" && raw.message && typeof raw.message === "object") {
const message = raw.message as Record<string, unknown>;
return {
notificationID,
type,
timestamp,
title: readString(message.title) || "Message",
subtitle: readString(message.subtitle),
messageID: Number(message.messageID) || undefined,
firstSavedAt: now,
lastSeenAt: now,
raw: { ...raw },
};
}
if (
type === "coneqtassessments" &&
raw.coneqtAssessments &&
typeof raw.coneqtAssessments === "object"
) {
const assessment = raw.coneqtAssessments as Record<string, unknown>;
return {
notificationID,
type,
timestamp,
title: readString(assessment.title) || "Assessment",
subtitle: readString(assessment.subtitle) || readString(assessment.subjectCode),
assessmentID: Number(assessment.assessmentID) || undefined,
programmeID: Number(assessment.programmeID) || undefined,
metaclassID: Number(assessment.metaclassID) || undefined,
subjectCode: readString(assessment.subjectCode) || undefined,
firstSavedAt: now,
lastSeenAt: now,
raw: { ...raw },
};
}
return {
notificationID,
type,
timestamp,
title: readString(raw.title) || "Notification",
subtitle: readString(raw.subtitle),
firstSavedAt: now,
lastSeenAt: now,
raw: { ...raw },
};
function archiveTimestamp(
item: ArchivedNotification & { timestamp?: string },
): number {
const ms = new Date(
String(item.raw?.timestamp ?? item.timestamp ?? 0),
).getTime();
return Number.isNaN(ms) ? 0 : ms;
}
export function mergeNotificationsIntoArchive(
existing: ArchiveMap,
notifications: RawNotification[],
): ArchiveMap {
): { archive: ArchiveMap; changed: boolean } {
const now = new Date().toISOString();
const merged: ArchiveMap = { ...existing };
let changed = false;
const archive = { ...existing };
for (const raw of notifications) {
const normalized = normalizeArchivedNotification(raw, now);
if (!normalized) continue;
const notificationID = Number(raw.notificationID);
if (!notificationID || Number.isNaN(notificationID)) continue;
const key = String(normalized.notificationID);
const prev = merged[key];
if (prev) {
merged[key] = {
...prev,
...normalized,
timestamp: normalized.timestamp || prev.timestamp,
firstSavedAt: prev.firstSavedAt,
lastSeenAt: now,
raw: { ...prev.raw, ...raw },
};
} else {
merged[key] = normalized;
}
const key = String(notificationID);
const prev = archive[key];
archive[key] = prev
? { ...prev, lastSeenAt: now, raw: { ...prev.raw, ...raw } }
: { notificationID, firstSavedAt: now, lastSeenAt: now, raw: { ...raw } };
changed = true;
}
return merged;
return { archive, changed };
}
export function listArchivedNotifications(archive: ArchiveMap): ArchivedNotification[] {
export function listArchivedNotifications(
archive: ArchiveMap,
): ArchivedNotification[] {
return Object.values(archive).sort(
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
(a, b) => archiveTimestamp(b) - archiveTimestamp(a),
);
}
export function archivedToApiNotification(
item: ArchivedNotification,
): RawNotification {
if (item.raw && typeof item.raw === "object") {
return {
...item.raw,
notificationID: item.notificationID,
type: item.type,
timestamp: item.timestamp,
};
}
if (item.type === "message") {
return {
notificationID: item.notificationID,
type: "message",
timestamp: item.timestamp,
message: {
title: item.title,
subtitle: item.subtitle,
messageID: item.messageID,
},
};
}
if (item.type === "coneqtassessments") {
return {
notificationID: item.notificationID,
type: "coneqtassessments",
timestamp: item.timestamp,
coneqtAssessments: {
title: item.title,
subtitle: item.subtitle,
assessmentID: item.assessmentID,
programmeID: item.programmeID,
metaclassID: item.metaclassID,
subjectCode: item.subjectCode,
term: "",
},
};
}
return {
notificationID: item.notificationID,
type: item.type,
timestamp: item.timestamp,
title: item.title,
subtitle: item.subtitle,
};
return { ...item.raw, notificationID: item.notificationID };
}
@@ -14,6 +14,9 @@ import {
} from "./injectArchivedNotifications";
import styles from "./styles.css?inline";
const BUBBLE_SELECTOR = "[class*='notifications__bubble___']";
const LIST_SELECTOR = '[class*="notifications__list___"]';
const notificationCollectorSettings = {
saveLocally: booleanSetting({
default: true,
@@ -60,15 +63,9 @@ const notificationCollectorPlugin: Plugin<
const baseInterval = 30000;
const maxInterval = 300000;
if (!api.storage.lastNotificationCount) {
api.storage.lastNotificationCount = 0;
}
if (!api.storage.consecutiveErrors) {
api.storage.consecutiveErrors = 0;
}
if (!api.storage.archivesByUser) {
api.storage.archivesByUser = {};
}
api.storage.lastNotificationCount ||= 0;
api.storage.consecutiveErrors ||= 0;
api.storage.archivesByUser ||= {};
const syncArchive = async () => {
if (!api.settings.saveLocally || archiveSyncInFlight) return;
@@ -81,12 +78,15 @@ const notificationCollectorPlugin: Plugin<
const notifications = await fetchAllNotifications();
const archivesByUser = { ...(api.storage.archivesByUser ?? {}) };
const existing = archivesByUser[userKey] ?? {};
const merged = mergeNotificationsIntoArchive(existing, notifications);
const { archive: merged, changed } = mergeNotificationsIntoArchive(
existing,
notifications,
);
if (JSON.stringify(existing) !== JSON.stringify(merged)) {
if (changed) {
archivesByUser[userKey] = merged;
api.storage.archivesByUser = archivesByUser;
} else if (document.querySelector('[class*="notifications__list___"]')) {
} else if (document.querySelector(LIST_SELECTOR)) {
await injectArchivedForUser(merged);
}
} catch (error) {
@@ -100,9 +100,7 @@ const notificationCollectorPlugin: Plugin<
if (!isVisible) return;
try {
const alertDiv = document.querySelector(
"[class*='notifications__bubble___']",
) as HTMLElement;
const alertDiv = document.querySelector(BUBBLE_SELECTOR) as HTMLElement;
if (alertDiv && api.storage.lastNotificationCount !== 0) {
alertDiv.textContent = api.storage.lastNotificationCount.toString();
@@ -159,9 +157,7 @@ const notificationCollectorPlugin: Plugin<
if (pollInterval) {
window.clearTimeout(pollInterval);
pollInterval = null;
const alertDiv = document.querySelector(
"[class*='notifications__bubble___']",
) as HTMLElement;
const alertDiv = document.querySelector(BUBBLE_SELECTOR) as HTMLElement;
if (alertDiv) {
if (api.storage.lastNotificationCount > 9) {
alertDiv.textContent = "9+";
@@ -175,10 +171,7 @@ const notificationCollectorPlugin: Plugin<
const handleVisibilityChange = () => {
isVisible = !document.hidden;
if (isVisible && !pollInterval) {
const alertDiv = document.querySelector(
"[class*='notifications__bubble___']",
);
if (alertDiv) startPolling();
if (document.querySelector(BUBBLE_SELECTOR)) startPolling();
}
};
@@ -195,18 +188,16 @@ const notificationCollectorPlugin: Plugin<
resolveNotificationUserKey,
);
api.seqta.onMount("[class*='notifications__bubble___']", () => {
const onBubbleMount = () => {
startPolling();
if (api.settings.saveLocally) {
void syncArchive();
}
});
if (api.settings.saveLocally) void syncArchive();
};
const onListMount = () => {
if (api.settings.saveLocally) void syncArchive();
};
api.seqta.onMount("[class*='notifications__list___']", () => {
if (api.settings.saveLocally) {
void syncArchive();
}
});
api.seqta.onMount(BUBBLE_SELECTOR, onBubbleMount);
api.seqta.onMount(LIST_SELECTOR, onListMount);
return () => {
stopPolling();
@@ -14,9 +14,6 @@ const ITEM_SELECTOR = '[class*="notifications__item___"]';
const BACKED_UP_CLASS = "bsplus-notification-backed-up";
const BACKUP_BADGE_CLASS = "bsplus-notification-backup-badge";
const BACKUP_CHECK_SVG =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>';
function notificationTimestamp(item: Record<string, unknown>): number {
const ms = new Date(String(item.timestamp ?? 0)).getTime();
return Number.isNaN(ms) ? 0 : ms;
@@ -38,17 +35,6 @@ function mergeLiveWithArchived(
);
}
function sameItemOrder(
current: Record<string, unknown>[],
merged: Record<string, unknown>[],
): boolean {
if (current.length !== merged.length) return false;
return current.every(
(item, index) =>
Number(item.notificationID) === Number(merged[index]?.notificationID),
);
}
async function tryInjectArchived(archive: ArchiveMap): Promise<boolean> {
if (!document.querySelector(LIST_SELECTOR)) return false;
@@ -58,16 +44,22 @@ async function tryInjectArchived(archive: ArchiveMap): Promise<boolean> {
const liveItems = state.items as Record<string, unknown>[];
const merged = mergeLiveWithArchived(liveItems, archive);
if (!merged) return true;
if (sameItemOrder(liveItems, merged)) return true;
const sameOrder =
liveItems.length === merged.length &&
liveItems.every(
(item, index) =>
Number(item.notificationID) === Number(merged[index]?.notificationID),
);
if (sameOrder) return true;
await ReactFiber.find(LIST_SELECTOR).setState({ items: merged });
return true;
}
async function injectWithRetries(archive: ArchiveMap, attempts = 10) {
for (let i = 0; i < attempts; i++) {
const done = await tryInjectArchived(archive);
if (done) break;
async function injectWithRetries(archive: ArchiveMap) {
for (let attempt = 0; attempt < 10; attempt++) {
if (await tryInjectArchived(archive)) break;
await delay(120);
}
applyBackupBadges(archive);
@@ -86,7 +78,7 @@ export function applyBackupBadges(archive: ArchiveMap) {
const badge = document.createElement("span");
badge.className = BACKUP_BADGE_CLASS;
badge.title = "Saved locally";
badge.innerHTML = BACKUP_CHECK_SVG;
badge.textContent = "✓";
itemEl.appendChild(badge);
}
} else {
@@ -119,30 +111,23 @@ export function mountArchivedNotificationInjection(
const watchItemsContainer = () => {
const itemsEl = document.querySelector(ITEMS_SELECTOR);
if (!itemsEl) return;
if (observer) observer.disconnect();
observer = new MutationObserver(() => scheduleInject());
observer?.disconnect();
observer = new MutationObserver(scheduleInject);
observer.observe(itemsEl, { childList: true });
};
api.seqta.onMount(LIST_SELECTOR, () => {
const onNotificationsMount = () => {
scheduleInject();
watchItemsContainer();
});
api.seqta.onMount(ITEMS_SELECTOR, () => {
watchItemsContainer();
scheduleInject();
});
api.storage.onChange("archivesByUser", () => scheduleInject());
return () => {
if (observer) observer.disconnect();
};
api.seqta.onMount(LIST_SELECTOR, onNotificationsMount);
api.seqta.onMount(ITEMS_SELECTOR, onNotificationsMount);
api.storage.onChange("archivesByUser", scheduleInject);
return () => observer?.disconnect();
}
export async function injectArchivedForUser(
archive: ArchiveMap,
): Promise<void> {
export async function injectArchivedForUser(archive: ArchiveMap): Promise<void> {
await injectWithRetries(archive);
}
@@ -10,16 +10,11 @@
height: 14px;
border-radius: 50%;
background: var(--better-main, #22c55e);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 9px;
line-height: 14px;
text-align: center;
pointer-events: none;
z-index: 2;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.bsplus-notification-backup-badge svg {
width: 9px;
height: 9px;
color: #fff;
}