mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-08-28 17:21:05 +00:00
feat(notifications): local per-user archive
This commit is contained in:
@@ -13,6 +13,7 @@ export interface ArchivedNotification {
|
||||
subjectCode?: string;
|
||||
firstSavedAt: string;
|
||||
lastSeenAt: string;
|
||||
raw: RawNotification;
|
||||
}
|
||||
|
||||
export type ArchiveMap = Record<string, ArchivedNotification>;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
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();
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user