Files
webmail/components/email/attachment-chips.tsx
T
Shuki Vaknin 0e1c34cdec feat(list): show attachments on list rows and open them from there
A message with attachments showed a paperclip and nothing else, so the
only way to reach a file was to open the message, scroll to the bottom
and find it. The list rows never had the information to do better:
EMAIL_LIST_PROPERTIES asked for hasAttachment, a boolean.

Request attachments alongside it and render the files as chips under the
preview, each one opening that file directly. Chips honour the existing
mailAttachmentAction setting, so they preview or download exactly as the
viewer's attachment list does.

Inline parts are filtered out. A single Outlook message routinely carries
half a dozen cid: signature spacers of under 200 bytes; chipping those
would bury the one file the reader actually wants. Only parts that are
neither inline nor cid-referenced, and that carry a filename, are shown —
two at a time, with the remainder collapsed into a count.

Downloads resolve the blob source from the row's own message rather than
the selected one, so they stay correct in the unified inbox where each
row can belong to a different account.
2026-09-01 18:51:51 +03:00

86 lines
3.5 KiB
TypeScript

"use client";
import { FileText, FileSpreadsheet, FileImage, FileArchive, File as FileIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Attachment } from "@/lib/jmap/types";
/**
* A message can carry a dozen inline images — signature logos, tracking
* pixels, spacers a couple of hundred bytes each — none of which anyone
* wants to download. Only parts the sender actually attached belong in
* the list, so anything marked inline or referenced by a Content-ID is
* dropped.
*/
export function realAttachments(attachments?: Attachment[]): Attachment[] {
if (!attachments?.length) return [];
return attachments.filter(
(a) => a.disposition !== "inline" && !a.cid && !!a.name,
);
}
const ICON_BY_TYPE: { match: RegExp; icon: typeof FileIcon; className: string }[] = [
{ match: /^image\//, icon: FileImage, className: "text-violet-600 dark:text-violet-400" },
{ match: /pdf/, icon: FileText, className: "text-red-600 dark:text-red-400" },
{ match: /sheet|excel|csv/, icon: FileSpreadsheet, className: "text-emerald-600 dark:text-emerald-400" },
{ match: /zip|compress|tar|rar|7z/, icon: FileArchive, className: "text-amber-600 dark:text-amber-400" },
{ match: /word|document|rtf|text\//, icon: FileText, className: "text-sky-600 dark:text-sky-400" },
];
function iconFor(type: string, name: string) {
const probe = `${type || ""} ${name || ""}`.toLowerCase();
return ICON_BY_TYPE.find((e) => e.match.test(probe)) ?? { icon: FileIcon, className: "text-muted-foreground" };
}
/** Long names are unreadable truncated at the tail — the extension is the
* most identifying part, so keep it and elide the middle. */
function shortName(name: string, max = 18): string {
if (name.length <= max) return name;
const dot = name.lastIndexOf(".");
const ext = dot > 0 && name.length - dot <= 6 ? name.slice(dot) : "";
const head = name.slice(0, Math.max(1, max - ext.length - 1));
return `${head}…${ext}`;
}
interface AttachmentChipsProps {
attachments?: Attachment[];
onOpen: (attachment: Attachment) => void;
/** How many chips to show before collapsing the rest into a count. */
max?: number;
className?: string;
}
export function AttachmentChips({ attachments, onOpen, max = 2, className }: AttachmentChipsProps) {
const real = realAttachments(attachments);
if (!real.length) return null;
const shown = real.slice(0, max);
const overflow = real.length - shown.length;
return (
<div className={cn("flex items-center gap-1.5 flex-wrap", className)}>
{shown.map((a) => {
const { icon: Icon, className: iconClass } = iconFor(a.type, a.name ?? "");
return (
<button
key={a.blobId + a.partId}
type="button"
// The row itself opens the message; a chip must not do both.
onClick={(e) => { e.stopPropagation(); onOpen(a); }}
onDoubleClick={(e) => e.stopPropagation()}
title={a.name}
className="inline-flex max-w-[12rem] items-center gap-1.5 rounded-md border border-border bg-background/60 px-2 py-0.5 text-xs text-foreground/80 hover:bg-muted hover:text-foreground"
>
<Icon className={cn("h-3.5 w-3.5 flex-shrink-0", iconClass)} />
<span className="truncate">{shortName(a.name ?? "")}</span>
</button>
);
})}
{overflow > 0 && (
<span className="rounded-md border border-border px-1.5 py-0.5 text-xs text-muted-foreground tabular-nums">
+{overflow}
</span>
)}
</div>
);
}