diff --git a/src/css/injected.scss b/src/css/injected.scss index f36d058c..7a9f2b1a 100644 --- a/src/css/injected.scss +++ b/src/css/injected.scss @@ -673,6 +673,32 @@ html.bsplus-custom-sidebar-pending #menu > .icon-cover, clip: rect(0, 0, 0, 0) !important; } +/* Custom title bar: keep native `#title` children for sync, hide them visually. + Loading overlay stays until waitForCustomTitleBarReady() so this is ready underneath. */ +html.bsplus-custom-title-pending #title > :not(#bsplus-title-root), +#title.bsplus-custom-title > :not(#bsplus-title-root) { + position: absolute !important; + left: -10000px !important; + width: 1px !important; + height: 1px !important; + margin: 0 !important; + padding: 0 !important; + overflow: hidden !important; + opacity: 0 !important; + pointer-events: none !important; + clip: rect(0, 0, 0, 0) !important; +} + +#bsplus-title-root { + display: contents; +} + +/* `#title span { display: none }` hides the page title; keep search chip icons visible. */ +#title.bsplus-custom-title .search-trigger > span { + display: inline-flex !important; + align-items: center; +} + /* Custom Svelte sidebar: keep `#menu > ul > li.item` / `.sub` shape for theme CSS. */ #menu.bsplus-custom-sidebar { position: absolute !important; diff --git a/src/interface/components/FeedbackModal.svelte b/src/interface/components/FeedbackModal.svelte new file mode 100644 index 00000000..16b122d4 --- /dev/null +++ b/src/interface/components/FeedbackModal.svelte @@ -0,0 +1,347 @@ + + +
{ + if (e.target === e.currentTarget && !busy) onClose(); + }} + onkeydown={(e) => { + if (e.key === "Escape" && !busy) onClose(); + }} + role="button" + tabindex="-1" + transition:fade={{ duration: 150 }} +> + +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="feedback-modal-title" + tabindex="-1" + > +
+ + +
+ + {#if tab === "send"} + {#if successId} +

Thanks for the feedback

+

Reference ID:

+

{successId}

+
+ + +
+ {:else} +

Send feedback

+

Anonymous by default. Contact and school details are optional.

+ +
+ + + + + + +
+
+

Include contact details

+

Name and email so we can reply

+
+ (includeContact = v)} /> +
+ {#if includeContact} + + + {/if} + +
+
+

Include SEQTA instance

+

+ {#if instanceHostname} + Hostname only: {instanceHostname} + {:else} + Open SEQTA first to detect hostname + {/if} +

+
+ { + if (instanceHostname) includeInstance = v; + }} + /> +
+ + {#if errorMessage} + + {/if} + +

+ Sent to betterseqta.org. + Privacy +

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

Feedback status

+ +
+

{selectedItem.subject || "Untitled"} · {formatStatus(selectedItem.status)}

+

{selectedItem.id}

+ {#if hasReply(selectedItem)} +
+

Response

+

{selectedItem.response}

+
+ {:else} +

No response yet.

+ {/if} + {#if statusError}{/if} +
+ + +
+ {:else} +
+

My feedback

+ +
+ {#if statusError}{/if} + {#if statusLoading && !statusItems.length} +

Loading…

+ {:else if !statusItems.length} +

No feedback yet.

+ + {:else} + + {/if} +
+ +
+ {/if} +
+
+ + diff --git a/src/interface/pages/settings.svelte b/src/interface/pages/settings.svelte index 72a89b48..a325e24d 100644 --- a/src/interface/pages/settings.svelte +++ b/src/interface/pages/settings.svelte @@ -16,7 +16,9 @@ import FontPickerModal from "../components/FontPickerModal.svelte"; import CloudPanel from "../components/CloudPanel.svelte"; import DisclaimerModal from "../components/DisclaimerModal.svelte"; + import FeedbackModal from "../components/FeedbackModal.svelte"; import { settingsPopup } from "@/seqta/utils/settingsPopup"; + import { consumeOpenFeedbackRequest } from "@/seqta/utils/feedback/client"; import { checkGithubReleaseUpdate, dismissNightlyUpdate, @@ -158,11 +160,18 @@ let showColourPicker = $state(false); let showFontPicker = $state(false); let showCloudPanel = $state(false); + let showFeedbackModal = $state(false); + let feedbackFocusId = $state(null); const openCloudPanel = () => { showCloudPanel = true; }; + const openFeedback = (feedbackId?: string | null) => { + feedbackFocusId = feedbackId ?? null; + showFeedbackModal = true; + }; + const showDisclaimer = ( onConfirm: () => void, onCancel: () => void, @@ -179,6 +188,8 @@ showColourPicker = false; showFontPicker = false; showCloudPanel = false; + showFeedbackModal = false; + feedbackFocusId = null; }; const handleClose = () => { @@ -209,6 +220,17 @@ }); } + const pendingFeedbackId = consumeOpenFeedbackRequest(); + if (pendingFeedbackId) { + openFeedback(pendingFeedbackId); + } + + const onOpenFeedback = (event: Event) => { + const id = (event as CustomEvent<{ id?: string }>).detail?.id; + if (typeof id === "string" && id) openFeedback(id); + }; + window.addEventListener("bsplus:open-feedback", onOpenFeedback); + const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape" && !standalone) { closeExtensionPopup(); @@ -218,6 +240,7 @@ return () => { window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("bsplus:open-feedback", onOpenFeedback); }; }); @@ -350,12 +373,12 @@
@@ -516,3 +566,13 @@ }} /> {/if} + +{#if showFeedbackModal} + { + showFeedbackModal = false; + feedbackFocusId = null; + }} + /> +{/if} diff --git a/src/plugins/built-in/globalSearch/lazy.ts b/src/plugins/built-in/globalSearch/lazy.ts index 22127ae9..c8005266 100644 --- a/src/plugins/built-in/globalSearch/lazy.ts +++ b/src/plugins/built-in/globalSearch/lazy.ts @@ -11,7 +11,12 @@ import { resetSearchIndexes, notifyOpenTabsResetSearchIndex, } from "./src/indexing/resetIndexes"; -import { getDefaultSearchHotkey } from "./src/utils/hotkeyUtils"; +import { + formatHotkeyForDisplay, + getDefaultSearchHotkey, + isValidHotkey, +} from "./src/utils/hotkeyUtils"; +import { titleBarState } from "@/seqta/ui/titlebar/titleBarState.svelte"; const settings = defineSettings({ searchHotkey: hotkeySetting({ @@ -66,7 +71,10 @@ const settings = defineSettings({ }), }); -// Create the lazy plugin definition - this loads immediately but doesn't import heavy dependencies +/** + * Shell loads immediately so the Quick Search chip can show in the title bar. + * Heavy indexing / SearchBar chunk stays in the lazy core loader. + */ const globalSearchPlugin = defineLazyPlugin({ id: "global-search", name: "Global Search", @@ -75,20 +83,34 @@ const globalSearchPlugin = defineLazyPlugin({ settings, disableToggle: true, defaultEnabled: false, - styles: styles, - - // Lazy loader - only imports the heavy plugin when actually needed - loader: () => import("./src/core/index") + styles, + loader: () => import("./src/core/index"), }); const runGlobalSearch = globalSearchPlugin.run!; globalSearchPlugin.run = async (api) => { - if (isSeqtaEngageExperience()) { - return () => {}; - } + if (isSeqtaEngageExperience()) return () => {}; - return runGlobalSearch(api); + // Eager chrome (like Analytics menu injection) — heavy chunk loads behind it. + const hotkey = isValidHotkey(api.settings.searchHotkey ?? "") + ? (api.settings.searchHotkey as string) + : getDefaultSearchHotkey(); + titleBarState.searchHotkeyLabel = formatHotkeyForDisplay(hotkey); + titleBarState.showSearch = true; + + let heavyCleanup: (() => void) | void; + const heavyPromise = runGlobalSearch(api).then((cleanup) => { + heavyCleanup = cleanup; + }); + + return () => { + titleBarState.showSearch = false; + void heavyPromise.then(() => { + if (typeof heavyCleanup === "function") heavyCleanup(); + }); + if (typeof heavyCleanup === "function") heavyCleanup(); + }; }; export default globalSearchPlugin; diff --git a/src/plugins/built-in/globalSearch/src/core/mountSearchBar.ts b/src/plugins/built-in/globalSearch/src/core/mountSearchBar.ts index 2b64fa22..013448ef 100644 --- a/src/plugins/built-in/globalSearch/src/core/mountSearchBar.ts +++ b/src/plugins/built-in/globalSearch/src/core/mountSearchBar.ts @@ -2,110 +2,143 @@ import SearchBar from "../components/SearchBar.svelte"; import { unmount } from "svelte"; import { warmUpVectorSearchOnInteraction } from "../search/vector/vectorSearch"; import { formatHotkeyForDisplay, isValidHotkey } from "../utils/hotkeyUtils"; +import { waitForElm } from "@/seqta/utils/waitForElm"; import browser from "webextension-polyfill"; +type AppRef = { + current: any; + storageChangeHandler?: any; + progressHandler?: any; + clearDoneFlashTimer?: () => void; + clickHandler?: () => void; + ownedTrigger?: boolean; +}; + +const SEARCH_SVG = + ''; + +async function resolveTriggerWrapper( + titleElement: Element, +): Promise { + const existing = titleElement.querySelector( + ".search-trigger-wrapper", + ) as HTMLElement | null; + if (existing) return existing; + + const custom = + titleElement.classList.contains("bsplus-custom-title") || + document.documentElement.classList.contains("bsplus-custom-title-pending") || + document.getElementById("bsplus-title-root"); + if (!custom) return null; + + try { + return (await waitForElm( + "#bsplus-title-root .search-trigger-wrapper", + true, + 50, + 80, + )) as HTMLElement; + } catch { + return null; + } +} + +function buildTriggerWrapper() { + const searchWrapper = document.createElement("div"); + searchWrapper.className = "search-trigger-wrapper"; + searchWrapper.innerHTML = ` +
+
+ ${SEARCH_SVG} +

Quick search...

+ +
+
+
+
+
+
+
+
`; + return searchWrapper; +} + +function triggerParts(searchWrapper: HTMLElement) { + const q = (sel: string) => + searchWrapper.querySelector(sel) as T; + return { + searchWrapper, + searchAnchor: q(".search-trigger-anchor"), + searchButton: q(".search-trigger"), + searchIcon: q(".search-trigger > span"), + searchLabel: q(".search-trigger > p"), + hotkeySpan: q(".search-trigger-hotkey"), + progressBarWrapper: q(".search-progress-bar-wrapper"), + progressBar: q(".search-progress-bar"), + progressText: q(".search-progress-text"), + }; +} + export async function mountSearchBar( titleElement: Element, api: any, - appRef: { - current: any; - storageChangeHandler?: any; - progressHandler?: any; - clearDoneFlashTimer?: () => void; - }, + appRef: AppRef, ) { - if (titleElement.querySelector(".search-trigger")) { + const preRendered = await resolveTriggerWrapper(titleElement); + if (preRendered?.dataset.bsplusSearchWired === "1") return; + + let currentHotkey = isValidHotkey(api.settings.searchHotkey) + ? api.settings.searchHotkey + : "ctrl+k"; + let hotkeyDisplay = formatHotkeyForDisplay(currentHotkey); + + const ownedTrigger = !preRendered; + appRef.ownedTrigger = ownedTrigger; + + const { + searchWrapper, + searchAnchor, + searchButton, + searchIcon, + searchLabel, + hotkeySpan, + progressBarWrapper, + progressBar, + progressText, + } = triggerParts(preRendered ?? buildTriggerWrapper()); + + if ( + !searchAnchor || + !searchButton || + !hotkeySpan || + !progressBarWrapper || + !progressBar || + !progressText + ) { + console.error("[Global Search] Search trigger markup incomplete"); return; } - // Fallback to default hotkey if the current one is invalid - let currentHotkey = isValidHotkey(api.settings.searchHotkey) ? api.settings.searchHotkey : "ctrl+k"; - let hotkeyDisplay = formatHotkeyForDisplay(currentHotkey); - - // Search trigger + progress UI live in one wrapper so the auto-margin - // pushes the whole group to the left edge of the topbar instead of - // stranding the progress text on the far right of the screen. - const searchWrapper = document.createElement("div"); - searchWrapper.className = "search-trigger-wrapper"; - - // Anchor stacks button + slim progress strip in one rounded chip (see - // `.search-trigger-anchor` in styles.css). - const searchAnchor = document.createElement("div"); - searchAnchor.className = "search-trigger-anchor"; - - const searchButton = document.createElement("div"); - searchButton.className = "search-trigger"; - - const searchIcon = document.createElement("span"); - searchIcon.innerHTML = - ''; - - const searchLabel = document.createElement("p"); - searchLabel.textContent = "Quick search..."; - - const hotkeySpan = document.createElement("span"); - hotkeySpan.className = "search-trigger-hotkey"; - hotkeySpan.style.marginLeft = "auto"; - hotkeySpan.style.display = "flex"; - hotkeySpan.style.alignItems = "center"; - hotkeySpan.style.color = "#777"; - hotkeySpan.style.fontSize = "12px"; - - const progressBarWrapper = document.createElement("div"); - progressBarWrapper.className = "search-progress-bar-wrapper"; - - const progressTrack = document.createElement("div"); - progressTrack.className = "search-progress-track"; - - const progressBar = document.createElement("div"); - progressBar.className = "search-progress-bar"; - progressTrack.appendChild(progressBar); - progressBarWrapper.appendChild(progressTrack); - - // Use a block-level
so the label reliably participates in flex - // layout. A defaults to `display: inline`, which silently ignores - // `max-width`, `overflow`, and `text-overflow: ellipsis`, and was the - // reason the label appeared blank when the bar was visible. - const progressText = document.createElement("div"); - progressText.className = "search-progress-text"; - progressText.setAttribute("aria-live", "polite"); - - searchAnchor.appendChild(searchButton); - searchAnchor.appendChild(progressBarWrapper); - searchWrapper.appendChild(searchAnchor); - searchWrapper.appendChild(progressText); - - // Indexing state let isIndexing = false; - /** True while indexing has run until it finishes/fails — used for Done! flash only */ let ranIndexingCycle = false; let completedJobs = 0; let totalJobs = 0; let indexingStatus: string | null = null; let doneFlashTimer: ReturnType | null = null; let doneFadeTimer: ReturnType | null = null; - /** Captures `wasIndexing && !indexing` for the current dispatcher tick */ let indexingJustStoppedFlag = false; const DONE_HOLD_MS = 5000; const DONE_FADE_MS = 550; - - /** Treat as failure copy — plain “Done!” would be misleading */ - const statusLooksRough = (s: string) => - /\b(fail|error|cancel)\b/i.test(s); - + const statusLooksRough = (s: string) => /\b(fail|error|cancel)\b/i.test(s); const truncateStatus = (s: string, max = 44) => s.length > max ? s.slice(0, max - 1) + "…" : s; const clearDoneFlashTimer = () => { - if (doneFlashTimer) { - clearTimeout(doneFlashTimer); - doneFlashTimer = null; - } - if (doneFadeTimer) { - clearTimeout(doneFadeTimer); - doneFadeTimer = null; - } + if (doneFlashTimer) clearTimeout(doneFlashTimer); + if (doneFadeTimer) clearTimeout(doneFadeTimer); + doneFlashTimer = null; + doneFadeTimer = null; }; const resetIdleProgressUi = () => { @@ -145,7 +178,9 @@ export async function mountSearchBar( searchAnchor.classList.remove("is-indexing"); searchButton.classList.remove("is-indexing"); progressText.classList.remove("is-fading-done"); - progressText.textContent = rough ? truncateStatus(indexingStatus!, 52) : "Done!"; + progressText.textContent = rough + ? truncateStatus(indexingStatus!, 52) + : "Done!"; progressText.classList.toggle("is-rough", rough); progressBarWrapper.classList.toggle("is-rough-complete", rough); progressText.classList.add("is-active", "is-done-message"); @@ -163,38 +198,35 @@ export async function mountSearchBar( const updateProgressDisplay = () => { const indexingStoppedThisTick = indexingJustStoppedFlag; indexingJustStoppedFlag = false; - const active = isIndexing && totalJobs > 0; - // Stray pulses (missing total, 0 completed, etc.) used to hit the idle - // branch and call clearDoneFlashTimer(), killing the Done! hold/fade. if (doneFlashTimer !== null || doneFadeTimer !== null) { if (!active) return; clearDoneFlashTimer(); } + if (active) { + showActiveIndexingUi(Math.round((completedJobs / totalJobs) * 100)); + return; + } + const completionEligible = ranIndexingCycle && !active && totalJobs > 0 && (completedJobs >= totalJobs || indexingStoppedThisTick); - if (active) { - showActiveIndexingUi(Math.round((completedJobs / totalJobs) * 100)); - return; - } - if (completionEligible) { if (doneFlashTimer !== null || doneFadeTimer !== null) return; - const rough = indexingStatus != null && statusLooksRough(indexingStatus); - scheduleCompletionFlash(rough); + scheduleCompletionFlash( + indexingStatus != null && statusLooksRough(indexingStatus), + ); return; } resetIdleProgressUi(); }; - // Listen for indexing progress events const progressHandler = (event: CustomEvent) => { const { completed, total, indexing, status } = event.detail as { completed?: number; @@ -203,86 +235,85 @@ export async function mountSearchBar( status?: string; }; const wasIndexing = isIndexing; - completedJobs = completed ?? 0; totalJobs = total ?? 0; isIndexing = Boolean(indexing); indexingStatus = status ?? null; indexingJustStoppedFlag = wasIndexing && !isIndexing; - if (!wasIndexing && isIndexing) ranIndexingCycle = true; if (wasIndexing && !isIndexing) ranIndexingCycle = true; if (totalJobs > 0 && completedJobs >= totalJobs && !isIndexing) { ranIndexingCycle = true; } - updateProgressDisplay(); }; - window.addEventListener('indexing-progress', progressHandler as EventListener); + window.addEventListener("indexing-progress", progressHandler as EventListener); appRef.progressHandler = progressHandler; appRef.clearDoneFlashTimer = clearDoneFlashTimer; - + const updateSearchButtonDisplay = () => { hotkeySpan.textContent = hotkeyDisplay; searchButton.replaceChildren(searchIcon, searchLabel, hotkeySpan); }; - updateSearchButtonDisplay(); - titleElement.appendChild(searchWrapper); - // Listen for hotkey setting changes + if (ownedTrigger) { + const customRoot = document.getElementById("bsplus-title-root"); + (customRoot ?? titleElement).appendChild(searchWrapper); + } + searchWrapper.dataset.bsplusSearchWired = "1"; + const handleStorageChange = (changes: any, area: string) => { - if (area === 'local' && changes['plugin.global-search.settings']) { - const newSettings = changes['plugin.global-search.settings'].newValue as { searchHotkey?: string } | undefined; - if (newSettings?.searchHotkey && isValidHotkey(newSettings.searchHotkey)) { - currentHotkey = newSettings.searchHotkey; - hotkeyDisplay = formatHotkeyForDisplay(currentHotkey); - updateSearchButtonDisplay(); - } - } + if (area !== "local" || !changes["plugin.global-search.settings"]) return; + const next = changes["plugin.global-search.settings"].newValue as + | { searchHotkey?: string } + | undefined; + if (!next?.searchHotkey || !isValidHotkey(next.searchHotkey)) return; + currentHotkey = next.searchHotkey; + hotkeyDisplay = formatHotkeyForDisplay(currentHotkey); + updateSearchButtonDisplay(); }; - browser.storage.onChanged.addListener(handleStorageChange); - - // Store reference to cleanup function for proper removal appRef.storageChangeHandler = handleStorageChange; const searchRoot = document.createElement("div"); + searchRoot.setAttribute("data-search-root", ""); document.body.appendChild(searchRoot); - const searchRootShadow = searchRoot.attachShadow({ mode: "open" }); - searchButton.addEventListener("click", () => { + const clickHandler = () => { warmUpVectorSearchOnInteraction(); - // @ts-ignore - Intentionally adding to window + // @ts-ignore window.setCommandPalleteOpen(true); - }); + }; + searchButton.addEventListener("click", clickHandler); + appRef.clickHandler = clickHandler; try { const { default: renderSvelte } = await import("@/interface/main"); - appRef.current = renderSvelte(SearchBar, searchRootShadow, { - transparencyEffects: api.settings.transparencyEffects, - showRecentFirst: api.settings.showRecentFirst, - searchHotkey: currentHotkey, - }, "content"); + appRef.current = renderSvelte( + SearchBar, + searchRoot.attachShadow({ mode: "open" }), + { + transparencyEffects: api.settings.transparencyEffects, + showRecentFirst: api.settings.showRecentFirst, + searchHotkey: currentHotkey, + }, + "content", + ); } catch (error) { console.error("Error rendering Svelte component:", error); } } -export function cleanupSearchBar(appRef: { - current: any; - storageChangeHandler?: any; - progressHandler?: any; - clearDoneFlashTimer?: () => void; -}) { +export function cleanupSearchBar(appRef: AppRef) { if (appRef.current) { try { unmount(appRef.current); - appRef.current = null; } catch (error) { console.error("Error unmounting Svelte component:", error); } + appRef.current = null; } try { @@ -292,36 +323,47 @@ export function cleanupSearchBar(appRef: { } appRef.clearDoneFlashTimer = undefined; - // Remove progress event listener if (appRef.progressHandler) { - window.removeEventListener('indexing-progress', appRef.progressHandler as EventListener); + window.removeEventListener( + "indexing-progress", + appRef.progressHandler as EventListener, + ); appRef.progressHandler = null; } - // Remove search trigger wrapper (which contains the button and progress UI) - const searchWrapper = document.querySelector(".search-trigger-wrapper"); + const searchWrapper = document.querySelector( + ".search-trigger-wrapper", + ) as HTMLElement | null; + const customOwns = Boolean( + document.querySelector("#title.bsplus-custom-title") || + document.getElementById("bsplus-title-root"), + ); + if (searchWrapper) { - searchWrapper.remove(); + const btn = searchWrapper.querySelector(".search-trigger"); + if (btn && appRef.clickHandler) { + btn.removeEventListener("click", appRef.clickHandler); + } + appRef.clickHandler = undefined; + + if (customOwns || appRef.ownedTrigger === false) { + delete searchWrapper.dataset.bsplusSearchWired; + } else { + searchWrapper.remove(); + } } - // Defensive cleanup for older mounts that may have left the trigger or - // progress container as direct children of the topbar. - document.querySelector(".search-trigger")?.remove(); - document.querySelector(".search-progress-container")?.remove(); + document.querySelector("div[data-search-root]")?.remove(); - // Remove search root - const searchRoot = document.querySelector("div[data-search-root]"); - if (searchRoot) { - searchRoot.remove(); - } - - // Clean up vector worker when it was started (indexing or search interaction) - void import("../indexing/worker/vectorWorkerManager").then(({ VectorWorkerManager }) => { - VectorWorkerManager.getInstance().terminate(); - }).catch(() => {}); + void import("../indexing/worker/vectorWorkerManager") + .then(({ VectorWorkerManager }) => { + VectorWorkerManager.getInstance().terminate(); + }) + .catch(() => {}); if (appRef.storageChangeHandler) { browser.storage.onChanged.removeListener(appRef.storageChangeHandler); appRef.storageChangeHandler = null; } + appRef.ownedTrigger = undefined; } diff --git a/src/plugins/built-in/gradeAnalytics/loadAnalyticsPage.ts b/src/plugins/built-in/gradeAnalytics/loadAnalyticsPage.ts index 86978406..3d062b4e 100644 --- a/src/plugins/built-in/gradeAnalytics/loadAnalyticsPage.ts +++ b/src/plugins/built-in/gradeAnalytics/loadAnalyticsPage.ts @@ -47,8 +47,13 @@ async function loadAnalyticsPageInner(): Promise { main.appendChild(viewShell); const container = viewShell; - const titlediv = document.getElementById("title")?.firstChild; - if (titlediv) (titlediv as HTMLElement).innerText = "Analytics"; + void import("@/seqta/ui/titlebar/mountCustomTitleBar").then((mod) => { + mod.setCustomTitleBarText("Analytics"); + }); + if (!document.getElementById("bsplus-title-root")) { + const titlediv = document.getElementById("title")?.firstChild; + if (titlediv instanceof HTMLElement) titlediv.innerText = "Analytics"; + } renderAnalyticsPage(container); } diff --git a/src/plugins/monofile.ts b/src/plugins/monofile.ts index 9b2fc1bb..7d2024b6 100644 --- a/src/plugins/monofile.ts +++ b/src/plugins/monofile.ts @@ -77,6 +77,14 @@ export async function finishLoad() { betterSeqtaFinishLoadDone = true; try { + // Keep the loading overlay up until the custom title bar is fully ready. + if (!isSeqtaEngageExperience() && settingsState.onoff) { + const { waitForCustomTitleBarReady } = await import( + "@/seqta/ui/titlebar/mountCustomTitleBar" + ); + await waitForCustomTitleBarReady(); + } + document.querySelector(".legacy-root")?.classList.remove("hidden"); const loadingbk = document.getElementById("loading"); @@ -191,6 +199,9 @@ async function LoadPageElements(): Promise { void import("@/seqta/ui/sidebar/mountCustomSidebar").then((mod) => { void mod.mountCustomSidebar(); }); + void import("@/seqta/ui/titlebar/mountCustomTitleBar").then((mod) => { + void mod.mountCustomTitleBar(); + }); const sublink: string | undefined = getEngageRoutePage(); if (isSeqtaEngageExperience() && !engageHashListenerAttached) { @@ -336,8 +347,15 @@ async function handleDefault(): Promise { async function handleMessages(node: Element): Promise { if (!(node instanceof HTMLElement)) return; - const element = document.getElementById("title")!.firstChild as HTMLElement; - element.innerText = "Direct Messages"; + const titleText = "Direct Messages"; + void import("@/seqta/ui/titlebar/mountCustomTitleBar").then((mod) => { + mod.setCustomTitleBarText(titleText); + }); + // Fallback when the custom title bar is not mounted yet. + if (!document.getElementById("bsplus-title-root")) { + const legacy = document.getElementById("title")?.firstChild; + if (legacy instanceof HTMLElement) legacy.innerText = titleText; + } document.title = "Direct Messages ― SEQTA Learn"; SortMessagePageItems(node); @@ -684,11 +702,16 @@ export function init() { // Engage keeps its native React menu — never apply the pending hide class there. if (!isSeqtaEngageExperience()) { document.documentElement.classList.add("bsplus-custom-sidebar-pending"); + document.documentElement.classList.add("bsplus-custom-title-pending"); void import("@/seqta/ui/sidebar/mountCustomSidebar").then((mod) => { mod.prepareCustomSidebarEarly(); }); + void import("@/seqta/ui/titlebar/mountCustomTitleBar").then((mod) => { + mod.prepareCustomTitleBarEarly(); + }); } else { document.documentElement.classList.remove("bsplus-custom-sidebar-pending"); + document.documentElement.classList.remove("bsplus-custom-title-pending"); } void observeMenuItemPosition(); diff --git a/src/seqta/ui/titlebar/TitleBar.svelte b/src/seqta/ui/titlebar/TitleBar.svelte new file mode 100644 index 00000000..ccd3cf18 --- /dev/null +++ b/src/seqta/ui/titlebar/TitleBar.svelte @@ -0,0 +1,42 @@ + + +
+ {titleBarState.pageTitle} + {#if titleBarState.showSearch} +
+
+
+ + + + + + +

Quick search...

+ {titleBarState.searchHotkeyLabel} +
+
+
+
+
+
+
+
+
+ {/if} +
diff --git a/src/seqta/ui/titlebar/index.ts b/src/seqta/ui/titlebar/index.ts new file mode 100644 index 00000000..bc320af4 --- /dev/null +++ b/src/seqta/ui/titlebar/index.ts @@ -0,0 +1,8 @@ +export { + prepareCustomTitleBarEarly, + mountCustomTitleBar, + unmountCustomTitleBar, + setCustomTitleBarText, + waitForCustomTitleBarReady, +} from "./mountCustomTitleBar"; +export { titleBarState } from "./titleBarState.svelte"; diff --git a/src/seqta/ui/titlebar/mountCustomTitleBar.ts b/src/seqta/ui/titlebar/mountCustomTitleBar.ts new file mode 100644 index 00000000..2ef3480e --- /dev/null +++ b/src/seqta/ui/titlebar/mountCustomTitleBar.ts @@ -0,0 +1,161 @@ +import { mount, unmount } from "svelte"; +import { settingsState } from "@/seqta/utils/listeners/SettingsState"; +import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage"; +import { waitForElm } from "@/seqta/utils/waitForElm"; +import TitleBar from "./TitleBar.svelte"; +import { titleBarState } from "./titleBarState.svelte"; + +const ROOT_ID = "bsplus-title-root"; +const TITLE_CLASS = "bsplus-custom-title"; +const PENDING_CLASS = "bsplus-custom-title-pending"; + +let app: ReturnType | null = null; +let titleEl: HTMLElement | null = null; +let hostObserver: MutationObserver | null = null; +let earlyPrepareStarted = false; +let remountTimer: ReturnType | null = null; + +function nativePageTitleEl(host: HTMLElement) { + return [...host.children].find( + (el) => + el instanceof HTMLElement && + el.id !== ROOT_ID && + el.matches('span[data-testid="page-title"]'), + ) as HTMLElement | undefined; +} + +function syncPageTitle() { + if (!titleEl) return; + titleBarState.pageTitle = (nativePageTitleEl(titleEl)?.textContent ?? "").trim(); +} + +function needsSearchChip() { + const all = settingsState.getAll() as unknown as Record; + const plugin = all["plugin.global-search.settings"] as + | { enabled?: boolean } + | undefined; + return plugin?.enabled === true || titleBarState.showSearch; +} + +function isReady() { + const root = document.getElementById(ROOT_ID); + if (!root || !titleEl?.classList.contains(TITLE_CLASS)) return false; + if (!needsSearchChip()) return true; + return Boolean(root.querySelector(".search-trigger-wrapper")); +} + +/** finishLoad waits here so the overlay stays until the title bar is ready. */ +export async function waitForCustomTitleBarReady(timeoutMs = 10000) { + if (isSeqtaEngageExperience() || !settingsState.onoff) { + document.documentElement.classList.remove(PENDING_CLASS); + return; + } + + await mountCustomTitleBar(); + + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (isReady()) break; + await new Promise((r) => setTimeout(r, 50)); + } + document.documentElement.classList.remove(PENDING_CLASS); +} + +function observeHost(host: HTMLElement) { + hostObserver?.disconnect(); + syncPageTitle(); + hostObserver = new MutationObserver(() => { + if (!document.getElementById(ROOT_ID)) { + if (remountTimer) clearTimeout(remountTimer); + remountTimer = setTimeout(() => { + remountTimer = null; + if (!settingsState.onoff || isSeqtaEngageExperience()) return; + app = null; + void mountCustomTitleBar(); + }, 50); + return; + } + syncPageTitle(); + }); + hostObserver.observe(host, { + childList: true, + subtree: true, + characterData: true, + }); +} + +export function prepareCustomTitleBarEarly() { + if (isSeqtaEngageExperience() || !settingsState.onoff || earlyPrepareStarted) { + return; + } + earlyPrepareStarted = true; + document.documentElement.classList.add(PENDING_CLASS); + void mountCustomTitleBar(); +} + +export async function mountCustomTitleBar(): Promise { + if (isSeqtaEngageExperience() || !settingsState.onoff) return false; + + if (app && titleEl && document.getElementById(ROOT_ID)) { + observeHost(titleEl); + return true; + } + + document.documentElement.classList.add(PENDING_CLASS); + + let title: HTMLElement; + try { + title = (await waitForElm("#title", true, 50, 200)) as HTMLElement; + } catch { + return false; + } + + titleEl = title; + title.classList.add(TITLE_CLASS); + syncPageTitle(); + + if (!document.getElementById(ROOT_ID)) { + if (app) { + try { + unmount(app); + } catch { + /* ignore */ + } + app = null; + } + app = mount(TitleBar, { target: title }); + } + + observeHost(title); + return true; +} + +export function unmountCustomTitleBar() { + hostObserver?.disconnect(); + hostObserver = null; + if (remountTimer) clearTimeout(remountTimer); + remountTimer = null; + + if (app) { + try { + unmount(app); + } catch { + /* ignore */ + } + app = null; + } + + document.getElementById(ROOT_ID)?.remove(); + titleEl?.classList.remove(TITLE_CLASS); + titleEl = null; + titleBarState.pageTitle = ""; + titleBarState.showSearch = false; + earlyPrepareStarted = false; + document.documentElement.classList.remove(PENDING_CLASS); +} + +export function setCustomTitleBarText(text: string) { + titleBarState.pageTitle = text; + const native = titleEl && nativePageTitleEl(titleEl); + if (native) native.textContent = text; +} diff --git a/src/seqta/ui/titlebar/titleBarState.svelte.ts b/src/seqta/ui/titlebar/titleBarState.svelte.ts new file mode 100644 index 00000000..fd47eb46 --- /dev/null +++ b/src/seqta/ui/titlebar/titleBarState.svelte.ts @@ -0,0 +1,5 @@ +export const titleBarState = $state({ + pageTitle: "", + showSearch: false, + searchHotkeyLabel: "Ctrl+K", +}); diff --git a/src/seqta/utils/Openers/OpenFeedbackReplyPopup.ts b/src/seqta/utils/Openers/OpenFeedbackReplyPopup.ts new file mode 100644 index 00000000..7849bed6 --- /dev/null +++ b/src/seqta/utils/Openers/OpenFeedbackReplyPopup.ts @@ -0,0 +1,82 @@ +import stringToHTML from "../stringToHTML"; +import { closePopup, openPopup } from "./PopupManager"; +import { + findPendingFeedbackWithReplies, + formatStatus, + openExtensionSettingsPopup, + removePendingFeedbackIds, + requestOpenFeedbackInSettings, + type FeedbackStatusItem, +} from "@/seqta/utils/feedback/client"; + +function esc(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +export function OpenFeedbackReplyPopup( + items: FeedbackStatusItem[], + onDismissed?: () => void, +): void { + if (!items.length || document.getElementById("whatsnewbk")) { + onDismissed?.(); + return; + } + + const primary = items[0]; + const extra = items.length - 1; + const title = primary.subject?.trim() || "Your feedback"; + const response = (primary.response ?? "").trim(); + const ids = items.map((i) => i.id); + + const header = stringToHTML(` +
+

Feedback reply

+

${esc(formatStatus(primary.status))}${extra > 0 ? ` · +${extra} more` : ""}

+
+ `).firstChild as HTMLElement; + + const text = stringToHTML(` +
+

${esc(title)}

+
${esc(response)}
+
+ + +
+
+ `).firstChild as HTMLElement; + + openPopup({ + header, + content: [text], + afterClose: () => { + void removePendingFeedbackIds(ids).then(() => onDismissed?.()); + }, + }); + + queueMicrotask(() => { + document.getElementById("bsplus-feedback-reply-dismiss")?.addEventListener("click", () => { + void closePopup(); + }); + document.getElementById("bsplus-feedback-reply-view")?.addEventListener("click", () => { + requestOpenFeedbackInSettings(primary.id); + void closePopup().then(() => openExtensionSettingsPopup()); + }); + }); +} + +export async function maybeQueueFeedbackReplyPopup(): Promise< + ((goNext: () => void) => void) | null +> { + try { + const items = await findPendingFeedbackWithReplies(); + if (!items.length) return null; + return (goNext) => OpenFeedbackReplyPopup(items, goNext); + } catch { + return null; + } +} diff --git a/src/seqta/utils/Openers/StartupPopupQueue.ts b/src/seqta/utils/Openers/StartupPopupQueue.ts index 63e66d40..8814bdb4 100644 --- a/src/seqta/utils/Openers/StartupPopupQueue.ts +++ b/src/seqta/utils/Openers/StartupPopupQueue.ts @@ -5,13 +5,15 @@ import { OpenThemeOfTheMonthPopup, shouldShowThemeOfTheMonth, } from "./OpenThemeOfTheMonthPopup"; +import { maybeQueueFeedbackReplyPopup } from "./OpenFeedbackReplyPopup"; 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 the user hasn't dismissed this calendar month). + * Theme of the Month (when the user hasn't dismissed this calendar month), + * then feedback reply notifications for pending submissions. */ export async function runStartupPopupQueue() { // Make sure the background script knows about any dev-mode API override @@ -33,6 +35,11 @@ export async function runStartupPopupQueue() { }); } + const feedbackReplyStep = await maybeQueueFeedbackReplyPopup(); + if (feedbackReplyStep) { + steps.push(feedbackReplyStep); + } + function runNext() { const step = steps.shift(); if (step) step(runNext); diff --git a/src/seqta/utils/Openers/whatsNewChangelog.ts b/src/seqta/utils/Openers/whatsNewChangelog.ts index 3f64827c..20b61f7a 100644 --- a/src/seqta/utils/Openers/whatsNewChangelog.ts +++ b/src/seqta/utils/Openers/whatsNewChangelog.ts @@ -9,6 +9,7 @@ export const WHATS_NEW_CHANGELOG: WhatsNewRelease[] = [ "items": [ "Added an option in the Timetable to sync to Google Calendar and Outlook Calendar", "Added a new sidebar customisation page in the settings menu to change the sidebar layout, icons, and more.", + "Added extension feedback in settings.", "Improved the sidebar to be more stable and performant.", "Fixed dropdown contrast and readability in settings and across SEQTA pages.", "Fixed Analytics sidebar item not hiding when toggled off in Edit Sidebar.", diff --git a/src/seqta/utils/SendNewsPage.ts b/src/seqta/utils/SendNewsPage.ts index 97189abb..60cf8b5b 100644 --- a/src/seqta/utils/SendNewsPage.ts +++ b/src/seqta/utils/SendNewsPage.ts @@ -48,9 +48,12 @@ export async function SendNewsPage() { main.append(html.firstChild!); - const titleBar = document.getElementById("title")?.firstChild; - if (titleBar) { - (titleBar as HTMLElement).innerText = "News"; + void import("@/seqta/ui/titlebar/mountCustomTitleBar").then((mod) => { + mod.setCustomTitleBarText("News"); + }); + if (!document.getElementById("bsplus-title-root")) { + const titleBar = document.getElementById("title")?.firstChild; + if (titleBar instanceof HTMLElement) titleBar.innerText = "News"; } AppendLoadingSymbol("newsloading", "#news-container"); diff --git a/src/seqta/utils/cloudSettingsSync.legacy.test.ts b/src/seqta/utils/cloudSettingsSync.legacy.test.ts index d4cbcbe9..423c917b 100644 --- a/src/seqta/utils/cloudSettingsSync.legacy.test.ts +++ b/src/seqta/utils/cloudSettingsSync.legacy.test.ts @@ -29,6 +29,7 @@ describe("migrateLegacyToPluginSettings", () => { describe("isKeyIncludedInCloudUploadPayload", () => { it("excludes auth and device cache prefixes", () => { expect(isKeyIncludedInCloudUploadPayload("bsplus_token")).toBe(false); + expect(isKeyIncludedInCloudUploadPayload("bsplus_install_id")).toBe(false); expect(isKeyIncludedInCloudUploadPayload("plugin.global-search.storage.index")).toBe( false, ); diff --git a/src/seqta/utils/cloudSettingsSync.patch.test.ts b/src/seqta/utils/cloudSettingsSync.patch.test.ts index a4523129..1eb03998 100644 --- a/src/seqta/utils/cloudSettingsSync.patch.test.ts +++ b/src/seqta/utils/cloudSettingsSync.patch.test.ts @@ -11,6 +11,7 @@ describe("normalizeStorageForSync", () => { const normalized = normalizeStorageForSync({ DarkMode: true, bsplus_token: "secret", + bsplus_install_id: "550e8400-e29b-41d4-a716-446655440000", bsplus_cloud_settings_known_remote_updated_at: "2026-01-01T00:00:00.000Z", "bsplus.analytics.v2.school.1": { cached: true }, }); diff --git a/src/seqta/utils/cloudSettingsSync.ts b/src/seqta/utils/cloudSettingsSync.ts index 6314e7b6..6c0b8c0b 100644 --- a/src/seqta/utils/cloudSettingsSync.ts +++ b/src/seqta/utils/cloudSettingsSync.ts @@ -38,6 +38,10 @@ export const KEYS_OMITTED_FROM_CLOUD_UPLOAD = [ "cloudAccessToken", "cloudUsername", "bsplus_google_calendar", + /** Anonymous feedback install id — device-local, never synced. */ + "bsplus_install_id", + /** Pending feedback ids awaiting a reply notification — device-local. */ + "bsplus_pending_feedback_ids", ] as const; /** diff --git a/src/seqta/utils/feedback/client.test.ts b/src/seqta/utils/feedback/client.test.ts new file mode 100644 index 00000000..ce0f4060 --- /dev/null +++ b/src/seqta/utils/feedback/client.test.ts @@ -0,0 +1,108 @@ +import browser from "webextension-polyfill"; +import { + addPendingFeedbackId, + findPendingFeedbackWithReplies, + formatStatus, + hasReply, + removePendingFeedbackIds, + validateFeedbackForm, +} from "./client"; +import { BSPLUS_PENDING_FEEDBACK_IDS_KEY } from "./constants"; +import { getOrCreateInstallId } from "./installId"; + +describe("validateFeedbackForm", () => { + const base = { + category: "bug" as const, + subject: "Hi", + message: "Long enough message here", + includeContact: false, + contactName: "", + contactEmail: "", + includeInstance: false, + }; + + it("requires message length", () => { + expect(validateFeedbackForm({ ...base, message: "short" })).toMatch(/at least/); + }); + + it("requires email when contact included", () => { + expect( + validateFeedbackForm({ + ...base, + includeContact: true, + contactName: "Alex", + contactEmail: "bad", + }), + ).toMatch(/email/i); + }); +}); + +describe("formatStatus / hasReply", () => { + it("formats known statuses and detects replies", () => { + expect(formatStatus("in_progress")).toBe("In progress"); + expect( + hasReply({ + id: "fb_1", + status: "resolved", + category: "bug", + subject: null, + created_at: "", + updated_at: "", + has_response: true, + response: "Thanks", + responded_at: "", + }), + ).toBe(true); + }); +}); + +describe("pending feedback + reply check", () => { + it("tracks pending ids and finds replies from the status list", async () => { + await addPendingFeedbackId("fb_a"); + await addPendingFeedbackId("fb_b"); + await removePendingFeedbackIds(["fb_unused"]); + + const installId = await getOrCreateInstallId(); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + install_id: installId, + count: 2, + items: [ + { + id: "fb_a", + status: "resolved", + category: "bug", + subject: "A", + created_at: "", + updated_at: "", + has_response: true, + response: "Fixed", + responded_at: "", + }, + { + id: "fb_b", + status: "received", + category: "bug", + subject: "B", + created_at: "", + updated_at: "", + has_response: false, + response: null, + responded_at: null, + }, + ], + }), + headers: new Headers(), + }) as unknown as typeof fetch; + + const items = await findPendingFeedbackWithReplies(); + expect(items.map((i) => i.id)).toEqual(["fb_a"]); + expect(browser.storage.local.set).toHaveBeenCalledWith( + expect.objectContaining({ + [BSPLUS_PENDING_FEEDBACK_IDS_KEY]: expect.any(Array), + }), + ); + }); +}); diff --git a/src/seqta/utils/feedback/client.ts b/src/seqta/utils/feedback/client.ts new file mode 100644 index 00000000..802b8460 --- /dev/null +++ b/src/seqta/utils/feedback/client.ts @@ -0,0 +1,296 @@ +import browser from "webextension-polyfill"; +import { getApiBase } from "@/seqta/utils/DevApiBase"; +import { SettingsClicked } from "@/seqta/utils/Closers/closeExtensionPopup"; +import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage"; +import { settingsState } from "@/seqta/utils/listeners/SettingsState"; +import { + BSPLUS_PENDING_FEEDBACK_IDS_KEY, + FEEDBACK_API_PATH, + FEEDBACK_MESSAGE_MAX, + FEEDBACK_MESSAGE_MIN, + FEEDBACK_SCHEMA_VERSION, + OPEN_FEEDBACK_SESSION_KEY, + type FeedbackBrowser, + type FeedbackCategory, + type FeedbackChannel, + type FeedbackProduct, +} from "./constants"; +import { getOrCreateInstallId } from "./installId"; + +export class FeedbackApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly code?: string, + ) { + super(message); + this.name = "FeedbackApiError"; + } +} + +export interface FeedbackStatusItem { + id: string; + status: string; + category: string; + subject: string | null; + created_at: string; + updated_at: string; + has_response: boolean; + response: string | null; + responded_at: string | null; +} + +export type FeedbackFormInput = { + category: FeedbackCategory; + subject: string; + message: string; + includeContact: boolean; + contactName: string; + contactEmail: string; + includeInstance: boolean; +}; + +export function validateFeedbackForm(input: FeedbackFormInput): string | null { + const message = input.message.trim(); + if (message.length < FEEDBACK_MESSAGE_MIN) { + return `Please enter at least ${FEEDBACK_MESSAGE_MIN} characters.`; + } + if (message.length > FEEDBACK_MESSAGE_MAX) { + return `Message must be at most ${FEEDBACK_MESSAGE_MAX} characters.`; + } + if (input.subject.trim().length > 120) return "Subject must be at most 120 characters."; + if (input.includeContact) { + if (!input.contactName.trim()) return "Please enter your name."; + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.contactEmail.trim())) { + return "Please enter a valid email."; + } + } + if (input.includeInstance) { + const host = getInstanceHostname(); + if (!host) return "Instance hostname unavailable — open SEQTA first, or turn this off."; + } + return null; +} + +export function getInstanceHostname(): string | null { + try { + const host = location.hostname?.toLowerCase(); + if (!host || host === "localhost") return null; + return host.slice(0, 253); + } catch { + return null; + } +} + +function mapBrowser(ua: string): FeedbackBrowser { + const n = ua.toLowerCase(); + if (n.includes("edg")) return "edge"; + if (n.includes("firefox")) return "firefox"; + if (n.includes("safari") && !n.includes("chrome")) return "safari"; + if (n.includes("chrome") || n.includes("chromium")) return "chrome"; + return "other"; +} + +function detectOs(): string { + const ua = navigator.userAgent; + if (/Windows/i.test(ua)) return "Windows"; + if (/Mac OS X|Macintosh/i.test(ua)) return "macOS"; + if (/Android/i.test(ua)) return "Android"; + if (/iPhone|iPad|iPod/i.test(ua)) return "iOS"; + if (/CrOS/i.test(ua)) return "ChromeOS"; + if (/Linux/i.test(ua)) return "Linux"; + return "Unknown"; +} + +function channel(): FeedbackChannel { + if (typeof __UPDATE_CHANNEL__ !== "undefined" && __UPDATE_CHANNEL__ === "nightly") { + return "nightly"; + } + if (typeof __UPDATE_CHANNEL__ !== "undefined" && __UPDATE_CHANNEL__ === "stable") { + return "stable"; + } + return "unknown"; +} + +async function apiFetch(path: string, init?: RequestInit): Promise { + try { + return await fetch(`${getApiBase()}${path}`, { + ...init, + headers: { Accept: "application/json", ...init?.headers }, + }); + } catch { + throw new FeedbackApiError("Could not reach the feedback server. Check your connection.", 0); + } +} + +async function throwApiError(res: Response): Promise { + let body: { error?: string; code?: string } = {}; + try { + body = await res.json(); + } catch { + /* ignore */ + } + if (res.status === 429) { + throw new FeedbackApiError( + "You've sent feedback too many times. Please try again later.", + 429, + body.code ?? "RATE_LIMITED", + ); + } + throw new FeedbackApiError(body.error || `Request failed (${res.status}).`, res.status, body.code); +} + +export async function submitFeedback(form: FeedbackFormInput): Promise<{ id: string }> { + const err = validateFeedbackForm(form); + if (err) throw new FeedbackApiError(err, 422); + + const installId = await getOrCreateInstallId(); + const host = getInstanceHostname(); + const product: FeedbackProduct = isSeqtaEngageExperience() ? "engage" : "learn"; + const version = browser.runtime.getManifest().version.slice(0, 32); + const browserName = mapBrowser(navigator.userAgent); + const browserVersion = navigator.userAgent.match( + /(?:Edg|OPR|Firefox|Chrome|Version)\/([\d.]+)/, + )?.[1]; + + const payload = { + schemaVersion: FEEDBACK_SCHEMA_VERSION, + installId, + category: form.category, + subject: form.subject.trim() || undefined, + message: form.message.trim(), + extension: { + version, + browser: browserName, + browserVersion, + os: detectOs(), + channel: channel(), + }, + contact: form.includeContact + ? { + include: true as const, + name: form.contactName.trim().slice(0, 80), + email: form.contactEmail.trim().slice(0, 254), + } + : { include: false as const }, + instance: + form.includeInstance && host + ? { include: true as const, hostname: host, product } + : { include: false as const }, + context: { + page: "settings", + locale: navigator.language, + darkMode: !!settingsState.DarkMode, + }, + clientSubmittedAt: new Date().toISOString(), + }; + + const res = await apiFetch(FEEDBACK_API_PATH, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (res.status !== 201 && res.status !== 200) await throwApiError(res); + const data = (await res.json()) as { id?: string }; + if (!data?.id) throw new FeedbackApiError("Unexpected response from the feedback server.", res.status); + return { id: data.id }; +} + +export async function fetchFeedbackStatusList(limit = 10): Promise { + const installId = await getOrCreateInstallId(); + const url = `${FEEDBACK_API_PATH}/status?installId=${encodeURIComponent(installId)}&limit=${Math.min(20, Math.max(1, limit))}`; + const res = await apiFetch(url); + if (!res.ok) await throwApiError(res); + const data = (await res.json()) as { items?: FeedbackStatusItem[] }; + return Array.isArray(data.items) ? data.items : []; +} + +export async function fetchFeedbackStatusItem(id: string): Promise { + const installId = await getOrCreateInstallId(); + const url = `${FEEDBACK_API_PATH}/status?installId=${encodeURIComponent(installId)}&id=${encodeURIComponent(id)}`; + const res = await apiFetch(url); + if (!res.ok) await throwApiError(res); + return (await res.json()) as FeedbackStatusItem; +} + +export function formatStatus(status: string): string { + const labels: Record = { + received: "Received", + triaged: "Triaged", + in_progress: "In progress", + resolved: "Resolved", + wontfix: "Won't fix", + spam: "Closed", + }; + return labels[status] ?? status.replace(/_/g, " "); +} + +export function categoryLabel(category: FeedbackCategory | string): string { + const labels: Record = { + bug: "Bug report", + feature: "Feature request", + question: "Question", + other: "Other", + }; + return labels[category] ?? category; +} + +export function hasReply(item: FeedbackStatusItem): boolean { + return !!item.has_response && !!item.response?.trim(); +} + +async function getPendingIds(): Promise { + const stored = await browser.storage.local.get(BSPLUS_PENDING_FEEDBACK_IDS_KEY); + const raw = stored[BSPLUS_PENDING_FEEDBACK_IDS_KEY]; + if (!Array.isArray(raw)) return []; + return [...new Set(raw.filter((id): id is string => typeof id === "string" && id.startsWith("fb_")))]; +} + +export async function addPendingFeedbackId(id: string): Promise { + if (!id.startsWith("fb_")) return; + const ids = await getPendingIds(); + if (ids.includes(id)) return; + await browser.storage.local.set({ [BSPLUS_PENDING_FEEDBACK_IDS_KEY]: [...ids, id] }); +} + +export async function removePendingFeedbackIds(ids: string[]): Promise { + if (!ids.length) return; + const drop = new Set(ids); + const next = (await getPendingIds()).filter((id) => !drop.has(id)); + await browser.storage.local.set({ [BSPLUS_PENDING_FEEDBACK_IDS_KEY]: next }); +} + +export async function findPendingFeedbackWithReplies(): Promise { + const pending = await getPendingIds(); + if (!pending.length) return []; + const set = new Set(pending); + try { + return (await fetchFeedbackStatusList(20)).filter((i) => set.has(i.id) && hasReply(i)); + } catch { + return []; + } +} + +export function requestOpenFeedbackInSettings(feedbackId: string): void { + try { + sessionStorage.setItem(OPEN_FEEDBACK_SESSION_KEY, feedbackId); + } catch { + /* ignore */ + } + window.dispatchEvent(new CustomEvent("bsplus:open-feedback", { detail: { id: feedbackId } })); +} + +export function consumeOpenFeedbackRequest(): string | null { + try { + const id = sessionStorage.getItem(OPEN_FEEDBACK_SESSION_KEY); + if (id) sessionStorage.removeItem(OPEN_FEEDBACK_SESSION_KEY); + return id; + } catch { + return null; + } +} + +export function openExtensionSettingsPopup(): void { + if (SettingsClicked) return; + document.getElementById("AddedSettings")?.click(); +} diff --git a/src/seqta/utils/feedback/constants.ts b/src/seqta/utils/feedback/constants.ts new file mode 100644 index 00000000..a1f8cc8a --- /dev/null +++ b/src/seqta/utils/feedback/constants.ts @@ -0,0 +1,15 @@ +export const FEEDBACK_SCHEMA_VERSION = 1; +export const BSPLUS_INSTALL_ID_KEY = "bsplus_install_id"; +export const BSPLUS_PENDING_FEEDBACK_IDS_KEY = "bsplus_pending_feedback_ids"; +export const FEEDBACK_API_PATH = "/api/bsplus/feedback"; +export const OPEN_FEEDBACK_SESSION_KEY = "bsplus_open_feedback_id"; + +export const FEEDBACK_CATEGORIES = ["bug", "feature", "question", "other"] as const; +export type FeedbackCategory = (typeof FEEDBACK_CATEGORIES)[number]; + +export type FeedbackBrowser = "chrome" | "firefox" | "safari" | "edge" | "other"; +export type FeedbackChannel = "stable" | "dev" | "nightly" | "unknown"; +export type FeedbackProduct = "learn" | "engage" | "unknown"; + +export const FEEDBACK_MESSAGE_MIN = 10; +export const FEEDBACK_MESSAGE_MAX = 4000; diff --git a/src/seqta/utils/feedback/installId.test.ts b/src/seqta/utils/feedback/installId.test.ts new file mode 100644 index 00000000..f089d7bf --- /dev/null +++ b/src/seqta/utils/feedback/installId.test.ts @@ -0,0 +1,19 @@ +import browser from "webextension-polyfill"; +import { BSPLUS_INSTALL_ID_KEY } from "./constants"; +import { generateInstallId, getOrCreateInstallId, isValidInstallId } from "./installId"; + +describe("installId", () => { + it("validates and generates UUIDs", () => { + expect(isValidInstallId("550e8400-e29b-41d4-a716-446655440000")).toBe(true); + expect(isValidInstallId("nope")).toBe(false); + expect(isValidInstallId(generateInstallId())).toBe(true); + }); + + it("persists a new id and reuses it", async () => { + const id = await getOrCreateInstallId(); + expect(isValidInstallId(id)).toBe(true); + expect(await getOrCreateInstallId()).toBe(id); + expect(browser.storage.local.set).toHaveBeenCalled(); + expect(BSPLUS_INSTALL_ID_KEY).toBe("bsplus_install_id"); + }); +}); diff --git a/src/seqta/utils/feedback/installId.ts b/src/seqta/utils/feedback/installId.ts new file mode 100644 index 00000000..f5e30658 --- /dev/null +++ b/src/seqta/utils/feedback/installId.ts @@ -0,0 +1,26 @@ +import browser from "webextension-polyfill"; +import { BSPLUS_INSTALL_ID_KEY } from "./constants"; + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export function isValidInstallId(value: unknown): value is string { + return typeof value === "string" && UUID_RE.test(value); +} + +export function generateInstallId(): string { + if (typeof crypto?.randomUUID === "function") return crypto.randomUUID(); + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + return (c === "x" ? r : (r & 0x3) | 0x8).toString(16); + }); +} + +export async function getOrCreateInstallId(): Promise { + const stored = await browser.storage.local.get(BSPLUS_INSTALL_ID_KEY); + const existing = stored[BSPLUS_INSTALL_ID_KEY]; + if (isValidInstallId(existing)) return existing; + const installId = generateInstallId(); + await browser.storage.local.set({ [BSPLUS_INSTALL_ID_KEY]: installId }); + return installId; +} diff --git a/src/seqta/utils/listeners/SettingsState.ts b/src/seqta/utils/listeners/SettingsState.ts index e5cf0c97..8c93083f 100644 --- a/src/seqta/utils/listeners/SettingsState.ts +++ b/src/seqta/utils/listeners/SettingsState.ts @@ -49,6 +49,8 @@ const EXCLUDED_FROM_SETTINGS_SURFACE = new Set([ "bsplus_user", "cloudAccessToken", "cloudUsername", + "bsplus_install_id", + "bsplus_pending_feedback_ids", ]); function isExcludedSettingsKey(key: string): boolean { diff --git a/src/test/mocks/webextension-polyfill.ts b/src/test/mocks/webextension-polyfill.ts index 0acee899..21d57028 100644 --- a/src/test/mocks/webextension-polyfill.ts +++ b/src/test/mocks/webextension-polyfill.ts @@ -6,7 +6,7 @@ const local = { return Object.fromEntries(storage); } if (typeof keys === "string") { - return keys in storage ? { [keys]: storage.get(keys) } : {}; + return storage.has(keys) ? { [keys]: storage.get(keys) } : {}; } const out: Record = {}; for (const key of keys) {