feat: extension feedback built in

This commit is contained in:
2026-07-26 15:58:03 +09:30
parent fe7127a83c
commit 318000009f
25 changed files with 1473 additions and 167 deletions
+42
View File
@@ -0,0 +1,42 @@
<script lang="ts">
import { titleBarState } from "./titleBarState.svelte";
</script>
<div id="bsplus-title-root">
<span data-testid="page-title">{titleBarState.pageTitle}</span>
{#if titleBarState.showSearch}
<div class="search-trigger-wrapper">
<div class="search-trigger-anchor">
<div class="search-trigger">
<span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</span>
<p>Quick search...</p>
<span
class="search-trigger-hotkey"
style="margin-left: auto; display: flex; align-items: center; color: rgb(119, 119, 119); font-size: 12px;"
>{titleBarState.searchHotkeyLabel}</span>
</div>
<div class="search-progress-bar-wrapper">
<div class="search-progress-track">
<div class="search-progress-bar" style="width: 0%;"></div>
</div>
</div>
</div>
<div class="search-progress-text" aria-live="polite"></div>
</div>
{/if}
</div>
+8
View File
@@ -0,0 +1,8 @@
export {
prepareCustomTitleBarEarly,
mountCustomTitleBar,
unmountCustomTitleBar,
setCustomTitleBarText,
waitForCustomTitleBarReady,
} from "./mountCustomTitleBar";
export { titleBarState } from "./titleBarState.svelte";
@@ -0,0 +1,161 @@
import { mount, unmount } from "svelte";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import { waitForElm } from "@/seqta/utils/waitForElm";
import TitleBar from "./TitleBar.svelte";
import { titleBarState } from "./titleBarState.svelte";
const ROOT_ID = "bsplus-title-root";
const TITLE_CLASS = "bsplus-custom-title";
const PENDING_CLASS = "bsplus-custom-title-pending";
let app: ReturnType<typeof mount> | null = null;
let titleEl: HTMLElement | null = null;
let hostObserver: MutationObserver | null = null;
let earlyPrepareStarted = false;
let remountTimer: ReturnType<typeof setTimeout> | null = null;
function nativePageTitleEl(host: HTMLElement) {
return [...host.children].find(
(el) =>
el instanceof HTMLElement &&
el.id !== ROOT_ID &&
el.matches('span[data-testid="page-title"]'),
) as HTMLElement | undefined;
}
function syncPageTitle() {
if (!titleEl) return;
titleBarState.pageTitle = (nativePageTitleEl(titleEl)?.textContent ?? "").trim();
}
function needsSearchChip() {
const all = settingsState.getAll() as unknown as Record<string, unknown>;
const plugin = all["plugin.global-search.settings"] as
| { enabled?: boolean }
| undefined;
return plugin?.enabled === true || titleBarState.showSearch;
}
function isReady() {
const root = document.getElementById(ROOT_ID);
if (!root || !titleEl?.classList.contains(TITLE_CLASS)) return false;
if (!needsSearchChip()) return true;
return Boolean(root.querySelector(".search-trigger-wrapper"));
}
/** finishLoad waits here so the overlay stays until the title bar is ready. */
export async function waitForCustomTitleBarReady(timeoutMs = 10000) {
if (isSeqtaEngageExperience() || !settingsState.onoff) {
document.documentElement.classList.remove(PENDING_CLASS);
return;
}
await mountCustomTitleBar();
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (isReady()) break;
await new Promise((r) => setTimeout(r, 50));
}
document.documentElement.classList.remove(PENDING_CLASS);
}
function observeHost(host: HTMLElement) {
hostObserver?.disconnect();
syncPageTitle();
hostObserver = new MutationObserver(() => {
if (!document.getElementById(ROOT_ID)) {
if (remountTimer) clearTimeout(remountTimer);
remountTimer = setTimeout(() => {
remountTimer = null;
if (!settingsState.onoff || isSeqtaEngageExperience()) return;
app = null;
void mountCustomTitleBar();
}, 50);
return;
}
syncPageTitle();
});
hostObserver.observe(host, {
childList: true,
subtree: true,
characterData: true,
});
}
export function prepareCustomTitleBarEarly() {
if (isSeqtaEngageExperience() || !settingsState.onoff || earlyPrepareStarted) {
return;
}
earlyPrepareStarted = true;
document.documentElement.classList.add(PENDING_CLASS);
void mountCustomTitleBar();
}
export async function mountCustomTitleBar(): Promise<boolean> {
if (isSeqtaEngageExperience() || !settingsState.onoff) return false;
if (app && titleEl && document.getElementById(ROOT_ID)) {
observeHost(titleEl);
return true;
}
document.documentElement.classList.add(PENDING_CLASS);
let title: HTMLElement;
try {
title = (await waitForElm("#title", true, 50, 200)) as HTMLElement;
} catch {
return false;
}
titleEl = title;
title.classList.add(TITLE_CLASS);
syncPageTitle();
if (!document.getElementById(ROOT_ID)) {
if (app) {
try {
unmount(app);
} catch {
/* ignore */
}
app = null;
}
app = mount(TitleBar, { target: title });
}
observeHost(title);
return true;
}
export function unmountCustomTitleBar() {
hostObserver?.disconnect();
hostObserver = null;
if (remountTimer) clearTimeout(remountTimer);
remountTimer = null;
if (app) {
try {
unmount(app);
} catch {
/* ignore */
}
app = null;
}
document.getElementById(ROOT_ID)?.remove();
titleEl?.classList.remove(TITLE_CLASS);
titleEl = null;
titleBarState.pageTitle = "";
titleBarState.showSearch = false;
earlyPrepareStarted = false;
document.documentElement.classList.remove(PENDING_CLASS);
}
export function setCustomTitleBarText(text: string) {
titleBarState.pageTitle = text;
const native = titleEl && nativePageTitleEl(titleEl);
if (native) native.textContent = text;
}
@@ -0,0 +1,5 @@
export const titleBarState = $state({
pageTitle: "",
showSearch: false,
searchHotkeyLabel: "Ctrl+K",
});
@@ -0,0 +1,82 @@
import stringToHTML from "../stringToHTML";
import { closePopup, openPopup } from "./PopupManager";
import {
findPendingFeedbackWithReplies,
formatStatus,
openExtensionSettingsPopup,
removePendingFeedbackIds,
requestOpenFeedbackInSettings,
type FeedbackStatusItem,
} from "@/seqta/utils/feedback/client";
function esc(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
export function OpenFeedbackReplyPopup(
items: FeedbackStatusItem[],
onDismissed?: () => void,
): void {
if (!items.length || document.getElementById("whatsnewbk")) {
onDismissed?.();
return;
}
const primary = items[0];
const extra = items.length - 1;
const title = primary.subject?.trim() || "Your feedback";
const response = (primary.response ?? "").trim();
const ids = items.map((i) => i.id);
const header = stringToHTML(`
<div class="whatsnewHeader">
<h1>Feedback reply</h1>
<p>${esc(formatStatus(primary.status))}${extra > 0 ? ` · +${extra} more` : ""}</p>
</div>
`).firstChild as HTMLElement;
const text = stringToHTML(`
<div class="whatsnewTextContainer" style="overflow-y:auto;font-size:1.2rem;line-height:1.6">
<p style="margin-bottom:.75rem"><strong>${esc(title)}</strong></p>
<div style="padding:.9rem 1rem;border-radius:.75rem;background:color-mix(in srgb,currentColor 8%,transparent);white-space:pre-wrap">${esc(response)}</div>
<div style="display:flex;gap:.75rem;justify-content:flex-end;margin-top:1.25rem;flex-wrap:wrap">
<button type="button" id="bsplus-feedback-reply-dismiss" style="padding:.55rem 1rem;border-radius:.6rem;border:none;cursor:pointer;font-size:1rem;background:color-mix(in srgb,currentColor 12%,transparent);color:inherit">Dismiss</button>
<button type="button" id="bsplus-feedback-reply-view" style="padding:.55rem 1rem;border-radius:.6rem;border:none;cursor:pointer;font-size:1rem;font-weight:600;background:currentColor;color:Canvas">View reply</button>
</div>
</div>
`).firstChild as HTMLElement;
openPopup({
header,
content: [text],
afterClose: () => {
void removePendingFeedbackIds(ids).then(() => onDismissed?.());
},
});
queueMicrotask(() => {
document.getElementById("bsplus-feedback-reply-dismiss")?.addEventListener("click", () => {
void closePopup();
});
document.getElementById("bsplus-feedback-reply-view")?.addEventListener("click", () => {
requestOpenFeedbackInSettings(primary.id);
void closePopup().then(() => openExtensionSettingsPopup());
});
});
}
export async function maybeQueueFeedbackReplyPopup(): Promise<
((goNext: () => void) => void) | null
> {
try {
const items = await findPendingFeedbackWithReplies();
if (!items.length) return null;
return (goNext) => OpenFeedbackReplyPopup(items, goNext);
} catch {
return null;
}
}
+8 -1
View File
@@ -5,13 +5,15 @@ import {
OpenThemeOfTheMonthPopup,
shouldShowThemeOfTheMonth,
} from "./OpenThemeOfTheMonthPopup";
import { maybeQueueFeedbackReplyPopup } from "./OpenFeedbackReplyPopup";
import { syncApiBaseToBackground } from "../DevApiBase";
type QueueStep = (goNext: () => void) => void;
/**
* Runs startup modals in order: What's New (if the extension just updated),
* Theme of the Month (when the user hasn't dismissed this calendar month).
* Theme of the Month (when the user hasn't dismissed this calendar month),
* then feedback reply notifications for pending submissions.
*/
export async function runStartupPopupQueue() {
// Make sure the background script knows about any dev-mode API override
@@ -33,6 +35,11 @@ export async function runStartupPopupQueue() {
});
}
const feedbackReplyStep = await maybeQueueFeedbackReplyPopup();
if (feedbackReplyStep) {
steps.push(feedbackReplyStep);
}
function runNext() {
const step = steps.shift();
if (step) step(runNext);
@@ -9,6 +9,7 @@ export const WHATS_NEW_CHANGELOG: WhatsNewRelease[] = [
"items": [
"Added an option in the Timetable to sync to Google Calendar and Outlook Calendar",
"Added a new sidebar customisation page in the settings menu to change the sidebar layout, icons, and more.",
"Added extension feedback in settings.",
"Improved the sidebar to be more stable and performant.",
"Fixed dropdown contrast and readability in settings and across SEQTA pages.",
"Fixed Analytics sidebar item not hiding when toggled off in Edit Sidebar.",
+6 -3
View File
@@ -48,9 +48,12 @@ export async function SendNewsPage() {
main.append(html.firstChild!);
const titleBar = document.getElementById("title")?.firstChild;
if (titleBar) {
(titleBar as HTMLElement).innerText = "News";
void import("@/seqta/ui/titlebar/mountCustomTitleBar").then((mod) => {
mod.setCustomTitleBarText("News");
});
if (!document.getElementById("bsplus-title-root")) {
const titleBar = document.getElementById("title")?.firstChild;
if (titleBar instanceof HTMLElement) titleBar.innerText = "News";
}
AppendLoadingSymbol("newsloading", "#news-container");
@@ -29,6 +29,7 @@ describe("migrateLegacyToPluginSettings", () => {
describe("isKeyIncludedInCloudUploadPayload", () => {
it("excludes auth and device cache prefixes", () => {
expect(isKeyIncludedInCloudUploadPayload("bsplus_token")).toBe(false);
expect(isKeyIncludedInCloudUploadPayload("bsplus_install_id")).toBe(false);
expect(isKeyIncludedInCloudUploadPayload("plugin.global-search.storage.index")).toBe(
false,
);
@@ -11,6 +11,7 @@ describe("normalizeStorageForSync", () => {
const normalized = normalizeStorageForSync({
DarkMode: true,
bsplus_token: "secret",
bsplus_install_id: "550e8400-e29b-41d4-a716-446655440000",
bsplus_cloud_settings_known_remote_updated_at: "2026-01-01T00:00:00.000Z",
"bsplus.analytics.v2.school.1": { cached: true },
});
+4
View File
@@ -38,6 +38,10 @@ export const KEYS_OMITTED_FROM_CLOUD_UPLOAD = [
"cloudAccessToken",
"cloudUsername",
"bsplus_google_calendar",
/** Anonymous feedback install id — device-local, never synced. */
"bsplus_install_id",
/** Pending feedback ids awaiting a reply notification — device-local. */
"bsplus_pending_feedback_ids",
] as const;
/**
+108
View File
@@ -0,0 +1,108 @@
import browser from "webextension-polyfill";
import {
addPendingFeedbackId,
findPendingFeedbackWithReplies,
formatStatus,
hasReply,
removePendingFeedbackIds,
validateFeedbackForm,
} from "./client";
import { BSPLUS_PENDING_FEEDBACK_IDS_KEY } from "./constants";
import { getOrCreateInstallId } from "./installId";
describe("validateFeedbackForm", () => {
const base = {
category: "bug" as const,
subject: "Hi",
message: "Long enough message here",
includeContact: false,
contactName: "",
contactEmail: "",
includeInstance: false,
};
it("requires message length", () => {
expect(validateFeedbackForm({ ...base, message: "short" })).toMatch(/at least/);
});
it("requires email when contact included", () => {
expect(
validateFeedbackForm({
...base,
includeContact: true,
contactName: "Alex",
contactEmail: "bad",
}),
).toMatch(/email/i);
});
});
describe("formatStatus / hasReply", () => {
it("formats known statuses and detects replies", () => {
expect(formatStatus("in_progress")).toBe("In progress");
expect(
hasReply({
id: "fb_1",
status: "resolved",
category: "bug",
subject: null,
created_at: "",
updated_at: "",
has_response: true,
response: "Thanks",
responded_at: "",
}),
).toBe(true);
});
});
describe("pending feedback + reply check", () => {
it("tracks pending ids and finds replies from the status list", async () => {
await addPendingFeedbackId("fb_a");
await addPendingFeedbackId("fb_b");
await removePendingFeedbackIds(["fb_unused"]);
const installId = await getOrCreateInstallId();
global.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
install_id: installId,
count: 2,
items: [
{
id: "fb_a",
status: "resolved",
category: "bug",
subject: "A",
created_at: "",
updated_at: "",
has_response: true,
response: "Fixed",
responded_at: "",
},
{
id: "fb_b",
status: "received",
category: "bug",
subject: "B",
created_at: "",
updated_at: "",
has_response: false,
response: null,
responded_at: null,
},
],
}),
headers: new Headers(),
}) as unknown as typeof fetch;
const items = await findPendingFeedbackWithReplies();
expect(items.map((i) => i.id)).toEqual(["fb_a"]);
expect(browser.storage.local.set).toHaveBeenCalledWith(
expect.objectContaining({
[BSPLUS_PENDING_FEEDBACK_IDS_KEY]: expect.any(Array),
}),
);
});
});
+296
View File
@@ -0,0 +1,296 @@
import browser from "webextension-polyfill";
import { getApiBase } from "@/seqta/utils/DevApiBase";
import { SettingsClicked } from "@/seqta/utils/Closers/closeExtensionPopup";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import {
BSPLUS_PENDING_FEEDBACK_IDS_KEY,
FEEDBACK_API_PATH,
FEEDBACK_MESSAGE_MAX,
FEEDBACK_MESSAGE_MIN,
FEEDBACK_SCHEMA_VERSION,
OPEN_FEEDBACK_SESSION_KEY,
type FeedbackBrowser,
type FeedbackCategory,
type FeedbackChannel,
type FeedbackProduct,
} from "./constants";
import { getOrCreateInstallId } from "./installId";
export class FeedbackApiError extends Error {
constructor(
message: string,
readonly status: number,
readonly code?: string,
) {
super(message);
this.name = "FeedbackApiError";
}
}
export interface FeedbackStatusItem {
id: string;
status: string;
category: string;
subject: string | null;
created_at: string;
updated_at: string;
has_response: boolean;
response: string | null;
responded_at: string | null;
}
export type FeedbackFormInput = {
category: FeedbackCategory;
subject: string;
message: string;
includeContact: boolean;
contactName: string;
contactEmail: string;
includeInstance: boolean;
};
export function validateFeedbackForm(input: FeedbackFormInput): string | null {
const message = input.message.trim();
if (message.length < FEEDBACK_MESSAGE_MIN) {
return `Please enter at least ${FEEDBACK_MESSAGE_MIN} characters.`;
}
if (message.length > FEEDBACK_MESSAGE_MAX) {
return `Message must be at most ${FEEDBACK_MESSAGE_MAX} characters.`;
}
if (input.subject.trim().length > 120) return "Subject must be at most 120 characters.";
if (input.includeContact) {
if (!input.contactName.trim()) return "Please enter your name.";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.contactEmail.trim())) {
return "Please enter a valid email.";
}
}
if (input.includeInstance) {
const host = getInstanceHostname();
if (!host) return "Instance hostname unavailable — open SEQTA first, or turn this off.";
}
return null;
}
export function getInstanceHostname(): string | null {
try {
const host = location.hostname?.toLowerCase();
if (!host || host === "localhost") return null;
return host.slice(0, 253);
} catch {
return null;
}
}
function mapBrowser(ua: string): FeedbackBrowser {
const n = ua.toLowerCase();
if (n.includes("edg")) return "edge";
if (n.includes("firefox")) return "firefox";
if (n.includes("safari") && !n.includes("chrome")) return "safari";
if (n.includes("chrome") || n.includes("chromium")) return "chrome";
return "other";
}
function detectOs(): string {
const ua = navigator.userAgent;
if (/Windows/i.test(ua)) return "Windows";
if (/Mac OS X|Macintosh/i.test(ua)) return "macOS";
if (/Android/i.test(ua)) return "Android";
if (/iPhone|iPad|iPod/i.test(ua)) return "iOS";
if (/CrOS/i.test(ua)) return "ChromeOS";
if (/Linux/i.test(ua)) return "Linux";
return "Unknown";
}
function channel(): FeedbackChannel {
if (typeof __UPDATE_CHANNEL__ !== "undefined" && __UPDATE_CHANNEL__ === "nightly") {
return "nightly";
}
if (typeof __UPDATE_CHANNEL__ !== "undefined" && __UPDATE_CHANNEL__ === "stable") {
return "stable";
}
return "unknown";
}
async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
try {
return await fetch(`${getApiBase()}${path}`, {
...init,
headers: { Accept: "application/json", ...init?.headers },
});
} catch {
throw new FeedbackApiError("Could not reach the feedback server. Check your connection.", 0);
}
}
async function throwApiError(res: Response): Promise<never> {
let body: { error?: string; code?: string } = {};
try {
body = await res.json();
} catch {
/* ignore */
}
if (res.status === 429) {
throw new FeedbackApiError(
"You've sent feedback too many times. Please try again later.",
429,
body.code ?? "RATE_LIMITED",
);
}
throw new FeedbackApiError(body.error || `Request failed (${res.status}).`, res.status, body.code);
}
export async function submitFeedback(form: FeedbackFormInput): Promise<{ id: string }> {
const err = validateFeedbackForm(form);
if (err) throw new FeedbackApiError(err, 422);
const installId = await getOrCreateInstallId();
const host = getInstanceHostname();
const product: FeedbackProduct = isSeqtaEngageExperience() ? "engage" : "learn";
const version = browser.runtime.getManifest().version.slice(0, 32);
const browserName = mapBrowser(navigator.userAgent);
const browserVersion = navigator.userAgent.match(
/(?:Edg|OPR|Firefox|Chrome|Version)\/([\d.]+)/,
)?.[1];
const payload = {
schemaVersion: FEEDBACK_SCHEMA_VERSION,
installId,
category: form.category,
subject: form.subject.trim() || undefined,
message: form.message.trim(),
extension: {
version,
browser: browserName,
browserVersion,
os: detectOs(),
channel: channel(),
},
contact: form.includeContact
? {
include: true as const,
name: form.contactName.trim().slice(0, 80),
email: form.contactEmail.trim().slice(0, 254),
}
: { include: false as const },
instance:
form.includeInstance && host
? { include: true as const, hostname: host, product }
: { include: false as const },
context: {
page: "settings",
locale: navigator.language,
darkMode: !!settingsState.DarkMode,
},
clientSubmittedAt: new Date().toISOString(),
};
const res = await apiFetch(FEEDBACK_API_PATH, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (res.status !== 201 && res.status !== 200) await throwApiError(res);
const data = (await res.json()) as { id?: string };
if (!data?.id) throw new FeedbackApiError("Unexpected response from the feedback server.", res.status);
return { id: data.id };
}
export async function fetchFeedbackStatusList(limit = 10): Promise<FeedbackStatusItem[]> {
const installId = await getOrCreateInstallId();
const url = `${FEEDBACK_API_PATH}/status?installId=${encodeURIComponent(installId)}&limit=${Math.min(20, Math.max(1, limit))}`;
const res = await apiFetch(url);
if (!res.ok) await throwApiError(res);
const data = (await res.json()) as { items?: FeedbackStatusItem[] };
return Array.isArray(data.items) ? data.items : [];
}
export async function fetchFeedbackStatusItem(id: string): Promise<FeedbackStatusItem> {
const installId = await getOrCreateInstallId();
const url = `${FEEDBACK_API_PATH}/status?installId=${encodeURIComponent(installId)}&id=${encodeURIComponent(id)}`;
const res = await apiFetch(url);
if (!res.ok) await throwApiError(res);
return (await res.json()) as FeedbackStatusItem;
}
export function formatStatus(status: string): string {
const labels: Record<string, string> = {
received: "Received",
triaged: "Triaged",
in_progress: "In progress",
resolved: "Resolved",
wontfix: "Won't fix",
spam: "Closed",
};
return labels[status] ?? status.replace(/_/g, " ");
}
export function categoryLabel(category: FeedbackCategory | string): string {
const labels: Record<string, string> = {
bug: "Bug report",
feature: "Feature request",
question: "Question",
other: "Other",
};
return labels[category] ?? category;
}
export function hasReply(item: FeedbackStatusItem): boolean {
return !!item.has_response && !!item.response?.trim();
}
async function getPendingIds(): Promise<string[]> {
const stored = await browser.storage.local.get(BSPLUS_PENDING_FEEDBACK_IDS_KEY);
const raw = stored[BSPLUS_PENDING_FEEDBACK_IDS_KEY];
if (!Array.isArray(raw)) return [];
return [...new Set(raw.filter((id): id is string => typeof id === "string" && id.startsWith("fb_")))];
}
export async function addPendingFeedbackId(id: string): Promise<void> {
if (!id.startsWith("fb_")) return;
const ids = await getPendingIds();
if (ids.includes(id)) return;
await browser.storage.local.set({ [BSPLUS_PENDING_FEEDBACK_IDS_KEY]: [...ids, id] });
}
export async function removePendingFeedbackIds(ids: string[]): Promise<void> {
if (!ids.length) return;
const drop = new Set(ids);
const next = (await getPendingIds()).filter((id) => !drop.has(id));
await browser.storage.local.set({ [BSPLUS_PENDING_FEEDBACK_IDS_KEY]: next });
}
export async function findPendingFeedbackWithReplies(): Promise<FeedbackStatusItem[]> {
const pending = await getPendingIds();
if (!pending.length) return [];
const set = new Set(pending);
try {
return (await fetchFeedbackStatusList(20)).filter((i) => set.has(i.id) && hasReply(i));
} catch {
return [];
}
}
export function requestOpenFeedbackInSettings(feedbackId: string): void {
try {
sessionStorage.setItem(OPEN_FEEDBACK_SESSION_KEY, feedbackId);
} catch {
/* ignore */
}
window.dispatchEvent(new CustomEvent("bsplus:open-feedback", { detail: { id: feedbackId } }));
}
export function consumeOpenFeedbackRequest(): string | null {
try {
const id = sessionStorage.getItem(OPEN_FEEDBACK_SESSION_KEY);
if (id) sessionStorage.removeItem(OPEN_FEEDBACK_SESSION_KEY);
return id;
} catch {
return null;
}
}
export function openExtensionSettingsPopup(): void {
if (SettingsClicked) return;
document.getElementById("AddedSettings")?.click();
}
+15
View File
@@ -0,0 +1,15 @@
export const FEEDBACK_SCHEMA_VERSION = 1;
export const BSPLUS_INSTALL_ID_KEY = "bsplus_install_id";
export const BSPLUS_PENDING_FEEDBACK_IDS_KEY = "bsplus_pending_feedback_ids";
export const FEEDBACK_API_PATH = "/api/bsplus/feedback";
export const OPEN_FEEDBACK_SESSION_KEY = "bsplus_open_feedback_id";
export const FEEDBACK_CATEGORIES = ["bug", "feature", "question", "other"] as const;
export type FeedbackCategory = (typeof FEEDBACK_CATEGORIES)[number];
export type FeedbackBrowser = "chrome" | "firefox" | "safari" | "edge" | "other";
export type FeedbackChannel = "stable" | "dev" | "nightly" | "unknown";
export type FeedbackProduct = "learn" | "engage" | "unknown";
export const FEEDBACK_MESSAGE_MIN = 10;
export const FEEDBACK_MESSAGE_MAX = 4000;
@@ -0,0 +1,19 @@
import browser from "webextension-polyfill";
import { BSPLUS_INSTALL_ID_KEY } from "./constants";
import { generateInstallId, getOrCreateInstallId, isValidInstallId } from "./installId";
describe("installId", () => {
it("validates and generates UUIDs", () => {
expect(isValidInstallId("550e8400-e29b-41d4-a716-446655440000")).toBe(true);
expect(isValidInstallId("nope")).toBe(false);
expect(isValidInstallId(generateInstallId())).toBe(true);
});
it("persists a new id and reuses it", async () => {
const id = await getOrCreateInstallId();
expect(isValidInstallId(id)).toBe(true);
expect(await getOrCreateInstallId()).toBe(id);
expect(browser.storage.local.set).toHaveBeenCalled();
expect(BSPLUS_INSTALL_ID_KEY).toBe("bsplus_install_id");
});
});
+26
View File
@@ -0,0 +1,26 @@
import browser from "webextension-polyfill";
import { BSPLUS_INSTALL_ID_KEY } from "./constants";
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export function isValidInstallId(value: unknown): value is string {
return typeof value === "string" && UUID_RE.test(value);
}
export function generateInstallId(): string {
if (typeof crypto?.randomUUID === "function") return crypto.randomUUID();
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
});
}
export async function getOrCreateInstallId(): Promise<string> {
const stored = await browser.storage.local.get(BSPLUS_INSTALL_ID_KEY);
const existing = stored[BSPLUS_INSTALL_ID_KEY];
if (isValidInstallId(existing)) return existing;
const installId = generateInstallId();
await browser.storage.local.set({ [BSPLUS_INSTALL_ID_KEY]: installId });
return installId;
}
@@ -49,6 +49,8 @@ const EXCLUDED_FROM_SETTINGS_SURFACE = new Set([
"bsplus_user",
"cloudAccessToken",
"cloudUsername",
"bsplus_install_id",
"bsplus_pending_feedback_ids",
]);
function isExcludedSettingsKey(key: string): boolean {