From 6f1981a1c8493a1ca6558cf29f1f9d1d86f33af4 Mon Sep 17 00:00:00 2001 From: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co> Date: Fri, 10 Jul 2026 17:32:52 +0100 Subject: [PATCH 01/16] feat(desktop): add Send feedback modal Adds a 'Send feedback' modal reachable from the profile menu. It mirrors the New DM modal layout with single-select category pills (Bug / Praise / Needs work), a feedback textarea, an inline image attachment shown beside the text, and an 'Attach logs' checkbox. Selecting the positive category fires the heart-burst emitter. Delivery resolves (or creates) a private 'Buzz feedback' channel and posts the feedback with imeta attachments; 'Attach logs' bundles a client-side diagnostics text file (app version, platform) since there is no on-disk log sink yet. Signed-off-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co> --- desktop/src/app/AppShell.tsx | 19 ++ .../features/profile/ui/ProfilePopover.tsx | 22 +- .../settings/hooks/useSendFeedback.ts | 149 ++++++++ .../settings/ui/SendFeedbackDialog.tsx | 321 ++++++++++++++++++ .../src/features/sidebar/ui/AppSidebar.tsx | 3 + .../sidebar/ui/SidebarProfileCard.tsx | 3 + 6 files changed, 516 insertions(+), 1 deletion(-) create mode 100644 desktop/src/features/settings/hooks/useSendFeedback.ts create mode 100644 desktop/src/features/settings/ui/SendFeedbackDialog.tsx diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index c5f802a666..7eff51ee05 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -55,6 +55,8 @@ import { useArchiveSync } from "@/features/local-archive/archiveSyncManager"; import { useObserverArchiveSeed } from "@/features/local-archive/useObserverArchiveSeed"; import { useAgentMetricArchiveSeed } from "@/features/local-archive/useAgentMetricArchiveSeed"; import { useProfileQuery } from "@/features/profile/hooks"; +import { useSendFeedback } from "@/features/settings/hooks/useSendFeedback"; +import { SendFeedbackDialog } from "@/features/settings/ui/SendFeedbackDialog"; import { DEFAULT_SETTINGS_SECTION, type SettingsSection, @@ -110,6 +112,8 @@ export function AppShell() { const [browseDialogType, setBrowseDialogType] = React.useState(null); const [isCreateChannelOpen, setIsCreateChannelOpen] = React.useState(false); + const [isSendFeedbackOpen, setIsSendFeedbackOpen] = React.useState(false); + const sendFeedback = useSendFeedback(); const [isHuddleDrawerOpen, setIsHuddleDrawerOpen] = React.useState(false); const mainInsetRef = React.useRef(null); const location = useLocation(); @@ -767,6 +771,7 @@ export function AppShell() { onNewMessage={handleOpenNewDm} onCreateChannelOpenChange={setIsCreateChannelOpen} onOpenAddCommunity={() => setIsAddCommunityOpen(true)} + onSendFeedback={() => setIsSendFeedbackOpen(true)} onUpdateCommunity={communitiesHook.updateCommunity} onRemoveCommunity={communitiesHook.removeCommunity} onSwitchCommunity={handleSwitchCommunity} @@ -924,6 +929,20 @@ export function AppShell() { void goChannel(channelId); }} /> + { + setIsSendFeedbackOpen(open); + if (!open) { + sendFeedback.reset(); + } + }} + onRemoveImage={sendFeedback.removeImage} + onSubmit={sendFeedback.submit} + open={isSendFeedbackOpen} + /> diff --git a/desktop/src/features/profile/ui/ProfilePopover.tsx b/desktop/src/features/profile/ui/ProfilePopover.tsx index 51f4236d01..d9f55a4392 100644 --- a/desktop/src/features/profile/ui/ProfilePopover.tsx +++ b/desktop/src/features/profile/ui/ProfilePopover.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { ChevronRight, Smile } from "lucide-react"; +import { ChevronRight, MessageSquareText, Smile } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; @@ -24,6 +24,7 @@ interface ProfilePopoverProps { onSetUserStatus: (text: string, emoji: string) => void; onClearUserStatus: () => void; onOpenSettings: (section?: "profile" | "appearance") => void; + onSendFeedback?: () => void; children: React.ReactNode; // Optional outer container whose clicks should NOT close the popover. // Used when auxiliary triggers (avatar, status text) live alongside the @@ -54,6 +55,7 @@ export function ProfilePopover({ onSetUserStatus, onClearUserStatus, onOpenSettings, + onSendFeedback, children, triggerContainerRef, communitySwitcherSlot, @@ -259,6 +261,24 @@ export function ProfilePopover({ + {onSendFeedback ? ( + + ) : null} + {communitySwitcherSlot ? ( <>
diff --git a/desktop/src/features/settings/hooks/useSendFeedback.ts b/desktop/src/features/settings/hooks/useSendFeedback.ts new file mode 100644 index 0000000000..0f84bfc962 --- /dev/null +++ b/desktop/src/features/settings/hooks/useSendFeedback.ts @@ -0,0 +1,149 @@ +import { getVersion } from "@tauri-apps/api/app"; +import { useMutation } from "@tanstack/react-query"; +import * as React from "react"; + +import { useChannelsQuery } from "@/features/channels/hooks"; +import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; +import { buildOutgoingMessage } from "@/features/messages/lib/imetaMediaMarkdown"; +import type { SendFeedbackInput } from "@/features/settings/ui/SendFeedbackDialog"; +import { + createChannel, + pickAndUploadMedia, + sendChannelMessage, + uploadMediaBytes, +} from "@/shared/api/tauri"; +import type { Channel } from "@/shared/api/types"; + +/** Name of the private channel feedback is delivered to. */ +export const FEEDBACK_CHANNEL_NAME = "Buzz feedback"; + +const CATEGORY_LABELS: Record = { + bug: "Bug", + praise: "Praise", + "needs-work": "Needs work", +}; + +function findFeedbackChannel(channels: Channel[] | undefined): Channel | null { + if (!channels) { + return null; + } + return ( + channels.find( + (channel) => + channel.channelType !== "dm" && + channel.name.trim().toLowerCase() === + FEEDBACK_CHANNEL_NAME.toLowerCase(), + ) ?? null + ); +} + +/** + * Best-effort diagnostics text bundled when the user checks "Attach logs". + * + * The desktop app has no on-disk log file yet (it logs to stderr), so this + * captures the environment context that is available client-side. When a real + * log sink lands, extend this to include recent log lines. + */ +async function collectDiagnostics(): Promise { + let appVersion = "unknown"; + try { + appVersion = await getVersion(); + } catch { + // Non-fatal β€” fall through with "unknown". + } + const nav = typeof navigator !== "undefined" ? navigator : undefined; + return [ + "Buzz feedback diagnostics", + `captured: ${new Date().toISOString()}`, + `app version: ${appVersion}`, + `platform: ${nav?.platform ?? "unknown"}`, + `user agent: ${nav?.userAgent ?? "unknown"}`, + `language: ${nav?.language ?? "unknown"}`, + ].join("\n"); +} + +/** + * Owns feedback delivery: resolves (or creates) the private "Buzz feedback" + * channel, manages the optional image attachment, gathers a diagnostics bundle + * when logs are requested, and posts the feedback message with imeta tags. + */ +export function useSendFeedback() { + const channelsQuery = useChannelsQuery(); + const [attachedImage, setAttachedImage] = React.useState( + null, + ); + + const attachImage = React.useCallback(async () => { + const descriptors = await pickAndUploadMedia(); + const first = descriptors[0]; + if (first) { + setAttachedImage(first); + } + }, []); + + const removeImage = React.useCallback(() => { + setAttachedImage(null); + }, []); + + const reset = React.useCallback(() => { + setAttachedImage(null); + }, []); + + const submitMutation = useMutation({ + mutationFn: async (input: SendFeedbackInput) => { + // Resolve or create the private feedback channel. + let channel = findFeedbackChannel(channelsQuery.data); + if (!channel) { + channel = await createChannel({ + name: FEEDBACK_CHANNEL_NAME, + channelType: "stream", + visibility: "private", + description: "In-app feedback and bug reports.", + }); + } + + const headerParts: string[] = []; + if (input.category) { + headerParts.push( + `**${CATEGORY_LABELS[input.category] ?? input.category}**`, + ); + } + const bodyLines = [headerParts.join(" "), input.message] + .filter((part) => part.length > 0) + .join("\n\n"); + + const attachments: ImetaMedia[] = []; + if (attachedImage) { + attachments.push(attachedImage); + } + if (input.includeLogs) { + const diagnostics = await collectDiagnostics(); + const bytes = Array.from(new TextEncoder().encode(diagnostics)); + const logDescriptor = await uploadMediaBytes( + bytes, + `feedback-diagnostics-${Date.now()}.txt`, + ); + attachments.push(logDescriptor); + } + + const { content, mediaTags } = buildOutgoingMessage( + bodyLines, + attachments, + ); + + await sendChannelMessage(channel.id, content, null, mediaTags); + }, + onSuccess: () => { + reset(); + }, + }); + + return { + attachImage, + attachedImage, + isPending: submitMutation.isPending, + removeImage, + reset, + submit: submitMutation.mutateAsync, + }; +} diff --git a/desktop/src/features/settings/ui/SendFeedbackDialog.tsx b/desktop/src/features/settings/ui/SendFeedbackDialog.tsx new file mode 100644 index 0000000000..1f87c45c6d --- /dev/null +++ b/desktop/src/features/settings/ui/SendFeedbackDialog.tsx @@ -0,0 +1,321 @@ +import { Bug, ImageIcon, Paperclip, ThumbsUp, Wrench, X } from "lucide-react"; +import * as React from "react"; + +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Checkbox } from "@/shared/ui/checkbox"; +import { + Dialog, + DialogClose, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { useEmojiBurst } from "@/shared/ui/EmojiBurstProvider"; +import { Textarea } from "@/shared/ui/textarea"; + +/** A random heart emoji so repeated bursts vary a little. */ +const HEART_BURST_EMOJIS = ["❀️", "🩷", "🧑", "πŸ’›", "πŸ’š", "πŸ’™", "πŸ’œ", "πŸ’–"]; + +/** + * Feedback categories. `id` is what we persist in the outbound message; `label` + * is user-facing. `positive` categories fire the heart-burst emitter on select. + */ +export type FeedbackCategoryId = "bug" | "praise" | "needs-work"; + +type FeedbackCategory = { + id: FeedbackCategoryId; + label: string; + icon: React.ComponentType<{ className?: string }>; + positive?: boolean; +}; + +const FEEDBACK_CATEGORIES: readonly FeedbackCategory[] = [ + { id: "bug", label: "Bug", icon: Bug }, + { id: "praise", label: "Praise", icon: ThumbsUp, positive: true }, + { id: "needs-work", label: "Needs work", icon: Wrench }, +]; + +export type SendFeedbackInput = { + category: FeedbackCategoryId | null; + includeLogs: boolean; + message: string; +}; + +/** + * "Send feedback" modal. + * + * Layout mirrors {@link NewDirectMessageDialog}: a pill row (here, selectable + * feedback categories in place of profile pills), a generic feedback box with an + * optional image attachment shown horizontally beside it, and an "Attach logs" + * checkbox. Selecting a positive category fires the heart-burst emitter. + * + * Delivery (channel resolution, upload, send) is delegated to `onSubmit`, and + * image attachment to `onAttachImage`, so this shell stays presentational. + */ +export function SendFeedbackDialog({ + attachedImageUrl, + isPending, + onAttachImage, + onOpenChange, + onRemoveImage, + onSubmit, + open, +}: { + /** Preview URL of the currently-attached image, or null when none. */ + attachedImageUrl: string | null; + isPending: boolean; + /** Opens a file picker and uploads; the parent owns the resulting URL. */ + onAttachImage: () => Promise; + onOpenChange: (open: boolean) => void; + onRemoveImage: () => void; + onSubmit: (input: SendFeedbackInput) => Promise; + open: boolean; +}) { + const { burstEmoji } = useEmojiBurst(); + const [category, setCategory] = React.useState( + null, + ); + const [message, setMessage] = React.useState(""); + const [includeLogs, setIncludeLogs] = React.useState(false); + const [isAttaching, setIsAttaching] = React.useState(false); + const [previewOpen, setPreviewOpen] = React.useState(false); + const [errorMessage, setErrorMessage] = React.useState(null); + + React.useEffect(() => { + if (!open) { + setCategory(null); + setMessage(""); + setIncludeLogs(false); + setIsAttaching(false); + setPreviewOpen(false); + setErrorMessage(null); + } + }, [open]); + + function selectCategory(next: FeedbackCategory, event: React.MouseEvent) { + const alreadySelected = category === next.id; + setCategory(alreadySelected ? null : next.id); + if (!alreadySelected && next.positive) { + const emoji = + HEART_BURST_EMOJIS[ + Math.floor(Math.random() * HEART_BURST_EMOJIS.length) + ] ?? "❀️"; + burstEmoji(emoji, event.currentTarget); + } + } + + async function attachImage() { + if (isAttaching) { + return; + } + setErrorMessage(null); + setIsAttaching(true); + try { + await onAttachImage(); + } catch (error) { + setErrorMessage( + error instanceof Error ? error.message : "Failed to attach image.", + ); + } finally { + setIsAttaching(false); + } + } + + async function submitFeedback() { + if (isPending || message.trim().length === 0) { + return; + } + setErrorMessage(null); + try { + await onSubmit({ category, includeLogs, message: message.trim() }); + onOpenChange(false); + } catch (error) { + setErrorMessage( + error instanceof Error ? error.message : "Failed to send feedback.", + ); + } + } + + return ( + + + +
+ Send feedback + + + Close + +
+
+ +
{ + event.preventDefault(); + void submitFeedback(); + }} + > + {/* Category pills β€” styled like DM recipient pills, single-select. */} +
+ {FEEDBACK_CATEGORIES.map((entry) => { + const Icon = entry.icon; + const selected = category === entry.id; + return ( + + ); + })} +
+ + {/* Feedback box + optional image attachment, laid out horizontally. */} +
+