391 lines
14 KiB
TypeScript
391 lines
14 KiB
TypeScript
import { Archive, Download, FileText, FileUp, Flag, MailOpen, Paperclip, PenLine, Search, Send, X } from "lucide-react";
|
||
import { useMemo, useRef, useState } from "react";
|
||
import type { MailAttachment, MailMessage, QOfficeState } from "../../types";
|
||
import { downloadBlobFile } from "../../utils/download";
|
||
import { attachmentToBlob, createMailAttachmentFromFile, exportEmlBlob, importMailFile } from "../../io/outlookMail";
|
||
import { replaceFileExtension } from "../../io/fileHelpers";
|
||
import { isDesktopOfficeBridgeAvailable, openDesktopOfficeFile, saveDesktopOfficeFile } from "../../io/desktopOfficeFiles";
|
||
import { exportPdfOrPrint } from "../../io/pdfExport";
|
||
import { useQOfficeCommand } from "../../hooks/useQOfficeCommand";
|
||
|
||
interface QOutlookProps {
|
||
mail: QOfficeState["mail"];
|
||
onChange: (updater: (mail: QOfficeState["mail"]) => QOfficeState["mail"]) => void;
|
||
onFolderChange: (folder: MailMessage["folder"]) => void;
|
||
}
|
||
|
||
const folders: Array<{ id: MailMessage["folder"]; label: string }> = [
|
||
{ id: "inbox", label: "Входящие" },
|
||
{ id: "sent", label: "Отправленные" },
|
||
{ id: "drafts", label: "Черновики" },
|
||
{ id: "archive", label: "Архив" }
|
||
];
|
||
|
||
function formatBytes(bytes: number): string {
|
||
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||
return "0 Б";
|
||
}
|
||
|
||
const units = ["Б", "КБ", "МБ", "ГБ"];
|
||
let value = bytes;
|
||
let unitIndex = 0;
|
||
|
||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||
value /= 1024;
|
||
unitIndex += 1;
|
||
}
|
||
|
||
return `${Number.isInteger(value) ? value : value.toFixed(1)} ${units[unitIndex]}`;
|
||
}
|
||
|
||
export function QOutlook({ mail, onChange, onFolderChange }: QOutlookProps) {
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const draftAttachmentInputRef = useRef<HTMLInputElement>(null);
|
||
const [query, setQuery] = useState("");
|
||
const [draft, setDraft] = useState<{ to: string; subject: string; body: string; attachments: MailAttachment[] }>({
|
||
to: "",
|
||
subject: "",
|
||
body: "",
|
||
attachments: []
|
||
});
|
||
const activeMessage = mail.messages.find((message) => message.id === mail.activeMessageId) ?? mail.messages[0];
|
||
|
||
const visibleMessages = useMemo(() => {
|
||
const normalizedQuery = query.trim().toLowerCase();
|
||
return mail.messages.filter((message) => {
|
||
const inFolder = message.folder === mail.activeFolder;
|
||
const matchesQuery =
|
||
!normalizedQuery ||
|
||
[message.from, message.to, message.subject, message.body, ...(message.attachments ?? []).map((attachment) => attachment.fileName)].some((field) =>
|
||
field.toLowerCase().includes(normalizedQuery)
|
||
);
|
||
return inFolder && matchesQuery;
|
||
});
|
||
}, [mail.activeFolder, mail.messages, query]);
|
||
|
||
function updateMessage(id: string, patch: Partial<MailMessage>) {
|
||
onChange((current) => ({
|
||
...current,
|
||
messages: current.messages.map((message) => (message.id === id ? { ...message, ...patch } : message))
|
||
}));
|
||
}
|
||
|
||
function sendDraft() {
|
||
if (!draft.to.trim() || !draft.subject.trim()) {
|
||
return;
|
||
}
|
||
|
||
const id = `mail-${Date.now()}`;
|
||
const message: MailMessage = {
|
||
id,
|
||
folder: "sent",
|
||
from: "product@qoffice.local",
|
||
to: draft.to.trim(),
|
||
subject: draft.subject.trim(),
|
||
body: draft.body.trim(),
|
||
time: new Date().toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }),
|
||
read: true,
|
||
flagged: false,
|
||
attachments: draft.attachments
|
||
};
|
||
|
||
onChange((current) => ({
|
||
...current,
|
||
activeFolder: "sent",
|
||
activeMessageId: id,
|
||
messages: [message, ...current.messages]
|
||
}));
|
||
setDraft({ to: "", subject: "", body: "", attachments: [] });
|
||
}
|
||
|
||
async function openMailFile(file: File | undefined) {
|
||
if (!file) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const imported = await importMailFile(file);
|
||
onChange((current) => ({
|
||
...current,
|
||
activeFolder: "inbox",
|
||
activeMessageId: imported.id,
|
||
messages: [imported, ...current.messages]
|
||
}));
|
||
} catch (error) {
|
||
window.alert(error instanceof Error ? error.message : "Не удалось открыть письмо.");
|
||
} finally {
|
||
if (fileInputRef.current) {
|
||
fileInputRef.current.value = "";
|
||
}
|
||
}
|
||
}
|
||
|
||
async function saveEml(message: MailMessage) {
|
||
try {
|
||
const blob = exportEmlBlob(message);
|
||
const fileName = replaceFileExtension(message.subject || "Письмо", ".eml");
|
||
if (isDesktopOfficeBridgeAvailable()) {
|
||
await saveDesktopOfficeFile("eml", fileName, blob);
|
||
return;
|
||
}
|
||
|
||
downloadBlobFile(fileName, blob);
|
||
} catch (error) {
|
||
window.alert(error instanceof Error ? error.message : "Не удалось сохранить EML.");
|
||
}
|
||
}
|
||
|
||
async function addDraftAttachments(files: FileList | null) {
|
||
if (!files || files.length === 0) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const attachments = await Promise.all(Array.from(files).map((file) => createMailAttachmentFromFile(file)));
|
||
setDraft((current) => ({ ...current, attachments: [...current.attachments, ...attachments] }));
|
||
} catch (error) {
|
||
window.alert(error instanceof Error ? error.message : "Не удалось добавить вложение.");
|
||
} finally {
|
||
if (draftAttachmentInputRef.current) {
|
||
draftAttachmentInputRef.current.value = "";
|
||
}
|
||
}
|
||
}
|
||
|
||
function removeDraftAttachment(id: string) {
|
||
setDraft((current) => ({
|
||
...current,
|
||
attachments: current.attachments.filter((attachment) => attachment.id !== id)
|
||
}));
|
||
}
|
||
|
||
function downloadAttachment(attachment: MailAttachment) {
|
||
downloadBlobFile(attachment.fileName, attachmentToBlob(attachment));
|
||
}
|
||
|
||
async function chooseMailFile() {
|
||
if (isDesktopOfficeBridgeAvailable()) {
|
||
const file = await openDesktopOfficeFile("mail");
|
||
await openMailFile(file ?? undefined);
|
||
return;
|
||
}
|
||
|
||
fileInputRef.current?.click();
|
||
}
|
||
|
||
async function exportPdf() {
|
||
try {
|
||
await exportPdfOrPrint(activeMessage?.subject || "Письмо QOutlook");
|
||
} catch (error) {
|
||
window.alert(error instanceof Error ? error.message : "Не удалось экспортировать PDF.");
|
||
}
|
||
}
|
||
|
||
useQOfficeCommand({
|
||
open: chooseMailFile,
|
||
save: () => {
|
||
if (activeMessage) {
|
||
void saveEml(activeMessage);
|
||
}
|
||
},
|
||
"export-pdf": exportPdf,
|
||
print: () => window.print()
|
||
});
|
||
|
||
const activeAttachments = activeMessage?.attachments ?? [];
|
||
|
||
return (
|
||
<div className="mail-layout">
|
||
<aside className="mail-folders" aria-label="Папки почты">
|
||
<input
|
||
ref={fileInputRef}
|
||
className="hidden-file-input"
|
||
type="file"
|
||
accept=".eml,.msg,message/rfc822,application/vnd.ms-outlook"
|
||
onChange={(event) => void openMailFile(event.target.files?.[0])}
|
||
aria-label="Открыть EML или MSG"
|
||
/>
|
||
<button className="wide-command compose-command" type="button">
|
||
<PenLine size={16} />
|
||
Написать
|
||
</button>
|
||
<button className="wide-command" type="button" onClick={() => void chooseMailFile()}>
|
||
<FileUp size={16} />
|
||
Открыть EML/MSG
|
||
</button>
|
||
{folders.map((folder) => {
|
||
const count = mail.messages.filter((message) => message.folder === folder.id).length;
|
||
return (
|
||
<button
|
||
type="button"
|
||
key={folder.id}
|
||
className={mail.activeFolder === folder.id ? "is-active" : ""}
|
||
onClick={() => onFolderChange(folder.id)}
|
||
>
|
||
<span>{folder.label}</span>
|
||
<span>{count}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</aside>
|
||
|
||
<section className="message-list-panel" aria-label="Список писем">
|
||
<label className="mail-search">
|
||
<Search size={16} />
|
||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Поиск писем" />
|
||
</label>
|
||
<div className="message-list">
|
||
{visibleMessages.map((message) => {
|
||
const attachmentCount = message.attachments?.length ?? 0;
|
||
return (
|
||
<button
|
||
type="button"
|
||
key={message.id}
|
||
className={`message-row ${message.id === activeMessage?.id ? "is-active" : ""} ${message.read ? "" : "is-unread"}`}
|
||
onClick={() => {
|
||
onChange((current) => ({ ...current, activeMessageId: message.id }));
|
||
updateMessage(message.id, { read: true });
|
||
}}
|
||
>
|
||
<span>
|
||
<strong>{message.from}</strong>
|
||
<small>{message.time}</small>
|
||
</span>
|
||
<b>{message.subject}</b>
|
||
<em>{message.body}</em>
|
||
{attachmentCount > 0 ? (
|
||
<small className="message-attachment-count">
|
||
<Paperclip size={13} />
|
||
{attachmentCount}
|
||
</small>
|
||
) : null}
|
||
</button>
|
||
);
|
||
})}
|
||
{visibleMessages.length === 0 ? <p className="empty-state">В этой папке нет писем.</p> : null}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="message-reader" aria-label="Чтение письма">
|
||
{activeMessage ? (
|
||
<>
|
||
<div className="message-actions">
|
||
<button type="button" onClick={() => updateMessage(activeMessage.id, { read: !activeMessage.read })}>
|
||
<MailOpen size={16} />
|
||
{activeMessage.read ? "Отметить непрочитанным" : "Отметить прочитанным"}
|
||
</button>
|
||
<button type="button" onClick={() => updateMessage(activeMessage.id, { flagged: !activeMessage.flagged })}>
|
||
<Flag size={16} />
|
||
{activeMessage.flagged ? "Снять флаг" : "Поставить флаг"}
|
||
</button>
|
||
<button type="button" onClick={() => updateMessage(activeMessage.id, { folder: "archive" })}>
|
||
<Archive size={16} />
|
||
В архив
|
||
</button>
|
||
<button type="button" onClick={() => void saveEml(activeMessage)}>
|
||
<Download size={16} />
|
||
Сохранить EML
|
||
</button>
|
||
<button type="button" onClick={() => void exportPdf()}>
|
||
<FileText size={16} />
|
||
Экспорт PDF
|
||
</button>
|
||
</div>
|
||
<article className="message-content">
|
||
<h2>{activeMessage.subject}</h2>
|
||
<p className="muted">
|
||
От: {activeMessage.from} · Кому: {activeMessage.to} · {activeMessage.time}
|
||
</p>
|
||
<p>{activeMessage.body}</p>
|
||
{activeAttachments.length > 0 ? (
|
||
<section className="attachments-panel" aria-label="Вложения письма">
|
||
<h3>Вложения</h3>
|
||
<ul className="attachment-list">
|
||
{activeAttachments.map((attachment) => (
|
||
<li key={attachment.id} className="attachment-item">
|
||
<span className="attachment-main">
|
||
<Paperclip size={16} />
|
||
<span>
|
||
<strong>{attachment.fileName}</strong>
|
||
<small>
|
||
{attachment.contentType} · {formatBytes(attachment.size)}
|
||
</small>
|
||
</span>
|
||
</span>
|
||
<button type="button" onClick={() => downloadAttachment(attachment)} aria-label={`Скачать ${attachment.fileName}`}>
|
||
<Download size={16} />
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
) : null}
|
||
</article>
|
||
</>
|
||
) : (
|
||
<p className="empty-state">Выберите письмо.</p>
|
||
)}
|
||
|
||
<form
|
||
className="compose-panel"
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
sendDraft();
|
||
}}
|
||
>
|
||
<h3>Новое письмо</h3>
|
||
<input value={draft.to} onChange={(event) => setDraft({ ...draft, to: event.target.value })} placeholder="Кому" aria-label="Кому" />
|
||
<input
|
||
value={draft.subject}
|
||
onChange={(event) => setDraft({ ...draft, subject: event.target.value })}
|
||
placeholder="Тема"
|
||
aria-label="Тема"
|
||
/>
|
||
<textarea
|
||
value={draft.body}
|
||
onChange={(event) => setDraft({ ...draft, body: event.target.value })}
|
||
placeholder="Текст письма"
|
||
aria-label="Текст письма"
|
||
/>
|
||
<input
|
||
ref={draftAttachmentInputRef}
|
||
className="hidden-file-input"
|
||
type="file"
|
||
multiple
|
||
onChange={(event) => void addDraftAttachments(event.target.files)}
|
||
aria-label="Добавить вложения"
|
||
/>
|
||
<div className="compose-attachments">
|
||
<button type="button" onClick={() => draftAttachmentInputRef.current?.click()}>
|
||
<Paperclip size={16} />
|
||
Добавить файлы
|
||
</button>
|
||
{draft.attachments.length > 0 ? (
|
||
<ul className="attachment-list compact">
|
||
{draft.attachments.map((attachment) => (
|
||
<li key={attachment.id} className="attachment-item">
|
||
<span className="attachment-main">
|
||
<Paperclip size={16} />
|
||
<span>
|
||
<strong>{attachment.fileName}</strong>
|
||
<small>{formatBytes(attachment.size)}</small>
|
||
</span>
|
||
</span>
|
||
<button type="button" onClick={() => removeDraftAttachment(attachment.id)} aria-label={`Удалить ${attachment.fileName}`}>
|
||
<X size={16} />
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : null}
|
||
</div>
|
||
<button className="primary-command" type="submit" disabled={!draft.to.trim() || !draft.subject.trim()}>
|
||
<Send size={16} />
|
||
Отправить
|
||
</button>
|
||
</form>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|