diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 9d8a858227..9838d06855 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -300,9 +300,15 @@ pub async fn upload_media( /// Read a picked path through the TOCTOU-safe pipeline (fd pin → sniff → /// transcode-or-passthrough → MIME validation → upload). +/// +/// When `images_only` is set, the file is rejected **before upload** if it is +/// not an image (videos and non-image files error out; HEIC/HEIF still +/// transcode to JPEG, which is an image). This keeps discarded/non-image +/// files from ever leaving the client on image-only surfaces. async fn process_picked_path( path: std::path::PathBuf, state: &State<'_, AppState>, + images_only: bool, ) -> Result { // Pin the inode by opening the fd BEFORE spawn_blocking. This prevents a // local attacker from swapping the file between dialog return and read. @@ -325,6 +331,9 @@ async fn process_picked_path( let n = file.read(&mut header).map_err(|e| e.to_string())?; if is_video_file(&header[..n]) { + if images_only { + return Err("Please choose an image file.".to_string()); + } // ffmpeg needs a path, not an fd. Resolve the fd's real path // so we pass the actual inode's location, not the original // (potentially swapped) pathname. Same pattern as upload_media. @@ -357,6 +366,12 @@ async fn process_picked_path( let mime = detect_and_validate_mime(&body)?; + // Image-only surfaces (e.g. "Send feedback"): reject anything that didn't + // sniff as an image, BEFORE the upload leaves the client. + if images_only && !mime.starts_with("image/") { + return Err("Please choose an image file.".to_string()); + } + // Upload video first, then poster (best-effort). If poster upload fails, // the video descriptor is returned without an image field. let mut descriptor = do_upload(body, &mime, state, None).await?; @@ -414,13 +429,51 @@ pub async fn pick_and_upload_media( let mut descriptors = Vec::with_capacity(file_paths.len()); for file_path in file_paths { let path = file_path.as_path().ok_or("invalid path")?.to_path_buf(); - let descriptor = process_picked_path(path, &state).await?; + let descriptor = process_picked_path(path, &state, false).await?; descriptors.push(descriptor); } Ok(descriptors) } +/// Open a native single-file dialog constrained to images, read the picked +/// file, and upload it — rejecting anything that doesn't sniff as an image +/// **before** the bytes leave the client. +/// +/// This is the secure path for image-only surfaces (e.g. the "Send feedback" +/// attachment). Unlike [`pick_and_upload_media`], the dialog is filtered to +/// common image extensions and `process_picked_path` runs with +/// `images_only = true`, so a user who bypasses the extension filter still +/// can't upload a non-image (videos and other files error out during MIME +/// validation, before `do_upload`). Returns `None` when the user cancels. +#[tauri::command] +pub async fn pick_and_upload_image( + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result, String> { + use tauri_plugin_dialog::DialogExt; + + let (tx, rx) = tokio::sync::oneshot::channel(); + app.dialog() + .file() + .add_filter( + "Images", + &["png", "jpg", "jpeg", "gif", "webp", "heic", "heif", "bmp"], + ) + .pick_file(move |path| { + let _ = tx.send(path); + }); + + let file_path = match rx.await.map_err(|_| "dialog cancelled".to_string())? { + Some(path) => path, + None => return Ok(None), + }; + + let path = file_path.as_path().ok_or("invalid path")?.to_path_buf(); + let descriptor = process_picked_path(path, &state, true).await?; + Ok(Some(descriptor)) +} + /// Upload raw bytes directly (for paste and drag-drop). /// /// The renderer already has the bytes in memory from the clipboard/drag event. diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index a9d37c5dc4..7dbea85c6d 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -776,6 +776,7 @@ pub fn run() { show_native_notification, upload_media, pick_and_upload_media, + pick_and_upload_image, upload_media_bytes, download_image, download_file, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index c5f802a666..08cf94a4c6 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -55,6 +55,7 @@ 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 { SendFeedbackController } from "@/features/settings/ui/SendFeedbackController"; import { DEFAULT_SETTINGS_SECTION, type SettingsSection, @@ -110,6 +111,7 @@ export function AppShell() { const [browseDialogType, setBrowseDialogType] = React.useState(null); const [isCreateChannelOpen, setIsCreateChannelOpen] = React.useState(false); + const [isSendFeedbackOpen, setIsSendFeedbackOpen] = React.useState(false); const [isHuddleDrawerOpen, setIsHuddleDrawerOpen] = React.useState(false); const mainInsetRef = React.useRef(null); const location = useLocation(); @@ -767,6 +769,7 @@ export function AppShell() { onNewMessage={handleOpenNewDm} onCreateChannelOpenChange={setIsCreateChannelOpen} onOpenAddCommunity={() => setIsAddCommunityOpen(true)} + onSendFeedback={() => setIsSendFeedbackOpen(true)} onUpdateCommunity={communitiesHook.updateCommunity} onRemoveCommunity={communitiesHook.removeCommunity} onSwitchCommunity={handleSwitchCommunity} @@ -924,6 +927,10 @@ export function AppShell() { void goChannel(channelId); }} /> + diff --git a/desktop/src/features/presence/lib/presence.ts b/desktop/src/features/presence/lib/presence.ts index e93fb3ed9f..b37dddf18c 100644 --- a/desktop/src/features/presence/lib/presence.ts +++ b/desktop/src/features/presence/lib/presence.ts @@ -57,3 +57,15 @@ export function getPresenceDotClassName(status: PresenceStatus) { return "bg-muted-foreground/35"; } } + +// Chip styling for the presence pill (colored fill + matching text, no dot). +export function getPresenceChipClassName(status: PresenceStatus) { + switch (status) { + case "online": + return "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"; + case "away": + return "bg-amber-500/15 text-amber-600 dark:text-amber-400"; + case "offline": + return "bg-muted-foreground/15 text-muted-foreground"; + } +} diff --git a/desktop/src/features/profile/ui/ProfilePopover.tsx b/desktop/src/features/profile/ui/ProfilePopover.tsx index 51f4236d01..a09df14a89 100644 --- a/desktop/src/features/profile/ui/ProfilePopover.tsx +++ b/desktop/src/features/profile/ui/ProfilePopover.tsx @@ -1,13 +1,21 @@ import * as React from "react"; -import { ChevronRight, Smile } from "lucide-react"; +import { Smile } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { + MaskedAvatarBadgeFrame, + STATUS_DOT_MASK_CURVE, +} from "@/features/profile/ui/MaskedAvatarBadgeFrame"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; -import { getPresenceLabel } from "@/features/presence/lib/presence"; +import { + getPresenceChipClassName, + getPresenceLabel, +} from "@/features/presence/lib/presence"; import { SetStatusDialog } from "@/features/user-status/ui/SetStatusDialog"; import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; import type { PresenceStatus } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; import { isMacPlatform } from "@/shared/lib/platform"; interface ProfilePopoverProps { @@ -24,6 +32,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,40 +63,16 @@ export function ProfilePopover({ onSetUserStatus, onClearUserStatus, onOpenSettings, + onSendFeedback, children, triggerContainerRef, communitySwitcherSlot, }: ProfilePopoverProps) { const [statusDialogOpen, setStatusDialogOpen] = React.useState(false); const [presenceMenuOpen, setPresenceMenuOpen] = React.useState(false); - const presenceHoverTimer = React.useRef(null); const hasUserStatus = Boolean(userStatusText || userStatusEmoji); const settingsShortcutLabel = isMacPlatform() ? "⌘," : "Ctrl+,"; - function clearPresenceHoverTimer() { - if (presenceHoverTimer.current !== null) { - window.clearTimeout(presenceHoverTimer.current); - presenceHoverTimer.current = null; - } - } - - function schedulePresenceMenu(nextOpen: boolean) { - clearPresenceHoverTimer(); - presenceHoverTimer.current = window.setTimeout( - () => setPresenceMenuOpen(nextOpen), - nextOpen ? 80 : 160, - ); - } - - React.useEffect( - () => () => { - if (presenceHoverTimer.current !== null) { - window.clearTimeout(presenceHoverTimer.current); - } - }, - [], - ); - function handlePopoverOpenChange(nextOpen: boolean) { if (!nextOpen) { setPresenceMenuOpen(false); @@ -96,7 +81,6 @@ export function ProfilePopover({ } function closePopover() { - clearPresenceHoverTimer(); setPresenceMenuOpen(false); onOpenChange(false); } @@ -130,26 +114,86 @@ export function ProfilePopover({
{/* ── Identity block ─────────────────────────────────── */}
-
+ + + + } + badgeBox={{ bottom: -2, height: 14, right: -2, width: 14 }} + className="h-8 w-8" + curve={STATUS_DOT_MASK_CURVE} + cutout={{ cx: 28, cy: 28, r: 7.5 }} + size={32} + > -
+

{displayName}

-
- - {getPresenceLabel(currentStatus)} -
+ + + + +
+ {ALL_STATUSES.map((status) => ( + + ))} +
+
+
@@ -186,58 +230,6 @@ export function ProfilePopover({
- {/* ── Presence ────────────────────────────────────────── */} - - - - - schedulePresenceMenu(true)} - onMouseLeave={() => schedulePresenceMenu(false)} - side="right" - sideOffset={4} - > -
- {ALL_STATUSES.map((status) => ( - - ))} -
-
-
-
{/* ── Settings ───────────────────────────────────────── */} @@ -259,6 +251,23 @@ export function ProfilePopover({ + {onSendFeedback ? ( + + ) : null} + {communitySwitcherSlot ? ( <>
diff --git a/desktop/src/features/settings/hooks/useSendFeedback.helpers.test.mjs b/desktop/src/features/settings/hooks/useSendFeedback.helpers.test.mjs new file mode 100644 index 0000000000..2dee013368 --- /dev/null +++ b/desktop/src/features/settings/hooks/useSendFeedback.helpers.test.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildProductFeedbackEvent } from "./useSendFeedback.ts"; + +test("buildProductFeedbackEvent uses body and category tag", () => { + assert.deepEqual( + buildProductFeedbackEvent({ category: "bug", message: " It broke " }, []), + { content: "It broke", tags: [["category", "bug"]] }, + ); +}); + +test("buildProductFeedbackEvent omits absent category and retains imeta", () => { + const attachment = { + url: "https://example.test/screenshot.png", + sha256: "ab".repeat(32), + size: 42, + type: "image/png", + uploaded: 42, + }; + const result = buildProductFeedbackEvent( + { category: null, message: "Useful feedback" }, + [attachment], + ); + assert.match(result.content, /Useful feedback/); + assert.equal( + result.tags.some((tag) => tag[0] === "category"), + false, + ); + assert.equal( + result.tags.some((tag) => tag[0] === "imeta"), + true, + ); +}); diff --git a/desktop/src/features/settings/hooks/useSendFeedback.ts b/desktop/src/features/settings/hooks/useSendFeedback.ts new file mode 100644 index 0000000000..53444363d5 --- /dev/null +++ b/desktop/src/features/settings/hooks/useSendFeedback.ts @@ -0,0 +1,141 @@ +import { getVersion } from "@tauri-apps/api/app"; +import { useMutation } from "@tanstack/react-query"; +import * as React from "react"; + +import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; +import { buildOutgoingMessage } from "@/features/messages/lib/imetaMediaMarkdown"; +import type { SendFeedbackInput } from "@/features/settings/ui/SendFeedbackDialog"; +import { relayClient } from "@/shared/api/relayClient"; +import { signRelayEvent, uploadMediaBytes } from "@/shared/api/tauri"; +import { pickAndUploadImage } from "@/shared/api/tauriMedia"; +import { KIND_PRODUCT_FEEDBACK } from "@/shared/constants/kinds"; + +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"); +} + +export function buildProductFeedbackEvent( + input: Pick, + attachments: ImetaMedia[], +): { content: string; tags: string[][] } { + const { content, mediaTags } = buildOutgoingMessage( + input.message.trim(), + attachments, + ); + return { + content, + tags: [ + ...(input.category ? [["category", input.category]] : []), + ...(mediaTags ?? []), + ], + }; +} + +/** Owns private product-feedback submission and optional attachment uploads. */ +export function useSendFeedback() { + const [attachedImage, setAttachedImage] = React.useState( + null, + ); + const [isAttaching, setIsAttaching] = React.useState(false); + const sessionRef = React.useRef(0); + const attachmentAttemptRef = React.useRef(0); + + const attachImage = React.useCallback(async () => { + const session = sessionRef.current; + const attempt = attachmentAttemptRef.current + 1; + attachmentAttemptRef.current = attempt; + setIsAttaching(true); + try { + const descriptor = await pickAndUploadImage(); + if ( + !descriptor || + sessionRef.current !== session || + attachmentAttemptRef.current !== attempt + ) { + return; + } + setAttachedImage(descriptor); + } catch (error) { + if ( + sessionRef.current === session && + attachmentAttemptRef.current === attempt + ) { + throw error; + } + } finally { + if ( + sessionRef.current === session && + attachmentAttemptRef.current === attempt + ) { + setIsAttaching(false); + } + } + }, []); + + const removeImage = React.useCallback(() => { + setAttachedImage(null); + }, []); + + const reset = React.useCallback(() => { + sessionRef.current += 1; + attachmentAttemptRef.current += 1; + setAttachedImage(null); + setIsAttaching(false); + }, []); + + const submitMutation = useMutation({ + mutationFn: async (input: SendFeedbackInput) => { + const attachments: ImetaMedia[] = []; + if (attachedImage) { + attachments.push(attachedImage); + } + if (input.includeLogs) { + const diagnostics = await collectDiagnostics(); + const bytes = Array.from(new TextEncoder().encode(diagnostics)); + attachments.push( + await uploadMediaBytes( + bytes, + `feedback-diagnostics-${Date.now()}.txt`, + ), + ); + } + + const payload = buildProductFeedbackEvent(input, attachments); + const event = await signRelayEvent({ + kind: KIND_PRODUCT_FEEDBACK, + content: payload.content, + tags: payload.tags, + }); + await relayClient.publishEvent( + event, + "Timed out while sending feedback.", + "Failed to send feedback.", + ); + }, + onSuccess: reset, + }); + + return { + attachImage, + attachedImage, + isAttaching, + isPending: submitMutation.isPending, + removeImage, + reset, + submit: submitMutation.mutateAsync, + }; +} diff --git a/desktop/src/features/settings/ui/SendFeedbackController.tsx b/desktop/src/features/settings/ui/SendFeedbackController.tsx new file mode 100644 index 0000000000..f7c22f519e --- /dev/null +++ b/desktop/src/features/settings/ui/SendFeedbackController.tsx @@ -0,0 +1,27 @@ +import { useSendFeedback } from "@/features/settings/hooks/useSendFeedback"; +import { SendFeedbackDialog } from "@/features/settings/ui/SendFeedbackDialog"; + +export function SendFeedbackController({ + onOpenChange, + open, +}: { + onOpenChange: (open: boolean) => void; + open: boolean; +}) { + const sendFeedback = useSendFeedback(); + return ( + { + onOpenChange(nextOpen); + if (!nextOpen) sendFeedback.reset(); + }} + onRemoveImage={sendFeedback.removeImage} + onSubmit={sendFeedback.submit} + open={open} + /> + ); +} diff --git a/desktop/src/features/settings/ui/SendFeedbackDialog.tsx b/desktop/src/features/settings/ui/SendFeedbackDialog.tsx new file mode 100644 index 0000000000..a0a64da8a9 --- /dev/null +++ b/desktop/src/features/settings/ui/SendFeedbackDialog.tsx @@ -0,0 +1,365 @@ +import { Bug, ImageIcon, ThumbsUp, Wrench, X } from "lucide-react"; +import * as React from "react"; + +import { cn } from "@/shared/lib/cn"; +import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; +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 }, +]; + +/** Single source of truth for category id → user-facing label. */ +export const FEEDBACK_CATEGORY_LABELS: Record = + Object.fromEntries( + FEEDBACK_CATEGORIES.map((entry) => [entry.id, entry.label]), + ) as Record; + +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 + * diagnostics" checkbox. Selecting a positive category fires the heart-burst + * emitter. + * + * Delivery (upload and private feedback submission) is delegated to `onSubmit`, and + * image attachment to `onAttachImage`, so this shell stays presentational. + */ +export function SendFeedbackDialog({ + attachedImageUrl, + isAttaching, + isPending, + onAttachImage, + onOpenChange, + onRemoveImage, + onSubmit, + open, +}: { + /** Preview URL of the currently-attached image, or null when none. */ + attachedImageUrl: string | null; + isAttaching: boolean; + 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 resolvedAttachedImageUrl = attachedImageUrl + ? rewriteRelayUrl(attachedImageUrl) + : null; + const [category, setCategory] = React.useState( + null, + ); + const [message, setMessage] = React.useState(""); + const [includeLogs, setIncludeLogs] = React.useState(false); + const [previewOpen, setPreviewOpen] = React.useState(false); + const [errorMessage, setErrorMessage] = React.useState(null); + + React.useEffect(() => { + if (!open) { + setCategory(null); + setMessage(""); + setIncludeLogs(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); + try { + await onAttachImage(); + } catch (error) { + setErrorMessage( + error instanceof Error ? error.message : "Failed to attach image.", + ); + } + } + + async function submitFeedback() { + if (isPending || isAttaching || 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 + +
+

+ Feedback is sent privately to this Buzz deployment and is not posted + to a channel. Attachments are uploaded before you send. +

+
+ +
{ + event.preventDefault(); + void submitFeedback(); + }} + > + {/* + Category pills — mirror the New DM recipient chips: the same + rounded-full silhouette with a circular icon slot on the left. When + a pill is selected, hovering swaps its icon for an X (the same + avatar→X affordance DM chips use to remove a recipient), signalling + that clicking deselects it. + */} +
+ {FEEDBACK_CATEGORIES.map((entry) => { + const Icon = entry.icon; + const selected = category === entry.id; + return ( + + ); + })} +
+ + {/* Feedback box + optional image attachment, laid out horizontally. */} +
+