Files
SRCmail/components/pro/pro-compose-tab-body.tsx
T
Stefan HildebrandtandLinus Rath 3516d3c727 chore: resolve react-hooks/exhaustive-deps warnings
Goes through the 7 exhaustive-deps warnings individually:

Added the genuinely-missing dependency (safe, no extra churn):
- email-viewer useMemo: add effectiveEmailContent.hasStyleTag (used for
  hasOwnLayout; changes in lockstep with .html, closing a latent staleness gap).
- pro-compose-tab-body handleSend: add refreshCurrentMailbox (stable zustand
  selector) and drop the stale fetchEmails/selectedMailbox deps — which left
  those two selectors entirely unused, so remove them too.
- use-mailbox-drop handleDrop: add sourceMailboxId (changes in lockstep with
  draggedEmails, already a dep).

Suppressed with a justified comment where depending on the whole object would
regress behavior — these are intentional fine-grained deps:
- email-composer signature-swap effect (keyed to signature fields + prev*Ref
  guards; whole signatureIdentity would re-splice the live editor).
- email-viewer auto-mark-as-read (whole email would reset the delay timer on
  any unrelated field update).
- email-viewer effective-attachments memo (derives from email.attachments;
  whole email would churn the list + its layout measurement).
- email-viewer auto-MDN effect (email already captured via id +
  sendReadReceiptNow; autoMdnRef guards double-send).

tsc --noEmit clean; eslint now reports 0 problems.
2026-06-19 12:31:07 +02:00

171 lines
6.5 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { EmailComposer, type ComposerDraftData } from "@/components/email/email-composer";
import { ErrorBoundary, ComposerErrorFallback } from "@/components/error";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store";
import { useProTabStore, type ProComposeTabData } from "@/stores/pro-tab-store";
import { debug } from "@/lib/debug";
interface ProComposeTabBodyProps {
tabId: string;
data: ProComposeTabData;
}
/**
* Renders a standalone `<EmailComposer />` inside its own Pro tab. Sending,
* draft autosave, and discard all flow through the shared `email-store`, so
* the result is identical to composing inline in the mail page - the
* composer is just hosted in its own tab instead of in the right pane.
*/
export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
const t = useTranslations();
const client = useAuthStore((s) => s.client);
const sendEmail = useEmailStore((s) => s.sendEmail);
const refreshCurrentMailbox = useEmailStore((s) => s.refreshCurrentMailbox);
const fetchScheduledEmails = useEmailStore((s) => s.fetchScheduledEmails);
const refreshScheduledMetadata = useEmailStore((s) => s.refreshScheduledMetadata);
const isScheduledView = useEmailStore((s) => s.isScheduledView);
const closeTab = useProTabStore((s) => s.closeTab);
const updateTabTitle = useProTabStore((s) => s.updateTabTitle);
const updateComposeDraft = useProTabStore((s) => s.updateComposeDraft);
// Keep stable references for the callbacks below so the composer's
// `key={sessionId}` doesn't churn.
const tabIdRef = useRef(tabId);
tabIdRef.current = tabId;
const handleScheduledSendCreated = useCallback(async () => {
if (client) {
await refreshScheduledMetadata(client);
if (isScheduledView) await fetchScheduledEmails(client);
}
closeTab(tabIdRef.current);
}, [client, refreshScheduledMetadata, isScheduledView, fetchScheduledEmails, closeTab]);
const handleSend = useCallback(async (sendData: Parameters<NonNullable<React.ComponentProps<typeof EmailComposer>['onSend']>>[0]) => {
if (!client) return;
try {
const result = await sendEmail(
client,
sendData.to,
sendData.subject,
sendData.body,
sendData.cc,
sendData.bcc,
sendData.identityId,
sendData.fromEmail,
sendData.draftId,
sendData.fromName,
sendData.htmlBody,
sendData.attachments,
sendData.inReplyTo,
sendData.references,
sendData.delayedUntil,
sendData.envelopeMailFrom,
);
if (result.scheduled) {
await handleScheduledSendCreated();
return;
}
// Mark the original message as $answered / $forwarded so the standard
// viewer and list reflect the action (same behaviour as inline compose).
if (data.sourceEmailId && (data.mode === 'reply' || data.mode === 'replyAll')) {
try {
await client.setKeyword(data.sourceEmailId, '$answered');
} catch (e) {
debug.error('Failed to set $answered keyword:', e);
}
} else if (data.sourceEmailId && data.mode === 'forward') {
try {
await client.setKeyword(data.sourceEmailId, '$forwarded');
} catch (e) {
debug.error('Failed to set $forwarded keyword:', e);
}
}
// Refresh the currently-active mail list so the new sent message /
// updated keyword status shows up.
await refreshCurrentMailbox(client);
// Re-fetch the replied thread's cross-folder data so the expanded
// view shows the newly sent reply without collapsing.
if (data.sourceEmailId) {
const emailState = useEmailStore.getState();
const repliedEmail = emailState.emails.find(e => e.id === data.sourceEmailId);
if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) {
const accountId = client.getAccountId();
const fullEmails = await client.getThreadEmails(repliedEmail.threadId, accountId);
if (fullEmails.length > 0) {
useEmailStore.setState((state) => {
const c = new Map(state.threadEmailsCache);
c.set(repliedEmail.threadId!, fullEmails);
return { threadEmailsCache: c };
});
}
}
}
closeTab(tabIdRef.current);
} catch (error) {
console.error('Failed to send email:', error);
toast.error(t('notifications.error_sending'));
}
}, [client, sendEmail, closeTab, data.sourceEmailId, data.mode, t, handleScheduledSendCreated, refreshCurrentMailbox]);
const handleClose = useCallback(() => {
closeTab(tabIdRef.current);
}, [closeTab]);
const handleDiscardDraft = useCallback(async (draftId: string) => {
if (!client) return;
try {
await client.deleteEmail(draftId);
} catch (error) {
console.error('Failed to discard draft:', error);
}
}, [client]);
const handleSaveState = useCallback((state: ComposerDraftData) => {
updateComposeDraft(tabIdRef.current, state);
// Keep the tab title in sync with the working subject.
const subject = state.subject?.trim() || t('email_composer.new_message');
updateTabTitle(tabIdRef.current, subject);
}, [updateComposeDraft, updateTabTitle, t]);
// On first mount, ensure the tab title matches whatever subject we were
// initialised with (replies start with "Re: …", forwards with "Fwd: …").
useEffect(() => {
const initialSubject = data.initialData?.subject?.trim()
?? data.replyTo?.subject
?? '';
const title = initialSubject || t('email_composer.new_message');
updateTabTitle(tabIdRef.current, title);
// Run once on mount only.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="flex h-full w-full flex-col bg-background">
<ErrorBoundary fallback={ComposerErrorFallback}>
<EmailComposer
key={data.sessionId}
mode={data.initialData?.mode ?? data.mode}
replyTo={data.replyTo}
initialDraftText={data.initialDraftText}
initialData={data.initialData}
onSend={handleSend}
onScheduledSendCreated={handleScheduledSendCreated}
onClose={handleClose}
onDiscardDraft={handleDiscardDraft}
onSaveState={handleSaveState}
className="flex-1"
/>
</ErrorBoundary>
</div>
);
}