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