"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
? ``
: ""
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"
| "maximized"
| "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,
maximized: preset.maximized ?? base.maximized,
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
}
export type ComposeActionsContextValue = {
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
}
export type ComposeDraftsContextValue = {
/** Brouillons de réponse/transfert par id de conversation */
savedThreadReplyDrafts: Record
}
export type ComposeWindowsContextValue = {
composeWindows: ComposeState[]
}
export type ComposeContextValue = ComposeActionsContextValue &
ComposeDraftsContextValue &
ComposeWindowsContextValue
const ComposeActionsContext = createContext(null)
const ComposeDraftsContext = createContext(null)
const ComposeWindowsContext = createContext(null)
export function ComposeProvider({ children }: { children: React.ReactNode }) {
const [composeWindows, setComposeWindows] = useState([])
const [savedThreadReplyDrafts, setSavedThreadReplyDrafts] = useState<
Record
>({})
const autoSaveTimers = useRef