src/mail/Reader.tsx
import type { KeyboardEvent, MouseEvent } from "react";
import type { Draft, GmailLabel, RendererMail as Mail, Provider, RendererState as State } from "../../shared/types";
import type { Api } from "../api";
import { customLabels } from "../mail-ui";
import { Address, MailPrivacy, Redact } from "../ui/Privacy";
import { Mail as MailIcon } from "../ui/icons";
import { Avatar } from "./Avatar";
import { ConversationReader } from "./ConversationReader";
import { OfferTimes } from "../work/OfferTimes";
import { InvitationPanel } from "../work/InvitationActions";
import { ReaderContent } from "./MessageContent";
import { mailBodyKey, useMailBody } from "../mail-body";
import { UnsubscribeAction } from "./UnsubscribeAction";
// Today's mail shows its time, this year's its day and time, older mail the full date.
function readerDate(date: number) {
const value = new Date(date);
const now = new Date();
const time: Intl.DateTimeFormatOptions = { hour: "numeric", minute: "2-digit" };
if (value.toDateString() === now.toDateString()) return value.toLocaleTimeString(undefined, time);
return value.toLocaleString(undefined, {
month: "short", day: "numeric", ...(value.getFullYear() === now.getFullYear() ? time : { year: "numeric" }),
});
}
/**
* An opened email: subject, then one sender line with the recipient beneath
* and the date at the right, Gmail labels, and the body with its image consent.
*/
function SingleReader({ mail, provider, labels, busy, api, onLabels, onMenu, onMenuKey, onOpen, onChanged, onReplyAll, onForward, onOpenDraft, onUnsnooze, onChangeTime, onReviewingUnsubscribe, state }: {
mail: Mail;
state?: State;
provider?: Provider;
labels: GmailLabel[];
busy: boolean;
api: Api;
onLabels: () => void;
onOpen?: (mail: Mail) => void;
onChanged?: () => Promise<unknown>;
onReplyAll?: () => void;
onForward?: () => void;
onOpenDraft?: (draft: Draft) => void;
onReviewingUnsubscribe?: (open: boolean) => void;
onUnsnooze?: () => void;
onChangeTime?: (event: MouseEvent<HTMLElement>) => void;
onMenu: (event: MouseEvent<HTMLElement>, mail: Mail) => void;
onMenuKey: (event: KeyboardEvent<HTMLElement>, mail: Mail) => void;
}) {
const content = useMailBody(mail, api);
const hydrated = content.mail;
return (
<MailPrivacy mail={mail}>
<article className="message" tabIndex={-1} onContextMenu={(event) => onMenu(event, mail)} onKeyDown={(event) => onMenuKey(event, mail)}>
<h1><Redact text={mail.subject || "(no subject)"} /></h1>
<div className="message-from">
<Avatar mail={mail} provider={provider} />
<div className="from-text">
<p className="from-line">
<strong><Redact text={mail.sender} /></strong>
<span className="from-address"><Address value={mail.address} /></span>
</p>
<p className="from-to">To <Redact text={mail.to} /></p>
{mail.cc && <p className="from-to">Cc <Redact text={mail.cc}/></p>}
</div>
<time dateTime={new Date(mail.date).toISOString()} title={new Date(mail.date).toLocaleString()}>
{readerDate(mail.date)}
</time>
</div>
{mail.snoozedUntil !== null && mail.snoozedUntil > Date.now() && <p className="message-return">
Returns to Inbox <time dateTime={new Date(mail.snoozedUntil).toISOString()}>{new Date(mail.snoozedUntil).toLocaleString()}</time> (local time)
{onUnsnooze && <> · <button type="button" className="link" disabled={busy} onClick={onUnsnooze}>Return to inbox</button></>}
{onChangeTime && <> · <button type="button" className="link" disabled={busy} onClick={onChangeTime}>Change time</button></>}
</p>}
{hydrated && <InvitationPanel key={`invitation:${mailBodyKey(mail)}`} mail={hydrated} api={api} onOpenDraft={onOpenDraft} onChanged={onChanged} />}
{!hydrated && <p role="status">{content.error || "Loading cached email…"}{content.error && <> <button type="button" className="link" onClick={content.retry}>Try again</button></>}</p>}
{hydrated && <ReaderContent api={api} key={`content:${mailBodyKey(mail)}`} mail={hydrated}
loadContent={(id) => api(`/messages/${encodeURIComponent(id)}/content`, "POST")}
loadImages={(id) => api(`/messages/${encodeURIComponent(id)}/images`, "POST")} />}
<footer className="reader-secondary-tools">
{hydrated && <UnsubscribeAction key={`unsubscribe:${mailBodyKey(mail)}`} mail={hydrated} api={api} busy={busy} onOpenDraft={onOpenDraft} onReviewing={onReviewingUnsubscribe} />}
{provider === "gmail" && (
<div className="message-labels">
<span>
<Redact text={customLabels(mail, labels).filter((label) => mail.labelIds?.includes(label.id)).map((label) => label.name).join(", ") || "No labels"} />
</span>
<button className="link" disabled={busy} onClick={onLabels}>Edit labels</button>
</div>
)}
{onForward && <div className="message-labels"><button type="button" className="link" disabled={busy} onClick={onForward}>Forward message</button></div>}
{onReplyAll && <div className="message-labels"><button type="button" className="link" disabled={busy} onClick={onReplyAll}>Reply all</button></div>}
{state && onOpenDraft && <OfferTimes key={`offers:${mail.id}`} mail={mail} state={state} api={api} onOpenDraft={onOpenDraft} />}
</footer>
</article>
</MailPrivacy>
);
}
// Only the selected message receives callbacks that close over the App action target.
export function Reader(props: Parameters<typeof SingleReader>[0]) {
if (!props.onOpen || !props.onChanged) return <SingleReader {...props} />;
return <ConversationReader key={JSON.stringify([props.mail.accountId, props.mail.id])}
mail={props.mail} gmail={props.provider === "gmail"} api={props.api} busy={props.busy}
onChanged={props.onChanged} onSelect={props.onOpen} onMenu={props.onMenu} onMenuKey={props.onMenuKey}
renderMessage={(mail) => <SingleReader {...props} mail={mail}
provider={mail.id === props.mail.id ? props.provider : undefined}
onReplyAll={mail.id === props.mail.id ? props.onReplyAll : undefined}
onForward={mail.id === props.mail.id ? props.onForward : undefined}
onUnsnooze={mail.id === props.mail.id ? props.onUnsnooze : undefined}
onChangeTime={mail.id === props.mail.id ? props.onChangeTime : undefined}
state={mail.id === props.mail.id ? props.state : undefined} />} />;
}
/** The reading pane before an email is opened. */
export function ReaderEmpty({ selected, openKey }: { selected: boolean; openKey: string }) {
return (
<div className="reader-empty">
<MailIcon color="currentColor" size={28} />
<p className="empty-title">No email open</p>
<p>{selected ? <>Press <kbd>{openKey}</kbd> to open the selected email.</> : "Choose an email to read it here."}</p>
</div>
);
}