feat: extension feedback built in

This commit is contained in:
2026-07-26 15:58:03 +09:30
parent fe7127a83c
commit 318000009f
25 changed files with 1473 additions and 167 deletions
+26
View File
@@ -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;
@@ -0,0 +1,347 @@
<script lang="ts">
import { fade } from "svelte/transition";
import { onMount } from "svelte";
import Switch from "./Switch.svelte";
import {
FEEDBACK_CATEGORIES,
FEEDBACK_MESSAGE_MAX,
FEEDBACK_MESSAGE_MIN,
type FeedbackCategory,
} from "@/seqta/utils/feedback/constants";
import {
FeedbackApiError,
addPendingFeedbackId,
categoryLabel,
fetchFeedbackStatusItem,
fetchFeedbackStatusList,
formatStatus,
getInstanceHostname,
hasReply,
removePendingFeedbackIds,
submitFeedback,
validateFeedbackForm,
type FeedbackStatusItem,
} from "@/seqta/utils/feedback/client";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
let { onClose, initialFeedbackId = null } = $props<{
onClose: () => void;
initialFeedbackId?: string | null;
}>();
let tab = $state<"send" | "status">(initialFeedbackId ? "status" : "send");
let category = $state<FeedbackCategory>("bug");
let subject = $state("");
let message = $state("");
let includeContact = $state(false);
let contactName = $state("");
let contactEmail = $state("");
let includeInstance = $state(false);
let submitting = $state(false);
let errorMessage = $state<string | null>(null);
let successId = $state<string | null>(null);
let statusLoading = $state(false);
let statusError = $state<string | null>(null);
let statusItems = $state<FeedbackStatusItem[]>([]);
let selectedItem = $state<FeedbackStatusItem | null>(null);
const instanceHostname = getInstanceHostname();
const isDark = $derived(!!$settingsState.DarkMode);
const busy = $derived(submitting || statusLoading);
const fieldStyle = $derived(
isDark
? "background-color:#18181b;color:#fafafa;border-color:#52525b;color-scheme:dark"
: "background-color:#fff;color:#18181b;border-color:#e4e4e7;color-scheme:light",
);
const field =
"feedback-field w-full px-3 py-2.5 text-[18px] rounded-lg border focus:outline-none focus:ring-2 focus:ring-zinc-400";
const btn =
"px-4 py-2 text-[18px] font-medium rounded-lg transition-all duration-200 hover:scale-105 active:scale-95 focus:outline-none focus:ring-2 focus:ring-zinc-400 disabled:opacity-50";
const btnMuted = $derived(
`${btn} ${isDark ? "bg-zinc-700 text-zinc-200" : "bg-zinc-200 text-zinc-700"}`,
);
const btnPrimary = $derived(
`${btn} ${isDark ? "bg-zinc-200 text-zinc-900" : "bg-zinc-800 text-white"}`,
);
function errText(e: unknown): string {
if (e instanceof FeedbackApiError) return e.message;
return e instanceof Error ? e.message : "Something went wrong.";
}
async function loadStatusList() {
statusLoading = true;
statusError = null;
selectedItem = null;
try {
statusItems = await fetchFeedbackStatusList(10);
} catch (e) {
statusItems = [];
statusError = errText(e);
} finally {
statusLoading = false;
}
}
async function openStatusItem(id: string) {
statusLoading = true;
statusError = null;
try {
selectedItem = await fetchFeedbackStatusItem(id);
tab = "status";
successId = null;
if (selectedItem && hasReply(selectedItem)) {
void removePendingFeedbackIds([selectedItem.id]);
}
} catch (e) {
statusError = errText(e);
} finally {
statusLoading = false;
}
}
function selectTab(next: "send" | "status") {
tab = next;
errorMessage = null;
statusError = null;
successId = null;
selectedItem = null;
if (next === "status") void loadStatusList();
}
onMount(() => {
if (initialFeedbackId) void openStatusItem(initialFeedbackId);
});
async function handleSubmit() {
const form = {
category,
subject,
message,
includeContact,
contactName,
contactEmail,
includeInstance,
};
errorMessage = validateFeedbackForm(form);
if (errorMessage) return;
submitting = true;
try {
const result = await submitFeedback(form);
successId = result.id;
void addPendingFeedbackId(result.id);
} catch (e) {
errorMessage = errText(e);
} finally {
submitting = false;
}
}
function tabClass(active: boolean): string {
if (active) {
return isDark
? "bg-zinc-700 text-white font-semibold shadow-sm"
: "bg-white text-zinc-900 font-semibold shadow-sm";
}
return isDark
? "bg-transparent text-zinc-400 hover:text-zinc-200"
: "bg-transparent text-zinc-500 hover:text-zinc-800";
}
</script>
<div
class="flex fixed inset-0 z-[99999] justify-center items-center bg-black/50 backdrop-blur-sm {isDark
? 'dark'
: ''}"
onclick={(e) => {
if (e.target === e.currentTarget && !busy) onClose();
}}
onkeydown={(e) => {
if (e.key === "Escape" && !busy) onClose();
}}
role="button"
tabindex="-1"
transition:fade={{ duration: 150 }}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="p-5 mx-4 w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-2xl shadow-2xl border text-[18px] {isDark
? 'bg-zinc-800 text-white border-zinc-700'
: 'bg-white text-zinc-900 border-zinc-200'}"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="feedback-modal-title"
tabindex="-1"
>
<div
class="flex gap-1 p-1 mb-4 rounded-full {isDark ? 'bg-zinc-900' : 'bg-zinc-100'}"
role="tablist"
aria-label="Feedback views"
>
<button type="button" role="tab" aria-selected={tab === "send"} onclick={() => selectTab("send")} class="flex-1 px-3 py-2.5 rounded-full transition-all duration-200 {tabClass(tab === 'send')}">
Send
</button>
<button type="button" role="tab" aria-selected={tab === "status"} onclick={() => selectTab("status")} class="flex-1 px-3 py-2.5 rounded-full transition-all duration-200 {tabClass(tab === 'status')}">
My feedback
</button>
</div>
{#if tab === "send"}
{#if successId}
<h2 id="feedback-modal-title" class="mb-3 text-xl font-bold">Thanks for the feedback</h2>
<p class="mb-2 text-zinc-600 dark:text-zinc-300">Reference ID:</p>
<p class="mb-4 px-3 py-2 font-mono text-base rounded-lg {isDark ? 'bg-zinc-900' : 'bg-zinc-100'} break-all">{successId}</p>
<div class="flex gap-3 justify-end">
<button type="button" class={btnMuted} onclick={() => successId && openStatusItem(successId)}>Check status</button>
<button type="button" class={btnPrimary} onclick={onClose}>Done</button>
</div>
{:else}
<h2 id="feedback-modal-title" class="mb-1 text-xl font-bold">Send feedback</h2>
<p class="mb-4 text-zinc-600 dark:text-zinc-400">Anonymous by default. Contact and school details are optional.</p>
<div class="flex flex-col gap-4">
<label class="flex flex-col gap-1.5 font-medium">
Category
<select id="feedback-category" bind:value={category} class={field} style={fieldStyle}>
{#each FEEDBACK_CATEGORIES as value (value)}
<option {value} style={fieldStyle}>{categoryLabel(value)}</option>
{/each}
</select>
</label>
<label class="flex flex-col gap-1.5 font-medium">
Subject <span class="font-normal text-zinc-500">(optional)</span>
<input type="text" maxlength={120} bind:value={subject} placeholder="Short summary" class={field} style={fieldStyle} />
</label>
<label class="flex flex-col gap-1.5 font-medium">
Message
<textarea rows={5} maxlength={FEEDBACK_MESSAGE_MAX} bind:value={message} placeholder="What happened, or what would you like to see?" class="{field} resize-y min-h-[120px]" style={fieldStyle}></textarea>
<span class="text-base font-normal text-zinc-500">{message.trim().length}/{FEEDBACK_MESSAGE_MAX} (min {FEEDBACK_MESSAGE_MIN})</span>
</label>
<div class="flex justify-between items-center gap-3">
<div>
<p class="font-medium">Include contact details</p>
<p class="text-base text-zinc-500">Name and email so we can reply</p>
</div>
<Switch state={includeContact} onChange={(v) => (includeContact = v)} />
</div>
{#if includeContact}
<input type="text" maxlength={80} bind:value={contactName} placeholder="Name" class={field} style={fieldStyle} />
<input type="email" maxlength={254} bind:value={contactEmail} placeholder="Email" class={field} style={fieldStyle} />
{/if}
<div class="flex justify-between items-center gap-3">
<div>
<p class="font-medium">Include SEQTA instance</p>
<p class="text-base text-zinc-500">
{#if instanceHostname}
Hostname only: <span class="font-mono">{instanceHostname}</span>
{:else}
Open SEQTA first to detect hostname
{/if}
</p>
</div>
<Switch
state={includeInstance && !!instanceHostname}
onChange={(v) => {
if (instanceHostname) includeInstance = v;
}}
/>
</div>
{#if errorMessage}
<p class="text-red-600 dark:text-red-400" role="alert">{errorMessage}</p>
{/if}
<p class="text-base text-zinc-500">
Sent to betterseqta.org.
<a href="https://betterseqta.org/privacy" target="_blank" rel="noopener noreferrer" class="underline">Privacy</a>
</p>
<div class="flex gap-3 justify-end">
<button type="button" class={btnMuted} onclick={onClose} disabled={submitting}>Cancel</button>
<button type="button" class={btnPrimary} onclick={handleSubmit} disabled={submitting}>
{submitting ? "Sending…" : "Send feedback"}
</button>
</div>
</div>
{/if}
{:else if selectedItem}
<div class="flex items-start justify-between gap-3 mb-3">
<h2 id="feedback-modal-title" class="text-xl font-bold">Feedback status</h2>
<button type="button" class="{btnMuted} !text-base !px-3 !py-1.5" onclick={() => { selectedItem = null; void loadStatusList(); }}>Back</button>
</div>
<p class="mb-1 font-medium">{selectedItem.subject || "Untitled"} · {formatStatus(selectedItem.status)}</p>
<p class="mb-4 font-mono text-base text-zinc-500 break-all">{selectedItem.id}</p>
{#if hasReply(selectedItem)}
<div class="p-3 mb-4 rounded-lg border {isDark ? 'border-zinc-700 bg-zinc-900/50' : 'border-zinc-200 bg-zinc-50'}">
<p class="mb-1 text-base font-semibold uppercase tracking-wide text-zinc-500">Response</p>
<p class="whitespace-pre-wrap">{selectedItem.response}</p>
</div>
{:else}
<p class="mb-4 text-zinc-500">No response yet.</p>
{/if}
{#if statusError}<p class="mb-3 text-red-600 dark:text-red-400" role="alert">{statusError}</p>{/if}
<div class="flex gap-3 justify-end">
<button type="button" class={btnMuted} onclick={() => openStatusItem(selectedItem.id)} disabled={statusLoading}>
{statusLoading ? "Refreshing…" : "Refresh"}
</button>
<button type="button" class={btnPrimary} onclick={onClose}>Close</button>
</div>
{:else}
<div class="flex items-start justify-between gap-3 mb-3">
<h2 id="feedback-modal-title" class="text-xl font-bold">My feedback</h2>
<button type="button" class="{btnMuted} !text-base !px-3 !py-1.5" onclick={() => void loadStatusList()} disabled={statusLoading}>
{statusLoading ? "…" : "Refresh"}
</button>
</div>
{#if statusError}<p class="mb-3 text-red-600 dark:text-red-400" role="alert">{statusError}</p>{/if}
{#if statusLoading && !statusItems.length}
<p class="text-zinc-500">Loading…</p>
{:else if !statusItems.length}
<p class="mb-4 text-zinc-500">No feedback yet.</p>
<button type="button" class={btnPrimary} onclick={() => selectTab("send")}>Send feedback</button>
{:else}
<ul class="flex flex-col gap-2 mb-4">
{#each statusItems as item (item.id)}
<li>
<button
type="button"
onclick={() => openStatusItem(item.id)}
class="w-full p-3 text-left rounded-lg border transition-all duration-200 hover:scale-[1.01] focus:outline-none focus:ring-2 focus:ring-zinc-400 {isDark
? 'border-zinc-700 bg-zinc-900/40'
: 'border-zinc-200 bg-white'}"
>
<p class="text-base text-zinc-500 mb-0.5">
{formatStatus(item.status)}{#if hasReply(item)} · Reply{/if} · {categoryLabel(item.category)}
</p>
<p class="font-medium truncate">{item.subject || "Untitled"}</p>
</button>
</li>
{/each}
</ul>
{/if}
<div class="flex justify-end">
<button type="button" class={btnMuted} onclick={onClose}>Close</button>
</div>
{/if}
</div>
</div>
<style>
:global(.feedback-field),
:global(.feedback-field option) {
-webkit-text-fill-color: currentColor !important;
caret-color: currentColor !important;
}
:global(.feedback-field::placeholder) {
-webkit-text-fill-color: #a1a1aa !important;
color: #a1a1aa !important;
opacity: 1;
}
</style>
+62 -2
View File
@@ -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<boolean>(false);
let showFontPicker = $state<boolean>(false);
let showCloudPanel = $state<boolean>(false);
let showFeedbackModal = $state<boolean>(false);
let feedbackFocusId = $state<string | null>(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 @@
<!-- Body: left nav + content -->
<div class="flex flex-1 min-h-0 overflow-hidden">
<nav
class="flex flex-col shrink-0 gap-5 overflow-y-auto no-scrollbar border-r border-zinc-200/60 dark:border-zinc-700/50 bg-zinc-50/80 dark:bg-zinc-900/40 {standalone
class="flex flex-col shrink-0 min-h-0 border-r border-zinc-200/60 dark:border-zinc-700/50 bg-zinc-50/80 dark:bg-zinc-900/40 {standalone
? 'w-[140px] px-2 py-3'
: 'w-[260px] px-4 py-5'}"
aria-label="Settings categories"
>
<div class="relative flex flex-col gap-5" bind:this={navTrackEl}>
<div class="relative flex flex-col flex-1 min-h-0 gap-5 overflow-y-auto no-scrollbar" bind:this={navTrackEl}>
{#if activePage === "settings" && indicatorReady}
<div
class="absolute left-0 right-0 top-0 z-0 rounded-lg bg-zinc-200/90 dark:bg-zinc-700/90 pointer-events-none"
@@ -416,6 +439,33 @@
</div>
{/if}
</div>
<button
type="button"
onclick={openFeedback}
class="shrink-0 mt-4 w-full px-3 py-2.5 text-left text-[18px] font-medium rounded-lg transition-all duration-200
text-zinc-700 dark:text-zinc-200
bg-zinc-200/70 dark:bg-zinc-800/80
hover:bg-zinc-300/80 dark:hover:bg-zinc-700
hover:scale-[1.02] active:scale-95
focus:outline-none focus:ring-2 focus:ring-zinc-400 focus:ring-offset-2 dark:focus:ring-offset-zinc-900"
>
<span class="flex items-center gap-2">
<svg
class="w-5 h-5 shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<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>
<span>Send us feedback!</span>
</span>
</button>
</nav>
<div class="flex flex-col flex-1 min-w-0 min-h-0">
@@ -516,3 +566,13 @@
}}
/>
{/if}
{#if showFeedbackModal}
<FeedbackModal
initialFeedbackId={feedbackFocusId}
onClose={() => {
showFeedbackModal = false;
feedbackFocusId = null;
}}
/>
{/if}
+32 -10
View File
@@ -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;
@@ -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 =
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>';
async function resolveTriggerWrapper(
titleElement: Element,
): Promise<HTMLElement | null> {
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 = `
<div class="search-trigger-anchor">
<div class="search-trigger">
<span>${SEARCH_SVG}</span>
<p>Quick search...</p>
<span class="search-trigger-hotkey" style="margin-left:auto;display:flex;align-items:center;color:#777;font-size:12px"></span>
</div>
<div class="search-progress-bar-wrapper">
<div class="search-progress-track">
<div class="search-progress-bar" style="width:0%"></div>
</div>
</div>
</div>
<div class="search-progress-text" aria-live="polite"></div>`;
return searchWrapper;
}
function triggerParts(searchWrapper: HTMLElement) {
const q = <T extends HTMLElement>(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 =
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>';
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 <div> so the label reliably participates in flex
// layout. A <span> 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<typeof setTimeout> | null = null;
let doneFadeTimer: ReturnType<typeof setTimeout> | 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;
}
@@ -47,8 +47,13 @@ async function loadAnalyticsPageInner(): Promise<void> {
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);
}
+25 -2
View File
@@ -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> {
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<void> {
async function handleMessages(node: Element): Promise<void> {
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();
+42
View File
@@ -0,0 +1,42 @@
<script lang="ts">
import { titleBarState } from "./titleBarState.svelte";
</script>
<div id="bsplus-title-root">
<span data-testid="page-title">{titleBarState.pageTitle}</span>
{#if titleBarState.showSearch}
<div class="search-trigger-wrapper">
<div class="search-trigger-anchor">
<div class="search-trigger">
<span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</span>
<p>Quick search...</p>
<span
class="search-trigger-hotkey"
style="margin-left: auto; display: flex; align-items: center; color: rgb(119, 119, 119); font-size: 12px;"
>{titleBarState.searchHotkeyLabel}</span>
</div>
<div class="search-progress-bar-wrapper">
<div class="search-progress-track">
<div class="search-progress-bar" style="width: 0%;"></div>
</div>
</div>
</div>
<div class="search-progress-text" aria-live="polite"></div>
</div>
{/if}
</div>
+8
View File
@@ -0,0 +1,8 @@
export {
prepareCustomTitleBarEarly,
mountCustomTitleBar,
unmountCustomTitleBar,
setCustomTitleBarText,
waitForCustomTitleBarReady,
} from "./mountCustomTitleBar";
export { titleBarState } from "./titleBarState.svelte";
@@ -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<typeof mount> | null = null;
let titleEl: HTMLElement | null = null;
let hostObserver: MutationObserver | null = null;
let earlyPrepareStarted = false;
let remountTimer: ReturnType<typeof setTimeout> | 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<string, unknown>;
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<boolean> {
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;
}
@@ -0,0 +1,5 @@
export const titleBarState = $state({
pageTitle: "",
showSearch: false,
searchHotkeyLabel: "Ctrl+K",
});
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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(`
<div class="whatsnewHeader">
<h1>Feedback reply</h1>
<p>${esc(formatStatus(primary.status))}${extra > 0 ? ` · +${extra} more` : ""}</p>
</div>
`).firstChild as HTMLElement;
const text = stringToHTML(`
<div class="whatsnewTextContainer" style="overflow-y:auto;font-size:1.2rem;line-height:1.6">
<p style="margin-bottom:.75rem"><strong>${esc(title)}</strong></p>
<div style="padding:.9rem 1rem;border-radius:.75rem;background:color-mix(in srgb,currentColor 8%,transparent);white-space:pre-wrap">${esc(response)}</div>
<div style="display:flex;gap:.75rem;justify-content:flex-end;margin-top:1.25rem;flex-wrap:wrap">
<button type="button" id="bsplus-feedback-reply-dismiss" style="padding:.55rem 1rem;border-radius:.6rem;border:none;cursor:pointer;font-size:1rem;background:color-mix(in srgb,currentColor 12%,transparent);color:inherit">Dismiss</button>
<button type="button" id="bsplus-feedback-reply-view" style="padding:.55rem 1rem;border-radius:.6rem;border:none;cursor:pointer;font-size:1rem;font-weight:600;background:currentColor;color:Canvas">View reply</button>
</div>
</div>
`).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;
}
}
+8 -1
View File
@@ -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);
@@ -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.",
+6 -3
View File
@@ -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");
@@ -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,
);
@@ -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 },
});
+4
View File
@@ -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;
/**
+108
View File
@@ -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),
}),
);
});
});
+296
View File
@@ -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<Response> {
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<never> {
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<FeedbackStatusItem[]> {
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<FeedbackStatusItem> {
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<string, string> = {
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<string, string> = {
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<string[]> {
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<void> {
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<void> {
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<FeedbackStatusItem[]> {
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();
}
+15
View File
@@ -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;
@@ -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");
});
});
+26
View File
@@ -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<string> {
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;
}
@@ -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 {
+1 -1
View File
@@ -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<string, unknown> = {};
for (const key of keys) {