Files
buzz/desktop/src/shared/ui/markdown/FileCard.tsx
Riley Crane 8e906bef47 fix(desktop): manage viewer panel focus and give the view picker its own row
Review P2 (#6731): in the narrow single-panel layout, opening a
document unmounted the focused attachment card and closing unmounted
the focused panel, dropping keyboard/screen-reader users onto <body>
(then the remounted channel's composer autofocus). The panel now moves
focus to its close control on mount and returns it to the opening card
on unmount, matched by data-doc-url since the original element was
remounted meanwhile; the restore yields to any control that already
claimed focus, overriding only <body> and the composer autofocus. A
narrow-layout e2e covers open-focus and Escape-restore.

Also moves the Preview/Code picker out of the title row onto its own
pinned row below the header (review feedback: the shared row squeezed
the filename out), inside the chrome-padded body since the header band
overlays anything placed after it in the header slot.

Signed-off-by: Riley Crane <rileycrane@squareup.com>
Co-authored-by: Fizz <d4ec90109b43092b0ec94cf136850915a3732a29ab3089a1dec43d2ed43dd6eb@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 13:30:51 -07:00

114 lines
4.2 KiB
TypeScript

import * as React from "react";
import { Download, FileText, PanelRight } from "lucide-react";
import { toast } from "sonner";
import { invokeTauri } from "@/shared/api/tauri";
import { useSmoothCorners } from "@/shared/ui/smoothCorners";
import { isRelayDownloadable } from "./mediaEntry";
import {
isMarkdownDocFilename,
MAX_MARKDOWN_DOC_BYTES,
} from "./markdownDocFile";
import { useMarkdownDocViewer } from "./markdownDocViewerContext";
import { useMarkdownRuntime } from "./runtimeContext";
/** Human-readable byte size: "820 B", "12.4 KB", "3.1 MB". */
function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return "";
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB", "TB"];
let size = bytes / 1024;
let i = 0;
while (size >= 1024 && i < units.length - 1) {
size /= 1024;
i += 1;
}
return `${size < 10 ? size.toFixed(1) : Math.round(size)} ${units[i]}`;
}
/**
* File card for a generic (non-image, non-video) attachment: icon, filename,
* size, and a download action.
*
* Downloads go through the native `download_file` Tauri command (HTTP inside
* the app's tunnel + a save dialog), not a plain `<a download>` link. A bare
* link navigates the webview to the blob URL, which escapes to the OS browser
* and gets bounced to a corporate CDN interstitial ("browser not supported").
* The native command mirrors the image-download path.
*
* Markdown attachments (`.md`/`.markdown`/`.mdx` by imeta filename) open the
* in-app markdown viewer panel instead when a hosting surface provides one —
* relay-hosted only, because the viewer fetches through the authenticated
* `fetch_markdown_doc_bytes` command (native 2 MiB cap), which accepts
* relay `/media/` origins alone.
*/
export function FileCard({
href,
filename,
size,
}: {
href: string;
filename: string;
size?: number;
}) {
const cardRef = React.useRef<HTMLButtonElement | null>(null);
const sizeLabel = size != null ? formatFileSize(size) : "";
useSmoothCorners(cardRef);
const openMarkdownDoc = useMarkdownDocViewer();
const { relayOrigin } = useMarkdownRuntime();
const opensInViewer =
openMarkdownDoc !== null &&
isMarkdownDocFilename(filename) &&
// Advertised-size pre-gate: skip fetching a doc the viewer would refuse
// anyway. The imeta size is untrusted, so the decoder re-checks post-fetch.
(size == null || size <= MAX_MARKDOWN_DOC_BYTES) &&
isRelayDownloadable(href, relayOrigin ?? undefined);
return (
<button
ref={cardRef}
type="button"
onClick={() => {
if (opensInViewer) {
openMarkdownDoc({ url: href, filename });
return;
}
invokeTauri("download_file", { url: href, filename }).catch(
(err: unknown) => {
const msg = err instanceof Error ? err.message : "Download failed";
toast.error(msg);
},
);
}}
aria-label={opensInViewer ? `Open ${filename}` : `Download ${filename}`}
// Focus-return anchor: when the viewer panel closes, focus comes back
// to the card that opened this URL (the element itself may have been
// unmounted and remounted in the narrow layout meanwhile).
data-doc-url={opensInViewer ? href : undefined}
data-testid="file-card"
className="my-1 inline-flex max-w-sm items-center gap-3 rounded-2xl border border-border/70 bg-muted/40 px-3 py-2 text-left no-underline transition-colors hover:bg-muted/70"
style={{ borderRadius: "1rem" }}
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-background text-muted-foreground">
<FileText className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">
{filename}
</span>
{sizeLabel ? (
<span className="block text-xs text-muted-foreground">
{sizeLabel}
</span>
) : null}
</span>
{opensInViewer ? (
<PanelRight className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<Download className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
</button>
);
}