mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: show attachment and code chips as Gmail pills in Mountain View
This commit is contained in:
@@ -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()} />);
|
||||
|
||||
@@ -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> </span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user