Add in-app feedback with attachments and renderer diagnostics

This commit is contained in:
webadderall
2026-09-23 16:54:04 +10:00
parent 8c6f679cc7
commit ff4be391a9
12 changed files with 479 additions and 103 deletions
+9
View File
@@ -0,0 +1,9 @@
# Desktop feedback
Apply `migrations/202609230001_feedback.sql` in the Supabase SQL editor for the project configured by `VITE_SUPABASE_URL` before releasing this feature. The desktop client uses its existing signed-in session; never put a service-role key in the app.
Feedback is stored in `public.feedback_reports`. View reports through the Supabase dashboard and retrieve attachments from the private `feedback-attachments` bucket using each report's attachment paths. There is no email notification or public issue creation. Owners can read their own reports; other users cannot. Staff use server-side service-role access.
On submission, the modal captures up to 100 recent renderer warnings/errors (2,000 characters each), browser/platform details, and a timestamp. Common secrets, URLs, emails, and local home paths are redacted. Arbitrary log objects are not serialized. Diagnostics are automatically included with each submission. Native-process logs are not collected. Attachments are chosen explicitly, up to five files and 10 MB total in the client, with a 10 MB per-object storage limit.
A local demo account can preview the form but cannot submit without a real Supabase session. Failed submissions retain the draft in memory; closing and reopening the same modal keeps it, but restarting the app does not. Failed uploads are cleaned up on a best-effort basis; periodically remove unattached objects left by interrupted sessions.
@@ -0,0 +1,26 @@
-- Apply to the Supabase project used by VITE_SUPABASE_URL before shipping feedback.
create table public.feedback_reports (
id uuid primary key,
user_id uuid not null references auth.users(id) on delete cascade default auth.uid(),
created_at timestamptz not null default now(),
title text not null check (length(trim(title)) between 1 and 160),
subject text not null check (subject in ('bug', 'idea', 'question', 'other')),
message text not null check (length(trim(message)) between 1 and 10000),
logs text check (octet_length(logs) <= 300000),
attachments jsonb not null default '[]' check (jsonb_typeof(attachments) = 'array' and jsonb_array_length(attachments) <= 5)
);
alter table public.feedback_reports enable row level security;
grant select, insert on public.feedback_reports to authenticated;
create policy "Submit own feedback" on public.feedback_reports for insert to authenticated with check (user_id = auth.uid());
create policy "Read own feedback" on public.feedback_reports for select to authenticated using (user_id = auth.uid());
-- Reports are read by staff using service-role access, never publicly.
insert into storage.buckets (id, name, public, file_size_limit) values ('feedback-attachments', 'feedback-attachments', false, 10485760);
create policy "Upload own feedback files" on storage.objects for insert to authenticated
with check (bucket_id = 'feedback-attachments' and (storage.foldername(name))[1] = auth.uid()::text);
create policy "Remove unfinished feedback uploads" on storage.objects for delete to authenticated
using (bucket_id = 'feedback-attachments' and (storage.foldername(name))[1] = auth.uid()::text and not exists (
select 1 from public.feedback_reports where id::text = (storage.foldername(name))[2]
));
-- Allow owners to locate their uploads for cleanup; the bucket remains private.
create policy "Read own feedback uploads" on storage.objects for select to authenticated
using (bucket_id = 'feedback-attachments' and (storage.foldername(name))[1] = auth.uid()::text);
+212
View File
@@ -0,0 +1,212 @@
import { useRef, useState } from "react";
import { Button, Description, Form, Input, Label, TextArea, TextField } from "@heroui/react";
import { useRecordlyAuth } from "@/components/auth/useRecordlyAuth";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { ChatDots, File, X } from "@/components/ui/icons";
import { feedbackDiagnostics } from "@/lib/feedback/diagnostics";
import { submitFeedback, validateAttachments } from "@/lib/feedback/submitFeedback";
export function FeedbackDialog({
className,
showLabel = true,
onSignIn,
}: {
className?: string;
showLabel?: boolean;
onSignIn?: () => void;
}) {
const auth = useRecordlyAuth();
const [open, setOpen] = useState(false);
const [subject, setSubject] = useState("");
const [message, setMessage] = useState("");
const [files, setFiles] = useState<File[]>([]);
const [error, setError] = useState("");
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false);
const sendingRef = useRef(false);
const picker = useRef<HTMLInputElement>(null);
function addFiles(incoming: File[]) {
const next = [...files, ...incoming];
const issue = validateAttachments(next);
if (issue) {
setError(issue);
return;
}
setFiles(next);
setError("");
}
return (
<Dialog
open={open}
onOpenChange={(value) => {
if (sendingRef.current) return;
setOpen(value);
if (value) setSent(false);
}}
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className={className} aria-label="Feedback">
<ChatDots className="size-4" />
{showLabel && "Feedback"}
</Button>
</DialogTrigger>
<DialogContent className="max-w-sm max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{sent ? "Thanks for your feedback" : "Send feedback"}</DialogTitle>
</DialogHeader>
{sent ? (
<Button onPress={() => setOpen(false)}>Done</Button>
) : !auth.user ? (
<div className="flex flex-col gap-4">
<Description>Sign in to send feedback.</Description>
{onSignIn ? (
<Button
onPress={() => {
setOpen(false);
onSignIn();
}}
>
Sign in
</Button>
) : (
<Description>Open Home to sign in.</Description>
)}
</div>
) : (
<Form
className="flex flex-col gap-4"
onSubmit={async (event) => {
event.preventDefault();
if (sendingRef.current) return;
if (!subject.trim() || !message.trim()) {
setError("Add a subject and description.");
return;
}
sendingRef.current = true;
setSending(true);
setError("");
try {
await submitFeedback({
title: subject,
subject: "other",
message,
files,
logs: feedbackDiagnostics(),
});
setSent(true);
setSubject("");
setMessage("");
setFiles([]);
} catch {
setError("Could not send feedback. Please try again.");
} finally {
sendingRef.current = false;
setSending(false);
}
}}
>
<TextField
name="subject"
isRequired
isDisabled={sending}
value={subject}
onChange={setSubject}
className="w-full"
>
<Label>Subject</Label>
<Input maxLength={160} placeholder="Whats on your mind?" />
</TextField>
<TextField
name="description"
isRequired
isDisabled={sending}
value={message}
onChange={setMessage}
className="w-full"
>
<Label>Description</Label>
<TextArea
maxLength={10000}
rows={5}
className="resize-y"
placeholder="Tell us more…"
onPaste={(event) => {
const pasted = Array.from(event.clipboardData.files);
if (pasted.length) {
event.preventDefault();
addFiles(pasted);
}
}}
/>
</TextField>
<input
ref={picker}
type="file"
multiple
hidden
disabled={sending}
aria-label="Attach files"
onChange={(event) => {
addFiles(Array.from(event.target.files ?? []));
event.target.value = "";
}}
/>
<Button
type="button"
variant="secondary"
size="sm"
className="self-start"
isDisabled={sending}
onPress={() => picker.current?.click()}
>
<File className="size-4" />
Attach files
</Button>
{files.length > 0 && (
<div className="flex flex-col gap-1">
{files.map((file, index) => (
<div
key={`${file.name}-${index}`}
className="flex items-center gap-2 text-sm"
>
<span className="min-w-0 flex-1 truncate">{file.name}</span>
<Button
type="button"
isIconOnly
size="sm"
variant="ghost"
isDisabled={sending}
aria-label={`Remove ${file.name}`}
onPress={() =>
setFiles(files.filter((_, i) => i !== index))
}
>
<X className="size-3" />
</Button>
</div>
))}
</div>
)}
<Description className="text-xs">
Diagnostics will be sent along with your feedback.
</Description>
{error && (
<Description role="alert" className="text-danger">
{error}
</Description>
)}
<Button type="submit" isDisabled={sending} className="w-full">
{sending ? "Sending…" : "Send feedback"}
</Button>
</Form>
)}
</DialogContent>
</Dialog>
);
}
+1 -103
View File
@@ -1,12 +1,9 @@
import {
ArrowRight,
ArrowSquareOut as ExternalLink,
Question as HelpCircle,
Keyboard,
ChatDots as MessageSquareMore,
Scissors,
GearSix as Settings2,
XLogo as Twitter,
} from "@/components/ui/icons";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
@@ -26,8 +23,6 @@ import { toast } from "@/components/ui/toast";
export const RECORDLY_ISSUES_URL = "https://github.com/webadderallorg/Recordly/issues";
const RECORDLY_DISCORD_URL = "https://discord.gg/sdv2FBVNgE";
const RECORDLY_X_URL = "https://x.com/webadderall";
const CONTACT_EMAIL = "youngchen3442@gmail.com";
export const APP_HEADER_ACTION_BUTTON_CLASS =
"h-7 px-2 text-xs text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all gap-1.5";
export const APP_HEADER_ICON_BUTTON_CLASS =
@@ -80,104 +75,7 @@ export function DiscordLinkButton() {
);
}
export function FeedbackDialog() {
const t = useScopedT("editor");
return (
<Dialog>
<DialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className={APP_HEADER_ICON_BUTTON_CLASS}
title={t("feedback.trigger", "Feedback")}
aria-label={t("feedback.trigger", "Feedback")}
>
<MessageSquareMore className="h-3.5 w-3.5" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="text-xl font-semibold text-foreground flex items-center gap-2">
<MessageSquareMore className="h-5 w-5 text-[#2563EB]" />{" "}
{t("feedback.title", "Feedback & contact")}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{t(
"feedback.description",
"Reach out directly or open an issue if something is broken or missing.",
)}
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-4">
<div className="rounded-xl border border-foreground/10 bg-foreground/[0.03] p-4 space-y-3">
<div className="flex items-center justify-between gap-3 rounded-lg border border-foreground/5 bg-foreground/5 px-3 py-3">
<div>
<p className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground/70">
{t("feedback.emailLabel", "Email")}
</p>
<p className="mt-1 text-sm font-medium text-foreground">
{CONTACT_EMAIL}
</p>
</div>
<Button
type="button"
variant="outline"
onClick={() =>
void openExternalLink(
`mailto:${CONTACT_EMAIL}`,
t("feedback.openFailed", "Failed to open link."),
)
}
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex items-center justify-between gap-3 rounded-lg border border-foreground/5 bg-foreground/5 px-3 py-3">
<div>
<p className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground/70">
{t("feedback.xLabel", "X")}
</p>
<p className="mt-1 text-sm font-medium text-foreground">
@webadderall
</p>
</div>
<Button
type="button"
variant="outline"
onClick={() =>
void openExternalLink(
RECORDLY_X_URL,
t("feedback.openFailed", "Failed to open link."),
)
}
>
<Twitter className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<Button
type="button"
variant="outline"
onClick={() =>
void openExternalLink(
RECORDLY_ISSUES_URL,
t("feedback.openFailed", "Failed to open link."),
)
}
className="h-10 w-full justify-between px-4"
>
<span className="flex items-center gap-2 text-sm font-medium">
<MessageSquareMore className="h-4 w-4" />
{t("feedback.reportIssue", "Report issue / send feedback")}
</span>
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground/70" />
</Button>
</div>
</DialogContent>
</Dialog>
);
}
export { FeedbackDialog } from "@/components/feedback/FeedbackDialog";
export function KeyboardShortcutsDialog({
triggerLabel,
@@ -1,3 +1,4 @@
import { FeedbackDialog } from "@/components/feedback/FeedbackDialog";
import { RecordNewButton } from "./RecordNewButton";
import { SidebarCards } from "./SidebarCards";
import { FolderRow } from "./FolderRow";
@@ -135,6 +136,7 @@ export function DashboardSidebar({
</div>
<div className="space-y-1 pt-6">
<SidebarCards />
<FeedbackDialog showLabel className={navClass(false)} onSignIn={onSignIn} />
<Button
variant="ghost"
className={navClass(section === "settings")}
@@ -1,3 +1,4 @@
import { FeedbackDialog } from "@/components/feedback/FeedbackDialog";
import { Separator } from "@heroui/react";
import {
House,
@@ -227,6 +228,7 @@ export function EditorHeader(props: Props) {
</Button>
</div>
{SHOW_PRESETS_BUTTON && <EditorPresetMenu t={t} presets={presets} />}
<FeedbackDialog />
<EditorExportMenu
t={t}
exportSettings={exportSettings}
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { redactDiagnostic } from "./diagnostics";
import { validateAttachments } from "./submitFeedback";
describe("feedback diagnostics", () => {
it("removes common secrets and personal paths from log messages", () => {
const result = redactDiagnostic(
"Bearer abc123 password=hunter2 someone@example.com /Users/young/private.txt https://example.com?token=abc",
);
for (const secret of ["abc123", "hunter2", "someone@example.com", "young", "example.com"])
expect(result).not.toContain(secret);
});
it("bounds individual log entries", () =>
expect(redactDiagnostic("x".repeat(5000))).toHaveLength(2000));
it("enforces attachment count, total size, and nonempty files", () => {
const file = (size: number) => ({ size }) as File;
expect(validateAttachments(Array.from({ length: 6 }, () => file(1)))).toBeTruthy();
expect(validateAttachments([file(6 * 1024 * 1024), file(5 * 1024 * 1024)])).toBeTruthy();
expect(validateAttachments([file(0)])).toBeTruthy();
expect(validateAttachments([file(100)])).toBeNull();
});
});
+54
View File
@@ -0,0 +1,54 @@
const entries: string[] = [];
let installed = false;
export function redactDiagnostic(value: string): string {
return value
.replace(/Bearer\s+[^\s"']+/gi, "Bearer [redacted]")
.replace(/\beyJ[\w-]+\.[\w-]+\.[\w-]+\b/g, "[token]")
.replace(
/((?:token|password|secret|authorization|api[_-]?key)["']?\s*[:=]\s*)[^\s,}]+/gi,
"$1[redacted]",
)
.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[email]")
.replace(/(?:\/Users\/|\/home\/|[A-Z]:\\Users\\)[^\s"']+/gi, "[local path]")
.replace(/https?:\/\/[^\s"']+/g, "[url]")
.slice(0, 2000);
}
function record(level: string, args: unknown[]) {
// Do not serialize arbitrary objects: they may contain session or project data.
const message = args
.map((value) =>
value instanceof Error
? value.message
: typeof value === "string"
? value
: `[${typeof value}]`,
)
.join(" ");
entries.push(`${new Date().toISOString()} ${level}: ${redactDiagnostic(message)}`);
if (entries.length > 100) entries.shift();
}
export function installFeedbackDiagnostics() {
if (installed) return;
installed = true;
for (const level of ["warn", "error"] as const) {
const original = console[level].bind(console);
console[level] = (...args: unknown[]) => {
record(level, args);
original(...args);
};
}
window.addEventListener("error", (event) => record("error", [event.message]));
window.addEventListener("unhandledrejection", (event) => record("rejection", [event.reason]));
}
export function feedbackDiagnostics() {
return JSON.stringify(
{
capturedAt: new Date().toISOString(),
platform: navigator.platform,
userAgent: navigator.userAgent,
logs: [...entries],
},
null,
2,
);
}
+52
View File
@@ -0,0 +1,52 @@
import { beforeEach, expect, it, vi } from "vitest";
const api = vi.hoisted(() => ({
getUser: vi.fn(),
upload: vi.fn(),
remove: vi.fn(),
insert: vi.fn(),
}));
vi.mock("@/lib/auth/recordlyAuth", () => ({
recordlyAuth: {
auth: { getUser: api.getUser },
storage: { from: () => ({ upload: api.upload, remove: api.remove }) },
from: () => ({ insert: api.insert }),
},
}));
import { submitFeedback } from "./submitFeedback";
const input = {
title: " Bug ",
subject: "bug",
message: " Steps ",
files: [new File(["notes"], "notes.txt")],
logs: "logs",
};
beforeEach(() => {
vi.resetAllMocks();
api.getUser.mockResolvedValue({ data: { user: { id: "user-id" } }, error: null });
api.upload.mockResolvedValue({ error: null });
api.insert.mockResolvedValue({ error: null });
api.remove.mockResolvedValue({ error: null });
});
it("stores a report only after its private attachments upload", async () => {
await submitFeedback(input);
expect(api.upload).toHaveBeenCalledOnce();
expect(api.insert).toHaveBeenCalledWith(
expect.objectContaining({
user_id: "user-id",
title: "Bug",
message: "Steps",
attachments: [expect.objectContaining({ name: "notes.txt" })],
}),
);
expect(api.remove).not.toHaveBeenCalled();
});
it("cleans uploaded attachments up when the report is rejected", async () => {
api.insert.mockResolvedValue({ error: new Error("unavailable") });
await expect(submitFeedback(input)).rejects.toThrow("unavailable");
expect(api.remove).toHaveBeenCalledWith([expect.stringMatching(/^user-id\//)]);
});
it("does not upload when the account session has expired", async () => {
api.getUser.mockResolvedValue({ data: { user: null }, error: null });
await expect(submitFeedback(input)).rejects.toThrow("sign in");
expect(api.upload).not.toHaveBeenCalled();
});
+56
View File
@@ -0,0 +1,56 @@
import { recordlyAuth } from "@/lib/auth/recordlyAuth";
export const MAX_FILES = 5;
export const MAX_BYTES = 10 * 1024 * 1024;
export function validateAttachments(files: File[]) {
if (files.length > MAX_FILES) return "Attach up to 5 files.";
if (files.some((file) => file.size === 0)) return "Empty files cannot be attached.";
if (files.reduce((total, file) => total + file.size, 0) > MAX_BYTES)
return "Attachments must total 10 MB or less.";
return null;
}
export async function submitFeedback(input: {
title: string;
subject: string;
message: string;
files: File[];
logs: string | null;
}) {
const client = recordlyAuth;
if (!client) throw new Error("Feedback is unavailable until account services are configured.");
const { data, error } = await client.auth.getUser();
if (error || !data.user) throw new Error("Please sign in again to send feedback.");
const validation = validateAttachments(input.files);
if (validation) throw new Error(validation);
const id = crypto.randomUUID();
const uploaded: string[] = [];
const attachments = [];
try {
for (const file of input.files) {
const path = `${data.user.id}/${id}/${crypto.randomUUID()}`;
const { error: uploadError } = await client.storage
.from("feedback-attachments")
.upload(path, file, { contentType: "application/octet-stream" });
if (uploadError) throw uploadError;
uploaded.push(path);
attachments.push({ path, name: file.name, size: file.size, type: file.type });
}
const { error: insertError } = await client.from("feedback_reports").insert({
id,
user_id: data.user.id,
title: input.title.trim(),
subject: input.subject,
message: input.message.trim(),
logs: input.logs,
attachments,
});
if (insertError) throw insertError;
} catch (error) {
if (uploaded.length)
await client.storage
.from("feedback-attachments")
.remove(uploaded)
.catch(() => undefined);
throw error;
}
}
+3
View File
@@ -1,3 +1,4 @@
import { installFeedbackDiagnostics } from "./lib/feedback/diagnostics";
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
@@ -5,6 +6,8 @@ import { I18nProvider } from "./contexts/I18nContext.tsx";
import { ThemeProvider } from "./contexts/ThemeContext.tsx";
import "./index.css";
installFeedbackDiagnostics();
document.documentElement.dataset.platform = /mac/i.test(navigator.platform) ? "macos" : "other";
ReactDOM.createRoot(document.getElementById("root")!).render(
+40
View File
@@ -0,0 +1,40 @@
import { expect, test } from "@playwright/test";
import { installDesktopBridge } from "./bridge";
test("feedback preserves a draft on failure, supports attachments, and includes diagnostics automatically", async ({
page,
}) => {
await installDesktopBridge(page);
await page.addInitScript(() => sessionStorage.setItem("recordly.demo-session", "1"));
await page.goto("/?windowType=editor");
await page.getByRole("button", { name: "Feedback", exact: true }).click();
const modal = page.getByRole("dialog", { name: "Send feedback", exact: true });
await modal.getByLabel("Subject", { exact: true }).fill("Export stops early");
await modal
.getByLabel("Description", { exact: true })
.fill("Export stopped before the last frame.");
await modal.getByLabel("Attach files", { exact: true }).setInputFiles({
name: "notes.txt",
mimeType: "text/plain",
buffer: Buffer.from("Reproduction steps"),
});
await expect(modal.getByText("notes.txt", { exact: true })).toBeVisible();
await modal.getByRole("button", { name: "Remove notes.txt" }).click();
await expect(modal.getByText("notes.txt", { exact: true })).toHaveCount(0);
await expect(
modal.getByText("Diagnostics will be sent along with your feedback."),
).toBeVisible();
await expect(modal.getByRole("checkbox")).toHaveCount(0);
await expect(modal.getByLabel("Title", { exact: true })).toHaveCount(0);
await modal.getByRole("button", { name: "Send feedback", exact: true }).click();
await expect(modal.getByRole("alert")).toContainText("Could not send feedback");
await expect(modal.getByLabel("Subject", { exact: true })).toHaveValue("Export stops early");
await page.screenshot({ path: "test-results/feedback.png" });
await modal.getByRole("button", { name: "Close", exact: true }).click();
await page.getByRole("button", { name: "Home", exact: true }).click();
await page
.getByRole("dialog", { name: "Projects dashboard", exact: true })
.getByRole("button", { name: "Feedback", exact: true })
.click();
await expect(page.getByRole("dialog", { name: "Send feedback", exact: true })).toBeVisible();
});