Files
buzz/desktop/src/shared/ui/markdown/FileCard.tsx
Riley Crane 332c54ea89 feat(desktop): open markdown attachments in an in-app viewer panel
Shared .md/.markdown/.mdx attachments now open in a right auxiliary
panel with a Preview/Code toggle instead of triggering a download,
mirroring berd's markdown-artifact viewer.

Nostr-specific adaptations vs berd: the relay stores markdown as
application/octet-stream ({sha256}.bin — no magic bytes), so
classification is by imeta filename only; relay media URLs require
Blossom auth (401 in plain browsers), so the panel fetches through the
authenticated fetch_media_bytes Tauri command; content-addressed URLs
allow staleTime: Infinity caching. Panel state is URL-search-param
backed (doc/docName) like the other channel panes. Strict UTF-8 decode
with a 2 MiB cap keeps binary/oversized files on the download path,
and surfaces without a panel host (forum, projects) keep download
behavior via a nullable context.

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:29:27 -07:00

103 lines
3.6 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 } 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_media_bytes` command, 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) &&
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}`}
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>
);
}