"use client" import { createContext, useCallback, useContext, useMemo, useRef, useState, } from "react" export interface Signature { id: string name: string html: string } export interface Identity { name: string email: string defaultSignatureId: string | null } export interface Contact { name: string email: string } export interface Attachment { id: string file: File name: string size: number type: string } export type ComposePlacement = "dock" | "inline" export type ThreadComposeKind = "reply" | "replyAll" | "forward" export interface ThreadingMeta { /** Valeur type Message-Id pour en-tête In-Reply-To */ inReplyTo: string /** Chaîne References (tokens séparés par des espaces) */ references: string[] } /** Snapshot pour détecter les changements dans une réponse / transfert inline */ export interface ThreadDraftBaseline { bodyHtml: string subject: string to: Contact[] cc: Contact[] bcc: Contact[] } /** Brouillon de réponse ou transfert rattaché à une conversation (`Email.id`) */ export interface SavedThreadReplyDraft { threadEmailId: string threadKind: ThreadComposeKind from: Identity to: Contact[] cc: Contact[] bcc: Contact[] subject: string bodyHtml: string threading: ThreadingMeta | null showCc: boolean showBcc: boolean signatureId: string | null autoInsertSignature: boolean } export interface ComposeState { id: string from: Identity to: Contact[] cc: Contact[] bcc: Contact[] subject: string bodyHtml: string attachments: Attachment[] signatureId: string | null autoInsertSignature: boolean minimized: boolean maximized: boolean showFrom: boolean showCc: boolean showBcc: boolean savedAt: number | null /** dock = fenêtres en bas à droite ; inline = sous le fil dans la vue message */ placement: ComposePlacement /** Si placement inline : id du mail ouvert (`Email.id`) */ threadEmailId: string | null threadKind?: ThreadComposeKind focusToOnMount?: boolean /** Réponse / reprise brouillon : focus corps, curseur en tête du message */ focusBodyOnMount?: boolean /** Après passage en fenêtre dock : focus sur le champ Objet une fois */ focusSubjectOnMount?: boolean threading?: ThreadingMeta | null /** Référence initiale (nouveau message ou hydratation brouillon) pour savoir si le fil a été modifié */ threadDraftBaseline: ThreadDraftBaseline | null /** Ouvert depuis la carte `savedThreadReplyDrafts` — si fermé sans modif, on conserve la carte */ threadDraftOpenedFromSaved: boolean /** Édition d’un envoi déjà dans « Planifié » (id stable côté placeholder API). */ editingScheduledId: string | null /** ISO date/heure d’envoi affichée et mise à jour par « Planifier ». */ scheduledSendAtIso: string | null } export const SIGNATURES: Signature[] = [ { id: "sig-personal", name: "Personnelle", html: `
CRIPPLINGDEPRESSION
redeathray@gmail.com
`, }, { id: "sig-pro", name: "Professionnelle", html: `
Eliott Pro
Software Engineer
eliott.pro@company.com
+33 6 12 34 56 78
`, }, ] export const DEFAULT_IDENTITIES: Identity[] = [ { name: "CRIPPLINGDEPRESSION", email: "redeathray@gmail.com", defaultSignatureId: "sig-personal" }, { name: "Eliott Pro", email: "eliott.pro@company.com", defaultSignatureId: "sig-pro" }, ] export const MOCK_CONTACTS: Contact[] = [ { name: "John Jeffrey", email: "john.jeffrey@gmail.com" }, { name: "Marie Dupont", email: "marie.dupont@outlook.com" }, { name: "Alice Martin", email: "alice.martin@yahoo.fr" }, { name: "Bob Wilson", email: "bob.wilson@proton.me" }, { name: "Clara Zhang", email: "clara.zhang@gmail.com" }, { name: "David Nakamura", email: "david.nakamura@icloud.com" }, { name: "Emma Bernard", email: "emma.bernard@hotmail.com" }, { name: "François Leroy", email: "francois.leroy@free.fr" }, { name: "Gabriel Santos", email: "gabriel.santos@gmail.com" }, { name: "Hana Kim", email: "hana.kim@naver.com" }, ] let composeCounter = 0 function createNewCompose(): ComposeState { composeCounter++ const identity = DEFAULT_IDENTITIES[0] const sigId = identity.defaultSignatureId const sig = sigId ? SIGNATURES.find((s) => s.id === sigId) : null const bodyHtml = sig ? `

--

${sig.html}
` : "" return { id: `compose-${Date.now()}-${composeCounter}`, from: identity, to: [], cc: [], bcc: [], subject: "", bodyHtml, attachments: [], signatureId: sigId, autoInsertSignature: true, minimized: false, maximized: false, showFrom: false, showCc: false, showBcc: false, savedAt: null, placement: "dock", threadEmailId: null, threadKind: undefined, focusToOnMount: true, focusBodyOnMount: false, focusSubjectOnMount: false, threading: null, threadDraftBaseline: null, threadDraftOpenedFromSaved: false, editingScheduledId: null, scheduledSendAtIso: null, } } export type ComposeOpenPreset = Partial< Pick< ComposeState, | "to" | "cc" | "bcc" | "subject" | "bodyHtml" | "placement" | "threadEmailId" | "threadKind" | "focusToOnMount" | "focusBodyOnMount" | "focusSubjectOnMount" | "threading" | "showCc" | "showBcc" | "from" | "signatureId" | "autoInsertSignature" | "editingScheduledId" | "scheduledSendAtIso" > > & { /** Hydratation depuis `savedThreadReplyDrafts` */ openedFromSavedThreadDraft?: boolean } function cloneContacts(list: Contact[]): Contact[] { return list.map((c) => ({ ...c })) } function cloneThreadDraftBaseline(b: ThreadDraftBaseline): ThreadDraftBaseline { return { bodyHtml: b.bodyHtml, subject: b.subject, to: cloneContacts(b.to), cc: cloneContacts(b.cc), bcc: cloneContacts(b.bcc), } } /** Copie profonde (sauf références `File` des pièces jointes) pour annulation d’envoi différé. */ export function cloneComposeForPendingSend(c: ComposeState): ComposeState { return { ...c, from: { ...c.from }, to: cloneContacts(c.to), cc: cloneContacts(c.cc), bcc: cloneContacts(c.bcc), attachments: c.attachments.map((a) => ({ ...a })), threading: c.threading ? { ...c.threading } : null, threadDraftBaseline: c.threadDraftBaseline ? cloneThreadDraftBaseline(c.threadDraftBaseline) : null, } } export function makeThreadDraftBaseline( c: Pick ): ThreadDraftBaseline { return { bodyHtml: c.bodyHtml, subject: c.subject, to: cloneContacts(c.to), cc: cloneContacts(c.cc), bcc: cloneContacts(c.bcc), } } function contactsEqual(a: Contact[], b: Contact[]): boolean { if (a.length !== b.length) return false return a.every( (x, i) => x.email === b[i]?.email && x.name === (b[i]?.name ?? "") ) } export function isThreadDraftModified(c: ComposeState): boolean { const baseline = c.threadDraftBaseline if ( !baseline || c.placement !== "inline" || !c.threadEmailId ) { return false } return ( c.bodyHtml !== baseline.bodyHtml || c.subject !== baseline.subject || !contactsEqual(c.to, baseline.to) || !contactsEqual(c.cc, baseline.cc) || !contactsEqual(c.bcc, baseline.bcc) ) } export function composeToSavedThreadDraft(c: ComposeState): SavedThreadReplyDraft { const tid = c.threadEmailId! const kind = c.threadKind ?? "reply" return { threadEmailId: tid, threadKind: kind, from: c.from, to: cloneContacts(c.to), cc: cloneContacts(c.cc), bcc: cloneContacts(c.bcc), subject: c.subject, bodyHtml: c.bodyHtml, threading: c.threading ?? null, showCc: c.showCc, showBcc: c.showBcc, signatureId: c.signatureId, autoInsertSignature: c.autoInsertSignature, } } export function savedThreadDraftToComposePreset( d: SavedThreadReplyDraft ): ComposeOpenPreset { return { from: d.from, to: d.to, cc: d.cc, bcc: d.bcc, subject: d.subject, bodyHtml: d.bodyHtml, placement: "inline", threadEmailId: d.threadEmailId, threadKind: d.threadKind, threading: d.threading ?? undefined, showCc: d.showCc, showBcc: d.showBcc, signatureId: d.signatureId ?? undefined, autoInsertSignature: d.autoInsertSignature, focusToOnMount: d.threadKind === "forward", focusBodyOnMount: d.threadKind !== "forward", openedFromSavedThreadDraft: true, } } function mergeComposeFromPreset( base: ComposeState, preset: ComposeOpenPreset ): ComposeState { return { ...base, from: preset.from ?? base.from, to: preset.to ?? base.to, cc: preset.cc ?? base.cc, bcc: preset.bcc ?? base.bcc, subject: preset.subject ?? base.subject, bodyHtml: preset.bodyHtml ?? base.bodyHtml, placement: preset.placement ?? base.placement, threadEmailId: preset.threadEmailId ?? base.threadEmailId, threadKind: preset.threadKind ?? base.threadKind, focusToOnMount: preset.focusToOnMount ?? base.focusToOnMount, focusBodyOnMount: preset.focusBodyOnMount ?? base.focusBodyOnMount, focusSubjectOnMount: preset.focusSubjectOnMount ?? base.focusSubjectOnMount, threading: preset.threading !== undefined ? preset.threading : base.threading, showCc: preset.showCc ?? base.showCc, showBcc: preset.showBcc ?? base.showBcc, signatureId: preset.signatureId !== undefined ? preset.signatureId : base.signatureId, autoInsertSignature: preset.autoInsertSignature !== undefined ? preset.autoInsertSignature : base.autoInsertSignature, editingScheduledId: preset.editingScheduledId !== undefined ? preset.editingScheduledId : base.editingScheduledId, scheduledSendAtIso: preset.scheduledSendAtIso !== undefined ? preset.scheduledSendAtIso : base.scheduledSendAtIso, } } function withInlineThreadDraftMeta( merged: ComposeState, openedFromSaved: boolean ): ComposeState { if (merged.placement !== "inline" || !merged.threadEmailId) { return { ...merged, threadDraftBaseline: null, threadDraftOpenedFromSaved: false, } } return { ...merged, threadDraftBaseline: makeThreadDraftBaseline(merged), threadDraftOpenedFromSaved: openedFromSaved, } } function applySavedDraftsAfterClosingInline( prevDrafts: Record, w: ComposeState ): Record { const tid = w.threadEmailId if (!tid || w.placement !== "inline") return prevDrafts const next: Record = { ...prevDrafts } if (isThreadDraftModified(w)) { next[tid] = composeToSavedThreadDraft(w) } else if (!w.threadDraftOpenedFromSaved) { delete next[tid] } return next } type ComposeContextValue = { composeWindows: ComposeState[] /** Brouillons de réponse/transfert par id de conversation */ savedThreadReplyDrafts: Record openCompose: () => void openComposeWithInitial: (preset: ComposeOpenPreset) => void /** Réouvre une fenêtre après annulation d’un envoi en cours (nouvel id, baseline recalculée). */ restoreComposeFromSnapshot: (snapshot: ComposeState) => void /** Met à jour une fenêtre existante avec un preset (ex. changer Répondre → Transférer en inline). */ applyComposePreset: (id: string, preset: ComposeOpenPreset) => void closeCompose: (id: string, opts?: { sent?: boolean; discardThreadReplyDraft?: boolean }) => void closeAllInlineComposes: () => void /** Ferme les inline d’autres fils ; conserve celui du fil `keepThreadEmailId` (vue message). */ pruneInlineComposesToOpenThread: (keepThreadEmailId: string) => void updateCompose: (id: string, patch: Partial) => void toggleMinimize: (id: string) => void toggleMaximize: (id: string) => void saveDraft: (id: string) => void } const ComposeContext = createContext(null) export function ComposeProvider({ children }: { children: React.ReactNode }) { const [composeWindows, setComposeWindows] = useState([]) const [savedThreadReplyDrafts, setSavedThreadReplyDrafts] = useState< Record >({}) const autoSaveTimers = useRef>(new Map()) const composeWindowsRef = useRef(composeWindows) composeWindowsRef.current = composeWindows const openCompose = useCallback(() => { const newCompose = createNewCompose() setComposeWindows((prev) => { const updated = prev.map((w) => !w.minimized ? { ...w, minimized: false } : w ) return [...updated, newCompose] }) }, []) const openComposeWithInitial = useCallback((preset: ComposeOpenPreset) => { const baseMerged = mergeComposeFromPreset(createNewCompose(), preset) const openedFromSaved = preset.openedFromSavedThreadDraft === true const merged = withInlineThreadDraftMeta(baseMerged, openedFromSaved) if (merged.placement === "inline" && merged.threadEmailId && !openedFromSaved) { setSavedThreadReplyDrafts((d) => { const next = { ...d } delete next[merged.threadEmailId!] return next }) } const prevSnap = composeWindowsRef.current if (merged.placement === "inline" && merged.threadEmailId) { const victim = prevSnap.find( (w) => w.placement === "inline" && w.threadEmailId === merged.threadEmailId ) if (victim && isThreadDraftModified(victim)) { setSavedThreadReplyDrafts((d) => applySavedDraftsAfterClosingInline(d, victim) ) } } setComposeWindows((prev) => { let next = prev if (merged.placement === "inline" && merged.threadEmailId) { next = prev.filter( (w) => !( w.placement === "inline" && w.threadEmailId === merged.threadEmailId ) ) } const updated = next.map((w) => !w.minimized ? { ...w, minimized: false } : w ) return [...updated, merged] }) }, []) const restoreComposeFromSnapshot = useCallback((snapshot: ComposeState) => { composeCounter++ const newId = `compose-${Date.now()}-${composeCounter}` const merged: ComposeState = { ...snapshot, id: newId, minimized: false, maximized: false, threadDraftBaseline: snapshot.placement === "inline" && snapshot.threadEmailId ? makeThreadDraftBaseline(snapshot) : null, threadDraftOpenedFromSaved: false, } const openedFromSaved = false if (merged.placement === "inline" && merged.threadEmailId && !openedFromSaved) { setSavedThreadReplyDrafts((d) => { const next = { ...d } delete next[merged.threadEmailId!] return next }) } const prevSnap = composeWindowsRef.current if (merged.placement === "inline" && merged.threadEmailId) { const victim = prevSnap.find( (w) => w.placement === "inline" && w.threadEmailId === merged.threadEmailId ) if (victim && isThreadDraftModified(victim)) { setSavedThreadReplyDrafts((d) => applySavedDraftsAfterClosingInline(d, victim) ) } } setComposeWindows((prev) => { let next = prev if (merged.placement === "inline" && merged.threadEmailId) { next = prev.filter( (w) => !( w.placement === "inline" && w.threadEmailId === merged.threadEmailId ) ) } const updated = next.map((w) => !w.minimized ? { ...w, minimized: false } : w ) return [...updated, merged] }) }, []) const applyComposePreset = useCallback((id: string, preset: ComposeOpenPreset) => { setComposeWindows((prev) => prev.map((w) => { if (w.id !== id) return w const m = mergeComposeFromPreset(w, preset) if (m.placement === "inline" && m.threadEmailId) { return withInlineThreadDraftMeta(m, w.threadDraftOpenedFromSaved) } return { ...m, threadDraftBaseline: null, threadDraftOpenedFromSaved: false, } }) ) }, []) const closeAllInlineComposes = useCallback(() => { const prev = composeWindowsRef.current setSavedThreadReplyDrafts((d) => { let next = d for (const w of prev) { if (w.placement === "inline" && w.threadEmailId) { next = applySavedDraftsAfterClosingInline(next, w) } } return next }) setComposeWindows((p) => p.filter((x) => x.placement !== "inline")) }, []) const pruneInlineComposesToOpenThread = useCallback( (keepThreadEmailId: string) => { const prev = composeWindowsRef.current setSavedThreadReplyDrafts((d) => { let next = d for (const w of prev) { if ( w.placement === "inline" && w.threadEmailId && w.threadEmailId !== keepThreadEmailId ) { next = applySavedDraftsAfterClosingInline(next, w) } } return next }) setComposeWindows((p) => p.filter( (w) => w.placement !== "inline" || w.threadEmailId === keepThreadEmailId ) ) }, [] ) const closeCompose = useCallback((id: string, opts?: { sent?: boolean; discardThreadReplyDraft?: boolean }) => { const timer = autoSaveTimers.current.get(id) if (timer) { clearTimeout(timer) autoSaveTimers.current.delete(id) } const w = composeWindowsRef.current.find((x) => x.id === id) if (w?.placement === "inline" && w.threadEmailId) { if (opts?.sent || opts?.discardThreadReplyDraft) { setSavedThreadReplyDrafts((d) => { const next = { ...d } delete next[w.threadEmailId!] return next }) } else { setSavedThreadReplyDrafts((d) => applySavedDraftsAfterClosingInline(d, w)) } } setComposeWindows((prev) => prev.filter((x) => x.id !== id)) }, []) const updateCompose = useCallback((id: string, patch: Partial) => { setComposeWindows((prev) => prev.map((w) => (w.id === id ? { ...w, ...patch } : w)) ) const existingTimer = autoSaveTimers.current.get(id) if (existingTimer) clearTimeout(existingTimer) const timer = setTimeout(() => { setComposeWindows((prev) => prev.map((w) => w.id === id ? { ...w, savedAt: Date.now() } : w ) ) autoSaveTimers.current.delete(id) }, 3000) autoSaveTimers.current.set(id, timer) }, []) const toggleMinimize = useCallback((id: string) => { setComposeWindows((prev) => prev.map((w) => w.id === id ? { ...w, minimized: !w.minimized, maximized: false } : w ) ) }, []) const toggleMaximize = useCallback((id: string) => { setComposeWindows((prev) => prev.map((w) => w.id === id ? { ...w, maximized: !w.maximized, minimized: false } : w ) ) }, []) const saveDraft = useCallback((id: string) => { setComposeWindows((prev) => prev.map((w) => w.id === id ? { ...w, savedAt: Date.now() } : w ) ) }, []) const value = useMemo( () => ({ composeWindows, savedThreadReplyDrafts, openCompose, openComposeWithInitial, restoreComposeFromSnapshot, applyComposePreset, closeCompose, closeAllInlineComposes, pruneInlineComposesToOpenThread, updateCompose, toggleMinimize, toggleMaximize, saveDraft, }), [ composeWindows, savedThreadReplyDrafts, openCompose, openComposeWithInitial, restoreComposeFromSnapshot, applyComposePreset, closeCompose, closeAllInlineComposes, pruneInlineComposesToOpenThread, updateCompose, toggleMinimize, toggleMaximize, saveDraft, ] ) return ( {children} ) } export function useCompose() { const ctx = useContext(ComposeContext) if (!ctx) throw new Error("useCompose must be used inside ") return ctx }