diff --git a/package.json b/package.json index 709d8cc9..27816cb9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "betterseqtaplus", - "version": "3.6.0", + "version": "3.6.2", "type": "module", "description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development and add heaps more features!", "browserslist": "> 0.5%, last 2 versions, not dead", diff --git a/src/background.ts b/src/background.ts index acdeddae..d0926ae7 100644 --- a/src/background.ts +++ b/src/background.ts @@ -133,6 +133,74 @@ function handleCloudLogin(request: any, sendResponse: MessageSender): boolean { return true; } +function handleCloudStartLogin(request: any, sendResponse: MessageSender): boolean { + const { client_id, redirect_uri } = request; + if (!client_id || !redirect_uri) { + sendResponse({ error: "Missing client_id or redirect_uri" }); + return true; + } + const authorizeUrl = `https://accounts.betterseqta.org/login?redirect=${encodeURIComponent(`/oauth/authorize?client_id=${client_id}&redirect_uri=${encodeURIComponent(redirect_uri)}`)}`; + browser.tabs.create({ url: authorizeUrl }).then(() => { + sendResponse({ success: true }); + }).catch((err) => { + console.error("[Background] cloudStartLogin error:", err); + sendResponse({ error: err?.message ?? "Failed to open login page" }); + }); + return true; +} + +const CALLBACK_URL_PREFIX = "https://accounts.betterseqta.org/auth/bsplus/callback"; + +function initCloudLoginCallbackListener() { + browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (changeInfo.url && changeInfo.url.startsWith(CALLBACK_URL_PREFIX)) { + try { + const url = new URL(changeInfo.url); + const token = url.searchParams.get("token"); + const refreshToken = url.searchParams.get("refresh_token"); + const userId = url.searchParams.get("user_id"); + + if (token && refreshToken) { + // Store tokens + void (async () => { + try { + await browser.storage.local.set({ + bsplus_token: token, + bsplus_refresh_token: refreshToken, + }); + + // Fetch full user info + const userRes = await fetch("https://accounts.betterseqta.org/api/auth/me", { + headers: { Authorization: `Bearer ${token}` }, + }); + if (userRes.ok) { + const user = await userRes.json(); + await browser.storage.local.set({ bsplus_user: user }); + } else if (userId) { + await browser.storage.local.set({ bsplus_user: { id: userId } }); + } + + // Trigger cloud settings download + void performCloudSettingsDownloadWithRetry(token).catch((err) => { + console.warn("[Background] Cloud settings download after login:", err); + }); + } catch (err) { + console.error("[Background] Failed to process login callback:", err); + } + })(); + + // Close the callback tab + void browser.tabs.remove(tabId); + } + } catch (err) { + console.error("[Background] Error parsing callback URL:", err); + } + } + }); +} + +initCloudLoginCallbackListener(); + function handleCloudRefresh(request: any, sendResponse: MessageSender): boolean { const { refresh_token, client_id } = request; if (!refresh_token || !client_id) { @@ -269,6 +337,7 @@ const MESSAGE_HANDLERS: Record = { fetchFromUrl: handleFetchFromUrl, cloudReserveClient: handleCloudReserveClient, cloudLogin: handleCloudLogin, + cloudStartLogin: handleCloudStartLogin, cloudRefresh: handleCloudRefresh, cloudFavorite: handleCloudFavorite, cloudSettingsUpload: handleCloudSettingsUpload, diff --git a/src/background/cloudSettingsAutoSync.ts b/src/background/cloudSettingsAutoSync.ts index 8aa23112..a75de192 100644 --- a/src/background/cloudSettingsAutoSync.ts +++ b/src/background/cloudSettingsAutoSync.ts @@ -13,9 +13,9 @@ export const CLOUD_SUMMARY_URL = `${ACCOUNTS_BASE}/api/user/cloud-summary`; const CLOUD_SETTINGS_SYNC_URL = `${ACCOUNTS_BASE}/api/bsplus/settings/sync`; const REFRESH_URL = `${ACCOUNTS_BASE}/api/bsplus/refresh`; -const ALARM_NAME = "bsplus_cloud_settings_auto_sync"; -const PERIOD_MINUTES = 60; const UPLOAD_DEBOUNCE_MS = 2000; +const POLL_THROTTLE_MS = 24 * 60 * 60 * 1000; +const POLL_THROTTLE_KEY = "bsplus_lastCloudPoll"; type CloudSummaryResponse = { desqta?: unknown; @@ -323,6 +323,9 @@ export function runCloudSettingsPoll(): Promise { if (pollInFlight) return pollInFlight; pollInFlight = (async () => { try { + const { [POLL_THROTTLE_KEY]: last } = await browser.storage.local.get(POLL_THROTTLE_KEY); + if (Date.now() - (Number(last) || 0) < POLL_THROTTLE_MS) return; + await browser.storage.local.set({ [POLL_THROTTLE_KEY]: Date.now() }); await runCloudSettingsPollInner(); } catch (e) { console.error("[BS+ cloud sync] Poll error:", e); @@ -360,14 +363,11 @@ async function runDebouncedUploadJob(): Promise { } } -async function syncAlarmWithStorage(): Promise { +async function syncAutoUploadWithStorage(): Promise { const all = (await browser.storage.local.get()) as Record; if (!isAutoCloudSyncEnabled(all)) { - await browser.alarms.clear(ALARM_NAME); clearUploadDebounce(); - return; } - await browser.alarms.create(ALARM_NAME, { periodInMinutes: PERIOD_MINUTES }); } function onStorageChanged( @@ -377,7 +377,7 @@ function onStorageChanged( if (area !== "local") return; if (Object.prototype.hasOwnProperty.call(changes, "autoCloudSettingsSync")) { - void syncAlarmWithStorage(); + void syncAutoUploadWithStorage(); } const keys = Object.keys(changes); @@ -392,15 +392,8 @@ function onStorageChanged( })(); } -function onAlarm(alarm: browser.Alarms.Alarm): void { - if (alarm.name !== ALARM_NAME) return; - void runCloudSettingsPoll(); -} - export function initCloudSettingsAutoSync(deps: { reloadSeqtaPages: () => void }): void { reloadSeqtaPagesFn = deps.reloadSeqtaPages; - browser.alarms.onAlarm.addListener(onAlarm); browser.storage.onChanged.addListener(onStorageChanged); - void syncAlarmWithStorage(); } diff --git a/src/css/injected.scss b/src/css/injected.scss index 7130e166..261552c2 100644 --- a/src/css/injected.scss +++ b/src/css/injected.scss @@ -1653,6 +1653,13 @@ html.transparencyEffects box-shadow: 0px 10px 15px -3px rgba(0, 0, 0, 0.4); } +/* Smoothed by attachNotificationsPanelAnimation (matches #ExtensionPopup spring) */ +.bsplus-notifications-panel { + transform-origin: top right; + will-change: opacity, transform; + filter: drop-shadow(0px 0px 20px rgba(0, 0, 0, 0.35)); +} + #menu li.active { color: #ffffff !important; background: rgba(0, 0, 0, 0.35); @@ -3524,6 +3531,26 @@ div.day-empty { font-size: 1em; color: var(--text-primary); } +.whatsnewHeader.engageParentsAnnouncementHeader { + height: auto; + min-height: unset; +} +.whatsnewHeader.engageParentsAnnouncementHeader h1 { + line-height: 1.2; +} +.whatsnewHeader.engageParentsAnnouncementHeader .engageParentsSubheading { + margin-top: 0.35rem; + font-size: 1.05rem; + font-weight: 600; + opacity: 0.92; +} +.seqtaEngageAccent { + color: #ea580c; + font-weight: 700; +} +.dark .seqtaEngageAccent { + color: #fb923c; +} .whatsnewBackground { width: 100%; height: 100%; @@ -3652,6 +3679,138 @@ div.day-empty { object-fit: cover; margin-bottom: 12px; } +.whatsnewTextContainer .engageParentsPromoWrap { + width: 100%; + margin-bottom: 12px; + border-radius: 16px; + overflow: hidden; + aspect-ratio: 16 / 9; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.28); + background: color-mix(in srgb, var(--background-secondary) 88%, var(--text-primary) 12%); +} +.whatsnewTextContainer .engageParentsPromoWrap .engageParentsPromoImg { + display: block; + width: 100%; + height: 100%; + margin: 0; + border-radius: 0; + aspect-ratio: unset; + object-fit: contain; + object-position: center; +} + +.whatsnewHeader.bsCloudAutoSyncAnnouncementHeader { + height: auto; + min-height: unset; +} +.whatsnewHeader.bsCloudAutoSyncAnnouncementHeader h1 { + line-height: 1.2; +} +.bsCloudAccent { + color: #059669; + font-weight: 700; +} +.dark .bsCloudAccent { + color: #34d399; +} +.whatsnewTextContainer .bsCloudAutoSyncSignupCallout { + margin: 1.5rem 0 0; + padding: 1.25rem 1rem 0; + border-top: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent); + font-size: clamp(1.35rem, 3.8vw, 1.85rem); + font-weight: 800; + line-height: 1.35; + letter-spacing: -0.02em; + text-align: center; + color: var(--text-primary); +} + +.popup-media-fullscreenable { + cursor: pointer; + transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out; +} +.popup-media-fullscreenable:hover { + opacity: 0.95; +} +.popup-media-fullscreenable:focus { + outline: none; +} +.popup-media-fullscreenable:focus-visible { + outline: 2px solid color-mix(in srgb, var(--text-primary) 70%, transparent); + outline-offset: 4px; +} + +.bsplus-popup-media-overlay-backdrop { + position: fixed; + inset: 0; + z-index: 2147483646; + display: flex; + align-items: center; + justify-content: center; + padding: clamp(20px, 4vw, 48px); + box-sizing: border-box; + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(6px); + opacity: 0; + transition: opacity 0.28s cubic-bezier(0.22, 0.03, 0.26, 1); +} + +.bsplus-popup-media-overlay-backdrop.bsplus-popup-media-overlay-backdrop--visible { + opacity: 1; +} + +.bsplus-popup-media-overlay-backdrop.bsplus-popup-media-overlay--instant { + transition: none; +} + +.bsplus-popup-media-overlay-inner { + position: relative; + display: flex; + flex-direction: column; + align-items: stretch; + width: 100%; + max-width: min(96vw, 1320px); + max-height: calc(100vh - clamp(40px, 10vw, 96px)); + border-radius: 20px; + overflow: hidden; + background: var(--background-primary); + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35); + opacity: 0; + transform: scale(0.94) translateY(12px); + transition: + opacity 0.28s cubic-bezier(0.22, 0.03, 0.26, 1), + transform 0.28s cubic-bezier(0.22, 0.03, 0.26, 1); +} + +.bsplus-popup-media-overlay-backdrop.bsplus-popup-media-overlay-backdrop--visible + .bsplus-popup-media-overlay-inner { + opacity: 1; + transform: scale(1) translateY(0); +} + +.bsplus-popup-media-overlay-backdrop.bsplus-popup-media-overlay--instant + .bsplus-popup-media-overlay-inner { + transition: none; +} + +.bsplus-popup-media-overlay-slot { + width: 100%; + max-height: inherit; + display: flex; + align-items: center; + justify-content: center; + padding: clamp(16px, 3vw, 28px); + box-sizing: border-box; +} + +.bsplus-popup-media-overlay-media { + max-width: 100%; + max-height: calc(100vh - clamp(120px, 22vh, 200px)); + width: auto; + height: auto; + object-fit: contain; + border-radius: 12px; +} @keyframes shimmer { 0% { @@ -4212,3 +4371,41 @@ h2.home-subtitle { font-size: 20px; font-weight: 400; } + +.bsplus-toast { + position: fixed; + bottom: 24px; + right: 24px; + z-index: 10000; + display: flex; + align-items: flex-start; + gap: 12px; + max-width: 380px; + padding: 16px 18px; + border-radius: 12px; + background: var(--background-secondary, #fff); + color: var(--text-primary, #1a1a1a); + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18); + font-size: 0.9rem; + line-height: 1.5; +} +.bsplus-toast-content p { + margin: 6px 0 0; + opacity: 0.8; + font-size: 0.85rem; +} +.bsplus-toast-close { + flex-shrink: 0; + background: none; + border: none; + color: var(--text-primary, #1a1a1a); + font-size: 1.3rem; + cursor: pointer; + padding: 0 2px; + line-height: 1; + opacity: 0.5; + transition: opacity 0.15s; +} +.bsplus-toast-close:hover { + opacity: 1; +} diff --git a/src/interface/components/CloudPanel.svelte b/src/interface/components/CloudPanel.svelte new file mode 100644 index 00000000..ee8faea2 --- /dev/null +++ b/src/interface/components/CloudPanel.svelte @@ -0,0 +1,157 @@ + + + +
{ if (e.key === "Enter") handleBackgroundClick; }} +> +
+

BetterSEQTA Cloud

+

Account & sync

+ +
+ {#if cloudState.isLoggedIn} +
+
+ {#if cloudState.user?.pfpUrl} + + {:else} +
+ {getInitials()} +
+ {/if} +
+

+ {cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"} +

+ {#if cloudState.user?.email && cloudState.user?.email !== (cloudState.user?.displayName || cloudState.user?.username)} +

{cloudState.user.email}

+ {/if} +
+
+ +
+ {:else} +
+

+ Sign in to sync settings across devices, use your cloud profile picture, and more. +

+ + {#if loginError} +

{loginError}

+ {/if} +

+ Opens accounts.betterseqta.org in a new tab +

+
+ {/if} +
+
+
diff --git a/src/interface/components/CloudSettingsSync.svelte b/src/interface/components/CloudSettingsSync.svelte index b50fe6b4..9acb24bf 100644 --- a/src/interface/components/CloudSettingsSync.svelte +++ b/src/interface/components/CloudSettingsSync.svelte @@ -2,17 +2,17 @@ import browser from "webextension-polyfill"; import { cloudAuth } from "@/seqta/utils/CloudAuth"; import { settingsState } from "@/seqta/utils/listeners/SettingsState"; - import DisclaimerModal from "./DisclaimerModal.svelte"; import Button from "./Button.svelte"; import Switch from "./Switch.svelte"; + let { showDisclaimer } = $props<{ + showDisclaimer: (onConfirm: () => void, onCancel: () => void) => void; + }>(); + let cloudState = $state(cloudAuth.state); let busy = $state(false); let statusMessage = $state(null); let statusError = $state(null); - let lastUploadAt = $state(null); - let lastDownloadAt = $state(null); - let showRestoreConfirm = $state(false); $effect(() => { const unsub = cloudAuth.subscribe((s) => { @@ -21,13 +21,6 @@ return unsub; }); - function formatNow(): string { - return new Date().toLocaleString(undefined, { - dateStyle: "short", - timeStyle: "short", - }); - } - async function upload() { const token = await cloudAuth.getStoredToken(); if (!token) return; @@ -40,8 +33,7 @@ token, })) as { success?: boolean; error?: string }; if (res?.success) { - statusMessage = "Settings saved to the cloud."; - lastUploadAt = formatNow(); + statusMessage = "Settings uploaded."; } else { statusError = res?.error ?? "Upload failed"; } @@ -53,11 +45,10 @@ } function promptDownload() { - showRestoreConfirm = true; + showDisclaimer(confirmDownload, () => {}); } async function confirmDownload() { - showRestoreConfirm = false; const token = await cloudAuth.getStoredToken(); if (!token) return; busy = true; @@ -69,8 +60,7 @@ token, })) as { success?: boolean; error?: string; notFound?: boolean }; if (res?.success) { - statusMessage = "Settings restored from the cloud. SEQTA tabs were reloaded."; - lastDownloadAt = formatNow(); + statusMessage = "Settings restored."; } else { statusError = res?.error ?? "Download failed"; } @@ -82,22 +72,13 @@ } -
-

Cloud settings backup

-

- Upload copies this browser’s BetterSEQTA+ settings to your account. Download replaces local settings with the - cloud copy (your sign-in stays on this device). -

- -
-
-

- Automatic sync -

+{#if cloudState.isLoggedIn} +
+
+
+

Automatic sync

+

Syncs settings when SEQTA loads and when you make changes

+
-

- When signed in, each time SEQTA loads and also hourly, if the cloud backup is newer it will replace local - settings. Settings you change will upload shortly after you adjust them. -

-

- Passwords, tokens, and other sensitive data are not included in the backup. + +

+
+ + {#if statusMessage} +

{statusMessage}

+ {/if} + {#if statusError} +

{statusError}

+ {/if} + +

+ Passwords and tokens are never synced. - Privacy policy - + class="font-medium text-emerald-600 underline decoration-emerald-600/50 underline-offset-2 hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300 rounded-sm" + >Privacy policy

- -
-
- - {#if !cloudState.isLoggedIn} -

- Sign in from the BetterSEQTA Cloud header above to sync settings. -

- {/if} - - {#if statusMessage} -

{statusMessage}

- {/if} - {#if statusError} -

{statusError}

- {/if} - {#if lastUploadAt || lastDownloadAt} -

- {#if lastUploadAt}Last upload: {lastUploadAt}{/if} - {#if lastUploadAt && lastDownloadAt}·{/if} - {#if lastDownloadAt}Last download: {lastDownloadAt}{/if} -

- {/if} -
- -{#if showRestoreConfirm} - (showRestoreConfirm = false)} - /> {/if} diff --git a/src/interface/components/SignInToFavoriteModal.svelte b/src/interface/components/SignInToFavoriteModal.svelte index 2f5f178f..133b3f98 100644 --- a/src/interface/components/SignInToFavoriteModal.svelte +++ b/src/interface/components/SignInToFavoriteModal.svelte @@ -3,7 +3,6 @@ import { animate } from "motion"; import { onMount } from "svelte"; import { cloudAuth } from "@/seqta/utils/CloudAuth"; - import CloudLoginForm from "@/interface/components/store/CloudLoginForm.svelte"; let { onClose } = $props<{ onClose: () => void }>(); let modalElement: HTMLElement; @@ -23,6 +22,10 @@ ); } }); + + async function handleSignIn() { + await cloudAuth.startLogin(); + }
- + +

+ Opens accounts.betterseqta.org in a new tab +

- {#if open} + {#if !onClick && open}
{:else} - { - open = false; - }} - /> +
+

+ Sign in to sync favorites across devices. +

+ +

+ Opens accounts.betterseqta.org in a new tab +

+
{/if}
diff --git a/src/interface/pages/settings.svelte b/src/interface/pages/settings.svelte index 777cedbc..bcff0e80 100644 --- a/src/interface/pages/settings.svelte +++ b/src/interface/pages/settings.svelte @@ -15,14 +15,16 @@ //import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup"; import ColourPicker from "../components/ColourPicker.svelte"; + import CloudPanel from "../components/CloudPanel.svelte"; import DisclaimerModal from "../components/DisclaimerModal.svelte"; - import CloudHeader from "@/interface/components/store/CloudHeader.svelte"; import { settingsPopup } from "../hooks/SettingsPopup"; let devModeSequence = ""; let settingsActiveTab = $state(0); let showDisclaimerModal = $state(false); let disclaimerCallbacks = $state<{ onConfirm: () => void, onCancel: () => void } | null>(null); + let disclaimerTitle = $state("Confirm"); + let disclaimerMessage = $state(""); const handleDevModeToggle = () => { const handleKeyDown = (event: KeyboardEvent) => { @@ -67,15 +69,23 @@ let { standalone } = $props<{ standalone?: boolean }>(); let showColourPicker = $state(false); + let showCloudPanel = $state(false); - const showDisclaimer = (onConfirm: () => void, onCancel: () => void) => { + const openCloudPanel = () => { + showCloudPanel = true; + }; + + const showDisclaimer = (onConfirm: () => void, onCancel: () => void, title?: string, message?: string) => { disclaimerCallbacks = { onConfirm, onCancel }; + disclaimerTitle = title ?? "Confirm"; + disclaimerMessage = message ?? ""; showDisclaimerModal = true; }; onMount(() => { settingsPopup.addListener(() => { showColourPicker = false; + showCloudPanel = false; }); if (standalone) { @@ -277,25 +287,13 @@ {/if}
-
-
-

BetterSEQTA Cloud

-

Account & sync

-
-
- -
-
- {/if} + + {#if showCloudPanel} + { + showCloudPanel = false; + }} + /> + {/if}
{#if showDisclaimerModal && disclaimerCallbacks} { disclaimerCallbacks?.onConfirm(); showDisclaimerModal = false; diff --git a/src/interface/pages/settings/general.svelte b/src/interface/pages/settings/general.svelte index 5e7236fd..2928fffa 100644 --- a/src/interface/pages/settings/general.svelte +++ b/src/interface/pages/settings/general.svelte @@ -12,6 +12,8 @@ import PickerSwatch from "@/interface/components/PickerSwatch.svelte" import ConnectMobileApp from "@/interface/components/ConnectMobileApp.svelte" import CloudSettingsSync from "@/interface/components/CloudSettingsSync.svelte" + import CloudHeader from "@/interface/components/store/CloudHeader.svelte" + import { cloudAuth } from "@/seqta/utils/CloudAuth" import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification" import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup" import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync" @@ -53,6 +55,12 @@ const pluginSettings = getAllPluginSettings() as Plugin[]; const pluginSettingsValues = $state>>({}); + + let cloudState = $state(cloudAuth.state); + $effect(() => { + const unsub = cloudAuth.subscribe((s) => { cloudState = s; }); + return unsub; + }); async function loadPluginSettings() { for (const plugin of pluginSettings) { @@ -95,9 +103,10 @@ loadPluginSettings(); }) - const { showColourPicker, showDisclaimer } = $props<{ + const { showColourPicker, showDisclaimer, showCloudPanel } = $props<{ showColourPicker: () => void; - showDisclaimer: (onConfirm: () => void, onCancel: () => void) => void; + showDisclaimer: (onConfirm: () => void, onCancel: () => void, title?: string, message?: string) => void; + showCloudPanel: () => void; }>(); async function exportCloudSettingsJsonToFile() { @@ -196,12 +205,11 @@ }, { title: "Default Page", - description: - "The page to load when SEQTA Learn or SEQTA Engage opens (uses the same #?page=/… URL as SEQTA). BetterSEQTA home on Engage only applies when Home is selected.", + description: "Choose which page loads first when you open SEQTA", id: 10, Component: Select, props: { - state: $settingsState.defaultPage, + state: $settingsState.defaultPage ?? "home", onChange: (value: string) => (settingsState.defaultPage = value), options: [ { value: "home", label: "Home" }, @@ -310,8 +318,9 @@ async () => { await updatePluginSetting(plugin.pluginId, 'enabled', true); }, - () => { - } + () => {}, + "Assessment Averages Disclaimer", + "This feature calculates a simple average of your assessment grades. It does not take into account:\n• Assessment weightings\n• Different grading scales\n• Other factors used in official reports\n\nThe displayed average may be inaccurate compared to your actual marks found in reports.\n\nDo you want to enable this feature?" ); return; } @@ -324,8 +333,8 @@ {#if !((plugin as any).disableToggle) || (pluginSettingsValues[plugin.pluginId]?.enabled ?? true)} {#each Object.entries(plugin.settings) as [key, setting]} - - {#if key !== 'enabled'} + + {#if key !== 'enabled' && !(key === 'useCloudPfp' && !cloudState.isLoggedIn)}

{setting.title || key}

@@ -388,6 +397,25 @@
{/each} +
+
+
+
+

BetterSEQTA Cloud

+

Account & sync

+
+
+ +
+
+ {#if cloudState.isLoggedIn} +
+ showDisclaimer(onConfirm, onCancel, "Restore from cloud?", "This will replace your local settings with the cloud backup. Continue?")} /> +
+ {/if} +
+
+
{@render Setting({ @@ -401,10 +429,6 @@ } })} -
- -
- {#if $settingsState.devMode}
diff --git a/src/manifests/manifest.json b/src/manifests/manifest.json index f768851e..b4bfe4e4 100644 --- a/src/manifests/manifest.json +++ b/src/manifests/manifest.json @@ -15,7 +15,7 @@ "64": "resources/icons/icon-64.png" } }, - "permissions": ["tabs", "notifications", "storage", "alarms"], + "permissions": ["tabs", "notifications", "storage"], "host_permissions": ["https://newsapi.org/", "https://betterseqta.org/", "https://accounts.betterseqta.org/", "*://*/*"], "background": { "service_worker": "background.ts" diff --git a/src/plugins/built-in/notificationCollector/index.ts b/src/plugins/built-in/notificationCollector/index.ts index dc4576c1..779183cb 100644 --- a/src/plugins/built-in/notificationCollector/index.ts +++ b/src/plugins/built-in/notificationCollector/index.ts @@ -1,4 +1,5 @@ import type { Plugin } from "../../core/types"; +import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage"; interface NotificationCollectorStorage { lastNotificationCount: number; @@ -15,6 +16,10 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = { disableToggle: true, run: async (api) => { + if (isSeqtaEngageExperience()) { + return () => {}; + } + let pollInterval: number | null = null; let isVisible = !document.hidden; let baseInterval = 30000; // 30 seconds diff --git a/src/plugins/built-in/profilePicture/index.ts b/src/plugins/built-in/profilePicture/index.ts index eee17cb9..9cf3e7d5 100644 --- a/src/plugins/built-in/profilePicture/index.ts +++ b/src/plugins/built-in/profilePicture/index.ts @@ -14,8 +14,7 @@ const settings = defineSettings({ useCloudPfp: booleanSetting({ default: false, title: "Use BetterSEQTA Cloud profile picture", - description: - "When enabled, uses the avatar from your BetterSEQTA Cloud account (sign in from the extension store). Otherwise uses the uploaded image below.", + description: "Use your cloud account avatar instead of the uploaded image below", }), picture: componentSetting({ title: "Profile Picture", diff --git a/src/plugins/built-in/themes/theme-manager.ts b/src/plugins/built-in/themes/theme-manager.ts index 2260e8df..b3bc490a 100644 --- a/src/plugins/built-in/themes/theme-manager.ts +++ b/src/plugins/built-in/themes/theme-manager.ts @@ -680,9 +680,17 @@ export class ThemeManager { * Compare installed store themes to GET /api/themes and refresh when the server is newer. * Skips themes with userEdited: true (theme creator / popup save, or custom accent vs default). */ + private static STORE_CHECK_KEY = "bsplus_lastStoreThemeCheck"; + private static STORE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours + public async checkStoreThemeUpdates(): Promise { if (this.storeUpdateCheckRunning) return; + + const lastCheck = Number(localStorage.getItem(ThemeManager.STORE_CHECK_KEY) || 0); + if (Date.now() - lastCheck < ThemeManager.STORE_CHECK_INTERVAL_MS) return; + this.storeUpdateCheckRunning = true; + localStorage.setItem(ThemeManager.STORE_CHECK_KEY, String(Date.now())); try { const token = await cloudAuth.getStoredToken(); const res = (await browser.runtime.sendMessage({ diff --git a/src/plugins/built-in/timetable/index.ts b/src/plugins/built-in/timetable/index.ts index 9a60d85a..99c9bd0a 100644 --- a/src/plugins/built-in/timetable/index.ts +++ b/src/plugins/built-in/timetable/index.ts @@ -63,7 +63,12 @@ function resetTimetableStyles(): void { } async function handleTimetable(): Promise { - await waitForElm(".time", true, 10); + // SEQTA uses `.times` blocks on entries, not necessarily `.time`; avoid infinite polling on a missing selector. + try { + await waitForElm(".timetablepage .times, .timetablepage .entry.class", true, 50, 200); + } catch { + /* timetable body may render after the shell */ + } // Convert time format if needed if (settingsState.timeFormat == "12") { diff --git a/src/plugins/built-in/timetableEdit/index.ts b/src/plugins/built-in/timetableEdit/index.ts index 0adefb62..02ca8911 100644 --- a/src/plugins/built-in/timetableEdit/index.ts +++ b/src/plugins/built-in/timetableEdit/index.ts @@ -271,7 +271,9 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = { }; const syncQuickbarFromDOM = () => { - const quickbar = document.querySelector(".timetablepage .quickbar.visible"); + const quickbar = document.querySelector( + ".timetablepage .quickbar.below.visible, .timetablepage .quickbar.visible", + ); if (quickbar && quickbar.getAttribute("data-type") === "class") { const titleEl = quickbar.querySelector(".title"); const roomEl = quickbar.querySelector(".meta .room"); @@ -287,7 +289,9 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = { if (!timetablePage || quickbarObserver) return; quickbarObserver = new MutationObserver(() => { - const quickbar = document.querySelector(".timetablepage .quickbar.visible"); + const quickbar = document.querySelector( + ".timetablepage .quickbar.below.visible, .timetablepage .quickbar.visible", + ); if (quickbar?.getAttribute("data-type") === "class") { addEditButtonToQuickbar(quickbar as HTMLElement); } @@ -302,7 +306,13 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = { }; const handleTimetable = async () => { - await waitForElm(".timetablepage .entry", true, 10, 100); + // Class entries (`div.entry.class`) load after the page shell; don't fail the whole + // setup if they are slow or briefly absent (e.g. navigation). Observers still catch them. + try { + await waitForElm(".timetablepage .entry.class", true, 50, 300); + } catch { + /* entries may appear later */ + } processAllEntries(); setupQuickbarObserver(); syncQuickbarFromDOM(); diff --git a/src/plugins/monofile.ts b/src/plugins/monofile.ts index 353386e3..4892c7df 100644 --- a/src/plugins/monofile.ts +++ b/src/plugins/monofile.ts @@ -29,8 +29,7 @@ import { updateEngageHomeMenuActive, } from "@/seqta/utils/Loaders/LoadEngageHomePage"; import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage"; -import { OpenWhatsNewPopup } from "@/seqta/utils/Openers/OpenWhatsNewPopup"; -import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification"; +import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue"; import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes"; @@ -106,14 +105,7 @@ export async function finishLoad() { console.error("Error during loading cleanup:", err); } - // Check and show privacy statement notification (before what's new) - if (!document.getElementById("privacy-notification")) { - await showPrivacyNotification(); - } - - if (settingsState.justupdated && !document.getElementById("whatsnewbk") && !document.getElementById("privacy-notification")) { - OpenWhatsNewPopup(); - } + runStartupPopupQueue(); } export function GetCSSElement(file: string) { @@ -517,6 +509,9 @@ function CheckNoticeTextColour(notice: any) { } function watchForEngageLogin() { + if (!document.querySelector(".login")) { + return; + } const observer = new MutationObserver(() => { if (!document.querySelector(".login")) { observer.disconnect(); @@ -526,24 +521,69 @@ function watchForEngageLogin() { observer.observe(document.body, { childList: true, subtree: true }); } +/** Wait until Engage shows either the login shell or the main app (`#content`), so we never call `LoadPageElements` while still on login (which would hang on `waitForElm("#content")`). */ +function waitForEngageLoginOrContent(): Promise<"login" | "app" | "timeout"> { + if (document.querySelector(".login")) { + return Promise.resolve("login"); + } + if (document.getElementById("content")) { + return Promise.resolve("app"); + } + return new Promise((resolve) => { + let settled = false; + const finish = (mode: "login" | "app") => { + if (settled) return; + settled = true; + mo.disconnect(); + window.clearTimeout(tid); + resolve(mode); + }; + const check = () => { + if (document.querySelector(".login")) finish("login"); + else if (document.getElementById("content")) finish("app"); + }; + const mo = new MutationObserver(check); + mo.observe(document.documentElement, { subtree: true, childList: true }); + const tid = window.setTimeout(() => { + if (settled) return; + mo.disconnect(); + settled = true; + if (document.querySelector(".login")) resolve("login"); + else if (document.getElementById("content")) resolve("app"); + else { + console.warn( + "[BetterSEQTA+] Engage: timed out waiting for .login or #content; unblocking load UI.", + ); + resolve("timeout"); + } + }, 120_000); + }); +} + export function tryLoad() { if (isSeqtaEngageExperience()) { updateIframesWithDarkMode(); window.addEventListener("load", () => removeThemeTagsFromNotices(), { once: true }); - const runEngageLoad = () => { - if (document.querySelector(".login")) { + const runEngageLoad = async () => { + const mode = await waitForEngageLoginOrContent(); + if (mode === "login") { finishLoad(); watchForEngageLogin(); return; } - void LoadPageElements(); + if (mode === "timeout") { + finishLoad(); + void waitForElm("#content").then(() => void LoadPageElements()); + return; + } + await LoadPageElements(); }; if (document.readyState === "complete") { - runEngageLoad(); + void runEngageLoad(); } else { - window.addEventListener("load", () => runEngageLoad(), { once: true }); + window.addEventListener("load", () => void runEngageLoad(), { once: true }); } return; } diff --git a/src/resources/bq+engage.png b/src/resources/bq+engage.png new file mode 100644 index 00000000..298ce3b6 Binary files /dev/null and b/src/resources/bq+engage.png differ diff --git a/src/resources/bsclouddemo.webm b/src/resources/bsclouddemo.webm new file mode 100644 index 00000000..4c212608 Binary files /dev/null and b/src/resources/bsclouddemo.webm differ diff --git a/src/seqta/ui/AddBetterSEQTAElements.ts b/src/seqta/ui/AddBetterSEQTAElements.ts index 8a7729a3..dd512c99 100644 --- a/src/seqta/ui/AddBetterSEQTAElements.ts +++ b/src/seqta/ui/AddBetterSEQTAElements.ts @@ -3,6 +3,7 @@ import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage"; import { loadEngageHomePage } from "@/seqta/utils/Loaders/LoadEngageHomePage"; import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage"; import { SendNewsPage } from "@/seqta/utils/SendNewsPage"; +import { attachNotificationsPanelAnimation } from "@/seqta/utils/attachNotificationsPanelAnimation"; import { setupSettingsButton } from "@/seqta/utils/setupSettingsButton"; import { waitForElm } from "@/seqta/utils/waitForElm"; @@ -89,6 +90,7 @@ export async function AddBetterSEQTAElements() { addExtensionSettings(); await createSettingsButton(); setupSettingsButton(); + attachNotificationsPanelAnimation(); } function createHomeButton(fragment: DocumentFragment, _: HTMLElement) { @@ -423,10 +425,12 @@ async function setupEngageSettingsButton() { await addDarkLightToggle(parent); await createSettingsButton(parent); setupSettingsButton(); + attachNotificationsPanelAnimation(); } catch { await addDarkLightToggle(); await createSettingsButton(); setupSettingsButton(); + attachNotificationsPanelAnimation(); } } diff --git a/src/seqta/utils/CloudAuth.ts b/src/seqta/utils/CloudAuth.ts index ff4f15f8..a2490959 100644 --- a/src/seqta/utils/CloudAuth.ts +++ b/src/seqta/utils/CloudAuth.ts @@ -83,6 +83,25 @@ class CloudAuthService { } } + /** Pull cloud settings backup after a fresh sign-in (matches manual “Download from cloud”). */ + private triggerCloudSettingsDownloadAfterLogin(accessToken: string): void { + void browser.runtime + .sendMessage({ + type: "cloudSettingsDownload", + token: accessToken, + }) + .then((res: unknown) => { + const r = res as { success?: boolean; notFound?: boolean; error?: string } | undefined; + if (r?.success || r?.notFound) return; + if (r?.error) { + console.warn("[BetterSEQTA+] Cloud settings download after login:", r.error); + } + }) + .catch((err) => { + console.warn("[BetterSEQTA+] Cloud settings download after login failed:", err); + }); + } + public async getStoredToken(): Promise { const result = await browser.storage.local.get(STORAGE_KEYS.accessToken); return (result[STORAGE_KEYS.accessToken] as string) ?? null; @@ -108,6 +127,26 @@ class CloudAuthService { return clientId; } + public async startLogin(): Promise<{ success: boolean; error?: string }> { + try { + const clientId = await this.getClientId(); + const result = (await browser.runtime.sendMessage({ + type: "cloudStartLogin", + client_id: clientId, + redirect_uri: REDIRECT_URI, + })) as { success?: boolean; error?: string }; + if (result?.success) { + return { success: true }; + } + return { success: false, error: result?.error ?? "Failed to open login page" }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : "Failed to open login page", + }; + } + } + public async login( login: string, password: string @@ -135,6 +174,7 @@ class CloudAuthService { user: result.user ?? null, }; this.notify(); + this.triggerCloudSettingsDownloadAfterLogin(result.access_token); return { success: true }; } return { diff --git a/src/seqta/utils/Openers/OpenBsCloudAutoSyncAnnouncement.ts b/src/seqta/utils/Openers/OpenBsCloudAutoSyncAnnouncement.ts new file mode 100644 index 00000000..440e3ca0 --- /dev/null +++ b/src/seqta/utils/Openers/OpenBsCloudAutoSyncAnnouncement.ts @@ -0,0 +1,71 @@ +import stringToHTML from "../stringToHTML"; +import { settingsState } from "../listeners/SettingsState"; +import { openPopup } from "./PopupManager"; +import { attachPopupMediaFullscreen } from "./attachPopupMediaFullscreen"; + +/** Same hosting pattern as the What's New update video (GitHub raw). */ +const BS_CLOUD_DEMO_VIDEO_URL = + "https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Plus/main/src/resources/bsclouddemo.webm"; + +export function shouldShowBsCloudAutoSyncAnnouncement(): boolean { + return !settingsState.bsCloudAutoSyncAnnouncementShown; +} + +/** + * One-time announcement for BetterSEQTA Cloud automatic settings sync (after other startup popups). + * Video layout matches {@link OpenWhatsNewPopup} (`whatsnewImgContainer` / `whatsnewImg`). + */ +export function showBsCloudAutoSyncAnnouncement(onDismissed?: () => void) { + if (document.getElementById("whatsnewbk")) { + onDismissed?.(); + return; + } + if (!shouldShowBsCloudAutoSyncAnnouncement()) { + onDismissed?.(); + return; + } + + const header = stringToHTML( + /* html */ + `
+

BetterSEQTA Cloud

+
`, + ).firstChild as HTMLElement; + + const imageContainer = document.createElement("div"); + imageContainer.classList.add("whatsnewImgContainer"); + + const video = document.createElement("video"); + const source = document.createElement("source"); + source.setAttribute("src", BS_CLOUD_DEMO_VIDEO_URL); + source.setAttribute("type", "video/webm"); + video.autoplay = true; + video.muted = true; + video.loop = true; + video.appendChild(source); + video.classList.add("whatsnewImg"); + imageContainer.appendChild(video); + attachPopupMediaFullscreen(video); + + const text = stringToHTML(/* html */ ` +
+

+ BetterSEQTA Cloud can keep your BetterSEQTA+ settings backed up and in + sync across browsers. Optional automatic settings sync runs when you are signed in (passwords + and tokens are never included). +

+

+ Close this dialog when you are done. We will not show this announcement again. +

+

Sign up in BetterSEQTA settings

+
+ `).firstChild as HTMLElement; + + settingsState.bsCloudAutoSyncAnnouncementShown = true; + + openPopup({ + header, + content: [imageContainer, text], + afterClose: onDismissed, + }); +} diff --git a/src/seqta/utils/Openers/OpenEngageParentsAnnouncement.ts b/src/seqta/utils/Openers/OpenEngageParentsAnnouncement.ts new file mode 100644 index 00000000..1cd72939 --- /dev/null +++ b/src/seqta/utils/Openers/OpenEngageParentsAnnouncement.ts @@ -0,0 +1,54 @@ +import { settingsState } from "../listeners/SettingsState"; +import { animate as motionAnimate } from "motion"; + +export function shouldShowEngageParentsAnnouncement(): boolean { + return !settingsState.engageParentsAnnouncementShown; +} + +/** + * Non-blocking bottom-right toast announcing SEQTA Engage support. Shown once. + */ +export function showEngageParentsToast() { + if (!shouldShowEngageParentsAnnouncement()) return; + + settingsState.engageParentsAnnouncementShown = true; + + const toast = document.createElement("div"); + toast.className = "bsplus-toast"; + toast.innerHTML = /* html */ ` +
+ BetterSEQTA+ now supports SEQTA Engage +

Buy your mum a BetterSEQTA Plus! Parents now get themes, a cleaner home page, and all the Plus polish on SEQTA Engage.

+
+ + `; + + toast.style.opacity = "0"; + document.getElementById("container")?.append(toast); + + if (settingsState.animations) { + (motionAnimate as any)( + toast, + { opacity: [0, 1], y: [40, 0] }, + { duration: 0.35, easing: [0.22, 0.03, 0.26, 1] }, + ); + } else { + toast.style.opacity = "1"; + } + + const dismiss = () => { + if (settingsState.animations) { + (motionAnimate as any)( + toast, + { opacity: [1, 0], y: [0, 40] }, + { duration: 0.2, easing: [0.22, 0.03, 0.26, 1] }, + ).then(() => toast.remove()); + } else { + toast.remove(); + } + }; + + toast.querySelector(".bsplus-toast-close")!.addEventListener("click", dismiss); + + setTimeout(dismiss, 10000); +} diff --git a/src/seqta/utils/Openers/OpenPrivacyNotification.ts b/src/seqta/utils/Openers/OpenPrivacyNotification.ts index fa573fc9..b42268f5 100644 --- a/src/seqta/utils/Openers/OpenPrivacyNotification.ts +++ b/src/seqta/utils/Openers/OpenPrivacyNotification.ts @@ -1,13 +1,31 @@ import stringToHTML from "../stringToHTML"; import { settingsState } from "../listeners/SettingsState"; import { openPopup } from "./PopupManager"; +import { attachPopupMediaFullscreenIfPresent } from "./attachPopupMediaFullscreen"; -export function showPrivacyNotification() { - const lastUpdated = "2025-12-19"; +const PRIVACY_STATEMENT_VERSION = "2025-12-19"; - if (document.getElementById("whatsnewbk")) return; - if (settingsState.privacyStatementShown) return; - if (settingsState.privacyStatementLastUpdated && new Date(settingsState.privacyStatementLastUpdated) > new Date(lastUpdated)) return; +export function shouldShowPrivacyNotification(): boolean { + if (settingsState.privacyStatementShown) return false; + if ( + settingsState.privacyStatementLastUpdated && + new Date(settingsState.privacyStatementLastUpdated) > + new Date(PRIVACY_STATEMENT_VERSION) + ) { + return false; + } + return true; +} + +export function showPrivacyNotification(onDismissed?: () => void) { + if (document.getElementById("whatsnewbk")) { + onDismissed?.(); + return; + } + if (!shouldShowPrivacyNotification()) { + onDismissed?.(); + return; + } const header = stringToHTML( /* html */ @@ -42,11 +60,14 @@ export function showPrivacyNotification() {
`).firstChild as HTMLElement; + attachPopupMediaFullscreenIfPresent(text, "img.aboutImg"); + settingsState.privacyStatementLastUpdated = "2025-12-20"; settingsState.privacyStatementShown = true; openPopup({ header, content: [text], + afterClose: onDismissed, }); } diff --git a/src/seqta/utils/Openers/OpenWhatsNewPopup.ts b/src/seqta/utils/Openers/OpenWhatsNewPopup.ts index dd7e8329..bbe7f386 100644 --- a/src/seqta/utils/Openers/OpenWhatsNewPopup.ts +++ b/src/seqta/utils/Openers/OpenWhatsNewPopup.ts @@ -2,8 +2,9 @@ import stringToHTML from "../stringToHTML"; import browser from "webextension-polyfill"; import kofi from "@/resources/kofi.png?base64"; import { openPopup } from "./PopupManager"; +import { attachPopupMediaFullscreen } from "./attachPopupMediaFullscreen"; -export function OpenWhatsNewPopup() { +export function OpenWhatsNewPopup(onDismissed?: () => void) { const header = stringToHTML( /* html */ `
@@ -28,11 +29,12 @@ export function OpenWhatsNewPopup() { video.appendChild(source); video.classList.add("whatsnewImg"); imageContainer.appendChild(video); + attachPopupMediaFullscreen(video); const text = stringToHTML(/* html */ `
-

3.6.0 - Cloud backup, various fixes & SEQTA Engage support

+

3.6.2 - Cloud backup, various fixes & SEQTA Engage support

  • BetterSEQTA Cloud: back up and restore extension settings from your account (General settings).
  • Optional automatic cloud sync if signed in (on by default).
  • Option to use cloud profile photo as the local SEQTA profile picture
  • @@ -43,7 +45,8 @@ export function OpenWhatsNewPopup() {
  • Fixed today's lessons on the homepage misbehaving in developer mode.
  • Reduced overlap between BetterSEQTA subject averages and SEQTA's built-in averages UI.
  • Updated outdated in-app links and update some under the hood code (Vite 8).
  • - +
  • Added a notifications panel animation to work like settings.
  • +
  • Fix timetable edit plugin not working correctly.
  • 3.5.3 - Adaptive theme updates

  • Fixed adaptive theming on current-year course and assessment pages.
  • @@ -364,5 +367,7 @@ export function OpenWhatsNewPopup() { openPopup({ header, content: [imageContainer, text, footer], + afterClose: onDismissed, + clearJustUpdated: true, }); } diff --git a/src/seqta/utils/Openers/PopupManager.ts b/src/seqta/utils/Openers/PopupManager.ts index 57125289..ca44c68d 100644 --- a/src/seqta/utils/Openers/PopupManager.ts +++ b/src/seqta/utils/Openers/PopupManager.ts @@ -4,6 +4,13 @@ import { animate as motionAnimate, stagger } from "motion"; type AnimationTarget = string | Element | Element[] | NodeList | null; let isClosing = false; +let pendingAfterClose: (() => void) | undefined; + +function invokeAfterClose() { + const fn = pendingAfterClose; + pendingAfterClose = undefined; + fn?.(); +} export async function closePopup() { if (isClosing) return; @@ -16,12 +23,14 @@ export async function closePopup() { if (!background || !popup) { isClosing = false; + invokeAfterClose(); return; } if (!settingsState.animations) { background.remove(); isClosing = false; + invokeAfterClose(); return; } @@ -33,19 +42,28 @@ export async function closePopup() { background.remove(); isClosing = false; + invokeAfterClose(); } interface OpenPopupOptions { header?: Node | null; content?: (Node | null | undefined)[]; animateSelector?: AnimationTarget; + /** Called once after this popup is fully closed (including skip-animation path). */ + afterClose?: () => void; + /** When true, clears the post-update flag when this popup opens (What's New only). */ + clearJustUpdated?: boolean; } export function openPopup({ header, content = [], animateSelector = ".whatsnewTextContainer *", + afterClose, + clearJustUpdated = false, }: OpenPopupOptions = {}) { + pendingAfterClose = afterClose; + const background = document.createElement("div"); background.id = "whatsnewbk"; background.classList.add("whatsnewBackground"); @@ -88,7 +106,9 @@ export function openPopup({ } } - delete settingsState.justupdated; + if (clearJustUpdated) { + delete settingsState.justupdated; + } background.addEventListener("click", (event) => { if (event.target === background) void closePopup(); diff --git a/src/seqta/utils/Openers/StartupPopupQueue.ts b/src/seqta/utils/Openers/StartupPopupQueue.ts new file mode 100644 index 00000000..7bfa4e8a --- /dev/null +++ b/src/seqta/utils/Openers/StartupPopupQueue.ts @@ -0,0 +1,32 @@ +import { settingsState } from "../listeners/SettingsState"; +import { OpenWhatsNewPopup } from "./OpenWhatsNewPopup"; +import { + shouldShowEngageParentsAnnouncement, + showEngageParentsToast, +} from "./OpenEngageParentsAnnouncement"; + +type QueueStep = (goNext: () => void) => void; + +/** + * Runs startup modals in order: What's New (if the extension just updated), + * then shows the SEQTA Engage toast (once, non-blocking). + */ +export function runStartupPopupQueue() { + const steps: QueueStep[] = []; + + if (settingsState.justupdated) { + steps.push((goNext) => OpenWhatsNewPopup(goNext)); + } + + function runNext() { + const step = steps.shift(); + if (step) step(runNext); + else { + if (shouldShowEngageParentsAnnouncement()) { + showEngageParentsToast(); + } + } + } + + runNext(); +} diff --git a/src/seqta/utils/Openers/attachPopupMediaFullscreen.ts b/src/seqta/utils/Openers/attachPopupMediaFullscreen.ts new file mode 100644 index 00000000..8990989c --- /dev/null +++ b/src/seqta/utils/Openers/attachPopupMediaFullscreen.ts @@ -0,0 +1,158 @@ +/** + * Makes popup hero images/videos open a padded overlay (not browser fullscreen) on click. + * Escape or backdrop click dismisses it. Clicks use stopPropagation so the + * parent SEQTA popup does not close. + */ + +import { settingsState } from "../listeners/SettingsState"; + +const FULLSCREENABLE_CLASS = "popup-media-fullscreenable"; +const OVERLAY_VISIBLE_CLASS = "bsplus-popup-media-overlay-backdrop--visible"; +const OVERLAY_ANIM_MS = 280; + +function isImageOrVideo(el: Element): el is HTMLImageElement | HTMLVideoElement { + return el instanceof HTMLImageElement || el instanceof HTMLVideoElement; +} + +export function attachPopupMediaFullscreen(el: HTMLImageElement | HTMLVideoElement) { + el.classList.add(FULLSCREENABLE_CLASS); + el.setAttribute("tabindex", "0"); + el.setAttribute("role", "button"); + el.setAttribute("aria-label", "View larger"); + el.title = "Click to view larger"; + + const open = (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + openMediaOverlayViewer(el); + }; + + el.addEventListener("click", open); + el.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + open(e); + } + }); +} + +function openMediaOverlayViewer(source: HTMLImageElement | HTMLVideoElement) { + const backdrop = document.createElement("div"); + backdrop.id = "bsplus-popup-media-overlay"; + backdrop.className = "bsplus-popup-media-overlay-backdrop"; + + const inner = document.createElement("div"); + inner.className = "bsplus-popup-media-overlay-inner"; + + const slot = document.createElement("div"); + slot.className = "bsplus-popup-media-overlay-slot"; + + let media: HTMLImageElement | HTMLVideoElement; + if (source instanceof HTMLVideoElement) { + const v = source; + const nv = document.createElement("video"); + nv.classList.add("bsplus-popup-media-overlay-media"); + nv.controls = true; + nv.playsInline = true; + nv.loop = v.loop; + nv.muted = v.muted; + nv.volume = v.volume; + for (const s of v.querySelectorAll("source")) { + const ns = document.createElement("source"); + ns.src = (s as HTMLSourceElement).src; + const t = (s as HTMLSourceElement).type; + if (t) ns.type = t; + nv.appendChild(ns); + } + nv.addEventListener( + "loadeddata", + () => { + try { + nv.currentTime = v.currentTime; + } catch { + /* ignore */ + } + void nv.play().catch(() => {}); + }, + { once: true }, + ); + v.pause(); + nv.load(); + media = nv; + } else { + const img = document.createElement("img"); + img.classList.add("bsplus-popup-media-overlay-media"); + img.src = source.currentSrc || source.src; + img.alt = source.alt || ""; + media = img; + } + + media.addEventListener("click", (e) => e.stopPropagation()); + + slot.appendChild(media); + inner.append(slot); + backdrop.appendChild(inner); + document.body.append(backdrop); + + if (!settingsState.animations) { + backdrop.classList.add("bsplus-popup-media-overlay--instant"); + backdrop.classList.add(OVERLAY_VISIBLE_CLASS); + } else { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + backdrop.classList.add(OVERLAY_VISIBLE_CLASS); + }); + }); + } + + inner.addEventListener("click", (e) => e.stopPropagation()); + + let done = false; + const removeOverlay = () => { + if (source instanceof HTMLVideoElement && media instanceof HTMLVideoElement) { + try { + source.currentTime = media.currentTime; + } catch { + /* ignore */ + } + void source.play().catch(() => {}); + } + backdrop.remove(); + }; + + const close = () => { + if (done) return; + done = true; + document.removeEventListener("keydown", onDocKey, true); + + if (!settingsState.animations) { + removeOverlay(); + return; + } + + backdrop.classList.remove(OVERLAY_VISIBLE_CLASS); + window.setTimeout(removeOverlay, OVERLAY_ANIM_MS); + }; + + const onDocKey = (ev: KeyboardEvent) => { + if (ev.key === "Escape") { + ev.stopPropagation(); + close(); + } + }; + + document.addEventListener("keydown", onDocKey, true); + + backdrop.addEventListener("click", () => { + close(); + }); +} + +export function attachPopupMediaFullscreenIfPresent( + root: ParentNode, + selector: string, +) { + const el = root.querySelector(selector); + if (el && isImageOrVideo(el)) { + attachPopupMediaFullscreen(el); + } +} diff --git a/src/seqta/utils/attachNotificationsPanelAnimation.ts b/src/seqta/utils/attachNotificationsPanelAnimation.ts new file mode 100644 index 00000000..1f74c558 --- /dev/null +++ b/src/seqta/utils/attachNotificationsPanelAnimation.ts @@ -0,0 +1,128 @@ +import { animate } from "motion"; +import { settingsState } from "@/seqta/utils/listeners/SettingsState"; +import { waitForElm } from "@/seqta/utils/waitForElm"; + +/** + * Finds the SEQTA notifications dropdown panel (the list container next to the bell). + */ +function findNotificationPanel(): HTMLElement | null { + const wrapper = document.querySelector(".connectedNotificationsWrapper"); + if (!wrapper) return null; + + const flat = wrapper.querySelector(":scope > div > button + div"); + if (flat) return flat; + + const notifBlock = wrapper.querySelector("[class*='notifications__notifications___']"); + if (notifBlock?.nextElementSibling instanceof HTMLElement) { + return notifBlock.nextElementSibling; + } + + const list = wrapper.querySelector("[class*='notifications__list___']"); + if (list) return list; + + return null; +} + +function isPanelVisible(el: HTMLElement): boolean { + return ( + el.getClientRects().length > 0 && getComputedStyle(el).visibility !== "hidden" + ); +} + +let lastVisible = false; +/** Invalidates in-flight open animations when the panel closes or reopens. */ +let motionGeneration = 0; + +function runOpenAnimation(panel: HTMLElement) { + const myGen = ++motionGeneration; + panel.classList.add("bsplus-notifications-panel"); + + if (!settingsState.animations) { + panel.style.opacity = "1"; + panel.style.transform = "scale(1)"; + return; + } + + panel.style.opacity = "0"; + panel.style.transform = "scale(0)"; + + requestAnimationFrame(() => { + if (myGen !== motionGeneration) return; + animate(0, 1, { + onUpdate: (progress) => { + panel.style.opacity = String(progress); + panel.style.transform = `scale(${progress})`; + }, + type: "spring", + stiffness: 280, + damping: 20, + }); + }); +} + +function clearPanelMotionStyles(panel: HTMLElement) { + motionGeneration++; + panel.style.opacity = ""; + panel.style.transform = ""; +} + +/** + * Spring open / fade close for the native SEQTA notifications dropdown, matching ExtensionPopup. + */ +export function attachNotificationsPanelAnimation() { + void setupNotificationsPanelAnimation(); +} + +async function setupNotificationsPanelAnimation() { + try { + await waitForElm(".connectedNotificationsWrapper", true, 100, 60); + } catch { + return; + } + + const wrapper = document.querySelector(".connectedNotificationsWrapper"); + if (!wrapper) return; + + const sync = () => { + const panel = findNotificationPanel(); + // When SEQTA removes the dropdown from the DOM on close, we must reset + // lastVisible — otherwise the next open still looks "already visible" and skips animation. + if (!panel) { + if (lastVisible) { + lastVisible = false; + motionGeneration++; + } + return; + } + + const visible = isPanelVisible(panel); + if (visible === lastVisible) return; + + if (visible) { + runOpenAnimation(panel); + } else { + clearPanelMotionStyles(panel); + } + lastVisible = visible; + }; + + const observer = new MutationObserver(() => { + sync(); + }); + observer.observe(wrapper, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ["style", "class"], + }); + + document.addEventListener( + "click", + () => { + requestAnimationFrame(() => requestAnimationFrame(sync)); + }, + true, + ); + + sync(); +} diff --git a/src/seqta/utils/cloudSettingsSync.ts b/src/seqta/utils/cloudSettingsSync.ts index 40392e03..8918cd80 100644 --- a/src/seqta/utils/cloudSettingsSync.ts +++ b/src/seqta/utils/cloudSettingsSync.ts @@ -36,7 +36,10 @@ export const SENSITIVE_DEVICE_STORAGE_KEYS_EXACT = [ /** e.g. any future `plugin.global-search.storage.*` keys in chrome.storage */ export const SENSITIVE_DEVICE_STORAGE_KEY_PREFIXES = ["plugin.global-search.storage."] as const; -const CLIENT_ONLY_CLOUD_KEYS_EXACT = [BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY] as const; +const CLIENT_ONLY_CLOUD_KEYS_EXACT = [ + BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY, + "bsplus_lastCloudPoll", +] as const; /** After restoring from cloud, keep local session so the user stays signed in. */ const AUTH_KEYS_TO_PRESERVE = [ @@ -102,11 +105,12 @@ export function buildUploadPayload(all: Record): { schemaVersion: number; data: Record; } { - const data: Record = {}; + const filtered: Record = {}; for (const [k, v] of Object.entries(all)) { if (shouldOmitKeyFromCloudPayload(k)) continue; - data[k] = v; + filtered[k] = v; } + const data = migrateLegacyToPluginSettings(filtered); return { schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, data }; } @@ -124,8 +128,77 @@ export async function setKnownRemoteUpdatedAt(iso: string | undefined): Promise< } /** - * Replace local extension storage with the downloaded snapshot, except auth keys - * and device-only sensitive caches, which are preserved from the current device. + * Migrate legacy storage keys to plugin settings format. + * Only applies migrations for keys present in the data; does not overwrite + * existing plugin settings if the legacy key is absent. + */ +function migrateLegacyToPluginSettings(data: Record): Record { + const result = { ...data }; + + function ensurePluginSettings(pluginId: string): Record { + const key = `plugin.${pluginId}.settings`; + if (!result[key] || typeof result[key] !== "object") { + result[key] = {}; + } + return result[key] as Record; + } + + // animatedbk -> plugin.animated-background.settings.enabled + if ("animatedbk" in result) { + const settings = ensurePluginSettings("animated-background"); + if (settings.enabled === undefined) { + settings.enabled = !!result.animatedbk; + } + delete result.animatedbk; + } + + // bksliderinput -> plugin.animated-background.settings.speed + // Legacy: string "0"-"100", New: float 0.1-2.0 + if ("bksliderinput" in result) { + const settings = ensurePluginSettings("animated-background"); + if (settings.speed === undefined) { + const legacy = parseFloat(String(result.bksliderinput)); + if (!isNaN(legacy)) { + settings.speed = Math.round((0.1 + (legacy / 100) * 1.9) * 100) / 100; + } + } + delete result.bksliderinput; + } + + // assessmentsAverage -> plugin.assessments-average.settings.enabled + if ("assessmentsAverage" in result) { + const settings = ensurePluginSettings("assessments-average"); + if (settings.enabled === undefined) { + settings.enabled = !!result.assessmentsAverage; + } + delete result.assessmentsAverage; + } + + // lettergrade -> plugin.assessments-average.settings.lettergrade + if ("lettergrade" in result) { + const settings = ensurePluginSettings("assessments-average"); + if (settings.lettergrade === undefined) { + settings.lettergrade = !!result.lettergrade; + } + delete result.lettergrade; + } + + // notificationCollector -> plugin.notificationCollector.settings.enabled + if ("notificationCollector" in result && typeof result.notificationCollector === "boolean") { + const settings = ensurePluginSettings("notificationCollector"); + if (settings.enabled === undefined) { + settings.enabled = result.notificationCollector; + } + delete result.notificationCollector; + } + + return result; +} + +/** + * Apply the downloaded cloud snapshot by setting each key individually, + * preserving auth keys and device-only sensitive caches. + * Legacy keys are automatically migrated to plugin settings format. */ export async function applyDownloadedEnvelope(envelope: unknown): Promise { let remoteFlat: Record; @@ -145,10 +218,7 @@ export async function applyDownloadedEnvelope(envelope: unknown): Promise throw new Error("Invalid cloud settings payload"); } - const local = await browser.storage.local.get(); - const preserved = collectLocalKeysToPreserve(local); - const remoteSanitized = stripExcludedKeysFromRemoteData(remoteFlat); - - await browser.storage.local.clear(); - await browser.storage.local.set({ ...remoteSanitized, ...preserved }); + const migrated = migrateLegacyToPluginSettings(remoteFlat); + const remoteSanitized = stripExcludedKeysFromRemoteData(migrated); + await browser.storage.local.set(remoteSanitized); } diff --git a/src/types/storage.ts b/src/types/storage.ts index 232bc4c2..adbaf8c9 100644 --- a/src/types/storage.ts +++ b/src/types/storage.ts @@ -32,6 +32,10 @@ export interface SettingsState { justupdated?: boolean; privacyStatementShown?: boolean; privacyStatementLastUpdated?: string; + /** One-time announcement: SEQTA Engage support for parents (dismissed popup queue). */ + engageParentsAnnouncementShown?: boolean; + /** One-time announcement: BS Cloud automatic settings sync (last in startup popup queue). */ + bsCloudAutoSyncAnnouncementShown?: boolean; timeFormat?: string; animations: boolean; defaultPage: string;