From b0eeacb85d92b7e1c3328b60b49f89fc391631da Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 30 Jun 2026 18:32:49 -0400 Subject: [PATCH 1/4] ENG-1886 Add send feedback button in Obsidian MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Send feedback" button to the plugin's info section that opens a modal form. Users can attach a screenshot, fill in email, title, description, and feedback type, then submit — which creates a Linear triage issue via the website API route. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../obsidian/src/components/FeedbackModal.tsx | 306 ++++++++++++++++++ .../src/components/GeneralSettings.tsx | 16 + apps/website/app/api/feedback/route.ts | 212 ++++++++++++ 3 files changed, 534 insertions(+) create mode 100644 apps/obsidian/src/components/FeedbackModal.tsx create mode 100644 apps/website/app/api/feedback/route.ts diff --git a/apps/obsidian/src/components/FeedbackModal.tsx b/apps/obsidian/src/components/FeedbackModal.tsx new file mode 100644 index 000000000..2a6612faf --- /dev/null +++ b/apps/obsidian/src/components/FeedbackModal.tsx @@ -0,0 +1,306 @@ +import { App, Modal, Notice, requestUrl, setIcon } from "obsidian"; +import { createRoot, Root } from "react-dom/client"; +import { StrictMode, useState, useRef, useEffect } from "react"; +import type DiscourseGraphPlugin from "~/index"; + +const FEEDBACK_ENDPOINT = `${process.env.NEXT_API_ROOT}/feedback`; + +type FeedbackType = "feedback" | "bug_report" | "feature_request"; + +const FEEDBACK_TYPE_LABELS: Record = { + bug_report: "Bug report", + feature_request: "Feature request", + feedback: "Feedback", +}; + +const readFileAsBase64 = (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const base64 = (reader.result as string).split(",")[1]; + if (!base64) { + reject(new Error("Failed to read file as base64")); + return; + } + resolve(base64); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); + +const fieldClass = + "w-full bg-modifier-form-field border border-modifier-border rounded text-normal px-3 py-2 text-sm box-border"; + +const selectClass = `${fieldClass} appearance-none h-9 leading-none`; + +const accentButtonClass = + "flex items-center justify-center gap-1.5 bg-accent text-on-accent rounded py-2.5 px-3 text-sm font-medium border-none cursor-pointer hover:opacity-90"; + +const isValidEmail = (value: string) => + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); + +type FeedbackContentProps = { + plugin: DiscourseGraphPlugin; + onClose: () => void; +}; + +const FeedbackContent = ({ plugin, onClose }: FeedbackContentProps) => { + const [email, setEmail] = useState(plugin.settings.username ?? ""); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [feedbackType, setFeedbackType] = useState("bug_report"); + const [screenshot, setScreenshot] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [emailError, setEmailError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const fileInputRef = useRef(null); + + const handleEmailChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setEmail(value); + if (emailError && (!value.trim() || isValidEmail(value.trim()))) { + setEmailError(null); + } + }; + + const handleEmailBlur = () => { + const trimmed = email.trim(); + if (trimmed && !isValidEmail(trimmed)) { + setEmailError("Please enter a valid email address."); + } else { + setEmailError(null); + } + }; + + useEffect(() => { + if (!screenshot) { + setPreviewUrl(null); + return; + } + const url = URL.createObjectURL(screenshot); + setPreviewUrl(url); + return () => URL.revokeObjectURL(url); + }, [screenshot]); + + const handleFileChange = (e: React.ChangeEvent) => { + setScreenshot(e.target.files?.[0] ?? null); + }; + + const removeScreenshot = () => { + setScreenshot(null); + if (fileInputRef.current) fileInputRef.current.value = ""; + }; + + const isSubmittable = + title.trim().length > 0 && + email.trim().length > 0 && + isValidEmail(email.trim()) && + !emailError; + + const handleSubmit = async () => { + if (!isSubmittable) return; + + setIsSubmitting(true); + try { + const screenshotBase64 = screenshot + ? await readFileAsBase64(screenshot) + : undefined; + + const payload = { + email: email.trim() || undefined, + title: title.trim(), + description: description.trim() || undefined, + type: feedbackType, + screenshot: screenshotBase64 + ? { + data: screenshotBase64, + mimeType: screenshot!.type, + name: screenshot!.name, + } + : undefined, + pluginVersion: plugin.manifest.version, + }; + + const response = await requestUrl({ + url: FEEDBACK_ENDPOINT, + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (response.status >= 200 && response.status < 300) { + new Notice("Feedback submitted — thank you!"); + onClose(); + } else { + new Notice("Failed to submit feedback. Please try again."); + } + } catch { + new Notice("Failed to submit feedback. Please check your connection."); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ {/* Screenshot button */} + + + + {/* Screenshot preview */} + {screenshot && previewUrl && ( +
+ Screenshot preview + + {screenshot.name} + + +
+ )} + + {/* Email */} +
+ + + {emailError && {emailError}} +
+ + {/* Title */} +
+ + setTitle(e.target.value)} + placeholder="Add a title" + className={fieldClass} + /> +
+ + {/* Description */} +
+ +