750 lines
22 KiB
TypeScript
750 lines
22 KiB
TypeScript
"use client"
|
||
|
||
import {
|
||
createContext,
|
||
useCallback,
|
||
useContext,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from "react"
|
||
|
||
export interface Signature {
|
||
id: string
|
||
name: string
|
||
html: string
|
||
}
|
||
|
||
export interface Identity {
|
||
id?: string
|
||
accountId?: string
|
||
name: string
|
||
email: string
|
||
defaultSignatureId: string | null
|
||
signatureHtml?: string | null
|
||
isDefault?: boolean
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
/** @deprecated Use server-backed signatures via mail-signatures-store. */
|
||
export const SIGNATURES: Signature[] = []
|
||
|
||
export const DEFAULT_IDENTITIES: Identity[] = []
|
||
|
||
export const FALLBACK_COMPOSE_IDENTITY: Identity = {
|
||
name: "",
|
||
email: "",
|
||
defaultSignatureId: null,
|
||
}
|
||
|
||
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" },
|
||
]
|
||
|
||
import { buildBodyWithSignature } from "@/lib/compose/identity-map"
|
||
import { resolveComposeIdentity } from "@/lib/compose/resolve-compose-identity"
|
||
|
||
let composeCounter = 0
|
||
|
||
function createNewCompose(): ComposeState {
|
||
composeCounter++
|
||
const identity = resolveComposeIdentity()
|
||
const sigId = identity.defaultSignatureId
|
||
const bodyHtml = buildBodyWithSignature("", identity)
|
||
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<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,
|
||
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<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 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<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(),
|
||
}
|
||
}
|