mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-08-28 09:11:06 +00:00
297 lines
9.4 KiB
TypeScript
297 lines
9.4 KiB
TypeScript
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();
|
|
}
|