Release v0.1.2
This commit is contained in:
@@ -1,23 +1,33 @@
|
||||
import { Paperclip } from "lucide-react";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { Archive, LockKeyhole, Paperclip } from "lucide-react";
|
||||
|
||||
export type MessageDisplayField = {
|
||||
label: string;
|
||||
value?: string | null;
|
||||
value?: ReactNode | null;
|
||||
};
|
||||
|
||||
export type MessageDisplayAttachment = {
|
||||
id?: string | null;
|
||||
filename?: string | null;
|
||||
contentType: string;
|
||||
sizeBytes: number;
|
||||
contentType?: string | null;
|
||||
sizeBytes?: number | null;
|
||||
detail?: string | null;
|
||||
archiveGroup?: string | null;
|
||||
archiveLabel?: string | null;
|
||||
protected?: boolean | null;
|
||||
protectionNote?: string | null;
|
||||
};
|
||||
|
||||
type MessageDisplayBodyMode = "text" | "html";
|
||||
|
||||
type MessageDisplayPanelProps = {
|
||||
title?: string | null;
|
||||
fields?: MessageDisplayField[];
|
||||
bodyText?: string | null;
|
||||
bodyHtml?: string | null;
|
||||
bodyPreview?: string | null;
|
||||
preferredBodyMode?: MessageDisplayBodyMode;
|
||||
deriveTextFromHtml?: boolean;
|
||||
headers?: Record<string, string>;
|
||||
attachments?: MessageDisplayAttachment[];
|
||||
emptyText?: string;
|
||||
@@ -29,57 +39,210 @@ export default function MessageDisplayPanel({
|
||||
bodyText,
|
||||
bodyHtml,
|
||||
bodyPreview,
|
||||
preferredBodyMode,
|
||||
deriveTextFromHtml = true,
|
||||
headers = {},
|
||||
attachments = [],
|
||||
emptyText = "Select an item to inspect its content."
|
||||
}: MessageDisplayPanelProps) {
|
||||
if (!title && fields.length === 0 && !bodyText && !bodyHtml && !bodyPreview && attachments.length === 0 && Object.keys(headers).length === 0) {
|
||||
const visibleFields = fields.filter((field) => hasRenderableValue(field.value));
|
||||
const headerEntries = Object.entries(headers);
|
||||
const hasHtml = Boolean(bodyHtml?.trim());
|
||||
const explicitTextBody = bodyText?.trim() || bodyPreview?.trim() || "";
|
||||
const textBody = explicitTextBody || (deriveTextFromHtml && hasHtml ? stripHtml(bodyHtml || "") : "");
|
||||
const hasText = Boolean(textBody.trim());
|
||||
const defaultBodyMode = preferredBodyMode === "html" && hasHtml ? "html" : preferredBodyMode === "text" && hasText ? "text" : hasHtml ? "html" : "text";
|
||||
const [bodyMode, setBodyMode] = useState<MessageDisplayBodyMode>(defaultBodyMode);
|
||||
const groupedAttachments = useMemo(() => groupAttachments(attachments), [attachments]);
|
||||
|
||||
useEffect(() => {
|
||||
setBodyMode(defaultBodyMode);
|
||||
}, [defaultBodyMode, title, bodyText, bodyHtml, bodyPreview]);
|
||||
|
||||
if (!title && visibleFields.length === 0 && !hasText && !hasHtml && attachments.length === 0 && headerEntries.length === 0) {
|
||||
return <p className="muted">{emptyText}</p>;
|
||||
}
|
||||
|
||||
const body = bodyText || bodyPreview || (bodyHtml ? stripHtml(bodyHtml) : "");
|
||||
const activeBodyMode = bodyMode === "html" && hasHtml ? "html" : "text";
|
||||
const showBodySwitch = hasHtml && hasText;
|
||||
|
||||
return (
|
||||
<div className="message-display-panel">
|
||||
<div className="message-display-header">
|
||||
<h3>{title || "(no subject)"}</h3>
|
||||
{fields.filter((field) => field.value).map((field) => (
|
||||
<div key={field.label}>
|
||||
<span>{field.label}</span>
|
||||
<strong>{field.value}</strong>
|
||||
</div>
|
||||
))}
|
||||
{visibleFields.length > 0 && (
|
||||
<dl className="message-display-fields">
|
||||
{visibleFields.map((field) => (
|
||||
<div key={field.label}>
|
||||
<dt>{field.label}</dt>
|
||||
<dd>{field.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<pre className="message-display-body">{body || "No readable body content."}</pre>
|
||||
<section className="message-display-body-section" aria-label="Message body">
|
||||
<div className="message-display-section-heading">
|
||||
<h4>Message</h4>
|
||||
{showBodySwitch && (
|
||||
<div className="message-display-body-switch" role="tablist" aria-label="Message body format">
|
||||
<button type="button" className={activeBodyMode === "html" ? "active" : ""} onClick={() => setBodyMode("html")}>HTML</button>
|
||||
<button type="button" className={activeBodyMode === "text" ? "active" : ""} onClick={() => setBodyMode("text")}>Text</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{activeBodyMode === "html" ? (
|
||||
<iframe className="message-display-html-frame" title="Rendered HTML message body" sandbox="" srcDoc={bodyHtml || "<p>No HTML body content.</p>"} />
|
||||
) : (
|
||||
<pre className="message-display-body">{textBody || "No readable body content."}</pre>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{attachments.length ? (
|
||||
<div className="message-display-attachments">
|
||||
<h4>Attachments</h4>
|
||||
{attachments.map((attachment, index) => (
|
||||
<div key={attachment.id || `${attachment.filename || "attachment"}-${index}`}>
|
||||
<Paperclip size={14} aria-hidden="true" />
|
||||
<span>{attachment.filename || "unnamed attachment"}</span>
|
||||
<small>{attachment.contentType} - {formatBytes(attachment.sizeBytes)}</small>
|
||||
</div>
|
||||
))}
|
||||
<div className="message-display-attachments-scroll">
|
||||
{groupedAttachments.direct.length > 0 && (
|
||||
<div className="message-display-attachment-list">
|
||||
{groupedAttachments.direct.map((attachment, index) => <AttachmentRow key={attachmentKey(attachment, index)} attachment={attachment} index={index} />)}
|
||||
</div>
|
||||
)}
|
||||
{groupedAttachments.archives.map((archive) => (
|
||||
<section className="message-display-attachment-archive" key={archive.key}>
|
||||
<header>
|
||||
<div>
|
||||
<strong><Archive size={15} aria-hidden="true" /> {archive.label}</strong>
|
||||
<span>{archive.items.length} file{archive.items.length === 1 ? "" : "s"} inside ZIP</span>
|
||||
</div>
|
||||
{archive.protected && <small><LockKeyhole size={13} aria-hidden="true" /> Password protected</small>}
|
||||
</header>
|
||||
{archive.protectionNote && <p className="muted small-note">{formatProtectionNote(archive.protectionNote)}</p>}
|
||||
<div className="message-display-attachment-list">
|
||||
{archive.items.map((attachment, index) => <AttachmentRow key={attachmentKey(attachment, index)} attachment={attachment} index={index} />)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<details className="message-display-headers">
|
||||
<summary>Headers</summary>
|
||||
<dl>
|
||||
{Object.entries(headers).map(([key, value]) => (
|
||||
<div key={key}><dt>{key}</dt><dd>{value}</dd></div>
|
||||
))}
|
||||
</dl>
|
||||
</details>
|
||||
{headerEntries.length > 0 && (
|
||||
<details className="message-display-headers">
|
||||
<summary>Headers</summary>
|
||||
<dl>
|
||||
{headerEntries.map(([key, value]) => (
|
||||
<div key={key}><dt>{key}</dt><dd>{value}</dd></div>
|
||||
))}
|
||||
</dl>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentRow({ attachment, index }: { attachment: MessageDisplayAttachment; index: number }) {
|
||||
const contentType = formatContentType(attachment.contentType);
|
||||
const size = formatBytes(attachment.sizeBytes);
|
||||
const hasMeta = Boolean(contentType || size);
|
||||
return (
|
||||
<div className="message-display-attachment-row">
|
||||
<Paperclip size={14} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{attachment.filename || `Attachment ${index + 1}`}</strong>
|
||||
{attachment.detail && <small className="message-display-attachment-detail">{attachment.detail}</small>}
|
||||
{hasMeta && (
|
||||
<small className="message-display-attachment-meta">
|
||||
{contentType && <span title={contentType.full}>{contentType.label}</span>}
|
||||
{size && <span>{size}</span>}
|
||||
</small>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function groupAttachments(attachments: MessageDisplayAttachment[]) {
|
||||
const direct = attachments.filter((attachment) => !attachment.archiveGroup);
|
||||
const archiveMap = new Map<string, MessageDisplayAttachment[]>();
|
||||
for (const attachment of attachments) {
|
||||
if (!attachment.archiveGroup) continue;
|
||||
const current = archiveMap.get(attachment.archiveGroup) ?? [];
|
||||
current.push(attachment);
|
||||
archiveMap.set(attachment.archiveGroup, current);
|
||||
}
|
||||
return {
|
||||
direct,
|
||||
archives: [...archiveMap.entries()].map(([key, items]) => ({
|
||||
key,
|
||||
label: items[0]?.archiveLabel || key,
|
||||
protected: items.some((item) => item.protected),
|
||||
protectionNote: items.find((item) => item.protectionNote)?.protectionNote ?? null,
|
||||
items
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function attachmentKey(attachment: MessageDisplayAttachment, index: number): string {
|
||||
return String(attachment.id || `${attachment.archiveGroup || "direct"}:${attachment.filename || "attachment"}:${index}`);
|
||||
}
|
||||
|
||||
function hasRenderableValue(value: ReactNode | null | undefined): boolean {
|
||||
if (value === null || value === undefined || value === false) return false;
|
||||
if (typeof value === "string") return value.trim() !== "";
|
||||
return true;
|
||||
}
|
||||
|
||||
function formatProtectionNote(value: string): string {
|
||||
return value
|
||||
.replace(/^Password-protected ZIP(?: using)?\s*/i, "")
|
||||
.replace(/^Password protected(?: using)?\s*/i, "")
|
||||
.replace(/^using\s+/i, "")
|
||||
.replace(/\.\s*Encryption:/i, " · Encryption:")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function formatContentType(value?: string | null): { label: string; full: string } | null {
|
||||
const full = value?.trim();
|
||||
if (!full) return null;
|
||||
const normalized = full.toLowerCase();
|
||||
const known: Record<string, string> = {
|
||||
"application/pdf": "PDF",
|
||||
"application/zip": "ZIP archive",
|
||||
"application/x-zip-compressed": "ZIP archive",
|
||||
"application/octet-stream": "Binary file",
|
||||
"text/plain": "Plain text",
|
||||
"text/html": "HTML",
|
||||
"text/csv": "CSV",
|
||||
"application/json": "JSON",
|
||||
"image/jpeg": "JPEG image",
|
||||
"image/png": "PNG image",
|
||||
"image/gif": "GIF image",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "XLSX spreadsheet",
|
||||
"application/vnd.ms-excel": "Excel spreadsheet",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "DOCX document",
|
||||
"application/msword": "Word document",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "PPTX presentation",
|
||||
"application/vnd.ms-powerpoint": "PowerPoint presentation"
|
||||
};
|
||||
if (known[normalized]) return { label: known[normalized], full };
|
||||
const subtype = normalized.split("/")[1]?.split(";")[0] || normalized;
|
||||
const cleaned = subtype
|
||||
.replace(/^vnd\./, "")
|
||||
.replace(/^x-/, "")
|
||||
.replace(/openxmlformats-officedocument\./g, "")
|
||||
.replace(/[.+_-]+/g, " ")
|
||||
.trim();
|
||||
const label = titleCase(cleaned || normalized);
|
||||
return { label: label.length > 34 ? `${label.slice(0, 31)}...` : label, full };
|
||||
}
|
||||
|
||||
function titleCase(value: string): string {
|
||||
return value.replace(/\b[a-z]/g, (match) => match.toUpperCase());
|
||||
}
|
||||
|
||||
function formatBytes(value?: number | null): string {
|
||||
if (!value) return "-";
|
||||
if (!value) return "";
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
|
||||
Reference in New Issue
Block a user