Files
QOffice/src/features/qoutlook/QOutlook.tsx
T
Курнат Андрей 9bf4dd3672 Initial QOffice implementation
2026-06-01 05:31:05 +03:00

391 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}