diff --git a/src/plugins/built-in/notificationCollector/archive.ts b/src/plugins/built-in/notificationCollector/archive.ts index 34ad8f0b..1479f9e1 100644 --- a/src/plugins/built-in/notificationCollector/archive.ts +++ b/src/plugins/built-in/notificationCollector/archive.ts @@ -13,6 +13,7 @@ export interface ArchivedNotification { subjectCode?: string; firstSavedAt: string; lastSeenAt: string; + raw: RawNotification; } export type ArchiveMap = Record; @@ -82,6 +83,7 @@ export function normalizeArchivedNotification( messageID: Number(message.messageID) || undefined, firstSavedAt: now, lastSeenAt: now, + raw: { ...raw }, }; } @@ -103,6 +105,7 @@ export function normalizeArchivedNotification( subjectCode: readString(assessment.subjectCode) || undefined, firstSavedAt: now, lastSeenAt: now, + raw: { ...raw }, }; } @@ -114,6 +117,7 @@ export function normalizeArchivedNotification( subtitle: readString(raw.subtitle), firstSavedAt: now, lastSeenAt: now, + raw: { ...raw }, }; } @@ -134,8 +138,10 @@ export function mergeNotificationsIntoArchive( merged[key] = { ...prev, ...normalized, + timestamp: normalized.timestamp || prev.timestamp, firstSavedAt: prev.firstSavedAt, lastSeenAt: now, + raw: { ...prev.raw, ...raw }, }; } else { 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(), ); } + +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, + }; +} diff --git a/src/plugins/built-in/notificationCollector/archiveUI.ts b/src/plugins/built-in/notificationCollector/archiveUI.ts deleted file mode 100644 index 49f415f7..00000000 --- a/src/plugins/built-in/notificationCollector/archiveUI.ts +++ /dev/null @@ -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; -} - -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, ArchiveUIStorage>, - getUserKey: () => Promise, -) { - 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); - }); - }); -} diff --git a/src/plugins/built-in/notificationCollector/index.ts b/src/plugins/built-in/notificationCollector/index.ts index afc9f7c1..8af0127c 100644 --- a/src/plugins/built-in/notificationCollector/index.ts +++ b/src/plugins/built-in/notificationCollector/index.ts @@ -8,15 +8,17 @@ import { mergeNotificationsIntoArchive, resolveNotificationUserKey, } from "./archive"; -import { mountArchiveUI } from "./archiveUI"; -import styles from "./styles.css?inline"; +import { + injectArchivedForUser, + mountArchivedNotificationInjection, +} from "./injectArchivedNotifications"; const notificationCollectorSettings = { saveLocally: booleanSetting({ default: true, title: "Save notification history locally", 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; @@ -35,9 +37,8 @@ const notificationCollectorPlugin: Plugin< name: "Notification Collector", description: "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, - styles, disableToggle: true, run: async (api) => { @@ -80,6 +81,8 @@ const notificationCollectorPlugin: Plugin< if (JSON.stringify(existing) !== JSON.stringify(merged)) { archivesByUser[userKey] = merged; api.storage.archivesByUser = archivesByUser; + } else if (document.querySelector('[class*="notifications__list___"]')) { + await injectArchivedForUser(merged); } } catch (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___']", () => { startPolling(); @@ -199,6 +205,7 @@ const notificationCollectorPlugin: Plugin< return () => { stopPolling(); + teardownInjection(); document.removeEventListener("visibilitychange", handleVisibilityChange); pageChangeUnregister.unregister(); }; diff --git a/src/plugins/built-in/notificationCollector/injectArchivedNotifications.ts b/src/plugins/built-in/notificationCollector/injectArchivedNotifications.ts new file mode 100644 index 00000000..c83b4507 --- /dev/null +++ b/src/plugins/built-in/notificationCollector/injectArchivedNotifications.ts @@ -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): number { + const ms = new Date(String(item.timestamp ?? 0)).getTime(); + return Number.isNaN(ms) ? 0 : ms; +} + +function mergeLiveWithArchived( + liveItems: Record[], + archive: ArchiveMap, +): Record[] | 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[], + merged: Record[], +): 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 { + 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[]; + 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, { archivesByUser?: ArchivesByUser }>, + getUserKey: () => Promise, +) { + 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 { + await injectWithRetries(archive); +} diff --git a/src/plugins/built-in/notificationCollector/styles.css b/src/plugins/built-in/notificationCollector/styles.css deleted file mode 100644 index 61ddec19..00000000 --- a/src/plugins/built-in/notificationCollector/styles.css +++ /dev/null @@ -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; -}