mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5
Wes
Pinky
parent
406cf7911e
commit
f5c33d3335
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user