ultisuite-client/lib/compose-context.tsx
2026-05-15 23:51:57 +02:00

751 lines
22 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.

"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 dun envoi déjà dans « Planifié » (id stable côté placeholder API). */
editingScheduledId: string | null
/** ISO date/heure denvoi affichée et mise à jour par « Planifier ». */
scheduledSendAtIso: string | null
}
export const SIGNATURES: Signature[] = [
{
id: "sig-personal",
name: "Personnelle",
html: `<div style="color:#5f6368;font-size:13px"><b>CRIPPLINGDEPRESSION</b><br>redeathray@gmail.com</div>`,
},
{
id: "sig-pro",
name: "Professionnelle",
html: `<div style="color:#5f6368;font-size:13px"><b>Eliott Pro</b><br>Software Engineer<br>eliott.pro@company.com<br>+33 6 12 34 56 78</div>`,
},
]
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
? `<p></p><div id="ultimail-signature"><p>--</p>${sig.html}</div>`
: ""
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 denvoi 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<ComposeState, "bodyHtml" | "subject" | "to" | "cc" | "bcc">
): 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<string, SavedThreadReplyDraft>,
w: ComposeState
): Record<string, SavedThreadReplyDraft> {
const tid = w.threadEmailId
if (!tid || w.placement !== "inline") return prevDrafts
const next: Record<string, SavedThreadReplyDraft> = { ...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 dun 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 dautres fils ; conserve celui du fil `keepThreadEmailId` (vue message). */
pruneInlineComposesToOpenThread: (keepThreadEmailId: string) => void
updateCompose: (id: string, patch: Partial<ComposeState>) => 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<string, SavedThreadReplyDraft>
}
export type ComposeWindowsContextValue = {
composeWindows: ComposeState[]
}
export type ComposeContextValue = ComposeActionsContextValue &
ComposeDraftsContextValue &
ComposeWindowsContextValue
const ComposeActionsContext = createContext<ComposeActionsContextValue | null>(null)
const ComposeDraftsContext = createContext<ComposeDraftsContextValue | null>(null)
const ComposeWindowsContext = createContext<ComposeWindowsContextValue | null>(null)
export function ComposeProvider({ children }: { children: React.ReactNode }) {
const [composeWindows, setComposeWindows] = useState<ComposeState[]>([])
const [savedThreadReplyDrafts, setSavedThreadReplyDrafts] = useState<
Record<string, SavedThreadReplyDraft>
>({})
const autoSaveTimers = useRef<Map<string, NodeJS.Timeout>>(new Map())
const composeWindowsRef = useRef<ComposeState[]>(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<ComposeState>) => {
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 actionsValue = useMemo<ComposeActionsContextValue>(
() => ({
openCompose,
openComposeWithInitial,
restoreComposeFromSnapshot,
applyComposePreset,
closeCompose,
closeAllInlineComposes,
pruneInlineComposesToOpenThread,
updateCompose,
toggleMinimize,
toggleMaximize,
saveDraft,
}),
[
openCompose,
openComposeWithInitial,
restoreComposeFromSnapshot,
applyComposePreset,
closeCompose,
closeAllInlineComposes,
pruneInlineComposesToOpenThread,
updateCompose,
toggleMinimize,
toggleMaximize,
saveDraft,
]
)
const draftsValue = useMemo<ComposeDraftsContextValue>(
() => ({ savedThreadReplyDrafts }),
[savedThreadReplyDrafts]
)
const windowsValue = useMemo<ComposeWindowsContextValue>(
() => ({ composeWindows }),
[composeWindows]
)
return (
<ComposeActionsContext.Provider value={actionsValue}>
<ComposeDraftsContext.Provider value={draftsValue}>
<ComposeWindowsContext.Provider value={windowsValue}>
{children}
</ComposeWindowsContext.Provider>
</ComposeDraftsContext.Provider>
</ComposeActionsContext.Provider>
)
}
export function useComposeActions(): ComposeActionsContextValue {
const ctx = useContext(ComposeActionsContext)
if (!ctx) throw new Error("useComposeActions must be used inside <ComposeProvider>")
return ctx
}
export function useComposeDrafts(): ComposeDraftsContextValue {
const ctx = useContext(ComposeDraftsContext)
if (!ctx) throw new Error("useComposeDrafts must be used inside <ComposeProvider>")
return ctx
}
export function useComposeWindows(): ComposeWindowsContextValue {
const ctx = useContext(ComposeWindowsContext)
if (!ctx) throw new Error("useComposeWindows must be used inside <ComposeProvider>")
return ctx
}
/** Merged read — rerenders when windows, drafts, or any action identity changes. Prefer split hooks for perf. */
export function useCompose(): ComposeContextValue {
return {
...useComposeActions(),
...useComposeDrafts(),
...useComposeWindows(),
}
}