274 lines
12 KiB
TypeScript
274 lines
12 KiB
TypeScript
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
|
import { Archive, LockKeyhole, Paperclip } from "lucide-react";
|
|
import { i18nMessage, usePlatformLanguage } from "../i18n/LanguageContext";
|
|
import SegmentedControl from "./SegmentedControl";
|
|
|
|
export type MessageDisplayField = {
|
|
label: string;
|
|
value?: ReactNode | null;
|
|
};
|
|
|
|
export type MessageDisplayAttachment = {
|
|
id?: string | null;
|
|
filename?: string | null;
|
|
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;
|
|
};
|
|
|
|
export default function MessageDisplayPanel({
|
|
title,
|
|
fields = [],
|
|
bodyText,
|
|
bodyHtml,
|
|
bodyPreview,
|
|
preferredBodyMode,
|
|
deriveTextFromHtml = true,
|
|
headers = {},
|
|
attachments = [],
|
|
emptyText = "i18n:govoplan-core.select_an_item_to_inspect_its_content.1f67f131"
|
|
}: MessageDisplayPanelProps) {
|
|
const { translateText } = usePlatformLanguage();
|
|
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">{translateText(emptyText)}</p>;
|
|
}
|
|
|
|
const activeBodyMode = bodyMode === "html" && hasHtml ? "html" : "text";
|
|
const showBodySwitch = hasHtml && hasText;
|
|
const htmlBodyFallback = translateText("i18n:govoplan-core.p_no_html_body_content_p.c305bfc6");
|
|
const textBodyFallback = translateText("i18n:govoplan-core.no_readable_body_content.37643a01");
|
|
|
|
return (
|
|
<div className="message-display-panel">
|
|
<div className="message-display-header">
|
|
<h3>{title || "i18n:govoplan-core.no_subject.49b20da0"}</h3>
|
|
{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>
|
|
|
|
<section className="message-display-body-section" aria-label="i18n:govoplan-core.message_body.daee2627">
|
|
<div className="message-display-section-heading">
|
|
<h4>i18n:govoplan-core.message.68f4145f</h4>
|
|
{showBodySwitch &&
|
|
<SegmentedControl
|
|
ariaLabel="i18n:govoplan-core.message_body_format.5fec42d2"
|
|
value={activeBodyMode}
|
|
onChange={setBodyMode}
|
|
options={[
|
|
{ id: "html", label: "i18n:govoplan-core.html.9f738ce8" },
|
|
{ id: "text", label: "i18n:govoplan-core.text.c3328c39" }
|
|
]}
|
|
/>
|
|
}
|
|
</div>
|
|
{activeBodyMode === "html" ?
|
|
<iframe className="message-display-html-frame" title="i18n:govoplan-core.rendered_html_message_body.8638b634" sandbox="" srcDoc={bodyHtml || htmlBodyFallback} /> :
|
|
|
|
<pre className="message-display-body">{textBody || textBodyFallback}</pre>
|
|
}
|
|
</section>
|
|
|
|
{attachments.length ?
|
|
<div className="message-display-attachments">
|
|
<h4>i18n:govoplan-core.attachments.6771ade6</h4>
|
|
<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"} i18n:govoplan-core.inside_zip.68c3fd85</span>
|
|
</div>
|
|
{archive.protected &&
|
|
<div className="message-display-attachment-protection">
|
|
<small><LockKeyhole size={13} aria-hidden="true" /> i18n:govoplan-core.password_protected.09d9e174</small>
|
|
{archive.protectionNote && <small className="message-display-attachment-protection-note">{formatProtectionNote(archive.protectionNote)}</small>}
|
|
</div>
|
|
}
|
|
</header>
|
|
<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}
|
|
|
|
{headerEntries.length > 0 &&
|
|
<details className="message-display-headers">
|
|
<summary>i18n:govoplan-core.headers.520de744</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 || i18nMessage("i18n:govoplan-core.attachment_value.01801a54", { value0: 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 {
|
|
const normalized = value.
|
|
replace(/^Password-protected ZIP(?: using)?\s*/i, "").
|
|
replace(/^Password protected(?: using)?\s*/i, "").
|
|
replace(/^using\s+/i, "").
|
|
trim();
|
|
const encryptionMatch = normalized.match(/^(.*?)\.\s*Encryption:\s*(.*)$/i);
|
|
if (!encryptionMatch) return normalized;
|
|
const source = encryptionMatch[1].trim();
|
|
const method = encryptionMatch[2].trim();
|
|
return source ?
|
|
i18nMessage("i18n:govoplan-core.value_encryption_value", { value0: source, value1: method }) :
|
|
i18nMessage("i18n:govoplan-core.encryption_value", { value0: method });
|
|
}
|
|
|
|
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": "i18n:govoplan-core.pdf.d613d88c",
|
|
"application/zip": "i18n:govoplan-core.zip_archive.5a2430dd",
|
|
"application/x-zip-compressed": "i18n:govoplan-core.zip_archive.5a2430dd",
|
|
"application/octet-stream": "i18n:govoplan-core.binary_file.a4de1904",
|
|
"text/plain": "i18n:govoplan-core.plain_text.9580fcbc",
|
|
"text/html": "i18n:govoplan-core.html.9f738ce8",
|
|
"text/csv": "i18n:govoplan-core.csv.32811883",
|
|
"application/json": "i18n:govoplan-core.json.031a4e76",
|
|
"image/jpeg": "i18n:govoplan-core.jpeg_image.6876b7ff",
|
|
"image/png": "i18n:govoplan-core.png_image.6715d4b8",
|
|
"image/gif": "i18n:govoplan-core.gif_image.d092a779",
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "i18n:govoplan-core.xlsx_spreadsheet.db8c2fc9",
|
|
"application/vnd.ms-excel": "i18n:govoplan-core.excel_spreadsheet.7107fea2",
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "i18n:govoplan-core.docx_document.f0b09822",
|
|
"application/msword": "i18n:govoplan-core.word_document.f593629d",
|
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "i18n:govoplan-core.pptx_presentation.855d193b",
|
|
"application/vnd.ms-powerpoint": "i18n:govoplan-core.powerpoint_presentation.b32b7115"
|
|
};
|
|
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 < 1024) return i18nMessage("i18n:govoplan-core.bytes_b", { value0: value });
|
|
if (value < 1024 * 1024) return i18nMessage("i18n:govoplan-core.bytes_kb", { value0: (value / 1024).toFixed(1) });
|
|
return i18nMessage("i18n:govoplan-core.bytes_mb", { value0: (value / 1024 / 1024).toFixed(1) });
|
|
}
|
|
|
|
function stripHtml(value: string): string {
|
|
return value.replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
}
|