diff --git a/.gitignore b/.gitignore index 46e7ae3b..47ffcf73 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,4 @@ betterseqtaplus-safari/ .env .env.submit dependency-graph.svg -/src/resources/themes + diff --git a/docs/CLOUD_SETTINGS_SYNC_SERVER.md b/docs/CLOUD_SETTINGS_SYNC_SERVER.md index c9c085af..d9b502e8 100644 --- a/docs/CLOUD_SETTINGS_SYNC_SERVER.md +++ b/docs/CLOUD_SETTINGS_SYNC_SERVER.md @@ -35,13 +35,16 @@ Upserts the caller’s settings backup. ```json { "schemaVersion": 1, + "themeId": "uuid-string-or-empty", "data": { - "...": "flat key-value map mirroring extension storage (see Payload shape)" + "...": "flat key-value map mirroring extension storage (see Payload shape)", + "selectedTheme": "uuid-or-empty-string" } } ``` - **`schemaVersion`**: integer. The extension currently sends `1`. The server may reject unknown major versions or store it for future migrations. +- **`themeId`**: optional but recommended duplicate of **`data.selectedTheme`**. Should be the UUID of the **installed** BetterSEQTA store theme (`selectedTheme`). This may be a normal theme id **or** a **flavour (slave) variant** id from themes with **`flavours[]`** — the extension uses it after restore to prefetch `theme.json` when missing locally (same **`GET …/themes/{id}/download`** as the store UI). Persist and return **`themeId`** in sync with **`data.selectedTheme`**. - **`data`**: object whose keys are storage keys (strings) and values are JSON-serializable values (same types as stored in `chrome.storage.local`). **Success response:** HTTP `200` (or `201` if you prefer create semantics). Example: @@ -67,15 +70,19 @@ Returns the caller’s latest settings backup. ```json { "schemaVersion": 1, + "themeId": "uuid-string-or-empty", "data": { }, "updated_at": "2026-04-07T12:00:00.000Z" } ``` - **`data`**: required for restore; must be the same shape as accepted in `PUT` (flat map of storage keys). +- **`themeId`**: optional; if present must match **`data.selectedTheme`** (see `PUT`). - **`schemaVersion`**: optional but recommended; should match what was stored. - **`updated_at`**: optional; included for UX if the client shows “last backup” time. +The extension resolves **`themeId`** (if non-empty), else **`data.selectedTheme`,** to [`resolveThemeIdForPostSyncDownload`](../src/seqta/utils/cloudSettingsSync.ts) after downloading the envelope — used only to reinstall theme assets from **`betterseqta.org`** when IndexedDB lacks that id (see **BetterSEQTA Cloud** flavour note in **[THEME_STORE_FLAVOURS_API](./THEME_STORE_FLAVOURS_API.md)** section “Cloud settings sync compatibility”). + **No backup yet:** HTTP **`404`**. The extension treats this as “nothing in the cloud” and shows an error to the user. **Error responses:** `401` if the token is invalid, etc. @@ -128,6 +135,6 @@ This uses standard **WebExtension** APIs (`browser.alarms`, `runtime` messages, ## Client reference (extension) -- Upload / dev export: `buildUploadPayload` / `getSnapshotForUpload` in `src/seqta/utils/cloudSettingsSync.ts` (strips OAuth-related keys, sensitive device keys, and **`bsplus_cloud_settings_known_remote_updated_at`**). -- Download: `applyDownloadedEnvelope` after `GET`; local auth keys, sensitive device keys, and the client-only watermark key are merged back after `chrome.storage.local.clear()`. +- Upload / dev export: `buildUploadPayload` / `getSnapshotForUpload` in `src/seqta/utils/cloudSettingsSync.ts` (strips OAuth-related keys, sensitive device keys, **`bsplus_pending_theme_ensure_after_cloud`**, and **`bsplus_cloud_settings_known_remote_updated_at`** — includes **`themeId`** aligned with **`selectedTheme`**). +- Download: resolve id via **`resolveThemeIdForPostSyncDownload`** → **`applyDownloadedEnvelope`** after `GET` → prefetch theme blobs in page context if needed (**`prepareThemeAfterCloudSync`** in **`ThemeManager`**) → reload SEQTA tabs; local auth keys, sensitive device keys, client-only watermark, and **`bsplus_pending_theme_ensure_after_cloud`** semantics preserved as documented above. - Auto sync (summary, debounced upload, alarms): `src/background/cloudSettingsAutoSync.ts`; content script triggers a poll on each verified SEQTA Learn/Engage page load (top frame) via `cloudSettingsPoll`. diff --git a/src/background.ts b/src/background.ts index d0926ae7..32f22083 100644 --- a/src/background.ts +++ b/src/background.ts @@ -5,6 +5,7 @@ import { initCloudSettingsAutoSync, performCloudSettingsDownloadWithRetry, performCloudSettingsUploadWithRetry, + requestCloudSettingsDebouncedUpload, runCloudSettingsPoll, } from "./background/cloudSettingsAutoSync"; @@ -346,6 +347,10 @@ const MESSAGE_HANDLERS: Record = { void runCloudSettingsPoll(); return false; }, + cloudSettingsRequestDebouncedUpload: () => { + requestCloudSettingsDebouncedUpload(); + return false; + }, getSeqtaSession: (req: { baseUrl?: string }, sendResponse: MessageSender, sender?: browser.Runtime.MessageSender) => { (async () => { try { diff --git a/src/background/cloudSettingsAutoSync.ts b/src/background/cloudSettingsAutoSync.ts index a75de192..1ac8433f 100644 --- a/src/background/cloudSettingsAutoSync.ts +++ b/src/background/cloudSettingsAutoSync.ts @@ -3,8 +3,10 @@ import { applyDownloadedEnvelope, buildUploadPayload, BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY, + BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY, CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, isKeyIncludedInCloudUploadPayload, + resolveThemeIdForPostSyncDownload, setKnownRemoteUpdatedAt, } from "@/seqta/utils/cloudSettingsSync"; @@ -220,7 +222,15 @@ async function getSettingsAndApplyOnce(token: string): Promise { error: data?.error ?? `Download failed (${r.status})`, }; } + const themeIdToEnsure = resolveThemeIdForPostSyncDownload(data); await applyDownloadedEnvelope(data); + if (themeIdToEnsure) { + await browser.storage.local.set({ + [BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY]: themeIdToEnsure, + }); + } else { + await browser.storage.local.remove(BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY); + } reloadSeqtaPagesFn?.(); const updated_at = data?.updated_at as string | undefined; await setKnownRemoteUpdatedAt(updated_at); @@ -352,6 +362,17 @@ function scheduleDebouncedUpload(): void { }, UPLOAD_DEBOUNCE_MS); } +/** Call after store theme install (and similar) so cloud upload runs even if storage events are flaky. */ +export function requestCloudSettingsDebouncedUpload(): void { + void (async () => { + const all = (await browser.storage.local.get()) as Record; + if (!isAutoCloudSyncEnabled(all)) return; + if (suppressAutoUploadDuringRestore) return; + if (!(await getAccessToken())) return; + scheduleDebouncedUpload(); + })(); +} + async function runDebouncedUploadJob(): Promise { const all = (await browser.storage.local.get()) as Record; if (!isAutoCloudSyncEnabled(all)) return; diff --git a/src/interface/components/store/CoverSwiper.svelte b/src/interface/components/store/CoverSwiper.svelte index 0b1a2c08..cd6eb126 100644 --- a/src/interface/components/store/CoverSwiper.svelte +++ b/src/interface/components/store/CoverSwiper.svelte @@ -1,10 +1,13 @@ -{#if coverThemes.length > 0} +{#if slides.length > 0}
-
- {#each coverThemes as theme} + {#each slides as slide (slide.imageUrl + slide.title + (slide.subtitle ?? ''))}
{ if (e.key === 'Enter') setDisplayTheme(theme) }} - onclick={() => setDisplayTheme(theme)} + onkeydown={(e) => { + if (e.key === 'Enter') setDisplayTheme(slide.openTheme); + }} + onclick={() => setDisplayTheme(slide.openTheme)} > - Theme Preview - {#if theme.featured === true} + + {#if slide.badgeFeatured === true}
- + Featured
{/if} -
-

{theme.name}

- {#if theme.author} -

By {theme.author}

+
+

{slide.title}

+ {#if slide.subtitle} +

{slide.subtitle}

+ {/if} + {#if slide.openTheme.author && !slide.subtitle} +

By {slide.openTheme.author}

+ {/if} + {#if slide.openTheme.description && !slide.subtitle} +

{slide.openTheme.description}

{/if} -

{theme.description}

-
+
{/each}
- -
- - -
-
-

- {theme.name} -

- {#if theme.featured === true} - - - - - Featured - - {/if} -
- {#if theme.author} -

- By {theme.author} -

- {/if} -
- - - - - {(theme.download_count ?? 0).toLocaleString()} downloads - - - - - - {(theme.favorite_count ?? 0).toLocaleString()} favorites - -
- Theme Cover -

- {theme.description} -

-
- {#if toggleFavorite && theme} + +
+ +
+ - {/if} - {#if currentThemes.includes(theme.id)} - - {:else} - - {/if} -
- {#if relatedThemes.length > 0} -
+ {'\ued8a'} + + -

- Related themes -

-
- {#each relatedThemes as relatedTheme (relatedTheme.id)} - - {/each}
- {/if} -
- {:else} -
- + +
+ +

+ + {theme.name} + +

+ + {#if theme.featured === true} + + + + + + + + + + Featured + + + + {/if} + +
+ + {#if theme.author} + +

+ + By {theme.author} + +

+ + {/if} + +
+ + + + + + + + + + {modalDisplayDownloadCount.toLocaleString()} downloads + + + + + + + + + + + + {(theme.favorite_count ?? 0).toLocaleString()} favorites + + + +
+ + + + {#if heroSlides.length > 0} + + {#key theme?.id} + +
+ +
+ +
+ + {#each heroSlides as slide, slideIdx (slideIdx)} + +
+ + {slide.caption} + +
+ + {/each} + +
+ +
+ + {#if heroSlides.length > 1} + +
+ + + + + +
+ + {/if} + +
+ + {/key} + + {/if} + + + + {#if hasFlavours} + + {@const masterThumb = masterCarouselImageUrl(theme)} + +

Variants

+ +
+ + {#if currentThemes.includes(theme.id)} + + + + {:else} + + + + {/if} + + {#each theme.flavours ?? [] as f, flavourIdx (f.id)} + + {@const thumb = flavourCarouselImageUrl(f)} + + {#if currentThemes.includes(f.id)} + + + + {:else} + + + + {/if} + + {/each} + +
+ + {/if} + + + +

+ + {theme.description} + +

+ + + +
+ + {#if toggleFavorite && theme} + + + + {/if} + + + + {#if !hasFlavours} + + {#if currentThemes.includes(theme.id)} + + + + {:else} + + + + {/if} + + {/if} + +
+ + + + {#if relatedThemes.length > 0} + +
+ + + +

+ + Related themes + +

+ +
+ + {#each relatedThemes as relatedTheme (relatedTheme.id)} + + + + {/each} + +
+ + {/if} +
+ + {:else} + +
+ + + +
+ {/if} +
+
+ + + diff --git a/src/interface/pages/store.svelte b/src/interface/pages/store.svelte index d9689bd4..92bcef14 100644 --- a/src/interface/pages/store.svelte +++ b/src/interface/pages/store.svelte @@ -7,6 +7,7 @@ import SkeletonLoader from '../components/SkeletonLoader.svelte'; import { settingsState } from '@/seqta/utils/listeners/SettingsState' import type { Theme } from '../types/Theme' + import { visibleStoreThemes, buildCoverSlidesForThemes } from '@/interface/utils/themeStoreFlavours' import browser from 'webextension-polyfill' import ThemeModal from '../components/store/ThemeModal.svelte' import Header from '../components/store/Header.svelte' @@ -26,7 +27,12 @@ // State variables let searchTerm = $state(''); let themes = $state([]); - let coverThemes = $state([]); + + /** Grid/search/cover: hides flat-listed slaves when API sends them */ + let listThemes = $derived(visibleStoreThemes(themes)); + + /** Cover marquee slides (master + flavour imagery for top masters) */ + let coverSlides = $derived(buildCoverSlidesForThemes(listThemes.slice(0, 3))); let loading = $state(true); let darkMode = $state(false); let displayTheme = $state(null); @@ -108,7 +114,6 @@ throw new Error(data?.error || 'Failed to fetch themes'); } themes = [...data.data.themes].sort(compareStoreThemes); - coverThemes = themes.slice(0, 3); loading = false; } catch (err) { @@ -128,13 +133,36 @@ // Filter themes (list is already featured-first, then newest; filter preserves order) let filteredThemes = $derived( - themes.filter( + listThemes.filter( (theme) => theme.name.toLowerCase().includes(searchTerm.toLowerCase()) || theme.description.toLowerCase().includes(searchTerm.toLowerCase()), ), ); + async function installThemeFromStore(themeId: string, meta: Theme) { + const fullRow = themes.find((x) => x.id === themeId); + if (fullRow) { + await themeManager.downloadTheme(fullRow); + } else { + const flavour = meta.flavours?.find((f) => f.id === themeId); + await themeManager.downloadTheme({ + id: themeId, + name: flavour?.name ?? meta.name, + } as Theme); + } + await themeManager.setTheme(themeId); + themeUpdates.triggerUpdate(); + await fetchCurrentThemes(); + void browser.runtime.sendMessage({ type: 'cloudSettingsRequestDebouncedUpload' }).catch(() => {}); + } + + async function removeThemeFromStore(themeId: string) { + await themeManager.deleteTheme(themeId); + themeUpdates.triggerUpdate(); + await fetchCurrentThemes(); + } + $effect(() => { loadBackground(); selectedBackground @@ -172,12 +200,13 @@ {#if activeTab === 'themes'} {#if searchTerm === ''} - + {/if} (showSignInOverlay = true)} - onInstall={async () => { - if (displayTheme) { - await themeManager.downloadTheme(displayTheme); - await themeManager.setTheme(displayTheme.id); - themeUpdates.triggerUpdate(); - await fetchCurrentThemes(); - } + onInstall={async (themeId: string) => { + if (displayTheme) await installThemeFromStore(themeId, displayTheme); }} - onRemove={async () => { - if (displayTheme?.id) { - console.debug('deleting theme', displayTheme.id); - await themeManager.deleteTheme(displayTheme.id); - themeUpdates.triggerUpdate(); - await fetchCurrentThemes(); - } + onRemove={async (themeId: string) => { + console.debug('deleting theme', themeId); + await removeThemeFromStore(themeId); }} /> {/if} diff --git a/src/interface/types/Theme.ts b/src/interface/types/Theme.ts index 407744ef..2c3c7268 100644 --- a/src/interface/types/Theme.ts +++ b/src/interface/types/Theme.ts @@ -1,3 +1,17 @@ +export type ThemeRole = "standard" | "master" | "slave"; + +/** List/detail metadata for variants of a master theme (full theme.json fetched at install by id). */ +export type ThemeFlavour = { + id: string; + name: string; + /** Mirrors theme.json accent (e.g. defaultColour); used for install picker buttons */ + accent_color: string; + cover_image: string; + marquee_image?: string; + /** Per-variant installs when slaves are not returned as flat `theme_role` rows */ + download_count?: number; +}; + export type Theme = { id: string; name: string; @@ -15,4 +29,22 @@ export type Theme = { created_at?: number; /** Unix seconds — last server update (GET /api/themes). */ updated_at?: number; + /** Omitted / `standard` — show in grid. `slave` hides from grid. `master` can list `flavours`. */ + theme_role?: ThemeRole; + /** Present when `theme_role === "slave"` and API returns a flat list during migration */ + master_id?: string; + /** Variants nested on master rows; installs use flavour `id` */ + flavours?: ThemeFlavour[]; +}; + +/** One marquee slide (cover hero or modal carousel). */ +export type ThemeCoverSlide = { + imageUrl: string; + /** Main line — usually master name */ + title: string; + /** Subline — flavour name when applicable */ + subtitle?: string; + /** Opening the modal uses this theme (always the grid row / master object) */ + openTheme: Theme; + badgeFeatured?: boolean; }; diff --git a/src/interface/utils/themeStoreFlavours.ts b/src/interface/utils/themeStoreFlavours.ts new file mode 100644 index 00000000..e6c55cd4 --- /dev/null +++ b/src/interface/utils/themeStoreFlavours.ts @@ -0,0 +1,165 @@ +import type { Theme, ThemeCoverSlide, ThemeFlavour } from "@/interface/types/Theme"; + +export function isHiddenStoreTheme(theme: Theme): boolean { + return theme.theme_role === "slave"; +} + +/** Grid and search: omit slave rows (when API sends a flattened list). */ +export function visibleStoreThemes(themes: Theme[]): Theme[] { + return themes.filter((t) => !isHiddenStoreTheme(t)); +} + +function marqueeOrCoverUrl(t: { marqueeImage?: string; coverImage: string }): string { + return t.marqueeImage || t.coverImage; +} + +/** + * Builds slides for CoverSwiper: for each top-N master, first master image then each flavour image. + */ +export function buildCoverSlidesForThemes(topThemes: Theme[]): ThemeCoverSlide[] { + const slides: ThemeCoverSlide[] = []; + for (const theme of topThemes) { + const flavours = theme.flavours ?? []; + if (flavours.length === 0) { + slides.push({ + imageUrl: marqueeOrCoverUrl(theme), + title: theme.name, + subtitle: theme.author ? `By ${theme.author}` : undefined, + openTheme: theme, + badgeFeatured: theme.featured === true, + }); + continue; + } + slides.push({ + imageUrl: marqueeOrCoverUrl(theme), + title: theme.name, + subtitle: theme.author ? `By ${theme.author}` : undefined, + openTheme: theme, + badgeFeatured: theme.featured === true, + }); + for (const f of flavours) { + slides.push({ + imageUrl: f.marquee_image || f.cover_image, + title: theme.name, + subtitle: f.name, + openTheme: theme, + badgeFeatured: theme.featured === true, + }); + } + } + return slides; +} + +export type ModalHeroSlide = { imageUrl: string; caption: string }; + +/** Preview image for carousel + flavour picker (matches hero slide order after master slide). */ +export function flavourCarouselImageUrl(f: ThemeFlavour): string { + const u = (f.marquee_image || f.cover_image || "").trim(); + return u; +} + +/** Preview image for master variant tile (modal hero slide 0). */ +export function masterCarouselImageUrl(t: Theme): string { + return (marqueeOrCoverUrl(t) || "").trim(); +} + +/** + * Ordered preview URLs for the store grid card rotator: master first, then each variant. + * Uses nested `flavours` when present; otherwise flat `slave` rows (same order as modal when possible). + */ +export function gridCardPreviewImageUrls(theme: Theme, allStoreRows?: Theme[]): string[] { + const urls: string[] = []; + const push = (raw: string) => { + const u = raw.trim(); + if (u && !urls.includes(u)) urls.push(u); + }; + + push(marqueeOrCoverUrl(theme) || ""); + + const flavours = theme.flavours ?? []; + if (flavours.length > 0) { + for (const f of flavours) { + push(flavourCarouselImageUrl(f)); + } + return urls.length > 0 ? urls : [(theme.coverImage || "").trim()].filter(Boolean); + } + + if (allStoreRows) { + const slaves = allStoreRows + .filter((t) => t.theme_role === "slave" && t.master_id === theme.id) + .sort((a, b) => a.id.localeCompare(b.id)); + for (const s of slaves) { + push((marqueeOrCoverUrl(s) || "").trim()); + } + } + + if (urls.length > 0) return urls; + const fallback = (theme.coverImage || "").trim(); + return fallback ? [fallback] : []; +} + +/** + * Downloads shown on the grid card for a master row: master's count plus each slave + * (flat `theme_role === "slave"` with `master_id`) and any flavour-only `download_count` + * when there is no matching flat slave id (nested-only API shape). + */ +export function masterGridDisplayDownloadCount(master: Theme, allStoreRows: Theme[]): number { + let total = master.download_count ?? 0; + const slaveRows = allStoreRows.filter( + (t) => t.theme_role === "slave" && t.master_id === master.id, + ); + const countedIds = new Set(slaveRows.map((r) => r.id)); + for (const r of slaveRows) { + total += r.download_count ?? 0; + } + for (const f of master.flavours ?? []) { + if (countedIds.has(f.id)) continue; + total += f.download_count ?? 0; + } + return total; +} + +/** Modal hero: master first, then each flavour (plan order). */ +export function buildModalHeroSlides(theme: Theme): ModalHeroSlide[] { + const slides: ModalHeroSlide[] = []; + slides.push({ + imageUrl: marqueeOrCoverUrl(theme), + caption: theme.name, + }); + const flavours = theme.flavours ?? []; + for (const f of flavours) { + slides.push({ + imageUrl: f.marquee_image || f.cover_image, + caption: `${theme.name} — ${f.name}`, + }); + } + return slides; +} + +/** + * Relative luminance 0–1 for rgba/rgb/hex-ish strings; fallback 0.5 → white text + */ +export function pickContrastingTextColor(backgroundCss: string): "#ffffff" | "#0a0a0a" { + const s = backgroundCss.trim(); + const rgba = s.match( + /rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)/i, + ); + if (rgba) { + const r = Number(rgba[1]) / 255; + const g = Number(rgba[2]) / 255; + const b = Number(rgba[3]) / 255; + const a = rgba[4] !== undefined ? Number(rgba[4]) : 1; + const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b; + const effective = lum * a + 0.05 * (1 - a); + return effective > 0.45 ? "#0a0a0a" : "#ffffff"; + } + const hex = s.match(/^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i); + if (hex) { + const r = parseInt(hex[1], 16) / 255; + const g = parseInt(hex[2], 16) / 255; + const b = parseInt(hex[3], 16) / 255; + const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b; + return lum > 0.45 ? "#0a0a0a" : "#ffffff"; + } + return "#ffffff"; +} diff --git a/src/plugins/built-in/assessmentsOverview/api.ts b/src/plugins/built-in/assessmentsOverview/api.ts index 38df693c..3b1d32fd 100644 --- a/src/plugins/built-in/assessmentsOverview/api.ts +++ b/src/plugins/built-in/assessmentsOverview/api.ts @@ -28,9 +28,17 @@ async function fetchJSON(url: string, body: any) { async function loadSubjects() { const res = await fetchJSON("/seqta/student/load/subjects?", {}); - return res.payload - .filter((s: any) => s.active === 1) + const activeGroup = res.payload.find((s: any) => s.active === 1); + const activeYear = activeGroup?.year; + const allSubjects = res.payload + .filter((s: any) => s.year === activeYear) .flatMap((s: any) => s.subjects); + const seen = new Set(); + return allSubjects.filter((s: Subject) => { + if (seen.has(s.code)) return false; + seen.add(s.code); + return true; + }); } async function loadPrefs(student: number) { diff --git a/src/plugins/built-in/messageFolders/index.ts b/src/plugins/built-in/messageFolders/index.ts new file mode 100644 index 00000000..e8170e86 --- /dev/null +++ b/src/plugins/built-in/messageFolders/index.ts @@ -0,0 +1,748 @@ +import type { Plugin } from "../../core/types"; +import { booleanSetting } from "@/plugins/core/settingsHelpers"; +import { waitForElm } from "@/seqta/utils/waitForElm"; +import styles from "./styles.css?inline"; + +const messageFoldersSettings = { + showTagsInAllMessages: booleanSetting({ + default: true, + title: "Show folder tags in All Messages", + description: + "When off, folder tags are not shown on the message list until you select a folder.", + }), + hideFolderedMessagesInAll: booleanSetting({ + default: true, + title: "Hide foldered messages in All Messages", + description: + "When on, messages assigned to a custom folder are hidden from the inbox until you open that folder.", + }), +} as const; + +interface Folder { + id: string; + name: string; + color: string; +} + +interface MessageFoldersStorage { + folders: Folder[]; + messageAssignments: Record; +} + +const FOLDER_COLORS = [ + "#3b82f6", "#ef4444", "#22c55e", "#f59e0b", + "#8b5cf6", "#ec4899", "#14b8a6", "#f97316", +]; + +const FOLDER_ICON_SVG = ``; +const PLUS_SVG = ``; +const CHECK_SVG_WHITE = ``; +const CLOSE_SVG = ``; +const EDIT_SVG = ``; +const TRASH_SVG = ``; + +function generateId(): string { + return Date.now().toString(36) + Math.random().toString(36).slice(2, 7); +} + +const messageFoldersPlugin: Plugin = { + id: "messageFolders", + name: "Message Folders", + description: "Organize direct messages into custom folders", + version: "1.0.0", + settings: messageFoldersSettings, + disableToggle: true, + defaultEnabled: true, + + run: async (api) => { + const styleEl = document.createElement("style"); + styleEl.textContent = styles; + document.head.appendChild(styleEl); + + await api.storage.loaded; + + if (!api.storage.folders) api.storage.folders = []; + if (!api.storage.messageAssignments) api.storage.messageAssignments = {}; + + let activeFolderId: string | null = null; + let messageListObserver: MutationObserver | null = null; + let sidebarObserver: MutationObserver | null = null; + let actionsObserver: MutationObserver | null = null; + let openDropdown: HTMLElement | null = null; + let dropdownCloseHandler: ((e: MouseEvent) => void) | null = null; + const unregisters: Array<{ unregister: () => void }> = []; + + // ── Storage accessors ── + + const getFolders = (): Folder[] => api.storage.folders ?? []; + const getAssignments = (): Record => api.storage.messageAssignments ?? {}; + + const saveFolders = (folders: Folder[]) => { + api.storage.folders = [...folders]; + }; + + const saveAssignments = (assignments: Record) => { + api.storage.messageAssignments = { ...assignments }; + }; + + const getMessageFolderIds = (messageId: string): string[] => { + const assignments = getAssignments(); + const ids: string[] = []; + for (const [folderId, msgIds] of Object.entries(assignments)) { + if (msgIds.includes(messageId)) ids.push(folderId); + } + return ids; + }; + + const toggleMessageInFolder = (messageId: string, folderId: string) => { + const assignments = getAssignments(); + if (!assignments[folderId]) assignments[folderId] = []; + const idx = assignments[folderId].indexOf(messageId); + if (idx >= 0) { + assignments[folderId].splice(idx, 1); + } else { + assignments[folderId].push(messageId); + } + saveAssignments(assignments); + }; + + const getFolderMessageCount = (folderId: string): number => { + return (getAssignments()[folderId] ?? []).length; + }; + + const restoreSubjectPlain = (subject: Element) => { + subject.querySelector(".bsplus-msg-badges")?.remove(); + const textWrap = subject.querySelector(".bsplus-subject-text"); + if (textWrap) { + subject.textContent = textWrap.textContent ?? ""; + } + }; + + const isMessageInAnyCustomFolder = (messageId: string): boolean => { + for (const msgIds of Object.values(getAssignments())) { + if (msgIds.includes(messageId)) return true; + } + return false; + }; + + const shouldShowBadgesInList = (): boolean => { + return api.settings.showTagsInAllMessages || activeFolderId !== null; + }; + + // ── Confirm modal ── + + const showConfirmModal = ( + title: string, + message: string, + onConfirm: () => void, + ) => { + const overlay = document.createElement("div"); + overlay.className = "bsplus-modal-overlay"; + + const modal = document.createElement("div"); + modal.className = "bsplus-modal"; + modal.innerHTML = ` +

${title}

+

${message}

+
+ + +
+ `; + overlay.appendChild(modal); + + const remove = () => { + overlay.remove(); + document.removeEventListener("keydown", onKey); + }; + + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") remove(); + }; + + overlay.addEventListener("click", (e) => { + if (e.target === overlay) remove(); + }); + modal.querySelector(".bsplus-modal-btn-cancel")!.addEventListener("click", remove); + modal.querySelector(".bsplus-modal-btn-danger")!.addEventListener("click", () => { + onConfirm(); + remove(); + }); + + document.body.appendChild(overlay); + document.addEventListener("keydown", onKey); + }; + + // ── Sidebar folder UI ── + + const renderSidebarFolders = () => { + const sidebar = document.querySelector("[class*='Viewer__sidebar___']"); + if (!sidebar) return; + + const ol = sidebar.querySelector("ol"); + if (!ol) return; + + let section = ol.querySelector(".bsplus-folders-section"); + if (!section) { + section = document.createElement("div"); + section.className = "bsplus-folders-section"; + ol.appendChild(section); + } + + const folders = getFolders(); + const existingInput = section.querySelector(".bsplus-folder-input"); + const existingColors = section.querySelector(".bsplus-folder-colors"); + + section.innerHTML = ""; + + // Header + const header = document.createElement("div"); + header.className = "bsplus-folders-header"; + + const label = document.createElement("span"); + label.textContent = "Folders"; + header.appendChild(label); + + const addBtn = document.createElement("button"); + addBtn.className = "bsplus-folders-add-btn"; + addBtn.title = "New folder"; + addBtn.innerHTML = PLUS_SVG; + addBtn.addEventListener("click", (e) => { + e.stopPropagation(); + showNewFolderInput(section!); + }); + header.appendChild(addBtn); + section.appendChild(header); + + // "All Messages" item + const allItem = document.createElement("div"); + allItem.className = `bsplus-folder-item${activeFolderId === null ? " bsplus-folder-active" : ""}`; + allItem.innerHTML = ` + + All Messages + `; + allItem.addEventListener("click", () => { + activeFolderId = null; + applyFolderFilter(); + applyBadges(); + renderSidebarFolders(); + }); + section.appendChild(allItem); + + // Folder items + for (const folder of folders) { + const item = document.createElement("div"); + item.className = `bsplus-folder-item${activeFolderId === folder.id ? " bsplus-folder-active" : ""}`; + item.dataset.folderId = folder.id; + + const dot = document.createElement("div"); + dot.className = "bsplus-folder-dot"; + dot.style.background = folder.color; + item.appendChild(dot); + + const name = document.createElement("span"); + name.className = "bsplus-folder-name"; + name.textContent = folder.name; + item.appendChild(name); + + const actions = document.createElement("div"); + actions.className = "bsplus-folder-actions"; + + const editBtn = document.createElement("button"); + editBtn.className = "bsplus-folder-action-btn"; + editBtn.title = "Rename"; + editBtn.innerHTML = EDIT_SVG; + editBtn.addEventListener("click", (e) => { + e.stopPropagation(); + showEditFolderInput(section!, folder); + }); + actions.appendChild(editBtn); + + const deleteBtn = document.createElement("button"); + deleteBtn.className = "bsplus-folder-action-btn"; + deleteBtn.title = "Delete"; + deleteBtn.innerHTML = TRASH_SVG; + deleteBtn.addEventListener("click", (e) => { + e.stopPropagation(); + showConfirmModal( + "Delete folder", + `Remove "${folder.name}"? Messages won't be deleted.`, + () => { + const folders = getFolders().filter((f) => f.id !== folder.id); + saveFolders(folders); + const assignments = getAssignments(); + delete assignments[folder.id]; + saveAssignments(assignments); + if (activeFolderId === folder.id) activeFolderId = null; + applyFolderFilter(); + applyBadges(); + renderSidebarFolders(); + }, + ); + }); + actions.appendChild(deleteBtn); + + item.appendChild(actions); + + const count = document.createElement("span"); + count.className = "bsplus-folder-count"; + const c = getFolderMessageCount(folder.id); + count.textContent = c > 0 ? String(c) : ""; + item.appendChild(count); + + item.addEventListener("click", () => { + activeFolderId = folder.id; + applyFolderFilter(); + applyBadges(); + renderSidebarFolders(); + }); + + section.appendChild(item); + } + + // Restore input if it was open + if (existingInput || existingColors) { + // Don't restore – let user re-trigger + } + }; + + const showNewFolderInput = (container: Element, editFolder?: Folder) => { + const existing = container.querySelector(".bsplus-folder-input"); + if (existing) existing.remove(); + container.querySelector(".bsplus-folder-colors")?.remove(); + + let selectedColor = editFolder?.color ?? FOLDER_COLORS[Math.floor(Math.random() * FOLDER_COLORS.length)]; + + const row = document.createElement("div"); + row.className = "bsplus-folder-input"; + + const input = document.createElement("input"); + input.type = "text"; + input.placeholder = editFolder ? "Rename folder…" : "Folder name…"; + input.value = editFolder?.name ?? ""; + input.maxLength = 30; + + const confirmBtn = document.createElement("button"); + confirmBtn.className = "bsplus-folder-input-confirm"; + confirmBtn.innerHTML = CHECK_SVG_WHITE; + + const cancelBtn = document.createElement("button"); + cancelBtn.className = "bsplus-folder-input-cancel"; + cancelBtn.innerHTML = CLOSE_SVG; + + row.appendChild(input); + row.appendChild(confirmBtn); + row.appendChild(cancelBtn); + + // Color picker + const colorRow = document.createElement("div"); + colorRow.className = "bsplus-folder-colors"; + for (const color of FOLDER_COLORS) { + const swatch = document.createElement("button"); + swatch.className = `bsplus-folder-color-opt${color === selectedColor ? " bsplus-color-selected" : ""}`; + swatch.style.background = color; + swatch.addEventListener("click", (e) => { + e.stopPropagation(); + selectedColor = color; + colorRow.querySelectorAll(".bsplus-folder-color-opt").forEach((s) => + s.classList.toggle("bsplus-color-selected", (s as HTMLElement).style.background === color), + ); + }); + colorRow.appendChild(swatch); + } + + const confirm = () => { + const name = input.value.trim(); + if (!name) return; + + if (editFolder) { + const folders = getFolders().map((f) => + f.id === editFolder.id ? { ...f, name, color: selectedColor } : f, + ); + saveFolders(folders); + } else { + const folder: Folder = { id: generateId(), name, color: selectedColor }; + saveFolders([...getFolders(), folder]); + } + applyBadges(); + renderSidebarFolders(); + }; + + confirmBtn.addEventListener("click", (e) => { + e.stopPropagation(); + confirm(); + }); + cancelBtn.addEventListener("click", (e) => { + e.stopPropagation(); + renderSidebarFolders(); + }); + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") confirm(); + if (e.key === "Escape") renderSidebarFolders(); + }); + + container.appendChild(row); + container.appendChild(colorRow); + requestAnimationFrame(() => input.focus()); + }; + + const showEditFolderInput = (container: Element, folder: Folder) => { + showNewFolderInput(container, folder); + }; + + // ── Intercept native sidebar clicks to clear folder filter ── + + const attachNativeSidebarListeners = () => { + const sidebar = document.querySelector("[class*='Viewer__sidebar___']"); + if (!sidebar) return; + + const ol = sidebar.querySelector("ol"); + if (!ol) return; + + ol.addEventListener("click", (e) => { + const target = e.target as HTMLElement; + if (target.closest(".bsplus-folders-section")) return; + + const li = target.closest("li"); + if (li && ol.contains(li)) { + if (activeFolderId !== null) { + activeFolderId = null; + applyFolderFilter(); + applyBadges(); + renderSidebarFolders(); + } + } + }); + }; + + // ── "Add to folder" button in message action bar ── + + const injectFolderButton = (actionsBar: Element) => { + if (actionsBar.querySelector(".bsplus-folder-btn")) return; + + const wrapper = document.createElement("div"); + wrapper.className = "bsplus-folder-btn"; + wrapper.style.position = "relative"; + wrapper.style.display = "inline-block"; + + const btn = document.createElement("button"); + const btnClasses = actionsBar.querySelector("button")?.className ?? ""; + btn.className = btnClasses; + btn.title = "Add to folder"; + btn.innerHTML = FOLDER_ICON_SVG; + + btn.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + closeDropdown(); + + const selectedMsg = document.querySelector("[class*='MessageList__selected___']"); + const messageId = selectedMsg?.getAttribute("data-message"); + if (!messageId) return; + + showFolderDropdown(wrapper, messageId); + }); + + wrapper.appendChild(btn); + + const moreMenu = actionsBar.querySelector("[class*='MenuButton__Menu___']"); + if (moreMenu) { + actionsBar.insertBefore(wrapper, moreMenu); + } else { + actionsBar.appendChild(wrapper); + } + }; + + const showFolderDropdown = (anchor: HTMLElement, messageId: string) => { + const dropdown = document.createElement("div"); + dropdown.className = "bsplus-folder-dropdown"; + + const folders = getFolders(); + const currentFolderIds = getMessageFolderIds(messageId); + + if (folders.length === 0) { + const empty = document.createElement("div"); + empty.className = "bsplus-folder-dropdown-empty"; + empty.textContent = "No folders yet"; + dropdown.appendChild(empty); + } else { + for (const folder of folders) { + const isChecked = currentFolderIds.includes(folder.id); + const item = document.createElement("button"); + item.className = `bsplus-folder-dropdown-item${isChecked ? " bsplus-checked" : ""}`; + + const check = document.createElement("div"); + check.className = "bsplus-folder-dropdown-check"; + check.style.borderColor = isChecked ? folder.color : ""; + check.style.background = isChecked ? folder.color : ""; + check.innerHTML = CHECK_SVG_WHITE; + + const dot = document.createElement("div"); + dot.className = "bsplus-folder-dot"; + dot.style.background = folder.color; + + const name = document.createElement("span"); + name.textContent = folder.name; + + item.appendChild(check); + item.appendChild(dot); + item.appendChild(name); + + item.addEventListener("click", (e) => { + e.stopPropagation(); + toggleMessageInFolder(messageId, folder.id); + + const nowChecked = getMessageFolderIds(messageId).includes(folder.id); + item.classList.toggle("bsplus-checked", nowChecked); + check.style.borderColor = nowChecked ? folder.color : ""; + check.style.background = nowChecked ? folder.color : ""; + + applyBadges(); + applyFolderFilter(); + renderSidebarFolders(); + }); + + dropdown.appendChild(item); + } + } + + anchor.appendChild(dropdown); + openDropdown = dropdown; + + dropdownCloseHandler = (e: MouseEvent) => { + if (!dropdown.contains(e.target as Node) && !anchor.contains(e.target as Node)) { + closeDropdown(); + } + }; + setTimeout(() => { + document.addEventListener("click", dropdownCloseHandler!, true); + }, 0); + }; + + const closeDropdown = () => { + if (openDropdown) { + openDropdown.remove(); + openDropdown = null; + } + if (dropdownCloseHandler) { + document.removeEventListener("click", dropdownCloseHandler, true); + dropdownCloseHandler = null; + } + }; + + // ── Message badges ── + + const applyBadges = () => { + const messageItems = document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]"); + + if (!shouldShowBadgesInList()) { + for (const li of messageItems) { + const subject = li.querySelector("[class*='MessageList__subject___']"); + if (subject && (subject.querySelector(".bsplus-msg-badges") || subject.querySelector(".bsplus-subject-text"))) { + restoreSubjectPlain(subject); + } else { + li.querySelector(".bsplus-msg-badges")?.remove(); + } + } + return; + } + + const folders = getFolders(); + const assignments = getAssignments(); + + for (const li of messageItems) { + const msgId = li.getAttribute("data-message"); + if (!msgId) continue; + + let badgeContainer = li.querySelector(".bsplus-msg-badges") as HTMLElement | null; + + const folderIds = []; + for (const [fId, mIds] of Object.entries(assignments)) { + if (mIds.includes(msgId)) folderIds.push(fId); + } + + if (folderIds.length === 0) { + badgeContainer?.remove(); + continue; + } + + if (!badgeContainer) { + badgeContainer = document.createElement("div"); + badgeContainer.className = "bsplus-msg-badges"; + const subject = li.querySelector("[class*='MessageList__subject___']"); + if (subject) { + if (!subject.querySelector(".bsplus-subject-text")) { + const textWrap = document.createElement("span"); + textWrap.className = "bsplus-subject-text"; + textWrap.textContent = subject.textContent; + subject.textContent = ""; + subject.appendChild(textWrap); + } + subject.appendChild(badgeContainer); + } else { + li.appendChild(badgeContainer); + } + } + + badgeContainer.innerHTML = ""; + for (const fId of folderIds) { + const folder = folders.find((f) => f.id === fId); + if (!folder) continue; + const badge = document.createElement("span"); + badge.className = "bsplus-msg-badge"; + badge.style.background = folder.color; + badge.textContent = folder.name; + badge.title = `Filter by "${folder.name}"`; + badge.addEventListener("click", (e) => { + e.stopPropagation(); + activeFolderId = folder.id; + applyFolderFilter(); + applyBadges(); + renderSidebarFolders(); + }); + badgeContainer.appendChild(badge); + } + } + }; + + // ── Folder filtering ── + + const applyFolderFilter = () => { + const messageItems = document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]"); + const moreBtn = document.querySelector("[class*='MessageList__MessageList___'] ol > button"); + + if (activeFolderId === null) { + if (api.settings.hideFolderedMessagesInAll) { + for (const li of messageItems) { + const msgId = li.getAttribute("data-message"); + if (msgId && isMessageInAnyCustomFolder(msgId)) { + li.classList.add("bsplus-folder-hidden"); + } else { + li.classList.remove("bsplus-folder-hidden"); + } + } + } else { + for (const li of messageItems) { + li.classList.remove("bsplus-folder-hidden"); + } + } + if (moreBtn) (moreBtn as HTMLElement).classList.remove("bsplus-folder-hidden"); + return; + } + + const folderMsgIds = getAssignments()[activeFolderId] ?? []; + + for (const li of messageItems) { + const msgId = li.getAttribute("data-message"); + if (msgId && folderMsgIds.includes(msgId)) { + li.classList.remove("bsplus-folder-hidden"); + } else { + li.classList.add("bsplus-folder-hidden"); + } + } + if (moreBtn) (moreBtn as HTMLElement).classList.add("bsplus-folder-hidden"); + }; + + // ── Observers ── + + const setupMessageListObserver = () => { + const messageList = document.querySelector("[class*='MessageList__MessageList___'] ol"); + if (!messageList || messageListObserver) return; + + messageListObserver = new MutationObserver(() => { + applyBadges(); + applyFolderFilter(); + }); + messageListObserver.observe(messageList, { childList: true, subtree: false }); + }; + + const setupActionsObserver = () => { + if (actionsObserver) return; + + const target = document.querySelector("[class*='Viewer__Viewer___']") ?? document.querySelector("div.messages"); + if (!target) return; + + actionsObserver = new MutationObserver(() => { + const actionsBar = document.querySelector("[class*='Message__actions___']"); + if (actionsBar && !actionsBar.querySelector(".bsplus-folder-btn")) { + injectFolderButton(actionsBar); + } + }); + actionsObserver.observe(target, { childList: true, subtree: true }); + }; + + // ── Main page handler ── + + const handleMessagesPage = async () => { + await waitForElm("[class*='Viewer__sidebar___'] ol", true, 50, 100); + + renderSidebarFolders(); + attachNativeSidebarListeners(); + + await waitForElm("[class*='MessageList__MessageList___'] ol", true, 50, 100); + applyBadges(); + applyFolderFilter(); + setupMessageListObserver(); + + // The actions bar only exists when a message is selected/open, + // so we observe the whole viewer for it to appear dynamically + setupActionsObserver(); + + // If a message is already selected, inject immediately + const actionsBar = document.querySelector("[class*='Message__actions___']"); + if (actionsBar) injectFolderButton(actionsBar); + + // Re-observe the sidebar for SEQTA re-renders + const sidebar = document.querySelector("[class*='Viewer__sidebar___']"); + if (sidebar && !sidebarObserver) { + sidebarObserver = new MutationObserver(() => { + const ol = sidebar.querySelector("ol"); + if (ol && !ol.querySelector(".bsplus-folders-section")) { + renderSidebarFolders(); + attachNativeSidebarListeners(); + } + }); + sidebarObserver.observe(sidebar, { childList: true, subtree: true }); + } + }; + + // ── Lifecycle ── + + const mountUnsub = api.seqta.onMount("div.messages", handleMessagesPage); + unregisters.push(mountUnsub); + + unregisters.push( + api.settings.onChange("showTagsInAllMessages", () => { + applyBadges(); + }), + ); + unregisters.push( + api.settings.onChange("hideFolderedMessagesInAll", () => { + applyFolderFilter(); + }), + ); + + return () => { + for (const u of unregisters) u.unregister(); + messageListObserver?.disconnect(); + sidebarObserver?.disconnect(); + actionsObserver?.disconnect(); + closeDropdown(); + styleEl.remove(); + document.querySelectorAll(".bsplus-folders-section").forEach((el) => el.remove()); + document.querySelectorAll(".bsplus-folder-btn").forEach((el) => el.remove()); + document.querySelectorAll(".bsplus-msg-badges").forEach((el) => el.remove()); + document.querySelectorAll("[class*='MessageList__subject___']").forEach((subject) => { + if (subject.querySelector(".bsplus-subject-text")) { + restoreSubjectPlain(subject); + } + }); + document.querySelectorAll(".bsplus-folder-hidden").forEach((el) => + el.classList.remove("bsplus-folder-hidden"), + ); + document.querySelectorAll(".bsplus-modal-overlay").forEach((el) => el.remove()); + }; + }, +}; + +export default messageFoldersPlugin; diff --git a/src/plugins/built-in/messageFolders/styles.css b/src/plugins/built-in/messageFolders/styles.css new file mode 100644 index 00000000..e239a883 --- /dev/null +++ b/src/plugins/built-in/messageFolders/styles.css @@ -0,0 +1,491 @@ +/* ── Sidebar folder section ── */ +.bsplus-folders-section { + border-top: 1px solid var(--background-secondary, rgba(128, 128, 128, 0.2)); + margin-top: 4px; + padding-top: 4px; +} + +.bsplus-folders-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 12px 2px; + user-select: none; +} + +.bsplus-folders-header span { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-primary, #666); + opacity: 0.5; +} + +.bsplus-folders-add-btn { + display: flex !important; + align-items: center !important; + justify-content: center !important; + width: 20px !important; + height: 20px !important; + min-width: 0 !important; + border: none !important; + background: transparent !important; + opacity: 0.5; + cursor: pointer; + border-radius: 4px !important; + padding: 0 !important; + margin: 0 !important; + transition: all 0.2s ease; + text-align: center !important; +} + +.bsplus-folders-add-btn:hover { + opacity: 1; + background: var(--background-secondary, rgba(128, 128, 128, 0.1)) !important; +} + +/* ── Folder list items ── */ +.bsplus-folder-item { + display: flex; + align-items: center; + padding: 6px 12px; + cursor: pointer; + transition: background 0.15s ease; + position: relative; + gap: 8px; + user-select: none; +} + +.bsplus-folder-item:hover { + background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08)); +} + +.bsplus-folder-item.bsplus-folder-active { + background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.12)); +} + +.bsplus-folder-item.bsplus-folder-active::before { + content: ""; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: var(--better-main, #007bff); + border-radius: 0 2px 2px 0; +} + +.bsplus-folder-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.bsplus-folder-name { + font-size: 13px; + color: var(--text-primary, #333); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bsplus-folder-count { + font-size: 11px; + color: var(--text-primary, #999); + opacity: 0.5; + flex-shrink: 0; +} + +.bsplus-folder-actions { + display: flex; + gap: 2px; + opacity: 0; + transition: opacity 0.15s ease; +} + +.bsplus-folder-item:hover .bsplus-folder-actions { + opacity: 1; +} + +.bsplus-folder-action-btn { + display: flex !important; + align-items: center !important; + justify-content: center !important; + width: 20px !important; + height: 20px !important; + min-width: 0 !important; + border: none !important; + background: transparent !important; + opacity: 0.6; + cursor: pointer; + border-radius: 4px !important; + padding: 0 !important; + margin: 0 !important; + transition: all 0.15s ease; +} + +.bsplus-folder-action-btn:hover { + opacity: 1; + background: var(--background-secondary, rgba(128, 128, 128, 0.15)) !important; +} + +/* ── Inline folder name input ── */ +.bsplus-folder-input { + display: flex; + align-items: center; + padding: 4px 12px; + gap: 6px; +} + +.bsplus-folder-input input { + flex: 1; + min-width: 0; + padding: 4px 8px; + font-size: 13px; + border: 1px solid var(--background-secondary, #ccc); + border-radius: 6px; + background: var(--background-secondary, #f5f5f5); + color: var(--text-primary, #333); + outline: none; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.bsplus-folder-input input:focus { + border-color: var(--better-main, #007bff); + box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.2); +} + +.bsplus-folder-input-confirm, +.bsplus-folder-input-cancel { + display: flex !important; + align-items: center !important; + justify-content: center !important; + width: 24px !important; + height: 24px !important; + min-width: 0 !important; + border: none !important; + border-radius: 4px !important; + cursor: pointer; + padding: 0 !important; + margin: 0 !important; + transition: all 0.15s ease; +} + +.bsplus-folder-input-confirm { + background: var(--better-main, #007bff) !important; +} + +.bsplus-folder-input-confirm:hover { + transform: scale(1.1); +} + +.bsplus-folder-input-cancel { + background: transparent !important; + opacity: 0.6; +} + +.bsplus-folder-input-cancel:hover { + opacity: 1; + background: var(--background-secondary, rgba(128, 128, 128, 0.1)) !important; +} + +/* ── Color picker row ── */ +.bsplus-folder-colors { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 6px; + padding: 4px 12px 6px; + max-width: 120px; +} + +.bsplus-folder-color-opt { + width: 20px; + height: 20px; + border-radius: 50%; + border: 2px solid transparent; + cursor: pointer; + transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), + border-color 0.2s ease, + box-shadow 0.25s cubic-bezier(0.4, 0, 0.2, 1); + padding: 0; + background: none; + box-sizing: border-box; +} + +.bsplus-folder-color-opt:hover { + transform: scale(1.25); + box-shadow: 0 0 0 3px rgba(128, 128, 128, 0.15); +} + +.bsplus-folder-color-opt.bsplus-color-selected { + border-color: var(--text-primary, #333); + transform: scale(1.15); + box-shadow: 0 0 0 3px rgba(128, 128, 128, 0.2); +} + +.bsplus-folder-color-opt.bsplus-color-selected:hover { + transform: scale(1.25); +} + +/* ── "Add to folder" button in message actions bar ── */ +.bsplus-folder-btn { + position: relative; +} + +.bsplus-folder-btn svg { + fill: currentColor; +} + +/* ── Folder dropdown ── */ +.bsplus-folder-dropdown { + position: absolute; + top: 100%; + right: 0; + margin-top: 4px; + min-width: 180px; + background: var(--background-primary, #fff); + border: 1px solid var(--background-secondary, #e0e0e0); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + z-index: 1000; + overflow: hidden; + animation: bsplus-dropdown-in 0.15s ease-out; +} + +@keyframes bsplus-dropdown-in { + from { + opacity: 0; + transform: translateY(-4px) scale(0.97); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.bsplus-folder-dropdown-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + cursor: pointer; + transition: background 0.1s ease; + border: none; + background: transparent; + width: 100%; + text-align: left; + color: var(--text-primary, #333); + font-size: 13px; +} + +.bsplus-folder-dropdown-item:hover { + background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08)); +} + +.bsplus-folder-dropdown-check { + width: 16px; + height: 16px; + border: 2px solid var(--background-secondary, #ccc); + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all 0.15s ease; +} + +.bsplus-folder-dropdown-item.bsplus-checked .bsplus-folder-dropdown-check { + background: var(--better-main, #007bff); + border-color: var(--better-main, #007bff); +} + +.bsplus-folder-dropdown-check svg { + width: 10px; + height: 10px; + color: white; + opacity: 0; + transition: opacity 0.1s ease; +} + +.bsplus-folder-dropdown-item.bsplus-checked .bsplus-folder-dropdown-check svg { + opacity: 1; +} + +.bsplus-folder-dropdown-empty { + padding: 12px; + text-align: center; + font-size: 12px; + color: var(--text-primary, #999); + opacity: 0.5; +} + +/* ── Let primary column use available space instead of being clipped ── */ +[class*='MessageList__primary___'] { + flex: 1 1 0% !important; + min-width: 0 !important; + overflow: hidden !important; +} + +/* ── Make subject line a flex row so badges sit inline ── */ +[class*='MessageList__subject___'] { + display: flex !important; + align-items: center; + gap: 6px; + min-width: 0 !important; + overflow: hidden !important; +} + +/* ── Subject text truncates to make room for badges ── */ +.bsplus-subject-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex: 1 1 auto; +} + +/* ── Shrink the secondary column to its content ── */ +[class*='MessageList__secondary___'] { + flex: 0 0 auto !important; + width: auto !important; + min-width: 0 !important; + max-width: 200px !important; +} + +/* ── Constrain the flags/attachment icon column ── */ +[class*='MessageList__flags___'] { + width: 24px !important; + min-width: 0 !important; + flex-shrink: 0 !important; +} + +/* ── Message list folder badges ── */ +.bsplus-msg-badges { + display: inline-flex; + align-items: center; + gap: 3px; + flex-shrink: 0; + margin-left: auto; +} + +.bsplus-msg-badge { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 1px 6px; + border-radius: 8px; + font-size: 10px; + font-weight: 500; + line-height: 1.4; + color: white; + white-space: nowrap; + cursor: pointer; + transition: opacity 0.2s ease, transform 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.bsplus-msg-badge:hover { + opacity: 0.85; + transform: scale(1.05); +} + +/* ── Folder filtering (hide messages not in active folder) ── */ +.bsplus-folder-hidden { + display: none !important; +} + +/* ── Delete confirmation modal ── */ +@keyframes bsplus-modal-overlay-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes bsplus-modal-in { + from { + opacity: 0; + transform: scale(0.95) translateY(-8px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.bsplus-modal-overlay { + position: fixed; + inset: 0; + z-index: 2147483647; + display: flex; + justify-content: center; + align-items: center; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + animation: bsplus-modal-overlay-in 0.2s ease-out forwards; +} + +.bsplus-modal { + padding: 1rem 1.5rem; + margin: 0 1rem; + min-width: 16rem; + max-width: 22rem; + width: 100%; + box-sizing: border-box; + background: var(--background-primary, #fff); + border-radius: 0.75rem; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + border: 1px solid var(--background-secondary, #e0e0e0); + animation: bsplus-modal-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; +} + +.bsplus-modal h3 { + margin: 0 0 0.5rem; + font-size: 1rem; + font-weight: 600; + color: var(--text-primary, #333); +} + +.bsplus-modal p { + margin: 0 0 1rem; + font-size: 0.875rem; + color: var(--text-primary, #666); + opacity: 0.8; +} + +.bsplus-modal-actions { + display: flex; + gap: 0.75rem; + justify-content: flex-end; +} + +.bsplus-modal-actions button { + padding: 0.4rem 1rem; + font-size: 0.875rem; + font-weight: 500; + border-radius: 0.5rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.bsplus-modal-btn-cancel { + background: transparent; + border: 1px solid var(--background-secondary, #ccc); + color: var(--text-primary, #333); +} + +.bsplus-modal-btn-cancel:hover { + background: var(--background-secondary, rgba(128, 128, 128, 0.1)); +} + +.bsplus-modal-btn-danger { + background: #e53e3e; + border: none; + color: white; +} + +.bsplus-modal-btn-danger:hover { + background: #c53030; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(229, 62, 62, 0.35); +} diff --git a/src/plugins/built-in/themes/index.ts b/src/plugins/built-in/themes/index.ts index b31abd86..9127d739 100644 --- a/src/plugins/built-in/themes/index.ts +++ b/src/plugins/built-in/themes/index.ts @@ -10,6 +10,7 @@ const themesPlugin: Plugin = { run: async (_) => { const themeManager = ThemeManager.getInstance(); + await themeManager.prepareThemeAfterCloudSync(); await themeManager.initialize(); }, }; diff --git a/src/plugins/built-in/themes/theme-manager.ts b/src/plugins/built-in/themes/theme-manager.ts index e37f90e5..0faf1449 100644 --- a/src/plugins/built-in/themes/theme-manager.ts +++ b/src/plugins/built-in/themes/theme-manager.ts @@ -6,6 +6,7 @@ import { type LoadedCustomTheme, shouldForceThemeAppearance, } from "@/types/CustomThemes"; +import { BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY } from "@/seqta/utils/cloudSettingsSync"; import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import debounce from "@/seqta/utils/debounce"; import { themeUpdates } from "@/interface/hooks/ThemeUpdates"; @@ -166,6 +167,33 @@ export class ThemeManager { } } + /** + * After cloud restore, IndexedDB/theme storage is only reachable from page context (not MV3 SW). + * Background sets BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY; we fetch the store JSON here before setTheme(). + * The resolved id matches cloud sync **`themeId` / `selectedTheme`**: it may be a standard theme uuid or a + * flavour (slave) variant id — **`downloadAndInstallStoreTheme`** is the same code path as the theme store installer. + */ + public async prepareThemeAfterCloudSync(): Promise { + try { + const snap = await browser.storage.local.get(BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY); + const pending = snap[BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY]; + if (pending === undefined) return; + + await browser.storage.local.remove(BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY); + + if (typeof pending !== "string") return; + const id = pending.trim(); + if (!id) return; + + const existing = (await localforage.getItem(id)) as CustomTheme | null; + if (existing) return; + + await this.downloadAndInstallStoreTheme({ id, name: id }); + } catch (e) { + console.warn("[ThemeManager] prepareThemeAfterCloudSync:", e); + } + } + /** * Initialize the theme system and restore previous state */ diff --git a/src/plugins/index.ts b/src/plugins/index.ts index 0648accb..009e9e43 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -10,6 +10,7 @@ import assessmentsAveragePlugin from "./built-in/assessmentsAverage"; import profilePicturePlugin from "./built-in/profilePicture"; import assessmentsOverviewPlugin from "./built-in/assessmentsOverview"; import backgroundMusicPlugin from "./built-in/backgroundMusic"; +import messageFoldersPlugin from "./built-in/messageFolders"; //import testPlugin from './built-in/test'; // Heavy plugins (lazy-loaded only when enabled) @@ -28,6 +29,7 @@ pluginManager.registerPlugin(timetableEditPlugin); pluginManager.registerPlugin(profilePicturePlugin); pluginManager.registerPlugin(assessmentsOverviewPlugin); pluginManager.registerPlugin(backgroundMusicPlugin); +pluginManager.registerPlugin(messageFoldersPlugin); //pluginManager.registerPlugin(testPlugin); // Register heavy plugins with lazy loading diff --git a/src/resources/update-video.webm b/src/resources/update-video.webm index 9e583a06..32abbdbd 100644 Binary files a/src/resources/update-video.webm and b/src/resources/update-video.webm differ diff --git a/src/seqta/utils/Loaders/LoadHomePage.ts b/src/seqta/utils/Loaders/LoadHomePage.ts index dcbd553d..e590e097 100644 --- a/src/seqta/utils/Loaders/LoadHomePage.ts +++ b/src/seqta/utils/Loaders/LoadHomePage.ts @@ -113,7 +113,16 @@ export async function loadHomePage() { callHomeTimetable(TodayFormatted, true); const activeClass = classes.find((c: any) => c.hasOwnProperty("active")); - const activeSubjects = activeClass?.subjects || []; + const activeYear = activeClass?.year; + const allSubjectsInYear = classes + .filter((c: any) => c.year === activeYear) + .flatMap((c: any) => c.subjects || []); + const seen = new Set(); + const activeSubjects = allSubjectsInYear.filter((s: any) => { + if (seen.has(s.code)) return false; + seen.add(s.code); + return true; + }); const activeSubjectCodes = activeSubjects.map((s: any) => s.code); const currentAssessments = assessments .filter((a: any) => activeSubjectCodes.includes(a.code)) diff --git a/src/seqta/utils/Openers/OpenWhatsNewPopup.ts b/src/seqta/utils/Openers/OpenWhatsNewPopup.ts index 76495fc9..19e5b86f 100644 --- a/src/seqta/utils/Openers/OpenWhatsNewPopup.ts +++ b/src/seqta/utils/Openers/OpenWhatsNewPopup.ts @@ -35,10 +35,16 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
-

3.6.4 - Patch for alpine theme

-
  • Added single channel adaptive CSS variables (r/g/b)
  • +

    3.6.4 - DM Folders, Theme falvours and fixes, Upcoming Assements improvement

    +
  • Added advanced colour adjustments variables for theme customisation.
  • +
  • Improved logic for upcoming assements dashlet to improve compatibility.
  • +
  • BS Cloud can now automatically download themes from other devices.
  • +
  • Added theme flavour's for multiple colour variations of the same theme.
  • +
  • Added custom message folder, customizable in settings.
  • +

    3.6.3 - Assessment overview fix

  • Fixed assessments overview failing to load.
  • +

    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).
  • @@ -52,6 +58,7 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
  • 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.
  • diff --git a/src/seqta/utils/cloudSettingsSync.ts b/src/seqta/utils/cloudSettingsSync.ts index 8918cd80..d474fc76 100644 --- a/src/seqta/utils/cloudSettingsSync.ts +++ b/src/seqta/utils/cloudSettingsSync.ts @@ -10,6 +10,13 @@ export const CLOUD_SETTINGS_SYNC_SCHEMA_VERSION = 1; export const BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY = "bsplus_cloud_settings_known_remote_updated_at"; +/** + * Written by the service worker after applying a cloud settings envelope; the SEQTA page’s + * ThemeManager reads and clears it (SW cannot share localforage/IndexedDB with the page). + */ +export const BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY = + "bsplus_pending_theme_ensure_after_cloud"; + /** * Never uploaded to the cloud backup (OAuth and legacy keys). * IndexedDB (e.g. Global Search’s `betterseqta-index` database) is not part of @@ -39,6 +46,7 @@ export const SENSITIVE_DEVICE_STORAGE_KEY_PREFIXES = ["plugin.global-search.stor const CLIENT_ONLY_CLOUD_KEYS_EXACT = [ BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY, "bsplus_lastCloudPoll", + BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY, ] as const; /** After restoring from cloud, keep local session so the user stays signed in. */ @@ -101,8 +109,15 @@ function stripExcludedKeysFromRemoteData(remote: Record): Recor return out; } +/** Stored theme id (`selectedTheme`); trims whitespace; empty string clears. */ +export function normalizeThemeIdForSync(raw: unknown): string { + if (typeof raw !== "string") return ""; + return raw.trim(); +} + export function buildUploadPayload(all: Record): { schemaVersion: number; + themeId: string; data: Record; } { const filtered: Record = {}; @@ -111,17 +126,57 @@ export function buildUploadPayload(all: Record): { filtered[k] = v; } const data = migrateLegacyToPluginSettings(filtered); - return { schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, data }; + const themeId = normalizeThemeIdForSync(all.selectedTheme); + return { + schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, + themeId, + data, + }; } export async function getSnapshotForUpload(): Promise<{ schemaVersion: number; + themeId: string; data: Record; }> { const all = await browser.storage.local.get(); return buildUploadPayload(all as Record); } +/** + * Theme to ensure is installed locally after a downloaded envelope (explicit `themeId` overrides `data.selectedTheme`). + * Works for any store-backed id, including **flavour (slave) variants** nested under masters in the catalogue. + */ +export function resolveThemeIdForPostSyncDownload(envelope: unknown): string | undefined { + if (envelope && typeof envelope === "object" && "themeId" in envelope) { + const top = normalizeThemeIdForSync( + (envelope as Record).themeId, + ); + if (top) return top; + } + + let remoteFlat: Record; + if ( + envelope && + typeof envelope === "object" && + "data" in envelope && + (envelope as { data?: unknown }).data !== undefined && + typeof (envelope as { data?: unknown }).data === "object" && + (envelope as { data?: unknown }).data !== null && + !Array.isArray((envelope as { data?: unknown }).data) + ) { + remoteFlat = (envelope as { data: Record }).data; + } else if (envelope && typeof envelope === "object" && !Array.isArray(envelope)) { + remoteFlat = envelope as Record; + } else { + return undefined; + } + + const migrated = migrateLegacyToPluginSettings(remoteFlat); + const fromData = normalizeThemeIdForSync(migrated.selectedTheme); + return fromData === "" ? undefined : fromData; +} + export async function setKnownRemoteUpdatedAt(iso: string | undefined): Promise { if (!iso || typeof iso !== "string") return; await browser.storage.local.set({ [BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY]: iso });