mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
205 lines
9.2 KiB
TypeScript
205 lines
9.2 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, type ReactNode } from "react";
|
|
import { FileText, FileSpreadsheet, FileImage, FileArchive, File as FileIcon } from "@/components/icons";
|
|
import { cn } from "@/lib/utils";
|
|
import { stripBidiControls } from "@/lib/bidi";
|
|
import type { Attachment, Email } from "@/lib/jmap/types";
|
|
import type { LoadListAttachments } from "@/lib/list-attachments";
|
|
|
|
/**
|
|
* 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,
|
|
);
|
|
}
|
|
|
|
// `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", badge: "bg-zinc-500" };
|
|
}
|
|
|
|
/** 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}`;
|
|
}
|
|
|
|
// Shared by the chips, the space a loading row holds for them and the
|
|
// verification-code chip, so all of them are the same height.
|
|
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-8 items-center gap-2 rounded-full border border-border ps-3 pe-4 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;
|
|
/** How many chips to show before collapsing the rest into a count. */
|
|
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, variant = "default", className }: AttachmentChipsProps) {
|
|
const mv = variant === "mountain-view";
|
|
const real = realAttachments(attachments);
|
|
if (!real.length && !leading) return null;
|
|
|
|
// Next to a leading chip one attachment is all that still fits on the line.
|
|
const shown = real.slice(0, leading ? Math.min(max, 1) : max);
|
|
const overflow = real.length - shown.length;
|
|
|
|
return (
|
|
<div className={cn("flex items-center flex-wrap", mv ? "gap-2" : "gap-1.5", className)}>
|
|
{leading}
|
|
{shown.map((a) => {
|
|
const { icon: Icon, className: iconClass, badge } = 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={stripBidiControls(a.name ?? "")}
|
|
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")}
|
|
>
|
|
{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={cn(
|
|
"border border-border text-muted-foreground tabular-nums",
|
|
mv ? "inline-flex h-8 items-center rounded-full px-3 text-sm" : "rounded-md px-1.5 py-0.5 text-xs",
|
|
)}>
|
|
+{overflow}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Attachment parts for a list row. List requests no longer carry
|
|
* `attachments` (#1089), so a row with a paperclip loads its own once it is
|
|
* mounted; an email that already has them (thread expansion, demo data) is
|
|
* used as is. `undefined` while the answer is still out.
|
|
*/
|
|
export function useListAttachments(email: Email, load?: LoadListAttachments): Attachment[] | undefined {
|
|
const needsLoad = !!load && !!email.hasAttachment && !email.attachments;
|
|
// The list unmounts rows that scroll away. One that comes back finds its
|
|
// answer cached and takes it in the first render, so it mounts at its full
|
|
// height instead of growing a frame later and making the list correct
|
|
// the scroll position under the reader.
|
|
const [loaded, setLoaded] = useState<{ id: string; attachments: Attachment[] } | null>(() => {
|
|
const cached = needsLoad ? load?.peek?.(email) : undefined;
|
|
return cached ? { id: email.id, attachments: cached } : null;
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!needsLoad || !load) return;
|
|
const id = email.id;
|
|
let answered = false;
|
|
const cancel = load(email, (attachments) => {
|
|
answered = true;
|
|
setLoaded((prev) => prev?.id === id && prev.attachments === attachments ? prev : { id, attachments });
|
|
});
|
|
// The peek ran during render, before the list's owner had caught up with
|
|
// a view change, so it may have read another account's entry for the
|
|
// same id. The loader's answer is the real one; without one yet, wait.
|
|
if (!answered) setLoaded(null);
|
|
return cancel;
|
|
// The row's email object is replaced on every keyword change; only a
|
|
// different message needs a different answer.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [needsLoad, load, email.id]);
|
|
|
|
if (email.attachments) return email.attachments;
|
|
return loaded?.id === email.id ? loaded.attachments : undefined;
|
|
}
|
|
|
|
/**
|
|
* Whether a list row shows the paperclip. Stalwart sets `hasAttachment` for
|
|
* inline images too (a logo in a notification mail), which the viewer never
|
|
* lists, so once the parts are in the flag only counts if one of them is a
|
|
* real attachment. Until then the server's flag stands.
|
|
*/
|
|
export function useListHasAttachment(email: Email, load?: LoadListAttachments): boolean {
|
|
const attachments = useListAttachments(email, load);
|
|
if (!email.hasAttachment) return false;
|
|
return attachments === undefined || realAttachments(attachments).length > 0;
|
|
}
|
|
|
|
/**
|
|
* Whether a list row will draw a chip row for this email, counting the room
|
|
* it holds while the parts load. Lets the list estimate a row's height
|
|
* before it has measured it.
|
|
*/
|
|
export function listRowShowsChips(email: Email, load?: LoadListAttachments): boolean {
|
|
if (email.attachments) return realAttachments(email.attachments).length > 0;
|
|
return !!load && !!email.hasAttachment;
|
|
}
|
|
|
|
interface ListAttachmentChipsProps extends Omit<AttachmentChipsProps, "attachments"> {
|
|
email: Email;
|
|
load?: LoadListAttachments;
|
|
}
|
|
|
|
export function ListAttachmentChips({ email, load, ...chipProps }: ListAttachmentChipsProps) {
|
|
const attachments = useListAttachments(email, load);
|
|
if (attachments === undefined && load && email.hasAttachment) {
|
|
// Hold a chip's height until the parts arrive, so the row does not grow
|
|
// under the reader when they do. A leading chip holds it just as well.
|
|
if (chipProps.leading) {
|
|
return <div className={cn("flex items-center gap-1.5", chipProps.className)}>{chipProps.leading}</div>;
|
|
}
|
|
return (
|
|
<div aria-hidden className={cn("flex", chipProps.className)}>
|
|
<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>
|
|
);
|
|
}
|
|
return <AttachmentChips attachments={attachments} {...chipProps} />;
|
|
}
|