diff --git a/components/email/__tests__/attachment-chips.test.tsx b/components/email/__tests__/attachment-chips.test.tsx index 1ed87c478..bdff69a81 100644 --- a/components/email/__tests__/attachment-chips.test.tsx +++ b/components/email/__tests__/attachment-chips.test.tsx @@ -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(); + const chip = screen.getByTitle("invoice.pdf"); + expect(chip.className).toContain("rounded-full"); + expect(chip.querySelector(".bg-red-600 svg")).toBeTruthy(); + + rerender(); + 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(); diff --git a/components/email/attachment-chips.tsx b/components/email/attachment-chips.tsx index 4ced28825..9a8118ea1 100644 --- a/components/email/attachment-chips.tsx +++ b/components/email/attachment-chips.tsx @@ -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 ( -
+
{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 ( ); })} {overflow > 0 && ( - + +{overflow} )} @@ -172,8 +193,8 @@ export function ListAttachmentChips({ email, load, ...chipProps }: ListAttachmen } return (
- - + +  
diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index eb04344a8..c320b133f 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -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 ? : undefined; + const leading = code ? : 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 ; + return ; } - return leading ?
{leading}
: null; + return leading ?
{leading}
: null; } /** @@ -740,7 +747,7 @@ const SingleEmailItem = React.forwardRef(
{mvRow && ( - + )} ) : ( @@ -864,7 +871,7 @@ const SingleEmailItem = React.forwardRef( )} )} - + )} @@ -1394,6 +1401,7 @@ export const ThreadListItem = React.forwardRef onOpenAttachment(latestEmail, a) : undefined} + mvRow /> )} @@ -1525,6 +1533,7 @@ export const ThreadListItem = React.forwardRef onOpenAttachment(latestEmail, a) : undefined} + mountainView={mvMobileRow} /> )} diff --git a/components/email/verification-code-chip.tsx b/components/email/verification-code-chip.tsx index d3aab8ceb..42fbe108b 100644 --- a/components/email/verification-code-chip.tsx +++ b/components/email/verification-code-chip.tsx @@ -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)} > - + {variant === "mountain-view" ? ( + + + + ) : ( + + )} {code} );