feat: show attachment and code chips as Gmail pills in Mountain View

This commit is contained in:
Linus Rath
2026-10-25 12:48:49 +01:00
parent 39c8b14574
commit 0b1463b7d5
4 changed files with 76 additions and 25 deletions
@@ -54,6 +54,18 @@ describe("ListAttachmentChips", () => {
expect(load).not.toHaveBeenCalled();
});
it("draws Gmail's pill with a filled type badge in Mountain View", () => {
const attachments = [att({ name: "invoice.pdf" })];
const { rerender } = render(<ListAttachmentChips email={row({ attachments })} onOpen={vi.fn()} variant="mountain-view" />);
const chip = screen.getByTitle("invoice.pdf");
expect(chip.className).toContain("rounded-full");
expect(chip.querySelector(".bg-red-600 svg")).toBeTruthy();
rerender(<ListAttachmentChips email={row({ attachments })} onOpen={vi.fn()} />);
expect(screen.getByTitle("invoice.pdf").className).not.toContain("rounded-full");
expect(screen.getByTitle("invoice.pdf").querySelector(".bg-red-600")).toBeNull();
});
it("does not load for a row without a paperclip", () => {
const load = vi.fn(() => () => {});
render(<ListAttachmentChips email={row({ hasAttachment: false })} load={load} onOpen={vi.fn()} />);
+36 -15
View File
@@ -21,17 +21,18 @@ export function realAttachments(attachments?: Attachment[]): Attachment[] {
);
}
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" },
// `badge` fills the square Mountain View draws behind a white glyph, as Gmail does.
const ICON_BY_TYPE: { match: RegExp; icon: typeof FileIcon; className: string; badge: string }[] = [
{ match: /^image\//, icon: FileImage, className: "text-violet-600 dark:text-violet-400", badge: "bg-red-600" },
{ match: /pdf/, icon: FileText, className: "text-red-600 dark:text-red-400", badge: "bg-red-600" },
{ match: /sheet|excel|csv/, icon: FileSpreadsheet, className: "text-emerald-600 dark:text-emerald-400", badge: "bg-emerald-600" },
{ match: /zip|compress|tar|rar|7z/, icon: FileArchive, className: "text-amber-600 dark:text-amber-400", badge: "bg-zinc-500" },
{ match: /word|document|rtf|text\//, icon: FileText, className: "text-sky-600 dark:text-sky-400", badge: "bg-blue-600" },
];
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" };
return ICON_BY_TYPE.find((e) => e.match.test(probe)) ?? { icon: FileIcon, className: "text-muted-foreground", badge: "bg-zinc-500" };
}
/** Long names are unreadable truncated at the tail — the extension is the
@@ -49,6 +50,13 @@ function shortName(name: string, max = 18): string {
export const CHIP_BOX_CLASS = "inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-0.5 text-xs";
export const CHIP_ICON_CLASS = "h-3.5 w-3.5 flex-shrink-0";
// Mountain View's chips are Gmail's: a pill with a filled square in front.
export const MV_CHIP_BOX_CLASS = "inline-flex h-7 items-center gap-2 rounded-full border border-border ps-2 pe-3 text-sm";
export const MV_CHIP_BADGE_CLASS = "inline-flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-[3px] text-white";
export const MV_CHIP_BADGE_ICON_CLASS = "h-3 w-3";
export type ChipVariant = "default" | "mountain-view";
interface AttachmentChipsProps {
attachments?: Attachment[];
onOpen: (attachment: Attachment) => void;
@@ -56,10 +64,12 @@ interface AttachmentChipsProps {
max?: number;
/** A chip drawn ahead of the attachments in the same row (the verification code). */
leading?: ReactNode;
variant?: ChipVariant;
className?: string;
}
export function AttachmentChips({ attachments, onOpen, max = 2, leading, className }: AttachmentChipsProps) {
export function AttachmentChips({ attachments, onOpen, max = 2, leading, variant = "default", className }: AttachmentChipsProps) {
const mv = variant === "mountain-view";
const real = realAttachments(attachments);
if (!real.length && !leading) return null;
@@ -68,10 +78,10 @@ export function AttachmentChips({ attachments, onOpen, max = 2, leading, classNa
const overflow = real.length - shown.length;
return (
<div className={cn("flex items-center gap-1.5 flex-wrap", className)}>
<div className={cn("flex items-center flex-wrap", mv ? "gap-2" : "gap-1.5", className)}>
{leading}
{shown.map((a) => {
const { icon: Icon, className: iconClass } = iconFor(a.type, a.name ?? "");
const { icon: Icon, className: iconClass, badge } = iconFor(a.type, a.name ?? "");
return (
<button
key={a.blobId + a.partId}
@@ -80,15 +90,26 @@ export function AttachmentChips({ attachments, onOpen, max = 2, leading, classNa
onClick={(e) => { e.stopPropagation(); onOpen(a); }}
onDoubleClick={(e) => e.stopPropagation()}
title={stripBidiControls(a.name ?? "")}
className={cn(CHIP_BOX_CLASS, "max-w-[12rem] bg-background/60 text-foreground/80 hover:bg-muted hover:text-foreground")}
className={mv
? cn(MV_CHIP_BOX_CLASS, "max-w-[14rem] bg-transparent text-foreground/80 hover:bg-muted")
: cn(CHIP_BOX_CLASS, "max-w-[12rem] bg-background/60 text-foreground/80 hover:bg-muted hover:text-foreground")}
>
<Icon className={cn(CHIP_ICON_CLASS, iconClass)} />
{mv ? (
<span className={cn(MV_CHIP_BADGE_CLASS, badge)}>
<Icon className={MV_CHIP_BADGE_ICON_CLASS} />
</span>
) : (
<Icon className={cn(CHIP_ICON_CLASS, iconClass)} />
)}
<span className="truncate">{shortName(stripBidiControls(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">
<span className={cn(
"border border-border text-muted-foreground tabular-nums",
mv ? "inline-flex h-7 items-center rounded-full px-2.5 text-sm" : "rounded-md px-1.5 py-0.5 text-xs",
)}>
+{overflow}
</span>
)}
@@ -172,8 +193,8 @@ export function ListAttachmentChips({ email, load, ...chipProps }: ListAttachmen
}
return (
<div aria-hidden className={cn("flex", chipProps.className)}>
<span className={cn(CHIP_BOX_CLASS, "invisible")}>
<span className={CHIP_ICON_CLASS} />
<span className={cn(chipProps.variant === "mountain-view" ? MV_CHIP_BOX_CLASS : CHIP_BOX_CLASS, "invisible")}>
<span className={chipProps.variant === "mountain-view" ? MV_CHIP_BADGE_CLASS : CHIP_ICON_CLASS} />
<span>&nbsp;</span>
</span>
</div>
+15 -6
View File
@@ -100,19 +100,26 @@ function UnreadDot({ besideAvatar }: { besideAvatar: boolean }) {
}
/** The chip row under a row's preview: the verification code, then the attachments. */
function RowChips({ email, loadAttachments, onOpenAttachment }: {
function RowChips({ email, loadAttachments, onOpenAttachment, mountainView, mvRow }: {
email: Email;
loadAttachments?: LoadListAttachments;
onOpenAttachment?: (attachment: Attachment) => void;
/** Gmail's pill chips. */
mountainView?: boolean;
/** A one-line Mountain View row: the chips start under the subject, as in Gmail. */
mvRow?: boolean;
}) {
const variant = mountainView || mvRow ? "mountain-view" : "default";
const code = useListVerificationCode(email);
const leading = code ? <VerificationCodeChip code={code} /> : undefined;
const leading = code ? <VerificationCodeChip code={code} variant={variant} /> : undefined;
// The sender column is w-32 (lg:w-40) and the row line has gap-3 after it.
const className = cn("mt-1.5", mvRow && "mt-0 pb-2 ps-[8.75rem] lg:ps-[10.75rem]");
// Most rows have no attachment: no loader for them, nor an empty chip row.
const mayHaveAttachments = email.hasAttachment || (email.attachments?.length ?? 0) > 0;
if (onOpenAttachment && mayHaveAttachments) {
return <ListAttachmentChips email={email} load={loadAttachments} onOpen={onOpenAttachment} leading={leading} className="mt-1.5" />;
return <ListAttachmentChips email={email} load={loadAttachments} onOpen={onOpenAttachment} leading={leading} variant={variant} max={variant === "mountain-view" ? 3 : 2} className={className} />;
}
return leading ? <div className="mt-1.5 flex">{leading}</div> : null;
return leading ? <div className={cn("flex", className)}>{leading}</div> : null;
}
/**
@@ -740,7 +747,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div>
</div>
{mvRow && (
<RowChips email={email} loadAttachments={loadAttachments} onOpenAttachment={onOpenAttachment} />
<RowChips email={email} loadAttachments={loadAttachments} onOpenAttachment={onOpenAttachment} mvRow />
)}
</>
) : (
@@ -864,7 +871,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
)}
</div>
)}
<RowChips email={email} loadAttachments={loadAttachments} onOpenAttachment={onOpenAttachment} />
<RowChips email={email} loadAttachments={loadAttachments} onOpenAttachment={onOpenAttachment} mountainView={mvMobileRow} />
</>
)}
</div>
@@ -1394,6 +1401,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
email={latestEmail}
loadAttachments={loadAttachments}
onOpenAttachment={onOpenAttachment ? (a) => onOpenAttachment(latestEmail, a) : undefined}
mvRow
/>
)}
</>
@@ -1525,6 +1533,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
email={latestEmail}
loadAttachments={loadAttachments}
onOpenAttachment={onOpenAttachment ? (a) => onOpenAttachment(latestEmail, a) : undefined}
mountainView={mvMobileRow}
/>
</>
)}
+13 -4
View File
@@ -8,7 +8,7 @@ import type { Email } from "@/lib/jmap/types";
import { listVerificationCode } from "@/lib/verification-code";
import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store";
import { CHIP_BOX_CLASS, CHIP_ICON_CLASS } from "./attachment-chips";
import { CHIP_BOX_CLASS, CHIP_ICON_CLASS, MV_CHIP_BADGE_CLASS, MV_CHIP_BADGE_ICON_CLASS, MV_CHIP_BOX_CLASS, type ChipVariant } from "./attachment-chips";
/** The code a list row offers: found in the subject and preview of a fresh mail. */
export function useListVerificationCode(email: Email): string | null {
@@ -22,11 +22,12 @@ export function useListVerificationCode(email: Email): string | null {
interface VerificationCodeChipProps {
code: string;
variant?: ChipVariant;
className?: string;
}
/** The one-time code of a sign-in mail (lib/verification-code.ts); a click copies it. */
export function VerificationCodeChip({ code, className }: VerificationCodeChipProps) {
export function VerificationCodeChip({ code, variant = "default", className }: VerificationCodeChipProps) {
const t = useTranslations("email_viewer.verification_code");
const [copied, setCopied] = useState(false);
@@ -56,9 +57,17 @@ export function VerificationCodeChip({ code, className }: VerificationCodeChipPr
title={t("copy", { code })}
aria-label={t("copy", { code })}
data-testid="verification-code-chip"
className={cn(CHIP_BOX_CLASS, "flex-shrink-0 bg-background/60 font-medium text-foreground hover:bg-muted", className)}
className={variant === "mountain-view"
? cn(MV_CHIP_BOX_CLASS, "flex-shrink-0 bg-transparent font-medium text-foreground/80 hover:bg-muted", className)
: cn(CHIP_BOX_CLASS, "flex-shrink-0 bg-background/60 font-medium text-foreground hover:bg-muted", className)}
>
<Icon className={cn(CHIP_ICON_CLASS, copied ? "text-success" : "text-amber-600 dark:text-amber-400")} />
{variant === "mountain-view" ? (
<span className={cn(MV_CHIP_BADGE_CLASS, copied ? "bg-success" : "bg-amber-500")}>
<Icon className={MV_CHIP_BADGE_ICON_CLASS} />
</span>
) : (
<Icon className={cn(CHIP_ICON_CLASS, copied ? "text-success" : "text-amber-600 dark:text-amber-400")} />
)}
<span className="font-mono tracking-wide">{code}</span>
</button>
);