mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-08-28 17:21:05 +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;
|
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. */
|
/* Custom Svelte sidebar: keep `#menu > ul > li.item` / `.sub` shape for theme CSS. */
|
||||||
#menu.bsplus-custom-sidebar {
|
#menu.bsplus-custom-sidebar {
|
||||||
position: absolute !important;
|
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 FontPickerModal from "../components/FontPickerModal.svelte";
|
||||||
import CloudPanel from "../components/CloudPanel.svelte";
|
import CloudPanel from "../components/CloudPanel.svelte";
|
||||||
import DisclaimerModal from "../components/DisclaimerModal.svelte";
|
import DisclaimerModal from "../components/DisclaimerModal.svelte";
|
||||||
|
import FeedbackModal from "../components/FeedbackModal.svelte";
|
||||||
import { settingsPopup } from "@/seqta/utils/settingsPopup";
|
import { settingsPopup } from "@/seqta/utils/settingsPopup";
|
||||||
|
import { consumeOpenFeedbackRequest } from "@/seqta/utils/feedback/client";
|
||||||
import {
|
import {
|
||||||
checkGithubReleaseUpdate,
|
checkGithubReleaseUpdate,
|
||||||
dismissNightlyUpdate,
|
dismissNightlyUpdate,
|
||||||
@@ -158,11 +160,18 @@
|
|||||||
let showColourPicker = $state<boolean>(false);
|
let showColourPicker = $state<boolean>(false);
|
||||||
let showFontPicker = $state<boolean>(false);
|
let showFontPicker = $state<boolean>(false);
|
||||||
let showCloudPanel = $state<boolean>(false);
|
let showCloudPanel = $state<boolean>(false);
|
||||||
|
let showFeedbackModal = $state<boolean>(false);
|
||||||
|
let feedbackFocusId = $state<string | null>(null);
|
||||||
|
|
||||||
const openCloudPanel = () => {
|
const openCloudPanel = () => {
|
||||||
showCloudPanel = true;
|
showCloudPanel = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openFeedback = (feedbackId?: string | null) => {
|
||||||
|
feedbackFocusId = feedbackId ?? null;
|
||||||
|
showFeedbackModal = true;
|
||||||
|
};
|
||||||
|
|
||||||
const showDisclaimer = (
|
const showDisclaimer = (
|
||||||
onConfirm: () => void,
|
onConfirm: () => void,
|
||||||
onCancel: () => void,
|
onCancel: () => void,
|
||||||
@@ -179,6 +188,8 @@
|
|||||||
showColourPicker = false;
|
showColourPicker = false;
|
||||||
showFontPicker = false;
|
showFontPicker = false;
|
||||||
showCloudPanel = false;
|
showCloudPanel = false;
|
||||||
|
showFeedbackModal = false;
|
||||||
|
feedbackFocusId = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
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) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === "Escape" && !standalone) {
|
if (event.key === "Escape" && !standalone) {
|
||||||
closeExtensionPopup();
|
closeExtensionPopup();
|
||||||
@@ -218,6 +240,7 @@
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("keydown", onKeyDown);
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
|
window.removeEventListener("bsplus:open-feedback", onOpenFeedback);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -350,12 +373,12 @@
|
|||||||
<!-- Body: left nav + content -->
|
<!-- Body: left nav + content -->
|
||||||
<div class="flex flex-1 min-h-0 overflow-hidden">
|
<div class="flex flex-1 min-h-0 overflow-hidden">
|
||||||
<nav
|
<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-[140px] px-2 py-3'
|
||||||
: 'w-[260px] px-4 py-5'}"
|
: 'w-[260px] px-4 py-5'}"
|
||||||
aria-label="Settings categories"
|
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}
|
{#if activePage === "settings" && indicatorReady}
|
||||||
<div
|
<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"
|
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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
</nav>
|
||||||
|
|
||||||
<div class="flex flex-col flex-1 min-w-0 min-h-0">
|
<div class="flex flex-col flex-1 min-w-0 min-h-0">
|
||||||
@@ -516,3 +566,13 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if showFeedbackModal}
|
||||||
|
<FeedbackModal
|
||||||
|
initialFeedbackId={feedbackFocusId}
|
||||||
|
onClose={() => {
|
||||||
|
showFeedbackModal = false;
|
||||||
|
feedbackFocusId = null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ import {
|
|||||||
resetSearchIndexes,
|
resetSearchIndexes,
|
||||||
notifyOpenTabsResetSearchIndex,
|
notifyOpenTabsResetSearchIndex,
|
||||||
} from "./src/indexing/resetIndexes";
|
} 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({
|
const settings = defineSettings({
|
||||||
searchHotkey: hotkeySetting({
|
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({
|
const globalSearchPlugin = defineLazyPlugin({
|
||||||
id: "global-search",
|
id: "global-search",
|
||||||
name: "Global Search",
|
name: "Global Search",
|
||||||
@@ -75,20 +83,34 @@ const globalSearchPlugin = defineLazyPlugin({
|
|||||||
settings,
|
settings,
|
||||||
disableToggle: true,
|
disableToggle: true,
|
||||||
defaultEnabled: false,
|
defaultEnabled: false,
|
||||||
styles: styles,
|
styles,
|
||||||
|
loader: () => import("./src/core/index"),
|
||||||
// Lazy loader - only imports the heavy plugin when actually needed
|
|
||||||
loader: () => import("./src/core/index")
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const runGlobalSearch = globalSearchPlugin.run!;
|
const runGlobalSearch = globalSearchPlugin.run!;
|
||||||
|
|
||||||
globalSearchPlugin.run = async (api) => {
|
globalSearchPlugin.run = async (api) => {
|
||||||
if (isSeqtaEngageExperience()) {
|
if (isSeqtaEngageExperience()) return () => {};
|
||||||
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;
|
export default globalSearchPlugin;
|
||||||
|
|||||||
@@ -2,110 +2,143 @@ import SearchBar from "../components/SearchBar.svelte";
|
|||||||
import { unmount } from "svelte";
|
import { unmount } from "svelte";
|
||||||
import { warmUpVectorSearchOnInteraction } from "../search/vector/vectorSearch";
|
import { warmUpVectorSearchOnInteraction } from "../search/vector/vectorSearch";
|
||||||
import { formatHotkeyForDisplay, isValidHotkey } from "../utils/hotkeyUtils";
|
import { formatHotkeyForDisplay, isValidHotkey } from "../utils/hotkeyUtils";
|
||||||
|
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||||
import browser from "webextension-polyfill";
|
import browser from "webextension-polyfill";
|
||||||
|
|
||||||
export async function mountSearchBar(
|
type AppRef = {
|
||||||
titleElement: Element,
|
|
||||||
api: any,
|
|
||||||
appRef: {
|
|
||||||
current: any;
|
current: any;
|
||||||
storageChangeHandler?: any;
|
storageChangeHandler?: any;
|
||||||
progressHandler?: any;
|
progressHandler?: any;
|
||||||
clearDoneFlashTimer?: () => void;
|
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;
|
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;
|
let isIndexing = false;
|
||||||
/** True while indexing has run until it finishes/fails — used for Done! flash only */
|
|
||||||
let ranIndexingCycle = false;
|
let ranIndexingCycle = false;
|
||||||
let completedJobs = 0;
|
let completedJobs = 0;
|
||||||
let totalJobs = 0;
|
let totalJobs = 0;
|
||||||
let indexingStatus: string | null = null;
|
let indexingStatus: string | null = null;
|
||||||
let doneFlashTimer: ReturnType<typeof setTimeout> | null = null;
|
let doneFlashTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let doneFadeTimer: ReturnType<typeof setTimeout> | null = null;
|
let doneFadeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
/** Captures `wasIndexing && !indexing` for the current dispatcher tick */
|
|
||||||
let indexingJustStoppedFlag = false;
|
let indexingJustStoppedFlag = false;
|
||||||
|
|
||||||
const DONE_HOLD_MS = 5000;
|
const DONE_HOLD_MS = 5000;
|
||||||
const DONE_FADE_MS = 550;
|
const DONE_FADE_MS = 550;
|
||||||
|
const statusLooksRough = (s: string) => /\b(fail|error|cancel)\b/i.test(s);
|
||||||
/** Treat as failure copy — plain “Done!” would be misleading */
|
|
||||||
const statusLooksRough = (s: string) =>
|
|
||||||
/\b(fail|error|cancel)\b/i.test(s);
|
|
||||||
|
|
||||||
const truncateStatus = (s: string, max = 44) =>
|
const truncateStatus = (s: string, max = 44) =>
|
||||||
s.length > max ? s.slice(0, max - 1) + "…" : s;
|
s.length > max ? s.slice(0, max - 1) + "…" : s;
|
||||||
|
|
||||||
const clearDoneFlashTimer = () => {
|
const clearDoneFlashTimer = () => {
|
||||||
if (doneFlashTimer) {
|
if (doneFlashTimer) clearTimeout(doneFlashTimer);
|
||||||
clearTimeout(doneFlashTimer);
|
if (doneFadeTimer) clearTimeout(doneFadeTimer);
|
||||||
doneFlashTimer = null;
|
doneFlashTimer = null;
|
||||||
}
|
|
||||||
if (doneFadeTimer) {
|
|
||||||
clearTimeout(doneFadeTimer);
|
|
||||||
doneFadeTimer = null;
|
doneFadeTimer = null;
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetIdleProgressUi = () => {
|
const resetIdleProgressUi = () => {
|
||||||
@@ -145,7 +178,9 @@ export async function mountSearchBar(
|
|||||||
searchAnchor.classList.remove("is-indexing");
|
searchAnchor.classList.remove("is-indexing");
|
||||||
searchButton.classList.remove("is-indexing");
|
searchButton.classList.remove("is-indexing");
|
||||||
progressText.classList.remove("is-fading-done");
|
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);
|
progressText.classList.toggle("is-rough", rough);
|
||||||
progressBarWrapper.classList.toggle("is-rough-complete", rough);
|
progressBarWrapper.classList.toggle("is-rough-complete", rough);
|
||||||
progressText.classList.add("is-active", "is-done-message");
|
progressText.classList.add("is-active", "is-done-message");
|
||||||
@@ -163,38 +198,35 @@ export async function mountSearchBar(
|
|||||||
const updateProgressDisplay = () => {
|
const updateProgressDisplay = () => {
|
||||||
const indexingStoppedThisTick = indexingJustStoppedFlag;
|
const indexingStoppedThisTick = indexingJustStoppedFlag;
|
||||||
indexingJustStoppedFlag = false;
|
indexingJustStoppedFlag = false;
|
||||||
|
|
||||||
const active = isIndexing && totalJobs > 0;
|
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 (doneFlashTimer !== null || doneFadeTimer !== null) {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
clearDoneFlashTimer();
|
clearDoneFlashTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
showActiveIndexingUi(Math.round((completedJobs / totalJobs) * 100));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const completionEligible =
|
const completionEligible =
|
||||||
ranIndexingCycle &&
|
ranIndexingCycle &&
|
||||||
!active &&
|
!active &&
|
||||||
totalJobs > 0 &&
|
totalJobs > 0 &&
|
||||||
(completedJobs >= totalJobs || indexingStoppedThisTick);
|
(completedJobs >= totalJobs || indexingStoppedThisTick);
|
||||||
|
|
||||||
if (active) {
|
|
||||||
showActiveIndexingUi(Math.round((completedJobs / totalJobs) * 100));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (completionEligible) {
|
if (completionEligible) {
|
||||||
if (doneFlashTimer !== null || doneFadeTimer !== null) return;
|
if (doneFlashTimer !== null || doneFadeTimer !== null) return;
|
||||||
const rough = indexingStatus != null && statusLooksRough(indexingStatus);
|
scheduleCompletionFlash(
|
||||||
scheduleCompletionFlash(rough);
|
indexingStatus != null && statusLooksRough(indexingStatus),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
resetIdleProgressUi();
|
resetIdleProgressUi();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Listen for indexing progress events
|
|
||||||
const progressHandler = (event: CustomEvent) => {
|
const progressHandler = (event: CustomEvent) => {
|
||||||
const { completed, total, indexing, status } = event.detail as {
|
const { completed, total, indexing, status } = event.detail as {
|
||||||
completed?: number;
|
completed?: number;
|
||||||
@@ -203,23 +235,20 @@ export async function mountSearchBar(
|
|||||||
status?: string;
|
status?: string;
|
||||||
};
|
};
|
||||||
const wasIndexing = isIndexing;
|
const wasIndexing = isIndexing;
|
||||||
|
|
||||||
completedJobs = completed ?? 0;
|
completedJobs = completed ?? 0;
|
||||||
totalJobs = total ?? 0;
|
totalJobs = total ?? 0;
|
||||||
isIndexing = Boolean(indexing);
|
isIndexing = Boolean(indexing);
|
||||||
indexingStatus = status ?? null;
|
indexingStatus = status ?? null;
|
||||||
indexingJustStoppedFlag = wasIndexing && !isIndexing;
|
indexingJustStoppedFlag = wasIndexing && !isIndexing;
|
||||||
|
|
||||||
if (!wasIndexing && isIndexing) ranIndexingCycle = true;
|
if (!wasIndexing && isIndexing) ranIndexingCycle = true;
|
||||||
if (wasIndexing && !isIndexing) ranIndexingCycle = true;
|
if (wasIndexing && !isIndexing) ranIndexingCycle = true;
|
||||||
if (totalJobs > 0 && completedJobs >= totalJobs && !isIndexing) {
|
if (totalJobs > 0 && completedJobs >= totalJobs && !isIndexing) {
|
||||||
ranIndexingCycle = true;
|
ranIndexingCycle = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateProgressDisplay();
|
updateProgressDisplay();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('indexing-progress', progressHandler as EventListener);
|
window.addEventListener("indexing-progress", progressHandler as EventListener);
|
||||||
appRef.progressHandler = progressHandler;
|
appRef.progressHandler = progressHandler;
|
||||||
appRef.clearDoneFlashTimer = clearDoneFlashTimer;
|
appRef.clearDoneFlashTimer = clearDoneFlashTimer;
|
||||||
|
|
||||||
@@ -227,62 +256,64 @@ export async function mountSearchBar(
|
|||||||
hotkeySpan.textContent = hotkeyDisplay;
|
hotkeySpan.textContent = hotkeyDisplay;
|
||||||
searchButton.replaceChildren(searchIcon, searchLabel, hotkeySpan);
|
searchButton.replaceChildren(searchIcon, searchLabel, hotkeySpan);
|
||||||
};
|
};
|
||||||
|
|
||||||
updateSearchButtonDisplay();
|
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) => {
|
const handleStorageChange = (changes: any, area: string) => {
|
||||||
if (area === 'local' && changes['plugin.global-search.settings']) {
|
if (area !== "local" || !changes["plugin.global-search.settings"]) return;
|
||||||
const newSettings = changes['plugin.global-search.settings'].newValue as { searchHotkey?: string } | undefined;
|
const next = changes["plugin.global-search.settings"].newValue as
|
||||||
if (newSettings?.searchHotkey && isValidHotkey(newSettings.searchHotkey)) {
|
| { searchHotkey?: string }
|
||||||
currentHotkey = newSettings.searchHotkey;
|
| undefined;
|
||||||
|
if (!next?.searchHotkey || !isValidHotkey(next.searchHotkey)) return;
|
||||||
|
currentHotkey = next.searchHotkey;
|
||||||
hotkeyDisplay = formatHotkeyForDisplay(currentHotkey);
|
hotkeyDisplay = formatHotkeyForDisplay(currentHotkey);
|
||||||
updateSearchButtonDisplay();
|
updateSearchButtonDisplay();
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
browser.storage.onChanged.addListener(handleStorageChange);
|
browser.storage.onChanged.addListener(handleStorageChange);
|
||||||
|
|
||||||
// Store reference to cleanup function for proper removal
|
|
||||||
appRef.storageChangeHandler = handleStorageChange;
|
appRef.storageChangeHandler = handleStorageChange;
|
||||||
|
|
||||||
const searchRoot = document.createElement("div");
|
const searchRoot = document.createElement("div");
|
||||||
|
searchRoot.setAttribute("data-search-root", "");
|
||||||
document.body.appendChild(searchRoot);
|
document.body.appendChild(searchRoot);
|
||||||
const searchRootShadow = searchRoot.attachShadow({ mode: "open" });
|
|
||||||
|
|
||||||
searchButton.addEventListener("click", () => {
|
const clickHandler = () => {
|
||||||
warmUpVectorSearchOnInteraction();
|
warmUpVectorSearchOnInteraction();
|
||||||
// @ts-ignore - Intentionally adding to window
|
// @ts-ignore
|
||||||
window.setCommandPalleteOpen(true);
|
window.setCommandPalleteOpen(true);
|
||||||
});
|
};
|
||||||
|
searchButton.addEventListener("click", clickHandler);
|
||||||
|
appRef.clickHandler = clickHandler;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { default: renderSvelte } = await import("@/interface/main");
|
const { default: renderSvelte } = await import("@/interface/main");
|
||||||
appRef.current = renderSvelte(SearchBar, searchRootShadow, {
|
appRef.current = renderSvelte(
|
||||||
|
SearchBar,
|
||||||
|
searchRoot.attachShadow({ mode: "open" }),
|
||||||
|
{
|
||||||
transparencyEffects: api.settings.transparencyEffects,
|
transparencyEffects: api.settings.transparencyEffects,
|
||||||
showRecentFirst: api.settings.showRecentFirst,
|
showRecentFirst: api.settings.showRecentFirst,
|
||||||
searchHotkey: currentHotkey,
|
searchHotkey: currentHotkey,
|
||||||
}, "content");
|
},
|
||||||
|
"content",
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error rendering Svelte component:", error);
|
console.error("Error rendering Svelte component:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cleanupSearchBar(appRef: {
|
export function cleanupSearchBar(appRef: AppRef) {
|
||||||
current: any;
|
|
||||||
storageChangeHandler?: any;
|
|
||||||
progressHandler?: any;
|
|
||||||
clearDoneFlashTimer?: () => void;
|
|
||||||
}) {
|
|
||||||
if (appRef.current) {
|
if (appRef.current) {
|
||||||
try {
|
try {
|
||||||
unmount(appRef.current);
|
unmount(appRef.current);
|
||||||
appRef.current = null;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error unmounting Svelte component:", error);
|
console.error("Error unmounting Svelte component:", error);
|
||||||
}
|
}
|
||||||
|
appRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -292,36 +323,47 @@ export function cleanupSearchBar(appRef: {
|
|||||||
}
|
}
|
||||||
appRef.clearDoneFlashTimer = undefined;
|
appRef.clearDoneFlashTimer = undefined;
|
||||||
|
|
||||||
// Remove progress event listener
|
|
||||||
if (appRef.progressHandler) {
|
if (appRef.progressHandler) {
|
||||||
window.removeEventListener('indexing-progress', appRef.progressHandler as EventListener);
|
window.removeEventListener(
|
||||||
|
"indexing-progress",
|
||||||
|
appRef.progressHandler as EventListener,
|
||||||
|
);
|
||||||
appRef.progressHandler = null;
|
appRef.progressHandler = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove search trigger wrapper (which contains the button and progress UI)
|
const searchWrapper = document.querySelector(
|
||||||
const searchWrapper = document.querySelector(".search-trigger-wrapper");
|
".search-trigger-wrapper",
|
||||||
|
) as HTMLElement | null;
|
||||||
|
const customOwns = Boolean(
|
||||||
|
document.querySelector("#title.bsplus-custom-title") ||
|
||||||
|
document.getElementById("bsplus-title-root"),
|
||||||
|
);
|
||||||
|
|
||||||
if (searchWrapper) {
|
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();
|
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)
|
document.querySelector("div[data-search-root]")?.remove();
|
||||||
void import("../indexing/worker/vectorWorkerManager").then(({ VectorWorkerManager }) => {
|
|
||||||
|
void import("../indexing/worker/vectorWorkerManager")
|
||||||
|
.then(({ VectorWorkerManager }) => {
|
||||||
VectorWorkerManager.getInstance().terminate();
|
VectorWorkerManager.getInstance().terminate();
|
||||||
}).catch(() => {});
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
if (appRef.storageChangeHandler) {
|
if (appRef.storageChangeHandler) {
|
||||||
browser.storage.onChanged.removeListener(appRef.storageChangeHandler);
|
browser.storage.onChanged.removeListener(appRef.storageChangeHandler);
|
||||||
appRef.storageChangeHandler = null;
|
appRef.storageChangeHandler = null;
|
||||||
}
|
}
|
||||||
|
appRef.ownedTrigger = undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,8 +47,13 @@ async function loadAnalyticsPageInner(): Promise<void> {
|
|||||||
main.appendChild(viewShell);
|
main.appendChild(viewShell);
|
||||||
const container = 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;
|
const titlediv = document.getElementById("title")?.firstChild;
|
||||||
if (titlediv) (titlediv as HTMLElement).innerText = "Analytics";
|
if (titlediv instanceof HTMLElement) titlediv.innerText = "Analytics";
|
||||||
|
}
|
||||||
|
|
||||||
renderAnalyticsPage(container);
|
renderAnalyticsPage(container);
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-2
@@ -77,6 +77,14 @@ export async function finishLoad() {
|
|||||||
betterSeqtaFinishLoadDone = true;
|
betterSeqtaFinishLoadDone = true;
|
||||||
|
|
||||||
try {
|
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");
|
document.querySelector(".legacy-root")?.classList.remove("hidden");
|
||||||
|
|
||||||
const loadingbk = document.getElementById("loading");
|
const loadingbk = document.getElementById("loading");
|
||||||
@@ -191,6 +199,9 @@ async function LoadPageElements(): Promise<void> {
|
|||||||
void import("@/seqta/ui/sidebar/mountCustomSidebar").then((mod) => {
|
void import("@/seqta/ui/sidebar/mountCustomSidebar").then((mod) => {
|
||||||
void mod.mountCustomSidebar();
|
void mod.mountCustomSidebar();
|
||||||
});
|
});
|
||||||
|
void import("@/seqta/ui/titlebar/mountCustomTitleBar").then((mod) => {
|
||||||
|
void mod.mountCustomTitleBar();
|
||||||
|
});
|
||||||
const sublink: string | undefined = getEngageRoutePage();
|
const sublink: string | undefined = getEngageRoutePage();
|
||||||
|
|
||||||
if (isSeqtaEngageExperience() && !engageHashListenerAttached) {
|
if (isSeqtaEngageExperience() && !engageHashListenerAttached) {
|
||||||
@@ -336,8 +347,15 @@ async function handleDefault(): Promise<void> {
|
|||||||
async function handleMessages(node: Element): Promise<void> {
|
async function handleMessages(node: Element): Promise<void> {
|
||||||
if (!(node instanceof HTMLElement)) return;
|
if (!(node instanceof HTMLElement)) return;
|
||||||
|
|
||||||
const element = document.getElementById("title")!.firstChild as HTMLElement;
|
const titleText = "Direct Messages";
|
||||||
element.innerText = "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";
|
document.title = "Direct Messages ― SEQTA Learn";
|
||||||
SortMessagePageItems(node);
|
SortMessagePageItems(node);
|
||||||
|
|
||||||
@@ -684,11 +702,16 @@ export function init() {
|
|||||||
// Engage keeps its native React menu — never apply the pending hide class there.
|
// Engage keeps its native React menu — never apply the pending hide class there.
|
||||||
if (!isSeqtaEngageExperience()) {
|
if (!isSeqtaEngageExperience()) {
|
||||||
document.documentElement.classList.add("bsplus-custom-sidebar-pending");
|
document.documentElement.classList.add("bsplus-custom-sidebar-pending");
|
||||||
|
document.documentElement.classList.add("bsplus-custom-title-pending");
|
||||||
void import("@/seqta/ui/sidebar/mountCustomSidebar").then((mod) => {
|
void import("@/seqta/ui/sidebar/mountCustomSidebar").then((mod) => {
|
||||||
mod.prepareCustomSidebarEarly();
|
mod.prepareCustomSidebarEarly();
|
||||||
});
|
});
|
||||||
|
void import("@/seqta/ui/titlebar/mountCustomTitleBar").then((mod) => {
|
||||||
|
mod.prepareCustomTitleBarEarly();
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.classList.remove("bsplus-custom-sidebar-pending");
|
document.documentElement.classList.remove("bsplus-custom-sidebar-pending");
|
||||||
|
document.documentElement.classList.remove("bsplus-custom-title-pending");
|
||||||
}
|
}
|
||||||
|
|
||||||
void observeMenuItemPosition();
|
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,
|
OpenThemeOfTheMonthPopup,
|
||||||
shouldShowThemeOfTheMonth,
|
shouldShowThemeOfTheMonth,
|
||||||
} from "./OpenThemeOfTheMonthPopup";
|
} from "./OpenThemeOfTheMonthPopup";
|
||||||
|
import { maybeQueueFeedbackReplyPopup } from "./OpenFeedbackReplyPopup";
|
||||||
import { syncApiBaseToBackground } from "../DevApiBase";
|
import { syncApiBaseToBackground } from "../DevApiBase";
|
||||||
|
|
||||||
type QueueStep = (goNext: () => void) => void;
|
type QueueStep = (goNext: () => void) => void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs startup modals in order: What's New (if the extension just updated),
|
* 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() {
|
export async function runStartupPopupQueue() {
|
||||||
// Make sure the background script knows about any dev-mode API override
|
// 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() {
|
function runNext() {
|
||||||
const step = steps.shift();
|
const step = steps.shift();
|
||||||
if (step) step(runNext);
|
if (step) step(runNext);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const WHATS_NEW_CHANGELOG: WhatsNewRelease[] = [
|
|||||||
"items": [
|
"items": [
|
||||||
"Added an option in the Timetable to sync to Google Calendar and Outlook Calendar",
|
"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 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.",
|
"Improved the sidebar to be more stable and performant.",
|
||||||
"Fixed dropdown contrast and readability in settings and across SEQTA pages.",
|
"Fixed dropdown contrast and readability in settings and across SEQTA pages.",
|
||||||
"Fixed Analytics sidebar item not hiding when toggled off in Edit Sidebar.",
|
"Fixed Analytics sidebar item not hiding when toggled off in Edit Sidebar.",
|
||||||
|
|||||||
@@ -48,9 +48,12 @@ export async function SendNewsPage() {
|
|||||||
|
|
||||||
main.append(html.firstChild!);
|
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;
|
const titleBar = document.getElementById("title")?.firstChild;
|
||||||
if (titleBar) {
|
if (titleBar instanceof HTMLElement) titleBar.innerText = "News";
|
||||||
(titleBar as HTMLElement).innerText = "News";
|
|
||||||
}
|
}
|
||||||
AppendLoadingSymbol("newsloading", "#news-container");
|
AppendLoadingSymbol("newsloading", "#news-container");
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ describe("migrateLegacyToPluginSettings", () => {
|
|||||||
describe("isKeyIncludedInCloudUploadPayload", () => {
|
describe("isKeyIncludedInCloudUploadPayload", () => {
|
||||||
it("excludes auth and device cache prefixes", () => {
|
it("excludes auth and device cache prefixes", () => {
|
||||||
expect(isKeyIncludedInCloudUploadPayload("bsplus_token")).toBe(false);
|
expect(isKeyIncludedInCloudUploadPayload("bsplus_token")).toBe(false);
|
||||||
|
expect(isKeyIncludedInCloudUploadPayload("bsplus_install_id")).toBe(false);
|
||||||
expect(isKeyIncludedInCloudUploadPayload("plugin.global-search.storage.index")).toBe(
|
expect(isKeyIncludedInCloudUploadPayload("plugin.global-search.storage.index")).toBe(
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ describe("normalizeStorageForSync", () => {
|
|||||||
const normalized = normalizeStorageForSync({
|
const normalized = normalizeStorageForSync({
|
||||||
DarkMode: true,
|
DarkMode: true,
|
||||||
bsplus_token: "secret",
|
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_cloud_settings_known_remote_updated_at: "2026-01-01T00:00:00.000Z",
|
||||||
"bsplus.analytics.v2.school.1": { cached: true },
|
"bsplus.analytics.v2.school.1": { cached: true },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ export const KEYS_OMITTED_FROM_CLOUD_UPLOAD = [
|
|||||||
"cloudAccessToken",
|
"cloudAccessToken",
|
||||||
"cloudUsername",
|
"cloudUsername",
|
||||||
"bsplus_google_calendar",
|
"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;
|
] 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",
|
"bsplus_user",
|
||||||
"cloudAccessToken",
|
"cloudAccessToken",
|
||||||
"cloudUsername",
|
"cloudUsername",
|
||||||
|
"bsplus_install_id",
|
||||||
|
"bsplus_pending_feedback_ids",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function isExcludedSettingsKey(key: string): boolean {
|
function isExcludedSettingsKey(key: string): boolean {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const local = {
|
|||||||
return Object.fromEntries(storage);
|
return Object.fromEntries(storage);
|
||||||
}
|
}
|
||||||
if (typeof keys === "string") {
|
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> = {};
|
const out: Record<string, unknown> = {};
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
|
|||||||
Reference in New Issue
Block a user