feat(desktop): Send feedback modal + profile presence chip (#1756)

Signed-off-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
klopez4212
2026-07-15 07:57:18 -07:00
committed by GitHub
co-authored by npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 Wes Pinky
parent 406cf7911e
commit f5c33d3335
15 changed files with 845 additions and 94 deletions
+54 -1
View File
@@ -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<BlobDescriptor, String> {
// 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<Option<BlobDescriptor>, 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.
+1
View File
@@ -815,6 +815,7 @@ pub fn run() {
show_native_notification,
upload_media,
pick_and_upload_media,
pick_and_upload_image,
upload_media_bytes,
download_image,
download_file,
+7
View File
@@ -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<BrowseDialogType>(null);
const [isCreateChannelOpen, setIsCreateChannelOpen] = React.useState(false);
const [isSendFeedbackOpen, setIsSendFeedbackOpen] = React.useState(false);
const [isHuddleDrawerOpen, setIsHuddleDrawerOpen] = React.useState(false);
const mainInsetRef = React.useRef<HTMLElement>(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);
}}
/>
<SendFeedbackController
onOpenChange={setIsSendFeedbackOpen}
open={isSendFeedbackOpen}
/>
</SidebarProvider>
</div>
@@ -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";
}
}
@@ -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<number | null>(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({
<div aria-label="Profile menu" role="menu">
{/* ── Identity block ─────────────────────────────────── */}
<div className="flex items-center gap-2 px-3 pt-2 pb-2">
<div className="relative shrink-0">
<MaskedAvatarBadgeFrame
badge={
<span
aria-label={getPresenceLabel(currentStatus)}
className="flex h-3.5 w-3.5 items-center justify-center rounded-full"
data-testid="profile-popover-current-status"
role="img"
>
<PresenceDot className="h-2 w-2" status={currentStatus} />
</span>
}
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}
>
<ProfileAvatar
avatarDataUrl={avatarDataUrl}
avatarUrl={avatarUrl}
className="h-8 w-8 text-xs"
className="h-full w-full text-xs"
iconClassName="h-4 w-4"
label={displayName}
/>
</div>
</MaskedAvatarBadgeFrame>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold leading-tight text-popover-foreground">
{displayName}
</p>
<div
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground"
data-testid="profile-popover-current-status"
{/* ── Presence chip (opens status chooser) ─────────── */}
<Popover
onOpenChange={setPresenceMenuOpen}
open={presenceMenuOpen}
>
<PresenceDot status={currentStatus} />
<span>{getPresenceLabel(currentStatus)}</span>
</div>
<PopoverTrigger asChild>
<button
aria-expanded={presenceMenuOpen}
aria-haspopup="menu"
className={cn(
"mt-0.5 inline-flex max-w-full items-center rounded-md px-2 py-0.5 text-xs font-medium outline-hidden transition-opacity hover:opacity-80 focus-visible:opacity-80 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring",
getPresenceChipClassName(currentStatus),
)}
data-testid="profile-popover-presence-trigger"
disabled={isStatusPending}
onClick={() => setPresenceMenuOpen((prev) => !prev)}
role="menuitem"
type="button"
>
<span className="truncate">
{getPresenceLabel(currentStatus)}
</span>
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-52 p-1"
side="bottom"
sideOffset={4}
>
<div aria-label="Presence status" role="menu">
{ALL_STATUSES.map((status) => (
<button
className={MENU_ITEM_CLASS}
data-testid={`profile-popover-status-${status}`}
disabled={isStatusPending}
key={status}
onClick={() => handlePresenceSelect(status)}
role="menuitem"
type="button"
>
<PresenceDot
className="h-2.5 w-2.5"
status={status}
/>
<span>{getPresenceLabel(status)}</span>
</button>
))}
</div>
</PopoverContent>
</Popover>
</div>
</div>
@@ -186,58 +230,6 @@ export function ProfilePopover({
</button>
</div>
{/* ── Presence ────────────────────────────────────────── */}
<Popover onOpenChange={setPresenceMenuOpen} open={presenceMenuOpen}>
<PopoverTrigger asChild>
<button
aria-expanded={presenceMenuOpen}
aria-haspopup="menu"
className={MENU_ITEM_CLASS}
data-testid="profile-popover-presence-trigger"
disabled={isStatusPending}
onClick={() => {
clearPresenceHoverTimer();
setPresenceMenuOpen((prev) => !prev);
}}
onMouseEnter={() => schedulePresenceMenu(true)}
onMouseLeave={() => schedulePresenceMenu(false)}
role="menuitem"
type="button"
>
<PresenceDot className="h-2.5 w-2.5" status={currentStatus} />
<span className="flex-1">
{getPresenceLabel(currentStatus)}
</span>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-60 p-1"
onMouseEnter={() => schedulePresenceMenu(true)}
onMouseLeave={() => schedulePresenceMenu(false)}
side="right"
sideOffset={4}
>
<div aria-label="Presence status" role="menu">
{ALL_STATUSES.map((status) => (
<button
className={MENU_ITEM_CLASS}
data-testid={`profile-popover-status-${status}`}
disabled={isStatusPending}
key={status}
onClick={() => handlePresenceSelect(status)}
role="menuitem"
type="button"
>
<PresenceDot className="h-2.5 w-2.5" status={status} />
<span>{getPresenceLabel(status)}</span>
</button>
))}
</div>
</PopoverContent>
</Popover>
<hr className="my-1 h-px border-0 bg-border" />
{/* ── Settings ───────────────────────────────────────── */}
@@ -259,6 +251,23 @@ export function ProfilePopover({
</kbd>
</button>
{onSendFeedback ? (
<button
className={MENU_ITEM_CLASS}
data-testid="profile-popover-send-feedback"
onClick={() => {
closePopover();
window.requestAnimationFrame(() => {
onSendFeedback();
});
}}
role="menuitem"
type="button"
>
<span className="flex-1">Send feedback</span>
</button>
) : null}
{communitySwitcherSlot ? (
<>
<hr className="my-1 h-px border-0 bg-border" />
@@ -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,
);
});
@@ -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<string> {
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<SendFeedbackInput, "category" | "message">,
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<ImetaMedia | null>(
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,
};
}
@@ -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 (
<SendFeedbackDialog
attachedImageUrl={sendFeedback.attachedImage?.url ?? null}
isAttaching={sendFeedback.isAttaching}
isPending={sendFeedback.isPending}
onAttachImage={sendFeedback.attachImage}
onOpenChange={(nextOpen) => {
onOpenChange(nextOpen);
if (!nextOpen) sendFeedback.reset();
}}
onRemoveImage={sendFeedback.removeImage}
onSubmit={sendFeedback.submit}
open={open}
/>
);
}
@@ -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<FeedbackCategoryId, string> =
Object.fromEntries(
FEEDBACK_CATEGORIES.map((entry) => [entry.id, entry.label]),
) as Record<FeedbackCategoryId, string>;
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<void>;
onOpenChange: (open: boolean) => void;
onRemoveImage: () => void;
onSubmit: (input: SendFeedbackInput) => Promise<void>;
open: boolean;
}) {
const { burstEmoji } = useEmojiBurst();
const resolvedAttachedImageUrl = attachedImageUrl
? rewriteRelayUrl(attachedImageUrl)
: null;
const [category, setCategory] = React.useState<FeedbackCategoryId | null>(
null,
);
const [message, setMessage] = React.useState("");
const [includeLogs, setIncludeLogs] = React.useState(false);
const [previewOpen, setPreviewOpen] = React.useState(false);
const [errorMessage, setErrorMessage] = React.useState<string | null>(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 (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent
aria-describedby={undefined}
className="max-w-xl gap-0 overflow-hidden border-0 px-6 pb-0 pt-6"
data-testid="send-feedback-dialog"
showCloseButton={false}
>
<DialogHeader className="space-y-0 pb-5">
<div className="flex items-center justify-between gap-4">
<DialogTitle>Send feedback</DialogTitle>
<DialogClose className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 ease-out hover:bg-accent hover:text-accent-foreground focus:outline-hidden focus:ring-1 focus:ring-ring">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
<p
className="pt-2 text-sm text-muted-foreground"
data-testid="feedback-privacy-disclosure"
>
Feedback is sent privately to this Buzz deployment and is not posted
to a channel. Attachments are uploaded before you send.
</p>
</DialogHeader>
<form
className="flex flex-col"
onSubmit={(event) => {
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.
*/}
<div className="flex flex-wrap items-center gap-2 pb-4">
{FEEDBACK_CATEGORIES.map((entry) => {
const Icon = entry.icon;
const selected = category === entry.id;
return (
<button
aria-label={entry.label}
aria-pressed={selected}
className={cn(
"group/feedback-pill inline-flex items-center gap-2 rounded-full border py-1 pl-1 pr-3 text-xs transition-colors duration-150 ease-out focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60",
selected
? "border-primary/60 bg-primary/10 text-foreground"
: "border-border/80 bg-background/80 text-foreground hover:bg-muted/50",
)}
data-testid={`feedback-category-${entry.id}`}
disabled={isPending}
key={entry.id}
onClick={(event) => selectCategory(entry, event)}
type="button"
>
<span className="relative flex h-8 w-8 shrink-0 items-center justify-center">
<span
className={cn(
"flex h-8 w-8 items-center justify-center rounded-full transition-colors duration-150 ease-out",
selected
? "bg-primary/20 text-primary group-hover/feedback-pill:opacity-0 group-focus-visible/feedback-pill:opacity-0"
: "bg-muted text-muted-foreground",
)}
>
<Icon className="h-4 w-4" />
</span>
{selected ? (
<span className="absolute inset-0 flex items-center justify-center rounded-full bg-primary text-primary-foreground opacity-0 transition-opacity duration-150 ease-out group-hover/feedback-pill:opacity-100 group-focus-visible/feedback-pill:opacity-100">
<X aria-hidden="true" className="h-4 w-4" />
</span>
) : null}
</span>
<span className="font-medium">{entry.label}</span>
</button>
);
})}
</div>
{/* Feedback box + optional image attachment, laid out horizontally. */}
<div className="flex items-stretch gap-3">
<Textarea
className="min-h-32 flex-1 resize-none"
data-testid="feedback-message"
disabled={isPending}
onChange={(event) => {
setMessage(event.target.value);
setErrorMessage(null);
}}
placeholder="Tell us what went wrong, or share general feedback."
value={message}
/>
{resolvedAttachedImageUrl ? (
<div className="group/attachment relative flex w-32 shrink-0 flex-col overflow-hidden rounded-lg border border-border/70 bg-muted/40">
<button
aria-label="View attached image"
className="flex flex-1 flex-col text-left focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
data-testid="feedback-attachment-thumb"
onClick={() => setPreviewOpen(true)}
type="button"
>
<img
alt="Attached"
className="h-20 w-full object-cover"
src={resolvedAttachedImageUrl}
/>
<span className="flex items-center gap-1 px-2 py-1.5 text-2xs font-medium text-muted-foreground">
<ImageIcon
aria-hidden="true"
className="h-3 w-3 shrink-0"
/>
<span className="truncate">Attached image</span>
</span>
</button>
<button
aria-label="Remove attachment"
className="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-background/90 text-muted-foreground opacity-0 shadow transition-opacity duration-150 ease-out hover:text-foreground focus-visible:opacity-100 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring group-hover/attachment:opacity-100"
data-testid="feedback-attachment-remove"
disabled={isPending}
onClick={onRemoveImage}
type="button"
>
<X className="h-3 w-3" />
</button>
</div>
) : (
<button
aria-label="Attach image"
className="flex w-32 shrink-0 flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border/70 bg-muted/20 p-3 text-center text-2xs font-medium text-muted-foreground transition-colors duration-150 ease-out hover:border-muted-foreground/50 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60"
data-testid="feedback-attach-image"
disabled={isPending || isAttaching}
onClick={() => void attachImage()}
type="button"
>
<ImageIcon aria-hidden="true" className="h-5 w-5" />
{isAttaching ? "Attaching…" : "Attach image"}
</button>
)}
</div>
{/* Optional environment diagnostics attachment. */}
<div className="mt-4 space-y-1.5">
<label
className="flex w-fit cursor-pointer items-center gap-2 text-sm text-muted-foreground"
htmlFor="feedback-include-logs"
>
<Checkbox
checked={includeLogs}
data-testid="feedback-include-logs"
disabled={isPending}
id="feedback-include-logs"
onCheckedChange={(checked) => setIncludeLogs(checked === true)}
/>
Attach diagnostics
</label>
<p className="pl-6 text-xs text-muted-foreground">
Includes capture time, app version, platform, user agent, and
language. No application log lines are collected.
</p>
</div>
{errorMessage ? (
<p
className="mt-4 text-sm text-destructive"
data-testid="feedback-error"
>
{errorMessage}
</p>
) : null}
<div className="flex items-center gap-3 py-4">
<div className="ml-auto flex items-center gap-2">
<Button
disabled={isPending}
onClick={() => onOpenChange(false)}
type="button"
variant="ghost"
>
Cancel
</Button>
<Button
data-testid="feedback-submit"
disabled={
isPending || isAttaching || message.trim().length === 0
}
type="submit"
>
{isPending ? "Sending…" : "Send feedback"}
</Button>
</div>
</div>
</form>
</DialogContent>
{/* Full-size attachment preview. */}
{resolvedAttachedImageUrl ? (
<Dialog onOpenChange={setPreviewOpen} open={previewOpen}>
<DialogContent
aria-describedby={undefined}
className="max-w-4xl border-0 p-2"
data-testid="feedback-attachment-preview"
>
<DialogTitle className="sr-only">Attached image</DialogTitle>
<img
alt="Attached"
className="max-h-[80vh] w-full rounded-lg bg-black/40 object-contain"
src={resolvedAttachedImageUrl}
/>
</DialogContent>
</Dialog>
) : null}
</Dialog>
);
}
@@ -118,6 +118,7 @@ type AppSidebarProps = {
templateId?: string;
}) => Promise<void>;
onOpenAddCommunity: () => void;
onSendFeedback?: () => void;
onHideDm: (channelId: string) => void;
onMarkChannelUnread: (channelId: string) => void;
onMarkChannelRead: (
@@ -189,6 +190,7 @@ export function AppSidebar({
onCreateChannel,
onCreateForum,
onOpenAddCommunity,
onSendFeedback,
onHideDm,
onMarkChannelUnread,
onMarkChannelRead,
@@ -835,6 +837,7 @@ export function AppSidebar({
isPresencePending={isPresencePending}
onOpenAddCommunity={onOpenAddCommunity}
onOpenSettings={onSelectSettings}
onSendFeedback={onSendFeedback}
onRemoveCommunity={onRemoveCommunity}
onSetPresenceStatus={onSetPresenceStatus}
onSetUserStatus={onSetUserStatus}
@@ -21,6 +21,7 @@ type SidebarProfileCardProps = {
onOpenAddCommunity: () => void;
onOpenSettings: (section?: "profile" | "appearance") => void;
onRemoveCommunity: (id: string) => void;
onSendFeedback?: () => void;
onSetPresenceStatus?: (status: PresenceStatus) => void;
onSetUserStatus: (text: string, emoji: string) => void;
onClearUserStatus: () => void;
@@ -41,6 +42,7 @@ export function SidebarProfileCard({
isPresencePending,
onOpenAddCommunity,
onOpenSettings,
onSendFeedback,
onRemoveCommunity,
onSetPresenceStatus,
onSetUserStatus,
@@ -145,6 +147,7 @@ export function SidebarProfileCard({
isStatusPending={isPresencePending}
onClearUserStatus={onClearUserStatus}
onOpenSettings={onOpenSettings}
onSendFeedback={onSendFeedback}
onSetStatus={onSetPresenceStatus ?? (() => {})}
onSetUserStatus={onSetUserStatus}
triggerContainerRef={profileCardRef}
+11 -1
View File
@@ -1,4 +1,14 @@
import { invokeTauri } from "./tauri";
import { type BlobDescriptor, invokeTauri } from "./tauri";
/**
* Open a native single-file picker constrained to images and upload the
* chosen file. Non-image files are rejected in Rust (via MIME sniffing)
* before the bytes leave the client, so discarded/non-image selections never
* reach the relay. Resolves to `null` when the user cancels the dialog.
*/
export async function pickAndUploadImage(): Promise<BlobDescriptor | null> {
return invokeTauri<BlobDescriptor | null>("pick_and_upload_image", {});
}
/**
* Fetch relay media bytes over IPC (Rust reqwest, WARP-tunneled).
+1
View File
@@ -9,6 +9,7 @@ export const KIND_NIP29_DELETE_EVENT = 9005;
// the mod queue only; commands (9040–9044) are relay-validated and never stored.
// Tag shapes are pinned by buzz-sdk builders + relay moderation_commands.rs.
export const KIND_REPORT = 1984;
export const KIND_PRODUCT_FEEDBACK = 42000;
export const KIND_MODERATION_BAN = 9040;
export const KIND_MODERATION_UNBAN = 9041;
export const KIND_MODERATION_TIMEOUT = 9042;
+2
View File
@@ -9340,6 +9340,8 @@ export function maybeInstallE2eTauriMocks() {
return MOCK_MEDIA_PROXY_PORT;
case "pick_and_upload_media":
return await resolveMockUploadDescriptors(activeConfig);
case "pick_and_upload_image":
return (await resolveMockUploadDescriptors(activeConfig))[0] ?? null;
case "upload_media_bytes":
return (await resolveMockUploadDescriptors(activeConfig))[0];
case "fetch_media_bytes": {
+86 -3
View File
@@ -623,26 +623,109 @@ test("snaps custom avatar colors to the dot grid", async ({ page }) => {
await expect(page.getByTestId("profile-avatar-done")).toBeVisible();
});
test("opens Send feedback from the profile menu", async ({ page }) => {
await page.goto("/");
await openProfileMenu(page);
await page.getByTestId("profile-popover-send-feedback").click();
await expect(page.getByTestId("send-feedback-dialog")).toBeVisible();
await expect(page.getByTestId("feedback-privacy-disclosure")).toContainText(
"not posted to a channel",
);
});
test("keeps Send disabled when a stale attachment attempt finishes", async ({
page,
}) => {
await installMockBridge(page, {
uploadDelayMs: 1_200,
uploadDescriptors: [
{
url: `https://mock.relay/media/${"b".repeat(64)}.png`,
sha256: "b".repeat(64),
size: 42,
type: "image/png",
uploaded: 42,
},
],
});
await page.goto("/");
await openProfileMenu(page);
await page.getByTestId("profile-popover-send-feedback").click();
await page.getByTestId("feedback-message").fill("Attachment race");
await page.getByTestId("feedback-attach-image").click();
await expect(page.getByTestId("feedback-attach-image")).toContainText(
"Attaching…",
);
await page.waitForTimeout(450);
await page.getByRole("button", { name: "Cancel" }).click();
await openProfileMenu(page);
await page.getByTestId("profile-popover-send-feedback").click();
await page.getByTestId("feedback-message").fill("Second attachment");
await page.getByTestId("feedback-attach-image").click();
const submit = page.getByTestId("feedback-submit");
await expect(submit).toBeDisabled();
await page.waitForTimeout(900);
await expect(page.getByTestId("feedback-attach-image")).toContainText(
"Attaching…",
);
await expect(submit).toBeDisabled();
await expect(page.getByTestId("feedback-attachment-thumb")).toBeVisible();
await expect(submit).toBeEnabled();
});
test("proxies feedback attachment previews", async ({ page }) => {
const sha256 = "c".repeat(64);
const proxyUrl = `http://127.0.0.1:54321/media/${sha256}.png`;
await installMockBridge(page, {
uploadDescriptors: [
{
url: `http://localhost:3000/media/${sha256}.png`,
sha256,
size: 42,
type: "image/png",
uploaded: 42,
},
],
});
await page.goto("/");
await openProfileMenu(page);
await page.getByTestId("profile-popover-send-feedback").click();
await page.getByTestId("feedback-attach-image").click();
const thumbnail = page.getByTestId("feedback-attachment-thumb");
await expect(thumbnail.locator("img")).toHaveAttribute("src", proxyUrl);
await thumbnail.click();
const preview = page.getByTestId("feedback-attachment-preview");
await expect(preview).toBeVisible();
await expect(preview.locator("img")).toHaveAttribute("src", proxyUrl);
});
test("updates presence from the profile menu", async ({ page }) => {
await page.goto("/");
await openProfileMenu(page);
await expect(
page.getByTestId("profile-popover-current-status"),
page.getByTestId("profile-popover-presence-trigger"),
).toContainText("Online");
await page.getByTestId("profile-popover-presence-trigger").click();
await page.getByTestId("profile-popover-status-away").click();
await openProfileMenu(page);
await expect(
page.getByTestId("profile-popover-current-status"),
page.getByTestId("profile-popover-presence-trigger"),
).toContainText("Away");
await page.getByTestId("profile-popover-presence-trigger").click();
await page.getByTestId("profile-popover-status-offline").click();
await openProfileMenu(page);
await expect(
page.getByTestId("profile-popover-current-status"),
page.getByTestId("profile-popover-presence-trigger"),
).toContainText("Offline");
});