mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 06:46:09 +00:00
Add in-app feedback with attachments and renderer diagnostics
This commit is contained in:
@@ -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);
|
||||
@@ -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="What’s 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,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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user