mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-13 15:14:40 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c08bea5f7 |
+8
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "betterseqtaplus",
|
||||
"version": "3.6.5",
|
||||
"version": "3.6.4",
|
||||
"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",
|
||||
@@ -19,7 +19,8 @@
|
||||
"dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg",
|
||||
"release": "gh release create $npm_package_name@$npm_package_version ./dist/*.zip --generate-notes",
|
||||
"publish": "bun lib/publish.js --b",
|
||||
"zip": "bedframe zip"
|
||||
"zip": "bedframe zip",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"targets": {
|
||||
"prod": {
|
||||
@@ -40,6 +41,7 @@
|
||||
"@babel/runtime": "^7.26.9",
|
||||
"@bedframe/cli": "^0.1.2",
|
||||
"@crxjs/vite-plugin": "^2.4.0",
|
||||
"@types/jsdom": "^28.0.1",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
@@ -47,6 +49,7 @@
|
||||
"dependency-cruiser": "^17.0.1",
|
||||
"eslint": "^9.33.0",
|
||||
"glob": "^11.0.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"mime-types": "^3.0.1",
|
||||
"prettier": "^3.5.3",
|
||||
"process": "^0.11.10",
|
||||
@@ -55,7 +58,8 @@
|
||||
"sass-loader": "^16.0.5",
|
||||
"semver": "^7.7.1",
|
||||
"tailwindcss": "3",
|
||||
"url": "^0.11.4"
|
||||
"url": "^0.11.4",
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bedframe/core": "^0.1.0",
|
||||
@@ -92,6 +96,7 @@
|
||||
"flexsearch": "^0.8.147",
|
||||
"fuse.js": "^7.1.0",
|
||||
"idb": "^8.0.2",
|
||||
"jspdf": "^4.2.1",
|
||||
"localforage": "^1.10.0",
|
||||
"lodash": "^4.17.21",
|
||||
"mathjs": "^14.4.0",
|
||||
|
||||
+3
-30
@@ -9,21 +9,6 @@ import {
|
||||
runCloudSettingsPoll,
|
||||
} from "./background/cloudSettingsAutoSync";
|
||||
|
||||
/**
|
||||
* Session-only dev-mode override of the content API base.
|
||||
*
|
||||
* Stored in a module-level variable (not `chrome.storage`) so it is wiped
|
||||
* automatically when the browser/service-worker process restarts. Content
|
||||
* scripts re-sync this on every page load via `setDevApiBase` so the value
|
||||
* survives transient service-worker terminations within the same browser
|
||||
* session.
|
||||
*/
|
||||
const DEFAULT_API_BASE = "https://betterseqta.org";
|
||||
let DEV_API_BASE: string | null = null;
|
||||
function apiBase(): string {
|
||||
return DEV_API_BASE ?? DEFAULT_API_BASE;
|
||||
}
|
||||
|
||||
function reloadSeqtaPages() {
|
||||
const result = browser.tabs.query({});
|
||||
function open(tabs: any) {
|
||||
@@ -44,7 +29,7 @@ type MessageSender = { (response?: unknown): void };
|
||||
|
||||
function handleFetchThemes(request: any, sendResponse: MessageSender): boolean {
|
||||
const { token } = request;
|
||||
const apiUrl = `${apiBase()}/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`;
|
||||
const apiUrl = `https://betterseqta.org/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`;
|
||||
const githubUrl = `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${Date.now()}`;
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
@@ -72,7 +57,7 @@ function handleFetchThemeDetails(request: any, sendResponse: MessageSender): boo
|
||||
}
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
fetch(`${apiBase()}/api/themes/${themeId}`, { cache: "no-store", headers })
|
||||
fetch(`https://betterseqta.org/api/themes/${themeId}`, { cache: "no-store", headers })
|
||||
.then((r) => r.json())
|
||||
.then(sendResponse)
|
||||
.catch((err) => {
|
||||
@@ -298,7 +283,7 @@ function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean
|
||||
return false;
|
||||
}
|
||||
const isFavorite = action === "favorite";
|
||||
fetch(`${apiBase()}/api/themes/${themeId}/favorite`, {
|
||||
fetch(`https://betterseqta.org/api/themes/${themeId}/favorite`, {
|
||||
method: isFavorite ? "POST" : "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
@@ -325,19 +310,8 @@ function isSeqtaOrigin(origin: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function handleSetDevApiBase(request: any): boolean {
|
||||
const url = typeof request?.url === "string" ? request.url.trim() : null;
|
||||
if (url && /^https?:\/\//.test(url)) {
|
||||
DEV_API_BASE = url.replace(/\/$/, "");
|
||||
} else {
|
||||
DEV_API_BASE = null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
|
||||
reloadTabs: () => reloadSeqtaPages(),
|
||||
setDevApiBase: handleSetDevApiBase,
|
||||
extensionPages: (req) => {
|
||||
browser.tabs.query({}).then((tabs) => {
|
||||
for (const tab of tabs) {
|
||||
@@ -495,7 +469,6 @@ function getDefaultValues(): SettingsState {
|
||||
adaptiveThemeColour: false,
|
||||
adaptiveThemeGradient: false,
|
||||
adaptiveThemeColourTransition: true,
|
||||
themeOfTheMonthDisabled: false,
|
||||
autoCloudSettingsSync: true,
|
||||
};
|
||||
}
|
||||
|
||||
+24
-193
@@ -3726,150 +3726,6 @@ div.day-empty {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.themeOfTheMonthCard {
|
||||
position: fixed;
|
||||
right: max(18px, env(safe-area-inset-right));
|
||||
bottom: max(18px, env(safe-area-inset-bottom));
|
||||
z-index: 48;
|
||||
width: min(360px, calc(100vw - 36px));
|
||||
overflow: visible;
|
||||
border: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent);
|
||||
border-radius: 20px;
|
||||
background: var(--background-primary);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 22px 70px rgba(0, 0, 0, 0.35);
|
||||
animation: themeOfTheMonthCardIn 0.24s ease-out;
|
||||
}
|
||||
.themeOfTheMonthCard::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
overflow: hidden;
|
||||
border-radius: inherit;
|
||||
background: inherit;
|
||||
}
|
||||
.themeOfTheMonthCardClosing {
|
||||
pointer-events: none;
|
||||
animation: themeOfTheMonthCardOut 0.18s ease-in forwards;
|
||||
}
|
||||
.themeOfTheMonthCardClose {
|
||||
position: absolute !important;
|
||||
top: 4px !important;
|
||||
right: 4px !important;
|
||||
z-index: 2;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
border-radius: 16px !important;
|
||||
background: rgba(0, 0, 0, 0.42);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 1.35rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.themeOfTheMonthCardImage {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
margin: 0;
|
||||
border-radius: 20px 20px 0 0;
|
||||
object-fit: cover;
|
||||
}
|
||||
.themeOfTheMonthCardBody {
|
||||
padding: 14px 16px 16px;
|
||||
}
|
||||
.themeOfTheMonthCardEyebrow {
|
||||
margin: 0 0 6px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--better-pri, #6366f1) 82%, var(--text-primary) 18%);
|
||||
}
|
||||
.themeOfTheMonthCard h2 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.themeOfTheMonthCardDescription {
|
||||
display: -webkit-box;
|
||||
margin: 8px 0 14px;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.45;
|
||||
color: color-mix(in srgb, var(--text-primary) 78%, transparent);
|
||||
}
|
||||
.themeOfTheMonthCardActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.themeOfTheMonthCardPrimary,
|
||||
.themeOfTheMonthCardSecondary {
|
||||
appearance: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 9999px;
|
||||
padding: 0.58rem 0.9rem;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
transition: transform 0.15s ease, filter 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.themeOfTheMonthCardPrimary {
|
||||
background: var(--better-pri, #6366f1);
|
||||
color: white;
|
||||
}
|
||||
.themeOfTheMonthCardSecondary {
|
||||
background: color-mix(in srgb, var(--text-primary) 10%, transparent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.themeOfTheMonthCardPrimary:hover,
|
||||
.themeOfTheMonthCardSecondary:hover {
|
||||
filter: brightness(1.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.themeOfTheMonthCardPrimary:active,
|
||||
.themeOfTheMonthCardSecondary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
@keyframes themeOfTheMonthCardIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(18px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes themeOfTheMonthCardOut {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.98);
|
||||
}
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.themeOfTheMonthCard {
|
||||
z-index: 2147483645;
|
||||
}
|
||||
}
|
||||
|
||||
.bsplus-theme-highlight {
|
||||
animation: bsplusThemeHighlightPulse 1.4s ease-in-out 2;
|
||||
}
|
||||
@keyframes bsplusThemeHighlightPulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--better-pri, #6366f1) 0%, transparent);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 6px color-mix(in srgb, var(--better-pri, #6366f1) 60%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.popup-media-fullscreenable {
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
|
||||
@@ -4519,63 +4375,38 @@ h2.home-subtitle {
|
||||
|
||||
.bsplus-toast {
|
||||
position: fixed;
|
||||
right: max(18px, env(safe-area-inset-right));
|
||||
bottom: max(18px, env(safe-area-inset-bottom));
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 10000;
|
||||
width: min(360px, calc(100vw - 36px));
|
||||
padding: 14px 16px 16px;
|
||||
border: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent);
|
||||
border-radius: 20px;
|
||||
background: var(--background-primary, #fff);
|
||||
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 22px 70px rgba(0, 0, 0, 0.35);
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.bsplus-toast-eyebrow {
|
||||
margin: 0 0 6px !important;
|
||||
font-size: 0.72rem !important;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: color-mix(in srgb, #ea580c 82%, var(--text-primary) 18%);
|
||||
opacity: 1 !important;
|
||||
}
|
||||
.dark .bsplus-toast-eyebrow {
|
||||
color: color-mix(in srgb, #fb923c 82%, var(--text-primary) 18%);
|
||||
}
|
||||
.bsplus-toast-content strong {
|
||||
display: block;
|
||||
padding-right: 34px;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.bsplus-toast-content p:not(.bsplus-toast-eyebrow) {
|
||||
display: -webkit-box;
|
||||
margin: 8px 0 0;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
color: color-mix(in srgb, var(--text-primary) 78%, transparent);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.45;
|
||||
.bsplus-toast-content p {
|
||||
margin: 6px 0 0;
|
||||
opacity: 0.8;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.bsplus-toast-close {
|
||||
position: absolute !important;
|
||||
top: 4px !important;
|
||||
right: 4px !important;
|
||||
z-index: 2;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
border-radius: 16px !important;
|
||||
background: rgba(0, 0, 0, 0.42);
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
font-size: 1.3rem;
|
||||
cursor: pointer;
|
||||
font-size: 1.35rem;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
transition: filter 0.15s ease;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.bsplus-toast-close:hover {
|
||||
filter: brightness(1.08);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -15,34 +15,8 @@
|
||||
import CloudHeader from "@/interface/components/store/CloudHeader.svelte"
|
||||
import { cloudAuth } from "@/seqta/utils/CloudAuth"
|
||||
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification"
|
||||
import { showThemeOfTheMonthPopupNow } from "@/seqta/utils/Openers/OpenThemeOfTheMonthPopup"
|
||||
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
|
||||
import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync"
|
||||
import { getStoredOverride, setApiBase } from "@/seqta/utils/DevApiBase"
|
||||
|
||||
let devApiBaseInput = $state<string>(getStoredOverride() ?? "")
|
||||
let devApiBaseActive = $state<string | null>(getStoredOverride())
|
||||
|
||||
function applyDevApiBase() {
|
||||
const trimmed = devApiBaseInput.trim()
|
||||
if (trimmed === "") {
|
||||
setApiBase(null)
|
||||
devApiBaseActive = null
|
||||
return
|
||||
}
|
||||
if (!/^https?:\/\//.test(trimmed)) {
|
||||
alert("Please enter a full URL starting with http:// or https://")
|
||||
return
|
||||
}
|
||||
setApiBase(trimmed)
|
||||
devApiBaseActive = trimmed.replace(/\/$/, "")
|
||||
}
|
||||
|
||||
function clearDevApiBase() {
|
||||
devApiBaseInput = ""
|
||||
setApiBase(null)
|
||||
devApiBaseActive = null
|
||||
}
|
||||
|
||||
import { getAllPluginSettings } from "@/plugins"
|
||||
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types"
|
||||
@@ -509,22 +483,6 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-4 py-3">
|
||||
<div class="pr-4">
|
||||
<h2 class="text-sm font-bold">Show Theme of the Month</h2>
|
||||
<p class="text-xs">Fetch and show the current month's popup now (ignores dismissed state)</p>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
closeExtensionPopup();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await showThemeOfTheMonthPopupNow();
|
||||
}}
|
||||
text="Show Now"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-4 py-3">
|
||||
<div class="pr-4">
|
||||
<h2 class="text-sm font-bold">Export cloud settings JSON</h2>
|
||||
@@ -534,31 +492,6 @@
|
||||
<Button onClick={exportCloudSettingsJsonToFile} text="Export to file" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 px-4 py-3">
|
||||
<div class="flex justify-between items-start gap-3">
|
||||
<div class="pr-4">
|
||||
<h2 class="text-sm font-bold">API Base URL (session only)</h2>
|
||||
<p class="text-xs">Override the content API host for this browser session. Cleared on restart. Affects themes, theme of the month, and other server-driven content.</p>
|
||||
{#if devApiBaseActive}
|
||||
<p class="text-xs mt-1 text-amber-600 dark:text-amber-400">
|
||||
Override active: <span class="font-mono">{devApiBaseActive}</span>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://betterseqta.org"
|
||||
bind:value={devApiBaseInput}
|
||||
class="flex-1 px-2 py-1 text-xs rounded border bg-white dark:bg-zinc-800 border-zinc-300 dark:border-zinc-700 text-zinc-900 dark:text-zinc-100"
|
||||
/>
|
||||
<Button onClick={applyDevApiBase} text="Apply" />
|
||||
{#if devApiBaseActive}
|
||||
<Button onClick={clearDevApiBase} text="Clear" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
import Backgrounds from '../components/store/Backgrounds.svelte'
|
||||
import { cloudAuth } from '@/seqta/utils/CloudAuth'
|
||||
import SignInToFavoriteModal from '../components/SignInToFavoriteModal.svelte'
|
||||
import { consumePendingHighlightThemeId } from '@/seqta/utils/openThemeStoreWithHighlight'
|
||||
|
||||
const themeManager = ThemeManager.getInstance();
|
||||
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
|
||||
@@ -123,39 +122,13 @@
|
||||
}
|
||||
};
|
||||
|
||||
function focusThemeById(themeId: string) {
|
||||
const match = themes.find((t) => t.id === themeId)
|
||||
?? themes.find((t) => t.flavours?.some((f) => f.id === themeId));
|
||||
if (match) {
|
||||
activeTab = 'themes';
|
||||
searchTerm = '';
|
||||
displayTheme = match;
|
||||
}
|
||||
}
|
||||
|
||||
function onHighlightThemeEvent(e: Event) {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (detail?.themeId && typeof detail.themeId === 'string') {
|
||||
focusThemeById(detail.themeId);
|
||||
}
|
||||
}
|
||||
|
||||
// On mount
|
||||
onMount(async () => {
|
||||
window.addEventListener('bsplus:highlight-theme', onHighlightThemeEvent);
|
||||
|
||||
await fetchThemes();
|
||||
await fetchCurrentThemes();
|
||||
|
||||
darkMode = (await browser.storage.local.get('DarkMode')).DarkMode === 'true';
|
||||
darkMode = $settingsState.DarkMode;
|
||||
|
||||
const pending = consumePendingHighlightThemeId();
|
||||
if (pending) focusThemeById(pending);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('bsplus:highlight-theme', onHighlightThemeEvent);
|
||||
};
|
||||
});
|
||||
|
||||
// Filter themes (list is already featured-first, then newest; filter preserves order)
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* BetterSEQTA Security — core XSS-focused protections for HTML rendered from SEQTA APIs.
|
||||
*
|
||||
* Execution vs detection: SEQTA loads message bodies in same-origin `iframe.userHTML`.
|
||||
* Scripts may run during parse before our scan completes. We set `sandbox="allow-same-origin"`
|
||||
* (without `allow-scripts`) on those iframes so script execution is suppressed while we can
|
||||
* still read `contentDocument` for scanning and existing theme/CSS injection.
|
||||
*
|
||||
* The warning UI is mounted on document.body (fixed layer aligned to the reading pane) so
|
||||
* React replacing `.uiFrameWrapper` / iframe siblings does not destroy it.
|
||||
*/
|
||||
import type { Plugin } from "../../core/types";
|
||||
import {
|
||||
analyzeHtmlThreats,
|
||||
type ThreatAnalysis,
|
||||
} from "@/seqta/security/analyzeHtmlThreats";
|
||||
import {
|
||||
mountBlockedContentUi,
|
||||
SECURITY_MESSAGE_OVERLAY_CLASS,
|
||||
} from "@/seqta/security/blockedContentUi";
|
||||
import { eventManager } from "@/seqta/utils/listeners/EventManager";
|
||||
|
||||
const USER_HTML_IFRAME_EVENT = "bssSecurityUserHtmlIframe";
|
||||
|
||||
const userHtmlIframeLoadHooked = new WeakSet<HTMLIFrameElement>();
|
||||
|
||||
/** Tear down body overlay + listeners for this iframe (safe navigation or cleanup). */
|
||||
const messageOverlayCleanups = new WeakMap<HTMLIFrameElement, () => void>();
|
||||
|
||||
function teardownMessageSecurityOverlay(iframe: HTMLIFrameElement): void {
|
||||
const fn = messageOverlayCleanups.get(iframe);
|
||||
if (fn) {
|
||||
fn();
|
||||
messageOverlayCleanups.delete(iframe);
|
||||
}
|
||||
}
|
||||
|
||||
function applyMessageIframeSandbox(iframe: HTMLIFrameElement): void {
|
||||
if (iframe.dataset.bssUserHtmlSandbox === "1") return;
|
||||
iframe.dataset.bssUserHtmlSandbox = "1";
|
||||
iframe.setAttribute("sandbox", "allow-same-origin");
|
||||
}
|
||||
|
||||
function wipeIframeDocument(iframe: HTMLIFrameElement): void {
|
||||
try {
|
||||
const d = iframe.contentDocument;
|
||||
if (!d) return;
|
||||
d.open();
|
||||
d.write(
|
||||
"<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body></body></html>",
|
||||
);
|
||||
d.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After we replace a malicious document, the iframe fires `load` again with this blank shell.
|
||||
* That pass must not tear down the blocker UI or the iframe would “recover” for one frame.
|
||||
*/
|
||||
function isPostWipeBlankDocument(doc: Document): boolean {
|
||||
const body = doc.body;
|
||||
if (!body || body.childElementCount > 0) return false;
|
||||
if ((body.textContent ?? "").trim().length > 0) return false;
|
||||
const meta = doc.head?.querySelector('meta[charset="utf-8"]');
|
||||
if (!meta) return false;
|
||||
return doc.documentElement.outerHTML.length < 800;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen body layer positioned over the reading pane so SEQTA/React can replace iframe
|
||||
* markup without removing this node.
|
||||
*/
|
||||
function mountBodyAnchoredMessageOverlay(
|
||||
iframe: HTMLIFrameElement,
|
||||
anchor: HTMLElement,
|
||||
opts: {
|
||||
analysis: ThreatAnalysis;
|
||||
rawSnippet: string;
|
||||
contextTitle?: string;
|
||||
},
|
||||
): void {
|
||||
teardownMessageSecurityOverlay(iframe);
|
||||
|
||||
const shell = document.createElement("div");
|
||||
shell.className = SECURITY_MESSAGE_OVERLAY_CLASS;
|
||||
Object.assign(shell.style, {
|
||||
position: "fixed",
|
||||
zIndex: "2147483646",
|
||||
overflow: "hidden",
|
||||
pointerEvents: "auto",
|
||||
boxSizing: "border-box",
|
||||
padding: "12px",
|
||||
background: "rgba(24,24,27,0.35)",
|
||||
});
|
||||
|
||||
const inner = document.createElement("div");
|
||||
Object.assign(inner.style, {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
boxSizing: "border-box",
|
||||
});
|
||||
shell.appendChild(inner);
|
||||
|
||||
let raf = 0;
|
||||
const syncRect = (): void => {
|
||||
cancelAnimationFrame(raf);
|
||||
raf = requestAnimationFrame(() => {
|
||||
if (!iframe.isConnected) {
|
||||
teardownMessageSecurityOverlay(iframe);
|
||||
return;
|
||||
}
|
||||
if (!anchor.isConnected) {
|
||||
teardownMessageSecurityOverlay(iframe);
|
||||
return;
|
||||
}
|
||||
const r = anchor.getBoundingClientRect();
|
||||
const pad = 10;
|
||||
const left = Math.max(8, r.left - pad);
|
||||
const top = Math.max(8, r.top - pad);
|
||||
const width = Math.min(window.innerWidth - left - 8, r.width + pad * 2);
|
||||
const height = Math.min(window.innerHeight - top - 8, r.height + pad * 2);
|
||||
shell.style.left = `${left}px`;
|
||||
shell.style.top = `${top}px`;
|
||||
shell.style.width = `${Math.max(0, width)}px`;
|
||||
shell.style.height = `${Math.max(0, height)}px`;
|
||||
});
|
||||
};
|
||||
|
||||
syncRect();
|
||||
document.body.appendChild(shell);
|
||||
|
||||
const ro = new ResizeObserver(syncRect);
|
||||
ro.observe(anchor);
|
||||
|
||||
window.addEventListener("resize", syncRect);
|
||||
window.addEventListener("scroll", syncRect, true);
|
||||
|
||||
const unmountPanel = mountBlockedContentUi(inner, {
|
||||
surface: "message",
|
||||
analysis: opts.analysis,
|
||||
rawSnippet: opts.rawSnippet,
|
||||
contextTitle: opts.contextTitle,
|
||||
rootOverlay: true,
|
||||
});
|
||||
|
||||
const cleanup = (): void => {
|
||||
cancelAnimationFrame(raf);
|
||||
ro.disconnect();
|
||||
window.removeEventListener("resize", syncRect);
|
||||
window.removeEventListener("scroll", syncRect, true);
|
||||
unmountPanel();
|
||||
shell.remove();
|
||||
};
|
||||
|
||||
messageOverlayCleanups.set(iframe, cleanup);
|
||||
}
|
||||
|
||||
function handleUserHtmlIframeLoaded(iframe: HTMLIFrameElement): void {
|
||||
let idoc: Document | null = null;
|
||||
try {
|
||||
idoc = iframe.contentDocument;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!idoc?.documentElement) return;
|
||||
|
||||
const wrapper =
|
||||
iframe.closest(".uiFrameWrapper") ??
|
||||
iframe.closest(".iframeWrapper") ??
|
||||
iframe.parentElement;
|
||||
if (!wrapper) return;
|
||||
|
||||
if (
|
||||
iframe.dataset.bssAwaitingWipeLoad === "1" &&
|
||||
isPostWipeBlankDocument(idoc)
|
||||
) {
|
||||
iframe.dataset.bssAwaitingWipeLoad = "";
|
||||
return;
|
||||
}
|
||||
|
||||
iframe.dataset.bssAwaitingWipeLoad = "";
|
||||
|
||||
teardownMessageSecurityOverlay(iframe);
|
||||
|
||||
iframe.style.visibility = "";
|
||||
iframe.style.height = "";
|
||||
iframe.style.minHeight = "";
|
||||
|
||||
const html = idoc.documentElement.outerHTML;
|
||||
const analysis = analyzeHtmlThreats(html);
|
||||
if (!analysis.blocked) return;
|
||||
|
||||
const pane = iframe.closest('[class*="ReadingPane__ReadingPane"]');
|
||||
const anchor = (pane ?? wrapper) as HTMLElement;
|
||||
|
||||
iframe.dataset.bssAwaitingWipeLoad = "1";
|
||||
wipeIframeDocument(iframe);
|
||||
iframe.style.visibility = "hidden";
|
||||
iframe.style.height = "0";
|
||||
iframe.style.minHeight = "0";
|
||||
|
||||
const subject = pane
|
||||
?.querySelector('[class*="Message__subject___"]')
|
||||
?.textContent?.trim();
|
||||
|
||||
mountBodyAnchoredMessageOverlay(iframe, anchor, {
|
||||
analysis,
|
||||
rawSnippet: html.slice(0, 50_000),
|
||||
contextTitle: subject,
|
||||
});
|
||||
}
|
||||
|
||||
const betterSeqtaSecurityPlugin: Plugin = {
|
||||
id: "better-seqta-security",
|
||||
name: "BetterSEQTA Security",
|
||||
description:
|
||||
"Blocks risky HTML in messages and notices and surfaces administrator-ready incident reports.",
|
||||
version: "1.0.0",
|
||||
settings: {},
|
||||
|
||||
run: () => {
|
||||
const { unregister } = eventManager.register(
|
||||
USER_HTML_IFRAME_EVENT,
|
||||
{
|
||||
elementType: "iframe",
|
||||
customCheck: (element) => element.classList.contains("userHTML"),
|
||||
},
|
||||
(element) => {
|
||||
const iframe = element as HTMLIFrameElement;
|
||||
if (userHtmlIframeLoadHooked.has(iframe)) return;
|
||||
userHtmlIframeLoadHooked.add(iframe);
|
||||
applyMessageIframeSandbox(iframe);
|
||||
|
||||
const onLoad = () => handleUserHtmlIframeLoaded(iframe);
|
||||
iframe.addEventListener("load", onLoad);
|
||||
queueMicrotask(onLoad);
|
||||
},
|
||||
);
|
||||
|
||||
return unregister;
|
||||
},
|
||||
};
|
||||
|
||||
export default betterSeqtaSecurityPlugin;
|
||||
@@ -22,7 +22,6 @@ interface Folder {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
interface MessageFoldersStorage {
|
||||
@@ -35,33 +34,12 @@ const FOLDER_COLORS = [
|
||||
"#8b5cf6", "#ec4899", "#14b8a6", "#f97316",
|
||||
];
|
||||
|
||||
const FOLDER_HEROICONS = [
|
||||
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>`,
|
||||
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>`,
|
||||
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`,
|
||||
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>`,
|
||||
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/></svg>`,
|
||||
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg>`,
|
||||
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>`,
|
||||
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>`,
|
||||
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>`,
|
||||
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>`,
|
||||
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>`,
|
||||
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`,
|
||||
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>`,
|
||||
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>`,
|
||||
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>`,
|
||||
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>`,
|
||||
];
|
||||
|
||||
const FOLDER_ICON_SVG = `<svg style="width:24px;height:24px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
|
||||
const PLUS_SVG = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>`;
|
||||
const CHECK_SVG_WHITE = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#fff" d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/></svg>`;
|
||||
const CLOSE_SVG = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>`;
|
||||
const EDIT_SVG = `<svg style="width:12px;height:12px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>`;
|
||||
const TRASH_SVG = `<svg style="width:12px;height:12px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>`;
|
||||
const CHEVRON_SVG = `<svg style="width:12px;height:12px;flex-shrink:0;transition:transform .2s" viewBox="0 0 24 24"><path fill="#888" d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>`;
|
||||
const DRAG_SVG = `<svg style="width:14px;height:14px;flex-shrink:0;cursor:grab" viewBox="0 0 24 24"><path fill="#888" d="M6.5 12.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5.5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5.5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z"/></svg>`;
|
||||
|
||||
function generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
||||
@@ -71,7 +49,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
id: "messageFolders",
|
||||
name: "Message Folders",
|
||||
description: "Organize direct messages into custom folders",
|
||||
version: "2.0.0",
|
||||
version: "1.0.0",
|
||||
settings: messageFoldersSettings,
|
||||
disableToggle: true,
|
||||
defaultEnabled: true,
|
||||
@@ -92,9 +70,10 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
let actionsObserver: MutationObserver | null = null;
|
||||
let openDropdown: HTMLElement | null = null;
|
||||
let dropdownCloseHandler: ((e: MouseEvent) => void) | null = null;
|
||||
let foldedSection: HTMLElement | null = null;
|
||||
const unregisters: Array<{ unregister: () => void }> = [];
|
||||
|
||||
// ── Storage accessors ──
|
||||
|
||||
const getFolders = (): Folder[] => api.storage.folders ?? [];
|
||||
const getAssignments = (): Record<string, string[]> => api.storage.messageAssignments ?? {};
|
||||
|
||||
@@ -115,18 +94,6 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
return ids;
|
||||
};
|
||||
|
||||
const assignMessageToFolder = (messageId: string, folderId: string, add: boolean) => {
|
||||
const assignments = getAssignments();
|
||||
if (!assignments[folderId]) assignments[folderId] = [];
|
||||
const idx = assignments[folderId].indexOf(messageId);
|
||||
if (add && idx < 0) {
|
||||
assignments[folderId].push(messageId);
|
||||
} else if (!add && idx >= 0) {
|
||||
assignments[folderId].splice(idx, 1);
|
||||
}
|
||||
saveAssignments(assignments);
|
||||
};
|
||||
|
||||
const toggleMessageInFolder = (messageId: string, folderId: string) => {
|
||||
const assignments = getAssignments();
|
||||
if (!assignments[folderId]) assignments[folderId] = [];
|
||||
@@ -162,28 +129,16 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
return api.settings.showTagsInAllMessages || activeFolderId !== null;
|
||||
};
|
||||
|
||||
const getSelectedMessageId = (): string | null => {
|
||||
const selectedMsg = document.querySelector("[class*='MessageList__selected___']");
|
||||
return selectedMsg?.getAttribute("data-message") ?? null;
|
||||
};
|
||||
// ── Confirm modal ──
|
||||
|
||||
const getMessageIdFromEvent = (target: HTMLElement): string | null => {
|
||||
const li = target.closest("li[data-message]");
|
||||
return li?.getAttribute("data-message") ?? null;
|
||||
};
|
||||
|
||||
const getAllVisibleMessageIds = (): string[] => {
|
||||
const ids: string[] = [];
|
||||
document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]").forEach((li) => {
|
||||
const id = li.getAttribute("data-message");
|
||||
if (id) ids.push(id);
|
||||
});
|
||||
return ids;
|
||||
};
|
||||
|
||||
const showConfirmModal = (title: string, message: string, onConfirm: () => void) => {
|
||||
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 = `
|
||||
@@ -195,13 +150,16 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
</div>
|
||||
`;
|
||||
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();
|
||||
});
|
||||
@@ -210,42 +168,36 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
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") as HTMLElement;
|
||||
let section = ol.querySelector(".bsplus-folders-section");
|
||||
if (!section) {
|
||||
section = document.createElement("div");
|
||||
section.className = "bsplus-folders-section";
|
||||
ol.appendChild(section);
|
||||
}
|
||||
|
||||
foldedSection = 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";
|
||||
header.dataset.folded = "false";
|
||||
|
||||
const collapseBtn = document.createElement("button");
|
||||
collapseBtn.className = "bsplus-folders-collapse";
|
||||
collapseBtn.innerHTML = CHEVRON_SVG;
|
||||
collapseBtn.title = "Collapse";
|
||||
collapseBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const isFolded = collapseBtn.classList.toggle("bsplus-folded");
|
||||
section.classList.toggle("bsplus-section-folded", isFolded);
|
||||
collapseBtn.title = isFolded ? "Expand" : "Collapse";
|
||||
});
|
||||
header.appendChild(collapseBtn);
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.textContent = "Folders";
|
||||
@@ -262,8 +214,9 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
header.appendChild(addBtn);
|
||||
section.appendChild(header);
|
||||
|
||||
// "All Messages" item
|
||||
const allItem = document.createElement("div");
|
||||
allItem.className = `bsplus-folder-item bsplus-all-msgs${activeFolderId === null ? " bsplus-folder-active" : ""}`;
|
||||
allItem.className = `bsplus-folder-item${activeFolderId === null ? " bsplus-folder-active" : ""}`;
|
||||
allItem.innerHTML = `
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" style="fill: currentcolor; opacity: 0.5; flex-shrink: 0;"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg>
|
||||
<span class="bsplus-folder-name">All Messages</span>
|
||||
@@ -273,34 +226,20 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
applyFolderFilter();
|
||||
applyBadges();
|
||||
renderSidebarFolders();
|
||||
setTimeout(() => {
|
||||
applyFolderFilter();
|
||||
applyBadges();
|
||||
}, 100);
|
||||
});
|
||||
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;
|
||||
item.draggable = true;
|
||||
|
||||
const dragHandle = document.createElement("div");
|
||||
dragHandle.className = "bsplus-folder-drag";
|
||||
dragHandle.innerHTML = DRAG_SVG;
|
||||
item.appendChild(dragHandle);
|
||||
|
||||
const dot = document.createElement("div");
|
||||
dot.className = "bsplus-folder-dot";
|
||||
dot.style.background = folder.color;
|
||||
item.appendChild(dot);
|
||||
|
||||
const iconSpan = document.createElement("span");
|
||||
iconSpan.className = "bsplus-folder-icon";
|
||||
iconSpan.innerHTML = folder.emoji || FOLDER_HEROICONS[0];
|
||||
item.appendChild(iconSpan);
|
||||
|
||||
const name = document.createElement("span");
|
||||
name.className = "bsplus-folder-name";
|
||||
name.textContent = folder.name;
|
||||
@@ -325,17 +264,21 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
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();
|
||||
});
|
||||
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);
|
||||
|
||||
@@ -352,89 +295,15 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
applyFolderFilter();
|
||||
applyBadges();
|
||||
renderSidebarFolders();
|
||||
setTimeout(() => {
|
||||
applyFolderFilter();
|
||||
applyBadges();
|
||||
}, 100);
|
||||
});
|
||||
|
||||
item.addEventListener("dragstart", (e) => {
|
||||
e.dataTransfer?.setData("text/plain", `reorder:${folder.id}`);
|
||||
item.classList.add("bsplus-dragging");
|
||||
});
|
||||
item.addEventListener("dragend", () => {
|
||||
item.classList.remove("bsplus-dragging");
|
||||
document.querySelectorAll(".bsplus-folder-item").forEach((el) => el.classList.remove("bsplus-drag-over"));
|
||||
});
|
||||
item.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
const data = e.dataTransfer?.getData("text/plain") || "";
|
||||
if (data.startsWith("reorder:") && !data.includes(folder.id)) {
|
||||
item.classList.add("bsplus-drag-over");
|
||||
}
|
||||
});
|
||||
item.addEventListener("dragleave", () => {
|
||||
item.classList.remove("bsplus-drag-over");
|
||||
});
|
||||
item.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
item.classList.remove("bsplus-drag-over");
|
||||
const data = e.dataTransfer?.getData("text/plain") || "";
|
||||
if (data.startsWith("reorder:")) {
|
||||
const draggedId = data.replace("reorder:", "");
|
||||
const folders = getFolders();
|
||||
const draggedIdx = folders.findIndex((f) => f.id === draggedId);
|
||||
const targetIdx = folders.findIndex((f) => f.id === folder.id);
|
||||
if (draggedIdx >= 0 && targetIdx >= 0 && draggedIdx !== targetIdx) {
|
||||
const [removed] = folders.splice(draggedIdx, 1);
|
||||
folders.splice(targetIdx, 0, removed);
|
||||
saveFolders(folders);
|
||||
renderSidebarFolders();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
section.appendChild(item);
|
||||
}
|
||||
|
||||
section.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
section.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
const data = e.dataTransfer?.getData("text/plain") || "";
|
||||
if (data.startsWith("msg:")) {
|
||||
const messageId = data.replace("msg:", "");
|
||||
const folderId = (e.target as HTMLElement).closest("[data-folder-id]")?.getAttribute("data-folder-id");
|
||||
if (messageId && folderId) {
|
||||
assignMessageToFolder(messageId, folderId, true);
|
||||
applyBadges();
|
||||
applyFolderFilter();
|
||||
renderSidebarFolders();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
attachDragListeners();
|
||||
};
|
||||
|
||||
const attachDragListeners = () => {
|
||||
document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]").forEach((li) => {
|
||||
if (li.getAttribute("data-bsplus-drag") === "true") return;
|
||||
li.setAttribute("data-bsplus-drag", "true");
|
||||
li.draggable = true;
|
||||
li.addEventListener("dragstart", (e) => {
|
||||
const id = li.getAttribute("data-message");
|
||||
if (id) {
|
||||
e.dataTransfer?.setData("text/plain", `msg:${id}`);
|
||||
li.classList.add("bsplus-msg-dragging");
|
||||
}
|
||||
});
|
||||
li.addEventListener("dragend", () => {
|
||||
li.classList.remove("bsplus-msg-dragging");
|
||||
document.querySelectorAll(".bsplus-folder-item").forEach((el) => el.classList.remove("bsplus-drag-over"));
|
||||
});
|
||||
});
|
||||
// Restore input if it was open
|
||||
if (existingInput || existingColors) {
|
||||
// Don't restore – let user re-trigger
|
||||
}
|
||||
};
|
||||
|
||||
const showNewFolderInput = (container: Element, editFolder?: Folder) => {
|
||||
@@ -443,34 +312,16 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
container.querySelector(".bsplus-folder-colors")?.remove();
|
||||
|
||||
let selectedColor = editFolder?.color ?? FOLDER_COLORS[Math.floor(Math.random() * FOLDER_COLORS.length)];
|
||||
let selectedIcon = editFolder?.emoji ?? FOLDER_HEROICONS[Math.floor(Math.random() * FOLDER_HEROICONS.length)];
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "bsplus-folder-input";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.placeholder = editFolder ? "Rename folder\u2026" : "Folder name\u2026";
|
||||
input.placeholder = editFolder ? "Rename folder…" : "Folder name…";
|
||||
input.value = editFolder?.name ?? "";
|
||||
input.maxLength = 30;
|
||||
|
||||
const iconBtn = document.createElement("button");
|
||||
iconBtn.className = "bsplus-folder-icon-btn";
|
||||
iconBtn.title = "Pick icon";
|
||||
iconBtn.innerHTML = selectedIcon;
|
||||
iconBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const picker = container.querySelector(".bsplus-icon-picker") as HTMLElement | null;
|
||||
if (picker) {
|
||||
picker.remove();
|
||||
return;
|
||||
}
|
||||
showIconPicker(container, (iconSvg) => {
|
||||
selectedIcon = iconSvg;
|
||||
iconBtn.innerHTML = iconSvg;
|
||||
});
|
||||
});
|
||||
|
||||
const confirmBtn = document.createElement("button");
|
||||
confirmBtn.className = "bsplus-folder-input-confirm";
|
||||
confirmBtn.innerHTML = CHECK_SVG_WHITE;
|
||||
@@ -479,11 +330,11 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
cancelBtn.className = "bsplus-folder-input-cancel";
|
||||
cancelBtn.innerHTML = CLOSE_SVG;
|
||||
|
||||
row.appendChild(iconBtn);
|
||||
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) {
|
||||
@@ -503,13 +354,14 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
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, emoji: selectedIcon } : f,
|
||||
f.id === editFolder.id ? { ...f, name, color: selectedColor } : f,
|
||||
);
|
||||
saveFolders(folders);
|
||||
} else {
|
||||
const folder: Folder = { id: generateId(), name, color: selectedColor, emoji: selectedIcon };
|
||||
const folder: Folder = { id: generateId(), name, color: selectedColor };
|
||||
saveFolders([...getFolders(), folder]);
|
||||
}
|
||||
applyBadges();
|
||||
@@ -534,38 +386,23 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
requestAnimationFrame(() => input.focus());
|
||||
};
|
||||
|
||||
const showIconPicker = (container: Element, onSelect: (iconSvg: string) => void) => {
|
||||
const existing = container.querySelector(".bsplus-icon-picker");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const picker = document.createElement("div");
|
||||
picker.className = "bsplus-icon-picker";
|
||||
for (const icon of FOLDER_HEROICONS) {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "bsplus-icon-opt";
|
||||
btn.innerHTML = icon;
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(icon);
|
||||
picker.remove();
|
||||
});
|
||||
picker.appendChild(btn);
|
||||
}
|
||||
container.appendChild(picker);
|
||||
};
|
||||
|
||||
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) {
|
||||
@@ -578,22 +415,47 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
});
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
if (openDropdown) {
|
||||
openDropdown.remove();
|
||||
openDropdown = null;
|
||||
}
|
||||
if (dropdownCloseHandler) {
|
||||
document.removeEventListener("click", dropdownCloseHandler, true);
|
||||
dropdownCloseHandler = null;
|
||||
// ── "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) => {
|
||||
closeDropdown();
|
||||
const dropdown = document.createElement("div");
|
||||
dropdown.className = "bsplus-folder-dropdown";
|
||||
dropdown.dataset.msgId = messageId;
|
||||
|
||||
const folders = getFolders();
|
||||
const currentFolderIds = getMessageFolderIds(messageId);
|
||||
@@ -608,7 +470,6 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
const isChecked = currentFolderIds.includes(folder.id);
|
||||
const item = document.createElement("button");
|
||||
item.className = `bsplus-folder-dropdown-item${isChecked ? " bsplus-checked" : ""}`;
|
||||
item.dataset.folderId = folder.id;
|
||||
|
||||
const check = document.createElement("div");
|
||||
check.className = "bsplus-folder-dropdown-check";
|
||||
@@ -620,26 +481,22 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
dot.className = "bsplus-folder-dot";
|
||||
dot.style.background = folder.color;
|
||||
|
||||
const iconSpan = document.createElement("span");
|
||||
iconSpan.className = "bsplus-folder-icon";
|
||||
iconSpan.innerHTML = folder.emoji || FOLDER_HEROICONS[0];
|
||||
|
||||
const name = document.createElement("span");
|
||||
name.textContent = folder.name;
|
||||
|
||||
item.appendChild(check);
|
||||
item.appendChild(dot);
|
||||
item.appendChild(iconSpan);
|
||||
item.appendChild(name);
|
||||
|
||||
item.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
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();
|
||||
@@ -662,105 +519,22 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
}, 0);
|
||||
};
|
||||
|
||||
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();
|
||||
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 closeDropdown = () => {
|
||||
if (openDropdown) {
|
||||
openDropdown.remove();
|
||||
openDropdown = null;
|
||||
}
|
||||
if (dropdownCloseHandler) {
|
||||
document.removeEventListener("click", dropdownCloseHandler, true);
|
||||
dropdownCloseHandler = null;
|
||||
}
|
||||
};
|
||||
|
||||
const showContextMenu = (e: MouseEvent, messageId: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeDropdown();
|
||||
const existing = document.querySelector(".bsplus-context-menu");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "bsplus-context-menu";
|
||||
menu.style.left = `${e.clientX}px`;
|
||||
menu.style.top = `${e.clientY}px`;
|
||||
|
||||
const title = document.createElement("div");
|
||||
title.className = "bsplus-context-title";
|
||||
title.textContent = "Add to folder";
|
||||
menu.appendChild(title);
|
||||
|
||||
const folders = getFolders();
|
||||
const currentFolderIds = getMessageFolderIds(messageId);
|
||||
|
||||
if (folders.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "bsplus-context-empty";
|
||||
empty.textContent = "No folders";
|
||||
menu.appendChild(empty);
|
||||
} else {
|
||||
for (const folder of folders) {
|
||||
const isChecked = currentFolderIds.includes(folder.id);
|
||||
const item = document.createElement("button");
|
||||
item.className = `bsplus-context-item${isChecked ? " bsplus-context-checked" : ""}`;
|
||||
const dot = document.createElement("div");
|
||||
dot.className = "bsplus-folder-dot";
|
||||
dot.style.background = folder.color;
|
||||
const iconSpan = document.createElement("span");
|
||||
iconSpan.className = "bsplus-folder-icon";
|
||||
iconSpan.innerHTML = folder.emoji || FOLDER_HEROICONS[0];
|
||||
const name = document.createElement("span");
|
||||
name.textContent = folder.name;
|
||||
item.appendChild(dot);
|
||||
item.appendChild(iconSpan);
|
||||
item.appendChild(name);
|
||||
if (isChecked) {
|
||||
const check = document.createElement("span");
|
||||
check.className = "bsplus-context-checkmark";
|
||||
check.textContent = "\u2713";
|
||||
item.appendChild(check);
|
||||
}
|
||||
item.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
toggleMessageInFolder(messageId, folder.id);
|
||||
applyBadges();
|
||||
applyFolderFilter();
|
||||
renderSidebarFolders();
|
||||
menu.remove();
|
||||
});
|
||||
menu.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
document.body.appendChild(menu);
|
||||
const closeMenu = (ev: MouseEvent) => {
|
||||
if (!menu.contains(ev.target as Node)) {
|
||||
menu.remove();
|
||||
document.removeEventListener("click", closeMenu);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener("click", closeMenu), 0);
|
||||
};
|
||||
// ── 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___']");
|
||||
@@ -772,20 +546,26 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
}
|
||||
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: string[] = [];
|
||||
|
||||
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";
|
||||
@@ -803,6 +583,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
li.appendChild(badgeContainer);
|
||||
}
|
||||
}
|
||||
|
||||
badgeContainer.innerHTML = "";
|
||||
for (const fId of folderIds) {
|
||||
const folder = folders.find((f) => f.id === fId);
|
||||
@@ -810,7 +591,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "bsplus-msg-badge";
|
||||
badge.style.background = folder.color;
|
||||
badge.innerHTML = `${folder.emoji ? `<span style="display:inline-flex;vertical-align:middle;margin-right:2px">${folder.emoji}</span>` : ""}${folder.name}`;
|
||||
badge.textContent = folder.name;
|
||||
badge.title = `Filter by "${folder.name}"`;
|
||||
badge.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -824,9 +605,12 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
}
|
||||
};
|
||||
|
||||
// ── 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) {
|
||||
@@ -845,7 +629,9 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
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)) {
|
||||
@@ -857,35 +643,25 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
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();
|
||||
attachDragListeners();
|
||||
attachContextMenuListeners();
|
||||
});
|
||||
messageListObserver.observe(messageList, { childList: true, subtree: false });
|
||||
};
|
||||
|
||||
const attachContextMenuListeners = () => {
|
||||
document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]").forEach((li) => {
|
||||
if (li.getAttribute("data-bsplus-ctx") === "true") return;
|
||||
li.setAttribute("data-bsplus-ctx", "true");
|
||||
li.addEventListener("contextmenu", (e) => {
|
||||
const msgId = li.getAttribute("data-message");
|
||||
if (msgId) {
|
||||
showContextMenu(e, msgId);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
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")) {
|
||||
@@ -895,19 +671,28 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
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();
|
||||
attachDragListeners();
|
||||
attachContextMenuListeners();
|
||||
|
||||
// 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(() => {
|
||||
@@ -921,8 +706,11 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
}
|
||||
};
|
||||
|
||||
// ── Lifecycle ──
|
||||
|
||||
const mountUnsub = api.seqta.onMount("div.messages", handleMessagesPage);
|
||||
unregisters.push(mountUnsub);
|
||||
|
||||
unregisters.push(
|
||||
api.settings.onChange("showTagsInAllMessages", () => {
|
||||
applyBadges();
|
||||
@@ -944,7 +732,6 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
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(".bsplus-context-menu").forEach((el) => el.remove());
|
||||
document.querySelectorAll("[class*='MessageList__subject___']").forEach((subject) => {
|
||||
if (subject.querySelector(".bsplus-subject-text")) {
|
||||
restoreSubjectPlain(subject);
|
||||
@@ -954,7 +741,6 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
el.classList.remove("bsplus-folder-hidden"),
|
||||
);
|
||||
document.querySelectorAll(".bsplus-modal-overlay").forEach((el) => el.remove());
|
||||
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,21 +3,12 @@
|
||||
border-top: 1px solid var(--background-secondary, rgba(128, 128, 128, 0.2));
|
||||
margin-top: 4px;
|
||||
padding-top: 4px;
|
||||
transition: opacity .2s;
|
||||
}
|
||||
|
||||
.bsplus-folders-section.bsplus-section-folded .bsplus-folder-item,
|
||||
.bsplus-folders-section.bsplus-section-folded .bsplus-folder-input,
|
||||
.bsplus-folders-section.bsplus-section-folded .bsplus-folder-colors,
|
||||
.bsplus-folders-section.bsplus-section-folded .bsplus-emoji-picker,
|
||||
.bsplus-folders-section.bsplus-section-folded .bsplus-all-msgs {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.bsplus-folders-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
justify-content: space-between;
|
||||
padding: 6px 12px 2px;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -29,33 +20,6 @@
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-primary, #666);
|
||||
opacity: 0.5;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bsplus-folders-collapse {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
min-width: 0 !important;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
opacity: 0.4;
|
||||
cursor: pointer;
|
||||
border-radius: 4px !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
transition: all .2s;
|
||||
}
|
||||
|
||||
.bsplus-folders-collapse:hover {
|
||||
opacity: 0.8;
|
||||
background: var(--background-secondary, rgba(128, 128, 128, 0.1)) !important;
|
||||
}
|
||||
|
||||
.bsplus-folders-collapse.bsplus-folded svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.bsplus-folders-add-btn {
|
||||
@@ -87,21 +51,12 @@
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, opacity 0.2s;
|
||||
transition: background 0.15s ease;
|
||||
position: relative;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.bsplus-folder-item.bsplus-dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.bsplus-folder-item.bsplus-drag-over {
|
||||
background: var(--better-main, #007bff22) !important;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.bsplus-folder-item:hover {
|
||||
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08));
|
||||
}
|
||||
@@ -121,18 +76,6 @@
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
.bsplus-folder-drag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
transition: opacity .15s;
|
||||
margin-right: -4px;
|
||||
}
|
||||
|
||||
.bsplus-folder-item:hover .bsplus-folder-drag {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.bsplus-folder-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
@@ -140,23 +83,6 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bsplus-folder-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
.bsplus-folder-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.bsplus-folder-name {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary, #333);
|
||||
@@ -171,8 +97,6 @@
|
||||
color: var(--text-primary, #999);
|
||||
opacity: 0.5;
|
||||
flex-shrink: 0;
|
||||
min-width: 16px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.bsplus-folder-actions {
|
||||
@@ -234,35 +158,6 @@
|
||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.2);
|
||||
}
|
||||
|
||||
.bsplus-folder-icon-btn {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
min-width: 0 !important;
|
||||
border: 1px solid var(--background-secondary, #ccc) !important;
|
||||
border-radius: 6px !important;
|
||||
background: var(--background-secondary, #f5f5f5) !important;
|
||||
cursor: pointer;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
transition: all .15s;
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
.bsplus-folder-icon-btn:hover {
|
||||
transform: scale(1.1);
|
||||
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.1)) !important;
|
||||
}
|
||||
|
||||
.bsplus-folder-icon-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.bsplus-folder-input-confirm,
|
||||
.bsplus-folder-input-cancel {
|
||||
display: flex !important;
|
||||
@@ -297,43 +192,6 @@
|
||||
background: var(--background-secondary, rgba(128, 128, 128, 0.1)) !important;
|
||||
}
|
||||
|
||||
/* ── Icon picker ── */
|
||||
.bsplus-icon-picker {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 4px;
|
||||
padding: 4px 12px 6px;
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
.bsplus-icon-opt {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
min-width: 0 !important;
|
||||
border: none !important;
|
||||
border-radius: 6px !important;
|
||||
background: transparent !important;
|
||||
cursor: pointer;
|
||||
padding: 0 !important;
|
||||
transition: all .15s;
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
.bsplus-icon-opt svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.bsplus-icon-opt:hover {
|
||||
transform: scale(1.3);
|
||||
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.1)) !important;
|
||||
}
|
||||
|
||||
/* ── Color picker row ── */
|
||||
.bsplus-folder-colors {
|
||||
display: grid;
|
||||
@@ -464,113 +322,14 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ── Context menu ── */
|
||||
.bsplus-context-menu {
|
||||
position: fixed;
|
||||
min-width: 160px;
|
||||
background: var(--background-primary, #fff) !important;
|
||||
border: 1px solid var(--background-secondary, #e0e0e0) !important;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
z-index: 2147483646;
|
||||
overflow: hidden;
|
||||
animation: bsplus-dropdown-in 0.12s ease-out;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.bsplus-context-title {
|
||||
padding: 6px 12px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-primary, #999) !important;
|
||||
opacity: 0.5;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.bsplus-context-item:hover {
|
||||
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08)) !important;
|
||||
}
|
||||
|
||||
.bsplus-context-item span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bsplus-context-checkmark {
|
||||
color: var(--better-main, #007bff) !important;
|
||||
font-weight: bold;
|
||||
flex: 0 !important;
|
||||
}
|
||||
|
||||
.bsplus-context-item {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: flex-start !important;
|
||||
gap: 8px;
|
||||
padding: 7px 12px !important;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
width: 100%;
|
||||
text-align: left !important;
|
||||
color: var(--text-primary, #333) !important;
|
||||
transition: background .1s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.bsplus-context-item .bsplus-folder-icon {
|
||||
color: var(--text-primary, #333) !important;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.bsplus-context-item .bsplus-folder-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.bsplus-context-item:hover {
|
||||
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08));
|
||||
}
|
||||
|
||||
.bsplus-context-item span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bsplus-context-checkmark {
|
||||
color: var(--better-main, #007bff) !important;
|
||||
font-weight: bold;
|
||||
flex: 0 !important;
|
||||
}
|
||||
|
||||
.bsplus-context-empty {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary, #999);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ── Drag feedback ── */
|
||||
.bsplus-msg-dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
[class*='MessageList__MessageList___'] ol > li[data-message] {
|
||||
transition: opacity .15s;
|
||||
}
|
||||
|
||||
/* ── Layout fixes ── */
|
||||
/* ── 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;
|
||||
@@ -579,6 +338,7 @@
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
/* ── Subject text truncates to make room for badges ── */
|
||||
.bsplus-subject-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -587,6 +347,7 @@
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* ── Shrink the secondary column to its content ── */
|
||||
[class*='MessageList__secondary___'] {
|
||||
flex: 0 0 auto !important;
|
||||
width: auto !important;
|
||||
@@ -594,6 +355,7 @@
|
||||
max-width: 200px !important;
|
||||
}
|
||||
|
||||
/* ── Constrain the flags/attachment icon column ── */
|
||||
[class*='MessageList__flags___'] {
|
||||
width: 24px !important;
|
||||
min-width: 0 !important;
|
||||
@@ -629,7 +391,7 @@
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* ── Folder filtering ── */
|
||||
/* ── Folder filtering (hide messages not in active folder) ── */
|
||||
.bsplus-folder-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -727,5 +489,3 @@
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(229, 62, 62, 0.35);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import debounce from "@/seqta/utils/debounce";
|
||||
import { themeUpdates } from "@/interface/hooks/ThemeUpdates";
|
||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||
import { getApiBase } from "@/seqta/utils/DevApiBase";
|
||||
import { updateAllColors } from "@/seqta/ui/colors/Manager";
|
||||
import {
|
||||
clearCustomThemeAdaptiveCssVariables,
|
||||
@@ -546,10 +545,7 @@ export class ThemeManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** Use a getter so dev-mode session-only base URL overrides take effect immediately. */
|
||||
private get THEME_API_BASE(): string {
|
||||
return `${getApiBase()}/api`;
|
||||
}
|
||||
private readonly THEME_API_BASE = 'https://betterseqta.org/api';
|
||||
private readonly GITHUB_THEMES_BASE = 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes';
|
||||
|
||||
/**
|
||||
|
||||
@@ -145,10 +145,8 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
||||
|
||||
let observer: MutationObserver | null = null;
|
||||
let quickbarObserver: MutationObserver | null = null;
|
||||
let quickbarSyncTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastClickedCi: number | null = null;
|
||||
let lastClickedEntry: { roomEl: HTMLElement; teacherEl: HTMLElement; item: TimetableEntryData } | null = null;
|
||||
let lastSyncedQuickbarCi: number | null = null;
|
||||
|
||||
const getOverrides = (): TimetableOverrides =>
|
||||
api.storage.timetableOverrides ?? {};
|
||||
@@ -188,11 +186,9 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
||||
if (override.staff !== undefined && teacherEl) teacherEl.textContent = override.staff;
|
||||
}
|
||||
|
||||
const captureClick = () => {
|
||||
const captureClick = (e: MouseEvent) => {
|
||||
lastClickedCi = ci;
|
||||
lastClickedEntry = { roomEl, teacherEl, item };
|
||||
lastSyncedQuickbarCi = null;
|
||||
scheduleQuickbarSync();
|
||||
};
|
||||
entry.addEventListener("click", captureClick, true);
|
||||
};
|
||||
@@ -203,76 +199,6 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
||||
});
|
||||
};
|
||||
|
||||
const getVisibleClassQuickbar = (): HTMLElement | null => {
|
||||
const quickbar = document.querySelector(
|
||||
".timetablepage .quickbar.below.visible, .timetablepage .quickbar.above.visible, .timetablepage .quickbar.visible",
|
||||
);
|
||||
if (!quickbar || quickbar.getAttribute("data-type") !== "class") return null;
|
||||
return quickbar as HTMLElement;
|
||||
};
|
||||
|
||||
const applyOverridesToQuickbar = (quickbar: HTMLElement): void => {
|
||||
if (lastClickedCi === null) return;
|
||||
if (lastSyncedQuickbarCi === lastClickedCi) return;
|
||||
|
||||
const description =
|
||||
quickbar.querySelector(".title")?.textContent?.trim() ??
|
||||
lastClickedEntry?.item.description ??
|
||||
"";
|
||||
const override = getEffectiveOverride(lastClickedCi, description);
|
||||
if (!override) {
|
||||
lastSyncedQuickbarCi = lastClickedCi;
|
||||
return;
|
||||
}
|
||||
|
||||
const roomEl = quickbar.querySelector(".meta .room");
|
||||
const teacherEl = quickbar.querySelector(".meta .teacher");
|
||||
if (override.room !== undefined && !roomEl) return;
|
||||
if (override.staff !== undefined && !teacherEl) return;
|
||||
|
||||
if (override.room !== undefined && roomEl && roomEl.textContent !== override.room) {
|
||||
roomEl.textContent = override.room;
|
||||
}
|
||||
if (override.staff !== undefined && teacherEl && teacherEl.textContent !== override.staff) {
|
||||
teacherEl.textContent = override.staff;
|
||||
}
|
||||
|
||||
lastSyncedQuickbarCi = lastClickedCi;
|
||||
};
|
||||
|
||||
const updateVisibleQuickbar = (room: string, staff: string): void => {
|
||||
const quickbar = getVisibleClassQuickbar();
|
||||
if (!quickbar) return;
|
||||
const roomEl = quickbar.querySelector(".meta .room");
|
||||
const teacherEl = quickbar.querySelector(".meta .teacher");
|
||||
if (roomEl && roomEl.textContent !== room) roomEl.textContent = room;
|
||||
if (teacherEl && teacherEl.textContent !== staff) teacherEl.textContent = staff;
|
||||
if (lastClickedCi !== null) lastSyncedQuickbarCi = lastClickedCi;
|
||||
};
|
||||
|
||||
const syncClassQuickbar = (quickbar: HTMLElement): void => {
|
||||
applyOverridesToQuickbar(quickbar);
|
||||
addEditButtonToQuickbar(quickbar);
|
||||
};
|
||||
|
||||
const scheduleQuickbarSync = (): void => {
|
||||
if (quickbarSyncTimer !== null) clearTimeout(quickbarSyncTimer);
|
||||
|
||||
let attempts = 0;
|
||||
const trySync = (): void => {
|
||||
const quickbar = getVisibleClassQuickbar();
|
||||
if (quickbar && lastClickedCi !== null) {
|
||||
syncClassQuickbar(quickbar);
|
||||
return;
|
||||
}
|
||||
if (++attempts < 6) {
|
||||
quickbarSyncTimer = setTimeout(trySync, 50);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(trySync);
|
||||
};
|
||||
|
||||
const addEditButtonToQuickbar = (quickbar: HTMLElement) => {
|
||||
if (quickbar.querySelector(".timetable-edit-quickbar-btn")) return;
|
||||
|
||||
@@ -325,7 +251,6 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
||||
}
|
||||
if (entryData.roomEl) entryData.roomEl.textContent = room;
|
||||
if (entryData.teacherEl) entryData.teacherEl.textContent = staff;
|
||||
updateVisibleQuickbar(room, staff);
|
||||
processAllEntries();
|
||||
},
|
||||
(ci) => {
|
||||
@@ -337,7 +262,6 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
||||
api.storage.timetableOverridesBySubject = bySubject;
|
||||
if (entryData.roomEl) entryData.roomEl.textContent = item.room;
|
||||
if (entryData.teacherEl) entryData.teacherEl.textContent = item.staff;
|
||||
updateVisibleQuickbar(item.room, item.staff);
|
||||
processAllEntries();
|
||||
},
|
||||
);
|
||||
@@ -347,30 +271,34 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
||||
};
|
||||
|
||||
const syncQuickbarFromDOM = () => {
|
||||
const quickbar = getVisibleClassQuickbar();
|
||||
if (!quickbar || lastClickedCi === null || !lastClickedEntry) return;
|
||||
syncClassQuickbar(quickbar);
|
||||
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");
|
||||
const teacherEl = quickbar.querySelector(".meta .teacher");
|
||||
if (titleEl && roomEl && teacherEl && lastClickedCi !== null && lastClickedEntry) {
|
||||
addEditButtonToQuickbar(quickbar as HTMLElement);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const setupQuickbarObserver = () => {
|
||||
const timetablePage = document.querySelector(".timetablepage");
|
||||
if (!timetablePage || quickbarObserver) return;
|
||||
|
||||
quickbarObserver = new MutationObserver((mutations) => {
|
||||
const quickbarBecameVisible = mutations.some(
|
||||
(mutation) =>
|
||||
mutation.type === "attributes" &&
|
||||
mutation.attributeName === "class" &&
|
||||
(mutation.target as HTMLElement).classList.contains("quickbar") &&
|
||||
(mutation.target as HTMLElement).classList.contains("visible"),
|
||||
quickbarObserver = new MutationObserver(() => {
|
||||
const quickbar = document.querySelector(
|
||||
".timetablepage .quickbar.below.visible, .timetablepage .quickbar.visible",
|
||||
);
|
||||
if (!quickbarBecameVisible || lastClickedCi === null) return;
|
||||
|
||||
const quickbar = getVisibleClassQuickbar();
|
||||
if (quickbar) syncClassQuickbar(quickbar);
|
||||
if (quickbar?.getAttribute("data-type") === "class") {
|
||||
addEditButtonToQuickbar(quickbar as HTMLElement);
|
||||
}
|
||||
});
|
||||
|
||||
quickbarObserver.observe(timetablePage, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
@@ -408,7 +336,6 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
||||
unregister();
|
||||
observer?.disconnect();
|
||||
quickbarObserver?.disconnect();
|
||||
if (quickbarSyncTimer !== null) clearTimeout(quickbarSyncTimer);
|
||||
styleEl.remove();
|
||||
document.querySelectorAll("[data-timetable-edit-processed]").forEach((el) => {
|
||||
el.removeAttribute("data-timetable-edit-processed");
|
||||
|
||||
@@ -10,7 +10,8 @@ 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 betterSeqtaSecurityPlugin from "./built-in/betterSeqtaSecurity";
|
||||
//import messageFoldersPlugin from "./built-in/messageFolders";
|
||||
//import testPlugin from './built-in/test';
|
||||
|
||||
// Heavy plugins (lazy-loaded only when enabled)
|
||||
@@ -21,6 +22,7 @@ const pluginManager = PluginManager.getInstance();
|
||||
|
||||
// Register built-in plugins
|
||||
pluginManager.registerPlugin(themesPlugin);
|
||||
pluginManager.registerPlugin(betterSeqtaSecurityPlugin);
|
||||
pluginManager.registerPlugin(animatedBackgroundPlugin);
|
||||
pluginManager.registerPlugin(assessmentsAveragePlugin);
|
||||
pluginManager.registerPlugin(notificationCollectorPlugin);
|
||||
@@ -29,7 +31,7 @@ pluginManager.registerPlugin(timetableEditPlugin);
|
||||
pluginManager.registerPlugin(profilePicturePlugin);
|
||||
pluginManager.registerPlugin(assessmentsOverviewPlugin);
|
||||
pluginManager.registerPlugin(backgroundMusicPlugin);
|
||||
pluginManager.registerPlugin(messageFoldersPlugin);
|
||||
//pluginManager.registerPlugin(messageFoldersPlugin);
|
||||
//pluginManager.registerPlugin(testPlugin);
|
||||
|
||||
// Register heavy plugins with lazy loading
|
||||
|
||||
@@ -105,7 +105,7 @@ export async function finishLoad() {
|
||||
console.error("Error during loading cleanup:", err);
|
||||
}
|
||||
|
||||
void runStartupPopupQueue();
|
||||
runStartupPopupQueue();
|
||||
}
|
||||
|
||||
export function GetCSSElement(file: string) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { analyzeHtmlThreats } from "./analyzeHtmlThreats";
|
||||
|
||||
describe("analyzeHtmlThreats", () => {
|
||||
it("does not flag benign HTML", () => {
|
||||
const r = analyzeHtmlThreats("<p>Hello <strong>world</strong></p>");
|
||||
expect(r.blocked).toBe(false);
|
||||
expect(r.findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("flags script tags", () => {
|
||||
const r = analyzeHtmlThreats('<p>x</p><script>alert(1)</script>');
|
||||
expect(r.blocked).toBe(true);
|
||||
expect(r.findings.some((f) => f.kind === "script_tag")).toBe(true);
|
||||
});
|
||||
|
||||
it("flags javascript: URLs", () => {
|
||||
const r = analyzeHtmlThreats('<a href="javascript:void(0)">click</a>');
|
||||
expect(r.blocked).toBe(true);
|
||||
expect(r.findings.some((f) => f.kind === "dangerous_url_scheme")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("flags inline event handlers", () => {
|
||||
const r = analyzeHtmlThreats('<img src="https://example.com/x.png" onerror="alert(1)">');
|
||||
expect(r.blocked).toBe(true);
|
||||
expect(
|
||||
r.findings.some((f) => f.kind === "inline_event_handler"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows data:image/png sources", () => {
|
||||
const r = analyzeHtmlThreats(
|
||||
'<img alt="i" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==">',
|
||||
);
|
||||
expect(r.blocked).toBe(false);
|
||||
});
|
||||
|
||||
it("flags data:text/html", () => {
|
||||
const r = analyzeHtmlThreats(
|
||||
'<iframe src="data:text/html,%3Cscript%3E%3C/script%3E"></iframe>',
|
||||
);
|
||||
expect(r.blocked).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
export interface ThreatFinding {
|
||||
kind: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface ThreatAnalysis {
|
||||
blocked: boolean;
|
||||
findings: ThreatFinding[];
|
||||
}
|
||||
|
||||
const INLINE_HANDLER_RE = /^on[a-z]+$/i;
|
||||
|
||||
const DANGEROUS_SCHEME_RE =
|
||||
/^\s*(javascript|vbscript|about\s*:|file\s*:)/i;
|
||||
|
||||
/** Inline data URIs except common raster images (emails often embed PNG/JPEG). */
|
||||
function isDangerousDataUri(url: string): boolean {
|
||||
const v = url.trim().toLowerCase();
|
||||
if (!v.startsWith("data:")) return false;
|
||||
if (/^data:image\/(png|jpe?g|gif|webp|bmp)([;,]|$)/i.test(v)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Patterns inside executable contexts (script bodies). */
|
||||
const SCRIPT_TEXT_SUSPICIOUS =
|
||||
/\beval\s*\(|new\s+Function\s*\(|document\s*\.\s*write|\.execScript\s*\(/i;
|
||||
|
||||
function addFinding(
|
||||
findings: ThreatFinding[],
|
||||
kind: string,
|
||||
detail: string,
|
||||
): void {
|
||||
if (findings.some((f) => f.kind === kind && f.detail === detail)) return;
|
||||
findings.push({ kind, detail });
|
||||
}
|
||||
|
||||
function inspectUrlAttr(attrName: string, value: string): ThreatFinding[] {
|
||||
const out: ThreatFinding[] = [];
|
||||
const v = value.trim();
|
||||
if (!v) return out;
|
||||
if (DANGEROUS_SCHEME_RE.test(v) || isDangerousDataUri(v)) {
|
||||
out.push({
|
||||
kind: "dangerous_url_scheme",
|
||||
detail: `${attrName}="${v.slice(0, 120)}${v.length > 120 ? "…" : ""}"`,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function walkElement(el: Element, findings: ThreatFinding[]): void {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
|
||||
if (tag === "script") {
|
||||
const src = el.getAttribute("src")?.trim() ?? "";
|
||||
if (
|
||||
src &&
|
||||
(DANGEROUS_SCHEME_RE.test(src) || isDangerousDataUri(src))
|
||||
) {
|
||||
addFinding(findings, "script_src", `script src="${src.slice(0, 160)}"`);
|
||||
} else if (!src && el.textContent && SCRIPT_TEXT_SUSPICIOUS.test(el.textContent)) {
|
||||
addFinding(
|
||||
findings,
|
||||
"script_pattern",
|
||||
"Inline script contains suspicious patterns (eval/new Function/document.write).",
|
||||
);
|
||||
} else {
|
||||
addFinding(findings, "script_tag", "A script element is present in HTML.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tag === "meta") {
|
||||
const httpEquiv = el.getAttribute("http-equiv")?.toLowerCase();
|
||||
if (httpEquiv === "refresh") {
|
||||
addFinding(
|
||||
findings,
|
||||
"meta_refresh",
|
||||
'meta http-equiv="refresh" can redirect or execute unexpectedly.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (tag === "iframe" || tag === "frame") {
|
||||
const src = el.getAttribute("src")?.trim() ?? "";
|
||||
const srcdoc = el.getAttribute("srcdoc") ?? "";
|
||||
findings.push(...inspectUrlAttr("iframe[src]", src));
|
||||
if (srcdoc.length > 0) {
|
||||
addFinding(
|
||||
findings,
|
||||
"iframe_srcdoc",
|
||||
"iframe srcdoc may embed arbitrary markup; nested analysis follows.",
|
||||
);
|
||||
nestedAnalyze(srcdoc, findings, 2);
|
||||
}
|
||||
}
|
||||
|
||||
if (tag === "object" || tag === "embed") {
|
||||
const url =
|
||||
el.getAttribute("data") ?? el.getAttribute("src") ?? "";
|
||||
findings.push(...inspectUrlAttr(`${tag}[src/data]`, url));
|
||||
}
|
||||
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
const name = attr.name;
|
||||
if (INLINE_HANDLER_RE.test(name)) {
|
||||
addFinding(
|
||||
findings,
|
||||
"inline_event_handler",
|
||||
`${tag}.${name}`,
|
||||
);
|
||||
}
|
||||
const val = attr.value ?? "";
|
||||
if (
|
||||
name === "href" ||
|
||||
name === "src" ||
|
||||
name === "action" ||
|
||||
name === "formaction" ||
|
||||
name === "poster" ||
|
||||
name === "data"
|
||||
) {
|
||||
findings.push(...inspectUrlAttr(`${tag}[${name}]`, val));
|
||||
}
|
||||
if (name === "style" && /\burl\s*\(\s*["']?\s*javascript:/i.test(val)) {
|
||||
addFinding(findings, "css_javascript_url", `${tag} style contains javascript: URL.`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const child of Array.from(el.children)) {
|
||||
walkElement(child, findings);
|
||||
}
|
||||
}
|
||||
|
||||
function nestedAnalyze(fragment: string, findings: ThreatFinding[], depth: number): void {
|
||||
if (depth <= 0) return;
|
||||
let doc: Document;
|
||||
try {
|
||||
doc = new DOMParser().parseFromString(fragment, "text/html");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
walkElement(doc.documentElement, findings);
|
||||
}
|
||||
|
||||
/**
|
||||
* High-confidence HTML threat signals for user-generated / API HTML (messages, notices).
|
||||
*
|
||||
* Note: This runs after load for iframes in many cases; pairing with iframe `sandbox`
|
||||
* (see BetterSEQTA Security plugin) is required for reliable script blocking — see plugin comments.
|
||||
*/
|
||||
export function analyzeHtmlThreats(html: string): ThreatAnalysis {
|
||||
const findings: ThreatFinding[] = [];
|
||||
|
||||
if (!html || !html.trim()) {
|
||||
return { blocked: false, findings: [] };
|
||||
}
|
||||
|
||||
let doc: Document;
|
||||
try {
|
||||
doc = new DOMParser().parseFromString(html, "text/html");
|
||||
} catch {
|
||||
return { blocked: false, findings: [] };
|
||||
}
|
||||
|
||||
walkElement(doc.documentElement, findings);
|
||||
|
||||
/** SEQTA home modal path uses DOMPurify with onclick allowed; flag removal under stricter rules. */
|
||||
const permissive = DOMPurify.sanitize(html, {
|
||||
ADD_ATTR: ["onclick"],
|
||||
ALLOWED_URI_REGEXP:
|
||||
/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|chrome-extension):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
|
||||
});
|
||||
const strict = DOMPurify.sanitize(html, {
|
||||
ALLOW_DATA_ATTR: false,
|
||||
ALLOW_UNKNOWN_PROTOCOLS: false,
|
||||
});
|
||||
if (strict !== permissive) {
|
||||
addFinding(
|
||||
findings,
|
||||
"dompurify_delta",
|
||||
"Content was altered under strict sanitization (likely inline handlers or risky markup).",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
blocked: findings.length > 0,
|
||||
findings,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import type { ThreatAnalysis } from "./analyzeHtmlThreats";
|
||||
import {
|
||||
buildIncidentReport,
|
||||
copyIncidentReport,
|
||||
downloadIncidentReportPdf,
|
||||
formatIncidentReportPlainText,
|
||||
openIncidentReportEmail,
|
||||
type IncidentReport,
|
||||
} from "./incidentReport";
|
||||
|
||||
/** Mounted by BetterSEQTA Security on messages / notices when HTML is blocked. */
|
||||
export const SECURITY_BLOCK_HOST_CLASS = "bss-security-block-host";
|
||||
/** Body-fixed layer for message pane (survives React replacing iframe wrappers). */
|
||||
export const SECURITY_MESSAGE_OVERLAY_CLASS = "bss-security-message-overlay";
|
||||
const HOST_CLASS = SECURITY_BLOCK_HOST_CLASS;
|
||||
const REPORT_LAYER_CLASS = "bss-security-report-layer";
|
||||
|
||||
function el<K extends keyof HTMLElementTagNameMap>(
|
||||
tag: K,
|
||||
style: Partial<CSSStyleDeclaration>,
|
||||
text?: string,
|
||||
): HTMLElementTagNameMap[K] {
|
||||
const node = document.createElement(tag);
|
||||
Object.assign(node.style, style);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
function button(
|
||||
label: string,
|
||||
onClick: () => void,
|
||||
): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.textContent = label;
|
||||
Object.assign(btn.style, {
|
||||
padding: "10px 18px",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid rgba(255,255,255,0.25)",
|
||||
background: "rgba(220,38,38,0.35)",
|
||||
color: "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
fontWeight: "600",
|
||||
transition: "background 0.2s, transform 0.2s",
|
||||
} as CSSStyleDeclaration);
|
||||
btn.addEventListener("mouseenter", () => {
|
||||
btn.style.background = "rgba(220,38,38,0.55)";
|
||||
});
|
||||
btn.addEventListener("mouseleave", () => {
|
||||
btn.style.background = "rgba(220,38,38,0.35)";
|
||||
});
|
||||
btn.addEventListener("click", onClick);
|
||||
return btn;
|
||||
}
|
||||
|
||||
function removeExistingReportLayer(doc: Document): void {
|
||||
doc.querySelectorAll(`.${REPORT_LAYER_CLASS}`).forEach((n) => n.remove());
|
||||
}
|
||||
|
||||
export interface MountBlockedContentOptions {
|
||||
surface: "message" | "notice";
|
||||
analysis: ThreatAnalysis;
|
||||
rawSnippet?: string;
|
||||
contextTitle?: string;
|
||||
contextSubtitle?: string;
|
||||
hostDocument?: Document;
|
||||
/**
|
||||
* Panel lives on document.body (fixed layer). Omits `position: relative` so the caller can pin position/size.
|
||||
*/
|
||||
rootOverlay?: boolean;
|
||||
}
|
||||
|
||||
export function mountBlockedContentUi(
|
||||
container: HTMLElement,
|
||||
options: MountBlockedContentOptions,
|
||||
): () => void {
|
||||
const doc = options.hostDocument ?? document;
|
||||
container.innerHTML = "";
|
||||
container.classList.add(HOST_CLASS);
|
||||
const basePanel: Partial<CSSStyleDeclaration> = {
|
||||
boxSizing: "border-box",
|
||||
minHeight: options.rootOverlay ? "min(100%, 260px)" : "220px",
|
||||
padding: "24px",
|
||||
borderRadius: "12px",
|
||||
background:
|
||||
"linear-gradient(145deg, rgba(30,30,35,0.98), rgba(20,20,24,0.98))",
|
||||
border: "1px solid rgba(239,68,68,0.45)",
|
||||
color: "#f4f4f5",
|
||||
fontFamily: "system-ui, Segoe UI, Roboto, sans-serif",
|
||||
lineHeight: "1.5",
|
||||
};
|
||||
if (options.rootOverlay) {
|
||||
Object.assign(container.style, {
|
||||
...basePanel,
|
||||
height: "100%",
|
||||
overflow: "auto",
|
||||
});
|
||||
} else {
|
||||
Object.assign(container.style, {
|
||||
...basePanel,
|
||||
position: "relative",
|
||||
});
|
||||
}
|
||||
|
||||
const title = el("h2", {
|
||||
margin: "0 0 12px 0",
|
||||
fontSize: "20px",
|
||||
fontWeight: "700",
|
||||
color: "#fff",
|
||||
}, "BetterSEQTA Security");
|
||||
|
||||
const lead = el("p", {
|
||||
margin: "0 0 12px 0",
|
||||
fontSize: "15px",
|
||||
color: "#e4e4e7",
|
||||
});
|
||||
lead.textContent =
|
||||
"This content was not shown because BetterSEQTA+ detected potentially malicious HTML (for example scripts or dangerous links). This helps protect your account from cross-site scripting.";
|
||||
|
||||
const admin = el("p", {
|
||||
margin: "0 0 20px 0",
|
||||
fontSize: "14px",
|
||||
color: "#a1a1aa",
|
||||
});
|
||||
admin.textContent =
|
||||
"Contact your school SEQTA or IT administrator so they can remove or fix the message or notice at source.";
|
||||
|
||||
const actions = el("div", {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "12px",
|
||||
alignItems: "center",
|
||||
});
|
||||
|
||||
let latestReport: IncidentReport | null = null;
|
||||
|
||||
const openReport = async () => {
|
||||
latestReport = await buildIncidentReport({
|
||||
surface: options.surface,
|
||||
analysis: options.analysis,
|
||||
rawSnippet: options.rawSnippet,
|
||||
contextTitle: options.contextTitle,
|
||||
contextSubtitle: options.contextSubtitle,
|
||||
});
|
||||
removeExistingReportLayer(doc);
|
||||
const layer = doc.createElement("div");
|
||||
layer.className = REPORT_LAYER_CLASS;
|
||||
Object.assign(layer.style, {
|
||||
position: "fixed",
|
||||
inset: "0",
|
||||
zIndex: "2147483647",
|
||||
background: "rgba(0,0,0,0.55)",
|
||||
backdropFilter: "blur(4px)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "24px",
|
||||
boxSizing: "border-box",
|
||||
});
|
||||
|
||||
const panel = doc.createElement("div");
|
||||
Object.assign(panel.style, {
|
||||
maxWidth: "640px",
|
||||
width: "100%",
|
||||
maxHeight: "85vh",
|
||||
overflow: "auto",
|
||||
background: "#18181b",
|
||||
color: "#fafafa",
|
||||
borderRadius: "12px",
|
||||
border: "1px solid #3f3f46",
|
||||
padding: "24px",
|
||||
boxShadow: "0 25px 50px rgba(0,0,0,0.45)",
|
||||
});
|
||||
|
||||
const pre = doc.createElement("pre");
|
||||
pre.style.whiteSpace = "pre-wrap";
|
||||
pre.style.wordBreak = "break-word";
|
||||
pre.style.fontSize = "12px";
|
||||
pre.style.lineHeight = "1.45";
|
||||
pre.style.margin = "0 0 16px 0";
|
||||
pre.textContent = formatIncidentReportPlainText(latestReport);
|
||||
|
||||
const row = doc.createElement("div");
|
||||
row.style.display = "flex";
|
||||
row.style.flexWrap = "wrap";
|
||||
row.style.gap = "10px";
|
||||
|
||||
const closeLayer = () => layer.remove();
|
||||
|
||||
row.appendChild(
|
||||
button("Close", closeLayer),
|
||||
);
|
||||
row.appendChild(
|
||||
button("Copy report", async () => {
|
||||
if (!latestReport) return;
|
||||
await copyIncidentReport(latestReport);
|
||||
}),
|
||||
);
|
||||
row.appendChild(
|
||||
button("Download PDF", () => {
|
||||
if (!latestReport) return;
|
||||
downloadIncidentReportPdf(latestReport);
|
||||
}),
|
||||
);
|
||||
row.appendChild(
|
||||
button("Email", () => {
|
||||
if (!latestReport) return;
|
||||
openIncidentReportEmail(latestReport);
|
||||
}),
|
||||
);
|
||||
|
||||
panel.appendChild(pre);
|
||||
panel.appendChild(row);
|
||||
layer.appendChild(panel);
|
||||
layer.addEventListener("click", (e) => {
|
||||
if (e.target === layer) closeLayer();
|
||||
});
|
||||
doc.body.appendChild(layer);
|
||||
};
|
||||
|
||||
actions.appendChild(button("View report", () => void openReport()));
|
||||
|
||||
container.appendChild(title);
|
||||
container.appendChild(lead);
|
||||
container.appendChild(admin);
|
||||
container.appendChild(actions);
|
||||
|
||||
return () => {
|
||||
container.innerHTML = "";
|
||||
container.classList.remove(HOST_CLASS);
|
||||
removeExistingReportLayer(doc);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { jsPDF } from "jspdf";
|
||||
import browser from "webextension-polyfill";
|
||||
|
||||
import type { ThreatAnalysis, ThreatFinding } from "./analyzeHtmlThreats";
|
||||
|
||||
export type ThreatSurface = "message" | "notice";
|
||||
|
||||
export interface IncidentReport {
|
||||
generatedAtIso: string;
|
||||
surface: ThreatSurface;
|
||||
extensionVersion: string;
|
||||
pageUrl: string;
|
||||
contextTitle?: string;
|
||||
contextSubtitle?: string;
|
||||
analysis: ThreatAnalysis;
|
||||
contentFingerprint?: string;
|
||||
}
|
||||
|
||||
async function sha256Hex(text: string): Promise<string> {
|
||||
const enc = new TextEncoder().encode(text);
|
||||
const digest = await crypto.subtle.digest("SHA-256", enc);
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function getExtensionVersion(): string {
|
||||
try {
|
||||
return browser.runtime?.getManifest?.()?.version ?? "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildIncidentReport(opts: {
|
||||
surface: ThreatSurface;
|
||||
analysis: ThreatAnalysis;
|
||||
rawSnippet?: string;
|
||||
contextTitle?: string;
|
||||
contextSubtitle?: string;
|
||||
extensionVersion?: string;
|
||||
}): Promise<IncidentReport> {
|
||||
let fingerprint: string | undefined;
|
||||
if (opts.rawSnippet?.trim()) {
|
||||
try {
|
||||
if (typeof crypto !== "undefined" && crypto.subtle) {
|
||||
fingerprint = await sha256Hex(opts.rawSnippet.slice(0, 50_000));
|
||||
}
|
||||
} catch {
|
||||
fingerprint = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const version = opts.extensionVersion ?? getExtensionVersion();
|
||||
|
||||
return {
|
||||
generatedAtIso: new Date().toISOString(),
|
||||
surface: opts.surface,
|
||||
extensionVersion: version,
|
||||
pageUrl:
|
||||
typeof window !== "undefined" ? window.location.href : "",
|
||||
contextTitle: opts.contextTitle,
|
||||
contextSubtitle: opts.contextSubtitle,
|
||||
analysis: opts.analysis,
|
||||
contentFingerprint: fingerprint,
|
||||
};
|
||||
}
|
||||
|
||||
function formatFindings(findings: ThreatFinding[]): string {
|
||||
return findings.map((f, i) => `${i + 1}. [${f.kind}] ${f.detail}`).join("\n");
|
||||
}
|
||||
|
||||
export function formatIncidentReportPlainText(report: IncidentReport): string {
|
||||
const lines = [
|
||||
"BetterSEQTA+ Security — incident report",
|
||||
"=====================================",
|
||||
"",
|
||||
`Generated (UTC): ${report.generatedAtIso}`,
|
||||
`Surface: ${report.surface}`,
|
||||
`Extension version: ${report.extensionVersion}`,
|
||||
`Page URL: ${report.pageUrl}`,
|
||||
];
|
||||
if (report.contextTitle) lines.push(`Title / subject: ${report.contextTitle}`);
|
||||
if (report.contextSubtitle) lines.push(`Detail: ${report.contextSubtitle}`);
|
||||
if (report.contentFingerprint) {
|
||||
lines.push(`Content SHA-256 (truncated input): ${report.contentFingerprint}`);
|
||||
}
|
||||
lines.push("", "Findings:", formatFindings(report.analysis.findings), "");
|
||||
lines.push(
|
||||
"Next steps:",
|
||||
"- Contact your school SEQTA / IT administrator and ask them to remove or sanitise the malicious content at source.",
|
||||
"- Attach this report (PDF or pasted text) when reporting.",
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export async function copyIncidentReport(report: IncidentReport): Promise<void> {
|
||||
const text = formatIncidentReportPlainText(report);
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
export function downloadIncidentReportPdf(report: IncidentReport): void {
|
||||
const doc = new jsPDF({ unit: "pt", format: "a4" });
|
||||
const margin = 48;
|
||||
let y = margin;
|
||||
const lineHeight = 14;
|
||||
const pageHeight = doc.internal.pageSize.getHeight();
|
||||
const maxWidth = doc.internal.pageSize.getWidth() - margin * 2;
|
||||
|
||||
const pushLines = (text: string, bold = false) => {
|
||||
doc.setFont("helvetica", bold ? "bold" : "normal");
|
||||
const wrapped = doc.splitTextToSize(text, maxWidth) as string[];
|
||||
for (const line of wrapped) {
|
||||
if (y > pageHeight - margin) {
|
||||
doc.addPage();
|
||||
y = margin;
|
||||
}
|
||||
doc.text(line, margin, y);
|
||||
y += lineHeight;
|
||||
}
|
||||
};
|
||||
|
||||
pushLines("BetterSEQTA+ Security — incident report", true);
|
||||
pushLines(`Generated (UTC): ${report.generatedAtIso}`);
|
||||
pushLines(`Surface: ${report.surface}`);
|
||||
pushLines(`Extension version: ${report.extensionVersion}`);
|
||||
pushLines(`Page URL: ${report.pageUrl}`);
|
||||
if (report.contextTitle) pushLines(`Title / subject: ${report.contextTitle}`);
|
||||
if (report.contextSubtitle) pushLines(`Detail: ${report.contextSubtitle}`);
|
||||
if (report.contentFingerprint) {
|
||||
pushLines(`Content SHA-256 (truncated input): ${report.contentFingerprint}`);
|
||||
}
|
||||
pushLines("");
|
||||
pushLines("Findings:", true);
|
||||
for (const f of report.analysis.findings) {
|
||||
pushLines(`• [${f.kind}] ${f.detail}`);
|
||||
}
|
||||
pushLines("");
|
||||
pushLines("Next steps:", true);
|
||||
pushLines(
|
||||
"Contact your school SEQTA / IT administrator and ask them to remove or sanitise the malicious content at source. Attach this PDF when reporting.",
|
||||
);
|
||||
|
||||
doc.save(`betterseqta-security-report-${report.surface}-${Date.now()}.pdf`);
|
||||
}
|
||||
|
||||
export function openIncidentReportEmail(report: IncidentReport): void {
|
||||
const subject = encodeURIComponent(
|
||||
"SEQTA: suspected malicious HTML blocked by BetterSEQTA+ Security",
|
||||
);
|
||||
const body = encodeURIComponent(
|
||||
formatIncidentReportPlainText(report).slice(0, 1800) +
|
||||
"\n\n[If truncated: use Copy in the report dialog for the full text.]",
|
||||
);
|
||||
window.location.href = `mailto:?subject=${subject}&body=${body}`;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import browser from "webextension-polyfill";
|
||||
|
||||
const DEFAULT_BASE = "https://betterseqta.org";
|
||||
const KEY = "bsplus_dev_api_base";
|
||||
|
||||
/**
|
||||
* Returns the current content-API base URL.
|
||||
*
|
||||
* Reads from `sessionStorage` so a developer can temporarily override the
|
||||
* server for testing. The value is cleared when the browser session ends,
|
||||
* leaving production traffic unaffected for normal users.
|
||||
*/
|
||||
export function getApiBase(): string {
|
||||
try {
|
||||
if (typeof sessionStorage === "undefined") return DEFAULT_BASE;
|
||||
const v = sessionStorage.getItem(KEY);
|
||||
if (v && /^https?:\/\//.test(v)) return v.replace(/\/$/, "");
|
||||
} catch {
|
||||
// sessionStorage may throw in some restricted contexts; fall back silently.
|
||||
}
|
||||
return DEFAULT_BASE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a session-scoped override and broadcast it to the background script
|
||||
* so its `fetch` calls hit the same host.
|
||||
*
|
||||
* Pass `null` to clear the override.
|
||||
*/
|
||||
export function setApiBase(url: string | null): void {
|
||||
try {
|
||||
if (!url) {
|
||||
sessionStorage.removeItem(KEY);
|
||||
} else {
|
||||
sessionStorage.setItem(KEY, url.replace(/\/$/, ""));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
void browser.runtime
|
||||
.sendMessage({ type: "setDevApiBase", url: url || null })
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/** Returns the override URL if one is currently set in this session. */
|
||||
export function getStoredOverride(): string | null {
|
||||
try {
|
||||
if (typeof sessionStorage === "undefined") return null;
|
||||
return sessionStorage.getItem(KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the current session override to the background script.
|
||||
* Call this early in page load so the background context stays in sync after
|
||||
* service-worker restarts.
|
||||
*/
|
||||
export function syncApiBaseToBackground(): void {
|
||||
const override = getStoredOverride();
|
||||
void browser.runtime
|
||||
.sendMessage({ type: "setDevApiBase", url: override })
|
||||
.catch(() => {});
|
||||
}
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
toISODate,
|
||||
weekRangeContaining,
|
||||
} from "@/seqta/utils/Loaders/engageParentTimetable";
|
||||
import { analyzeHtmlThreats } from "@/seqta/security/analyzeHtmlThreats";
|
||||
import { mountBlockedContentUi } from "@/seqta/security/blockedContentUi";
|
||||
|
||||
export function updateEngageHomeMenuActive(isHome: boolean): void {
|
||||
const home = document.getElementById("homebutton");
|
||||
@@ -356,6 +358,12 @@ function openEngageNoticeModal(
|
||||
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
|
||||
.replace(/ +/, " ");
|
||||
|
||||
const threatAnalysis = analyzeHtmlThreats(cleanContent);
|
||||
const noticeBlocked = threatAnalysis.blocked;
|
||||
const noticeBodyInner = noticeBlocked
|
||||
? `<div class="notice-content-body bss-security-notice-mount"></div>`
|
||||
: `<div class="notice-content-body">${cleanContent}</div>`;
|
||||
|
||||
document.getElementById("notice-modal")?.remove();
|
||||
|
||||
const sourceRect = sourceElement.getBoundingClientRect();
|
||||
@@ -389,7 +397,7 @@ function openEngageNoticeModal(
|
||||
<button class="notice-close-btn">×</button>
|
||||
</div>
|
||||
<h2 class="notice-content-title">${notice.title}</h2>
|
||||
<div class="notice-content-body">${cleanContent}</div>
|
||||
${noticeBodyInner}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -406,6 +414,23 @@ function openEngageNoticeModal(
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
if (noticeBlocked) {
|
||||
const mountEl = modal.querySelector(
|
||||
".bss-security-notice-mount",
|
||||
) as HTMLElement | null;
|
||||
if (mountEl) {
|
||||
mountBlockedContentUi(mountEl, {
|
||||
surface: "notice",
|
||||
analysis: threatAnalysis,
|
||||
rawSnippet: cleanContent.slice(0, 50_000),
|
||||
contextTitle: String(notice.title ?? ""),
|
||||
contextSubtitle: [notice.staff, notice.label_title]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
sourceElement.setAttribute("data-transitioning", "true");
|
||||
sourceElement.style.opacity = "0";
|
||||
sourceElement.style.transform = "scale(0.95)";
|
||||
@@ -431,7 +456,11 @@ function openEngageNoticeModal(
|
||||
<button class="notice-close-btn">×</button>
|
||||
</div>
|
||||
<h2 class="notice-content-title">${notice.title}</h2>
|
||||
<div class="notice-content-body">${cleanContent}</div>
|
||||
${
|
||||
noticeBlocked
|
||||
? `<div class="notice-content-body bss-security-notice-mount" style="min-height:280px;"></div>`
|
||||
: `<div class="notice-content-body">${cleanContent}</div>`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(tempMeasureDiv);
|
||||
|
||||
@@ -13,6 +13,8 @@ import { CreateElement } from "@/seqta/utils/CreateEnable/CreateElement";
|
||||
import { FilterUpcomingAssessments } from "@/seqta/utils/FilterUpcomingAssessments";
|
||||
import { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent";
|
||||
import { setupFixedTooltips } from "@/seqta/utils/fixedTooltip";
|
||||
import { analyzeHtmlThreats } from "@/seqta/security/analyzeHtmlThreats";
|
||||
import { mountBlockedContentUi } from "@/seqta/security/blockedContentUi";
|
||||
|
||||
let LessonInterval: any;
|
||||
let currentSelectedDate = new Date();
|
||||
@@ -384,6 +386,12 @@ function openNoticeModal(
|
||||
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
|
||||
.replace(/ +/, " ");
|
||||
|
||||
const threatAnalysis = analyzeHtmlThreats(cleanContent);
|
||||
const noticeBlocked = threatAnalysis.blocked;
|
||||
const noticeBodyInner = noticeBlocked
|
||||
? `<div class="notice-content-body bss-security-notice-mount"></div>`
|
||||
: `<div class="notice-content-body">${cleanContent}</div>`;
|
||||
|
||||
document.getElementById("notice-modal")?.remove();
|
||||
|
||||
const sourceRect = sourceElement.getBoundingClientRect();
|
||||
@@ -417,7 +425,7 @@ function openNoticeModal(
|
||||
<button class="notice-close-btn">×</button>
|
||||
</div>
|
||||
<h2 class="notice-content-title">${notice.title}</h2>
|
||||
<div class="notice-content-body">${cleanContent}</div>
|
||||
${noticeBodyInner}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -434,6 +442,23 @@ function openNoticeModal(
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
if (noticeBlocked) {
|
||||
const mountEl = modal.querySelector(
|
||||
".bss-security-notice-mount",
|
||||
) as HTMLElement | null;
|
||||
if (mountEl) {
|
||||
mountBlockedContentUi(mountEl, {
|
||||
surface: "notice",
|
||||
analysis: threatAnalysis,
|
||||
rawSnippet: cleanContent.slice(0, 50_000),
|
||||
contextTitle: String(notice.title ?? ""),
|
||||
contextSubtitle: [notice.staff, notice.label_title]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
sourceElement.setAttribute("data-transitioning", "true");
|
||||
sourceElement.style.opacity = "0";
|
||||
sourceElement.style.transform = "scale(0.95)";
|
||||
@@ -459,7 +484,11 @@ function openNoticeModal(
|
||||
<button class="notice-close-btn">×</button>
|
||||
</div>
|
||||
<h2 class="notice-content-title">${notice.title}</h2>
|
||||
<div class="notice-content-body">${cleanContent}</div>
|
||||
${
|
||||
noticeBlocked
|
||||
? `<div class="notice-content-body bss-security-notice-mount" style="min-height:280px;"></div>`
|
||||
: `<div class="notice-content-body">${cleanContent}</div>`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(tempMeasureDiv);
|
||||
|
||||
@@ -14,14 +14,13 @@ export function showEngageParentsToast() {
|
||||
settingsState.engageParentsAnnouncementShown = true;
|
||||
|
||||
const toast = document.createElement("div");
|
||||
toast.className = "bsplus-toast engageParentsToast";
|
||||
toast.className = "bsplus-toast";
|
||||
toast.innerHTML = /* html */ `
|
||||
<button class="bsplus-toast-close" aria-label="Dismiss">×</button>
|
||||
<div class="bsplus-toast-content">
|
||||
<p class="bsplus-toast-eyebrow">SEQTA Engage support</p>
|
||||
<strong>BetterSEQTA+ now supports <span class="seqtaEngageAccent">SEQTA Engage</span></strong>
|
||||
<p>Buy your mum a BetterSEQTA Plus! Parents now get themes, a cleaner home page, and all the Plus polish on SEQTA Engage.</p>
|
||||
</div>
|
||||
<button class="bsplus-toast-close" aria-label="Dismiss">×</button>
|
||||
`;
|
||||
|
||||
toast.style.opacity = "0";
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
import browser from "webextension-polyfill";
|
||||
import stringToHTML from "../stringToHTML";
|
||||
import { settingsState } from "../listeners/SettingsState";
|
||||
import { closePopup } from "./PopupManager";
|
||||
import { getApiBase } from "../DevApiBase";
|
||||
import { openThemeStoreWithHighlight } from "../openThemeStoreWithHighlight";
|
||||
import { cloudAuth } from "../CloudAuth";
|
||||
|
||||
/**
|
||||
* Server response shape from `/api/theme-of-the-month/current`.
|
||||
* Hero image is resolved client-side via the theme store API when `theme_id` is set.
|
||||
*/
|
||||
export interface ThemeOfTheMonthEntry {
|
||||
id: string;
|
||||
month: string;
|
||||
title: string;
|
||||
description: string;
|
||||
cover_image: string | null;
|
||||
theme_id: string | null;
|
||||
theme: { id: string; name: string; slug: string } | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current month's Theme of the Month entry from the API.
|
||||
* Returns `null` when no entry is configured for this month, or when the
|
||||
* request fails (we never want a network problem to block other startup
|
||||
* popups).
|
||||
*/
|
||||
export async function fetchThemeOfTheMonth(): Promise<ThemeOfTheMonthEntry | null> {
|
||||
try {
|
||||
const res = await fetch(`${getApiBase()}/api/theme-of-the-month/current`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const text = await res.text();
|
||||
if (!text) return null;
|
||||
const data = JSON.parse(text);
|
||||
if (!data || typeof data !== "object" || !data.id) return null;
|
||||
return data as ThemeOfTheMonthEntry;
|
||||
} catch (err) {
|
||||
console.warn("[ThemeOfTheMonth] Failed to fetch current entry:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when we have a new monthly entry the user hasn't dismissed yet. */
|
||||
export function shouldShowThemeOfTheMonth(entry: ThemeOfTheMonthEntry | null): boolean {
|
||||
if (!entry || settingsState.themeOfTheMonthDisabled) return false;
|
||||
return settingsState.themeOfTheMonthLastSeenId !== entry.id;
|
||||
}
|
||||
|
||||
function escapeHTML(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function formatMonthLabel(month: string): string {
|
||||
const [yyyy, mm] = month.split("-");
|
||||
if (!yyyy || !mm) return month;
|
||||
const date = new Date(parseInt(yyyy, 10), parseInt(mm, 10) - 1, 1);
|
||||
return date.toLocaleDateString("en-US", { year: "numeric", month: "long" });
|
||||
}
|
||||
|
||||
/** Same priority as the theme store: marquee, then cover/banner. */
|
||||
function heroUrlFromStoreTheme(theme: {
|
||||
marqueeImage?: string | null;
|
||||
coverImage?: string | null;
|
||||
}): string | null {
|
||||
const url = (theme.marqueeImage || theme.coverImage || "").trim();
|
||||
return url || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads hero image for a store theme via the background script (same path as
|
||||
* {@link ThemeSelector} / theme store detail fetches).
|
||||
*/
|
||||
export async function fetchThemeStoreHeroImage(themeId: string): Promise<string | null> {
|
||||
try {
|
||||
const token = await cloudAuth.getStoredToken();
|
||||
const res = (await browser.runtime.sendMessage({
|
||||
type: "fetchThemeDetails",
|
||||
themeId,
|
||||
token: token ?? undefined,
|
||||
})) as { success?: boolean; data?: { theme?: { marqueeImage?: string; coverImage?: string } } };
|
||||
|
||||
if (!res?.success || !res?.data?.theme) return null;
|
||||
return heroUrlFromStoreTheme(res.data.theme);
|
||||
} catch (err) {
|
||||
console.warn("[ThemeOfTheMonth] Failed to fetch theme store image:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Linked theme store image, else optional admin-uploaded cover. */
|
||||
async function resolvePopupHeroImageUrl(entry: ThemeOfTheMonthEntry): Promise<string | null> {
|
||||
const themeId = entry.theme_id ?? entry.theme?.id;
|
||||
if (themeId) {
|
||||
const fromStore = await fetchThemeStoreHeroImage(themeId);
|
||||
if (fromStore) return fromStore;
|
||||
}
|
||||
const fallback = entry.cover_image?.trim();
|
||||
return fallback || null;
|
||||
}
|
||||
|
||||
function closeThemeOfTheMonthCard(
|
||||
card: HTMLElement,
|
||||
onDismissed?: () => void,
|
||||
markSeen = true,
|
||||
) {
|
||||
if (card.classList.contains("themeOfTheMonthCardClosing")) return;
|
||||
|
||||
if (markSeen) {
|
||||
const entryId = card.dataset.entryId;
|
||||
if (entryId) settingsState.themeOfTheMonthLastSeenId = entryId;
|
||||
}
|
||||
|
||||
card.classList.add("themeOfTheMonthCardClosing");
|
||||
window.setTimeout(() => {
|
||||
card.remove();
|
||||
onDismissed?.();
|
||||
}, 180);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the Theme of the Month announcement card.
|
||||
*/
|
||||
export async function OpenThemeOfTheMonthPopup(
|
||||
entry: ThemeOfTheMonthEntry,
|
||||
onDismissed?: () => void,
|
||||
) {
|
||||
document.getElementById("theme-of-the-month-card")?.remove();
|
||||
|
||||
const monthLabel = formatMonthLabel(entry.month);
|
||||
const heroUrl = await resolvePopupHeroImageUrl(entry);
|
||||
const description = escapeHTML(entry.description).replace(/\n/g, " ");
|
||||
const linkedThemeId = entry.theme_id ?? entry.theme?.id;
|
||||
|
||||
const card = stringToHTML(/* html */ `
|
||||
<aside id="theme-of-the-month-card" class="themeOfTheMonthCard" role="dialog" aria-label="Theme of the Month">
|
||||
<button type="button" class="themeOfTheMonthCardClose" aria-label="Close Theme of the Month">×</button>
|
||||
${
|
||||
heroUrl
|
||||
? `<img class="themeOfTheMonthCardImage" src="${escapeHTML(heroUrl)}" alt="${escapeHTML(entry.title)}" />`
|
||||
: ""
|
||||
}
|
||||
<div class="themeOfTheMonthCardBody">
|
||||
<p class="themeOfTheMonthCardEyebrow">Theme of the Month · ${escapeHTML(monthLabel)}</p>
|
||||
<h2>${escapeHTML(entry.title)}</h2>
|
||||
<p class="themeOfTheMonthCardDescription">${description}</p>
|
||||
<div class="themeOfTheMonthCardActions">
|
||||
${
|
||||
linkedThemeId
|
||||
? `<button type="button" class="themeOfTheMonthCardPrimary">Open Store</button>`
|
||||
: ""
|
||||
}
|
||||
<button type="button" class="themeOfTheMonthCardSecondary">Don't show again</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
`).firstChild as HTMLElement;
|
||||
|
||||
card.dataset.entryId = entry.id;
|
||||
const autoCloseTimeout = window.setTimeout(() => {
|
||||
closeThemeOfTheMonthCard(card, onDismissed);
|
||||
}, 12000);
|
||||
|
||||
const dismiss = (markSeen = true) => {
|
||||
window.clearTimeout(autoCloseTimeout);
|
||||
closeThemeOfTheMonthCard(card, onDismissed, markSeen);
|
||||
};
|
||||
|
||||
card.addEventListener("mouseenter", () => window.clearTimeout(autoCloseTimeout), { once: true });
|
||||
|
||||
card.querySelector(".themeOfTheMonthCardClose")?.addEventListener("click", () => {
|
||||
dismiss();
|
||||
});
|
||||
|
||||
card.querySelector(".themeOfTheMonthCardPrimary")?.addEventListener("click", () => {
|
||||
dismiss();
|
||||
openThemeStoreWithHighlight(linkedThemeId!);
|
||||
});
|
||||
|
||||
card.querySelector(".themeOfTheMonthCardSecondary")?.addEventListener("click", () => {
|
||||
settingsState.themeOfTheMonthDisabled = true;
|
||||
dismiss();
|
||||
});
|
||||
|
||||
document.body.appendChild(card);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dev helper: fetch the current month's entry and show the popup immediately,
|
||||
* even if the user has already dismissed it this month.
|
||||
*/
|
||||
export async function showThemeOfTheMonthPopupNow(): Promise<void> {
|
||||
const entry = await fetchThemeOfTheMonth();
|
||||
if (!entry) {
|
||||
alert(
|
||||
"No Theme of the Month entry for the current month (UTC). Create one in the website admin, or check your dev API base URL.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
settingsState.themeOfTheMonthLastSeenId = undefined;
|
||||
|
||||
if (document.getElementById("whatsnewbk")) {
|
||||
await closePopup();
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
}
|
||||
|
||||
await OpenThemeOfTheMonthPopup(entry);
|
||||
}
|
||||
@@ -33,12 +33,9 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
|
||||
|
||||
const text = stringToHTML(/* html */ `
|
||||
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;">
|
||||
<h1>3.6.5 - Theme of the Month, custom message folders & assessment weighting overrides</h1>
|
||||
<li>Added Theme of the Month — a monthly featured theme popup with a link to view it in the theme store.</li>
|
||||
<li>Added custom message folders for organising direct DM's with drag to reorder.</li>
|
||||
|
||||
<h1>3.6.5 - Assessment weighting override & fixes</h1>
|
||||
<li>Added the ability to override/add weightings to assessments (on assessment page).</li>
|
||||
<li>Fixed custom room and teacher names not showing in the timetable popup.</li>
|
||||
<li>Fixed assessment averages treating N/A weightings incorrectly in subject average calculations.</li>
|
||||
<li>Fixed the display of weightings that could not automatically be discovered.</li>
|
||||
<li>Fixed the formatting of the weighting tag that was broken due to a SEQTA update.</li>
|
||||
|
||||
|
||||
@@ -4,40 +4,20 @@ import {
|
||||
shouldShowEngageParentsAnnouncement,
|
||||
showEngageParentsToast,
|
||||
} from "./OpenEngageParentsAnnouncement";
|
||||
import {
|
||||
fetchThemeOfTheMonth,
|
||||
OpenThemeOfTheMonthPopup,
|
||||
shouldShowThemeOfTheMonth,
|
||||
} from "./OpenThemeOfTheMonthPopup";
|
||||
import { syncApiBaseToBackground } from "../DevApiBase";
|
||||
|
||||
type QueueStep = (goNext: () => void) => void;
|
||||
|
||||
/**
|
||||
* Runs startup modals in order: What's New (if the extension just updated),
|
||||
* Theme of the Month (when a new monthly entry hasn't been seen), then shows
|
||||
* the SEQTA Engage toast (once, non-blocking).
|
||||
* then shows the SEQTA Engage toast (once, non-blocking).
|
||||
*/
|
||||
export async function runStartupPopupQueue() {
|
||||
// Make sure the background script knows about any dev-mode API override
|
||||
// before we start firing requests.
|
||||
syncApiBaseToBackground();
|
||||
|
||||
export function runStartupPopupQueue() {
|
||||
const steps: QueueStep[] = [];
|
||||
|
||||
if (settingsState.justupdated) {
|
||||
steps.push((goNext) => OpenWhatsNewPopup(goNext));
|
||||
}
|
||||
|
||||
// Fetch the Theme of the Month before queueing so we don't show an empty
|
||||
// popup if the network or server is unavailable.
|
||||
const themeOfTheMonth = await fetchThemeOfTheMonth();
|
||||
if (shouldShowThemeOfTheMonth(themeOfTheMonth)) {
|
||||
steps.push((goNext) => {
|
||||
void OpenThemeOfTheMonthPopup(themeOfTheMonth!, goNext);
|
||||
});
|
||||
}
|
||||
|
||||
function runNext() {
|
||||
const step = steps.shift();
|
||||
if (step) step(runNext);
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { OpenStorePage } from "@/seqta/ui/renderStore";
|
||||
|
||||
/**
|
||||
* Module-level handoff for "open the theme store and highlight this theme".
|
||||
*
|
||||
* The store page is mounted lazily inside a Shadow DOM the first time it
|
||||
* opens, so a `CustomEvent` listener would have to be wired up before mount
|
||||
* (causing a race). Using a shared cell keeps the producer (popup button) and
|
||||
* consumer (store `onMount`) decoupled without that timing constraint.
|
||||
*
|
||||
* The store reads & clears this on mount via {@link consumePendingHighlightThemeId}.
|
||||
*/
|
||||
let pendingHighlightThemeId: string | null = null;
|
||||
|
||||
/** Read and clear the pending theme id (called by the store on mount). */
|
||||
export function consumePendingHighlightThemeId(): string | null {
|
||||
const id = pendingHighlightThemeId;
|
||||
pendingHighlightThemeId = null;
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the theme store and asks it to focus / highlight the given theme.
|
||||
* If the store is already mounted we dispatch a DOM event so it can react
|
||||
* without remounting; otherwise the store consumes the pending id on mount.
|
||||
*/
|
||||
export function openThemeStoreWithHighlight(themeId: string): void {
|
||||
pendingHighlightThemeId = themeId;
|
||||
|
||||
const existing = document.getElementById("store");
|
||||
if (existing) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("bsplus:highlight-theme", { detail: { themeId } }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
OpenStorePage();
|
||||
}
|
||||
@@ -36,10 +36,6 @@ export interface SettingsState {
|
||||
engageParentsAnnouncementShown?: boolean;
|
||||
/** One-time announcement: BS Cloud automatic settings sync (last in startup popup queue). */
|
||||
bsCloudAutoSyncAnnouncementShown?: boolean;
|
||||
/** ID of the last Theme of the Month entry shown to the user (shows once per new entry). */
|
||||
themeOfTheMonthLastSeenId?: string;
|
||||
/** Permanently disables Theme of the Month startup prompts. */
|
||||
themeOfTheMonthDisabled?: boolean;
|
||||
timeFormat?: string;
|
||||
animations: boolean;
|
||||
defaultPage: string;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import path from "path";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user