feat(notifications): local per-user archive

This commit is contained in:
2026-06-22 21:54:32 +09:30
parent 37ae32be59
commit 79d68c812c
5 changed files with 188 additions and 258 deletions
@@ -13,6 +13,7 @@ export interface ArchivedNotification {
subjectCode?: string; subjectCode?: string;
firstSavedAt: string; firstSavedAt: string;
lastSeenAt: string; lastSeenAt: string;
raw: RawNotification;
} }
export type ArchiveMap = Record<string, ArchivedNotification>; export type ArchiveMap = Record<string, ArchivedNotification>;
@@ -82,6 +83,7 @@ export function normalizeArchivedNotification(
messageID: Number(message.messageID) || undefined, messageID: Number(message.messageID) || undefined,
firstSavedAt: now, firstSavedAt: now,
lastSeenAt: now, lastSeenAt: now,
raw: { ...raw },
}; };
} }
@@ -103,6 +105,7 @@ export function normalizeArchivedNotification(
subjectCode: readString(assessment.subjectCode) || undefined, subjectCode: readString(assessment.subjectCode) || undefined,
firstSavedAt: now, firstSavedAt: now,
lastSeenAt: now, lastSeenAt: now,
raw: { ...raw },
}; };
} }
@@ -114,6 +117,7 @@ export function normalizeArchivedNotification(
subtitle: readString(raw.subtitle), subtitle: readString(raw.subtitle),
firstSavedAt: now, firstSavedAt: now,
lastSeenAt: now, lastSeenAt: now,
raw: { ...raw },
}; };
} }
@@ -134,8 +138,10 @@ export function mergeNotificationsIntoArchive(
merged[key] = { merged[key] = {
...prev, ...prev,
...normalized, ...normalized,
timestamp: normalized.timestamp || prev.timestamp,
firstSavedAt: prev.firstSavedAt, firstSavedAt: prev.firstSavedAt,
lastSeenAt: now, lastSeenAt: now,
raw: { ...prev.raw, ...raw },
}; };
} else { } else {
merged[key] = normalized; merged[key] = normalized;
@@ -150,3 +156,54 @@ export function listArchivedNotifications(archive: ArchiveMap): ArchivedNotifica
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
); );
} }
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,
};
}
@@ -1,165 +0,0 @@
import type { PluginAPI } from "../../core/types";
import type { ArchivedNotification, ArchiveMap } from "./archive";
import { listArchivedNotifications } from "./archive";
import { delay } from "@/seqta/utils/delay";
import ReactFiber from "@/seqta/utils/ReactFiber";
import { waitForElm } from "@/seqta/utils/waitForElm";
interface ArchiveUIStorage {
archivesByUser?: Record<string, ArchiveMap>;
}
function formatArchiveDate(timestamp: string): string {
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return timestamp;
return date.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
async function openArchivedMessage(item: ArchivedNotification) {
if (!item.messageID) return;
location.hash = `#?page=/messages`;
await waitForElm('[class*="Viewer__Viewer___"] > div', true, 40);
ReactFiber.find('[class*="Viewer__Viewer___"] > div').setState({
selected: new Set([item.messageID]),
});
await delay(10);
const row = document.querySelector('[class*="MessageList__selected___"]');
if (row) (row as HTMLElement).click();
}
function openArchivedAssessment(item: ArchivedNotification) {
if (!item.programmeID || !item.metaclassID) return;
const base = `#?page=/assessments/${item.programmeID}:${item.metaclassID}`;
location.hash = item.assessmentID ? `${base}&item=${item.assessmentID}` : base;
}
function renderArchiveItem(item: ArchivedNotification): HTMLElement {
const row = document.createElement("button");
row.type = "button";
row.className = "bsplus-notification-archive-item";
row.dataset.notificationId = String(item.notificationID);
const title = document.createElement("span");
title.className = "bsplus-notification-archive-title";
title.textContent = item.title;
const meta = document.createElement("span");
meta.className = "bsplus-notification-archive-meta";
const parts = [formatArchiveDate(item.timestamp)];
if (item.subtitle) parts.push(item.subtitle);
if (item.subjectCode) parts.push(item.subjectCode);
meta.textContent = parts.join(" · ");
const badge = document.createElement("span");
badge.className = "bsplus-notification-archive-badge";
badge.textContent = "Saved locally";
row.append(title, meta, badge);
row.addEventListener("click", () => {
if (item.type === "message") {
void openArchivedMessage(item);
} else if (item.type === "coneqtassessments") {
openArchivedAssessment(item);
}
});
return row;
}
export function mountArchiveUI(
api: PluginAPI<Record<string, never>, ArchiveUIStorage>,
getUserKey: () => Promise<string | null>,
) {
const mountOnList = (listEl: HTMLElement) => {
if (listEl.querySelector(".bsplus-notification-archive-bar")) return;
const bar = document.createElement("div");
bar.className = "bsplus-notification-archive-bar";
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "bsplus-notification-archive-toggle";
const panel = document.createElement("div");
panel.className = "bsplus-notification-archive-panel";
panel.hidden = true;
const refresh = async () => {
const userKey = await getUserKey();
if (!userKey) {
toggle.textContent = "Saved notifications (sign in required)";
toggle.disabled = true;
panel.replaceChildren();
return;
}
const archivesByUser = api.storage.archivesByUser ?? {};
const archive = archivesByUser[userKey] ?? {};
const items = listArchivedNotifications(archive);
const liveIds = new Set(
Array.from(listEl.querySelectorAll('[class*="notifications__item___"]'))
.map((el) => el.getAttribute("data-id"))
.filter(Boolean),
);
const removedCount = items.filter(
(item) => !liveIds.has(String(item.notificationID)),
).length;
toggle.disabled = false;
toggle.textContent =
items.length === 0
? "Saved notifications (none yet)"
: `Saved notifications (${items.length}${removedCount > 0 ? `, ${removedCount} no longer on SEQTA` : ""})`;
panel.replaceChildren();
if (items.length === 0) {
const empty = document.createElement("p");
empty.className = "bsplus-notification-archive-empty";
empty.textContent =
"Notifications you receive are saved here per account in extension storage.";
panel.append(empty);
return;
}
for (const item of items) {
panel.append(renderArchiveItem(item));
}
};
toggle.addEventListener("click", () => {
panel.hidden = !panel.hidden;
toggle.setAttribute("aria-expanded", panel.hidden ? "false" : "true");
if (!panel.hidden) void refresh();
});
bar.append(toggle, panel);
listEl.append(bar);
void refresh();
};
api.seqta.onMount('[class*="notifications__list___"]', (el) => {
mountOnList(el as HTMLElement);
});
api.storage.onChange("archivesByUser", () => {
document
.querySelectorAll('[class*="notifications__list___"]')
.forEach((el) => {
const bar = el.querySelector(".bsplus-notification-archive-bar");
if (bar) bar.remove();
mountOnList(el as HTMLElement);
});
});
}
@@ -8,15 +8,17 @@ import {
mergeNotificationsIntoArchive, mergeNotificationsIntoArchive,
resolveNotificationUserKey, resolveNotificationUserKey,
} from "./archive"; } from "./archive";
import { mountArchiveUI } from "./archiveUI"; import {
import styles from "./styles.css?inline"; injectArchivedForUser,
mountArchivedNotificationInjection,
} from "./injectArchivedNotifications";
const notificationCollectorSettings = { const notificationCollectorSettings = {
saveLocally: booleanSetting({ saveLocally: booleanSetting({
default: true, default: true,
title: "Save notification history locally", title: "Save notification history locally",
description: description:
"Keeps a per-account copy in extension storage. SEQTA removes notifications after about a year; these stay until you clear extension data.", "Saves notifications per account in extension storage and restores missing ones into the SEQTA list",
}), }),
} as const; } as const;
@@ -35,9 +37,8 @@ const notificationCollectorPlugin: Plugin<
name: "Notification Collector", name: "Notification Collector",
description: description:
"Tracks notifications and saves a local per-account archive that outlasts SEQTA's server retention", "Tracks notifications and saves a local per-account archive that outlasts SEQTA's server retention",
version: "1.1.0", version: "1.2.0",
settings: notificationCollectorSettings, settings: notificationCollectorSettings,
styles,
disableToggle: true, disableToggle: true,
run: async (api) => { run: async (api) => {
@@ -80,6 +81,8 @@ const notificationCollectorPlugin: Plugin<
if (JSON.stringify(existing) !== JSON.stringify(merged)) { if (JSON.stringify(existing) !== JSON.stringify(merged)) {
archivesByUser[userKey] = merged; archivesByUser[userKey] = merged;
api.storage.archivesByUser = archivesByUser; api.storage.archivesByUser = archivesByUser;
} else if (document.querySelector('[class*="notifications__list___"]')) {
await injectArchivedForUser(merged);
} }
} catch (error) { } catch (error) {
console.warn("[BetterSEQTA+] Notification archive sync failed:", error); console.warn("[BetterSEQTA+] Notification archive sync failed:", error);
@@ -182,7 +185,10 @@ const notificationCollectorPlugin: Plugin<
} }
}); });
mountArchiveUI(api, resolveNotificationUserKey); const teardownInjection = mountArchivedNotificationInjection(
api,
resolveNotificationUserKey,
);
api.seqta.onMount("[class*='notifications__bubble___']", () => { api.seqta.onMount("[class*='notifications__bubble___']", () => {
startPolling(); startPolling();
@@ -199,6 +205,7 @@ const notificationCollectorPlugin: Plugin<
return () => { return () => {
stopPolling(); stopPolling();
teardownInjection();
document.removeEventListener("visibilitychange", handleVisibilityChange); document.removeEventListener("visibilitychange", handleVisibilityChange);
pageChangeUnregister.unregister(); pageChangeUnregister.unregister();
}; };
@@ -0,0 +1,118 @@
import type { PluginAPI } from "../../core/types";
import ReactFiber from "@/seqta/utils/ReactFiber";
import { delay } from "@/seqta/utils/delay";
import {
archivedToApiNotification,
listArchivedNotifications,
type ArchiveMap,
type ArchivesByUser,
} from "./archive";
const LIST_SELECTOR = '[class*="notifications__list___"]';
const ITEMS_SELECTOR = '[class*="notifications__items___"]';
function notificationTimestamp(item: Record<string, unknown>): number {
const ms = new Date(String(item.timestamp ?? 0)).getTime();
return Number.isNaN(ms) ? 0 : ms;
}
function mergeLiveWithArchived(
liveItems: Record<string, unknown>[],
archive: ArchiveMap,
): Record<string, unknown>[] | null {
const liveIds = new Set(liveItems.map((item) => Number(item.notificationID)));
const missing = listArchivedNotifications(archive)
.filter((item) => !liveIds.has(item.notificationID))
.map((item) => archivedToApiNotification(item));
if (missing.length === 0) return null;
return [...liveItems, ...missing].sort(
(a, b) => notificationTimestamp(b) - notificationTimestamp(a),
);
}
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;
const state = await ReactFiber.find(LIST_SELECTOR).getState();
if (!state || !Array.isArray(state.items)) return false;
const liveItems = state.items as Record<string, unknown>[];
const merged = mergeLiveWithArchived(liveItems, archive);
if (!merged) return true;
if (sameItemOrder(liveItems, merged)) 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) return;
await delay(120);
}
}
export function mountArchivedNotificationInjection(
api: PluginAPI<Record<string, never>, { archivesByUser?: ArchivesByUser }>,
getUserKey: () => Promise<string | null>,
) {
let observer: MutationObserver | null = null;
let injectScheduled = false;
const scheduleInject = () => {
if (injectScheduled) return;
injectScheduled = true;
window.setTimeout(async () => {
injectScheduled = false;
const userKey = await getUserKey();
if (!userKey) return;
const archive = api.storage.archivesByUser?.[userKey] ?? {};
if (Object.keys(archive).length === 0) return;
await injectWithRetries(archive);
}, 60);
};
const watchItemsContainer = () => {
const itemsEl = document.querySelector(ITEMS_SELECTOR);
if (!itemsEl) return;
if (observer) observer.disconnect();
observer = new MutationObserver(() => scheduleInject());
observer.observe(itemsEl, { childList: true });
};
api.seqta.onMount(LIST_SELECTOR, () => {
scheduleInject();
watchItemsContainer();
});
api.seqta.onMount(ITEMS_SELECTOR, () => {
watchItemsContainer();
scheduleInject();
});
api.storage.onChange("archivesByUser", () => scheduleInject());
return () => {
if (observer) observer.disconnect();
};
}
export async function injectArchivedForUser(
archive: ArchiveMap,
): Promise<void> {
await injectWithRetries(archive);
}
@@ -1,87 +0,0 @@
.bsplus-notification-archive-bar {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent);
}
.bsplus-notification-archive-toggle {
display: block;
width: 100%;
border: 1px solid color-mix(in srgb, var(--text-primary) 14%, transparent);
border-radius: 12px;
background: var(--theme-primary, var(--background-primary, #232323));
color: var(--text-primary);
padding: 0.55rem 0.75rem;
font-size: 0.8125rem;
font-weight: 600;
text-align: left;
cursor: pointer;
transition: background-color 150ms ease;
}
.bsplus-notification-archive-toggle:hover:not(:disabled) {
background: var(--theme-secondary, var(--background-secondary, #1a1a1a));
}
.bsplus-notification-archive-toggle:disabled {
opacity: 0.65;
cursor: default;
}
.bsplus-notification-archive-panel {
margin-top: 0.45rem;
max-height: 14rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.bsplus-notification-archive-panel[hidden] {
display: none !important;
}
.bsplus-notification-archive-item {
display: flex;
flex-direction: column;
gap: 0.2rem;
width: 100%;
border: none;
border-left: 3px solid var(--better-main, #3b82f6);
border-radius: 10px;
background: var(--theme-primary, var(--background-primary, #232323));
color: var(--text-primary);
padding: 0.5rem 0.65rem;
text-align: left;
cursor: pointer;
transition: background-color 150ms ease;
}
.bsplus-notification-archive-item:hover {
background: var(--theme-secondary, var(--background-secondary, #1a1a1a));
}
.bsplus-notification-archive-title {
font-size: 0.8125rem;
font-weight: 600;
line-height: 1.3;
}
.bsplus-notification-archive-meta {
font-size: 0.75rem;
opacity: 0.78;
line-height: 1.3;
}
.bsplus-notification-archive-badge {
font-size: 0.6875rem;
font-weight: 600;
opacity: 0.7;
}
.bsplus-notification-archive-empty {
font-size: 0.75rem;
opacity: 0.8;
margin: 0.25rem 0 0;
line-height: 1.4;
}