mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-08-28 09:11:06 +00:00
feat: extension feedback built in
This commit is contained in:
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
export async function mountSearchBar(
|
||||
titleElement: Element,
|
||||
api: any,
|
||||
appRef: {
|
||||
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: 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);
|
||||
if (doneFlashTimer) clearTimeout(doneFlashTimer);
|
||||
if (doneFadeTimer) clearTimeout(doneFadeTimer);
|
||||
doneFlashTimer = null;
|
||||
}
|
||||
if (doneFadeTimer) {
|
||||
clearTimeout(doneFadeTimer);
|
||||
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,23 +235,20 @@ 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;
|
||||
|
||||
@@ -227,62 +256,64 @@ export async function mountSearchBar(
|
||||
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;
|
||||
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, {
|
||||
appRef.current = renderSvelte(
|
||||
SearchBar,
|
||||
searchRoot.attachShadow({ mode: "open" }),
|
||||
{
|
||||
transparencyEffects: api.settings.transparencyEffects,
|
||||
showRecentFirst: api.settings.showRecentFirst,
|
||||
searchHotkey: currentHotkey,
|
||||
}, "content");
|
||||
},
|
||||
"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) {
|
||||
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();
|
||||
|
||||
// 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 }) => {
|
||||
document.querySelector("div[data-search-root]")?.remove();
|
||||
|
||||
void import("../indexing/worker/vectorWorkerManager")
|
||||
.then(({ VectorWorkerManager }) => {
|
||||
VectorWorkerManager.getInstance().terminate();
|
||||
}).catch(() => {});
|
||||
})
|
||||
.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;
|
||||
|
||||
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) (titlediv as HTMLElement).innerText = "Analytics";
|
||||
if (titlediv instanceof HTMLElement) titlediv.innerText = "Analytics";
|
||||
}
|
||||
|
||||
renderAnalyticsPage(container);
|
||||
}
|
||||
|
||||
+25
-2
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
@@ -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, "&")
|
||||
.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(`
|
||||
<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;
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -48,9 +48,12 @@ export async function SendNewsPage() {
|
||||
|
||||
main.append(html.firstChild!);
|
||||
|
||||
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) {
|
||||
(titleBar as HTMLElement).innerText = "News";
|
||||
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 },
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user