diff --git a/app/globals.css b/app/globals.css index b24fdcb..0a87624 100644 --- a/app/globals.css +++ b/app/globals.css @@ -429,6 +429,58 @@ html[data-mail-background]:not([data-mail-background='none']) background-color: var(--mail-invitation); } +/** + * Sidebar frosted strips — backdrop blur hides children scrolling behind sticky parents (sm+). + * Background stays transparent so there is no tint — the blur alone conceals the text. + */ +.ultimail-app .mail-sidebar-blur-surface { + background-color: transparent; + -webkit-backdrop-filter: blur(24px) saturate(150%); + backdrop-filter: blur(24px) saturate(150%); +} + +/** + * Hover-expanded sidebar frosted panel — uses ::before so it doesn't create + * a backdrop-filter stacking context that would clip children's backdrop-filters. + */ +.ultimail-app .mail-sidebar-hover-frosted::before { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + -webkit-backdrop-filter: blur(24px) saturate(150%); + backdrop-filter: blur(24px) saturate(150%); + pointer-events: none; +} + +/** + * Sticky sidebar strip — blur spans full rail width (not just indented label). + * Set --sidebar-sticky-pad-left on the element (px indent of row content). + */ +.ultimail-app .mail-sidebar-blur-sticky-strip { + position: relative; + isolation: isolate; +} + +.ultimail-app .mail-sidebar-blur-sticky-strip::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: calc(-1 * var(--sidebar-sticky-pad-left, 0px)); + right: calc(-1 * var(--sidebar-nav-rail-inset, 14px)); + z-index: 0; + background-color: transparent; + -webkit-backdrop-filter: blur(24px) saturate(150%); + backdrop-filter: blur(24px) saturate(150%); + pointer-events: none; +} + +.ultimail-app .mail-sidebar-blur-sticky-strip > * { + position: relative; + z-index: 1; +} + /** * Sidebar overlay (touch / xs) — fond opaque. * Nom hors préfixe bg-* pour éviter qu’un utility Tailwind écrase la règle. diff --git a/app/mail/mail-app-shell.tsx b/app/mail/mail-app-shell.tsx index 1ba8a6e..099c8ef 100644 --- a/app/mail/mail-app-shell.tsx +++ b/app/mail/mail-app-shell.tsx @@ -119,14 +119,13 @@ function MailAppInner() { onClick={() => setSidebarCollapsed(true)} /> )} + {/* xs: overlay (w-0). sm+: spacer matches rail; hover-expand can grow over main without shifting layout */}
` +- `use-sidebar-state.ts` — état local + effets +- `sidebar-header.tsx` — logo, compose, réglages +- `sidebar-nav-panel.tsx` — nav principale, dossiers, libellés +- `sidebar-folder-row-expanded.tsx`, `sidebar-label-item-row.tsx`, … + +## compose/ + +- `compose-window.tsx` — UI fenêtre +- `use-compose-window.ts` — éditeur TipTap, envoi, pièces jointes +- `compose-modal-manager.tsx` — pile de fenêtres / sheet mobile +- `compose-recipients.tsx`, `compose-editor-chrome.tsx`, `compose-toolbar.tsx` diff --git a/components/gmail/compose-modal.tsx b/components/gmail/compose-modal.tsx index 7248f5b..b2c824f 100644 --- a/components/gmail/compose-modal.tsx +++ b/components/gmail/compose-modal.tsx @@ -1,815 +1 @@ -"use client" - -import { - useState, - useRef, - useEffect, - useLayoutEffect, - useCallback, - useMemo, -} from "react" -import { useIsXs } from "@/hooks/use-xs" -import { readCoarsePointerMatches } from "@/hooks/use-touch-nav" -import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet" -import { useEditor, EditorContent } from "@tiptap/react" -import { type Extensions } from "@tiptap/core" -import StarterKit from "@tiptap/starter-kit" -import Underline from "@tiptap/extension-underline" -import Link from "@tiptap/extension-link" -import TextAlign from "@tiptap/extension-text-align" -import { TextStyle, FontFamily, FontSize, BackgroundColor } from "@tiptap/extension-text-style" -import Color from "@tiptap/extension-color" -import { - Reply, - ReplyAll, - Forward, - Maximize2, - Minimize2, - X, -} from "lucide-react" -import { - type ComposeState, - cloneComposeForPendingSend, - DEFAULT_IDENTITIES, - useComposeActions, - useComposeWindows, -} from "@/lib/compose-context" -import { useScheduledMail } from "@/lib/scheduled-mail-context" -import type { ScheduleSendPayload } from "@/lib/api/scheduled-mail" -import type { Email } from "@/lib/email-data" -import { - buildThreadComposePreset, - collectThreadParticipants, -} from "@/lib/thread-compose-preset" -import { toast } from "sonner" -import { showPendingSendToast } from "@/lib/pending-send-toast" -import { cn } from "@/lib/utils" -import { - MAIL_COMPOSE_TITLEBAR_CLASS, - MAIL_ICON_BTN, -} from "@/lib/mail-chrome-classes" -import { ComposeRecipientFields } from "@/components/gmail/compose/compose-recipients" -import { - ComposeBottomToolbar, - FormattingToolbar, -} from "@/components/gmail/compose/compose-toolbar" -import { - ComposeAttachmentsList, - ComposeDockTitleBar, - ComposeDropOverlay, - ComposeInlineRecipientHeader, - ComposeXsSheetHeader, -} from "@/components/gmail/compose/compose-editor-chrome" -import { SignatureBlock, stripSignature, insertSignatureHtml } from "@/components/gmail/compose/compose-shared" - -export function ComposeWindow({ - compose, - threadSourceEmail = null, - isXsSheet = false, - bindXsSheetClose, -}: { - compose: ComposeState - /** Fil courant : nécessaire pour le menu Répondre / Transférer en inline */ - threadSourceEmail?: Email | null - /** Plein écran dans une bottom sheet (xs) — pas de file ni réduction */ - isXsSheet?: boolean - bindXsSheetClose?: (fn: (() => void) | null) => void -}) { - const { - closeCompose, - updateCompose, - applyComposePreset, - toggleMinimize, - toggleMaximize, - restoreComposeFromSnapshot, - } = useComposeActions() - const { scheduleSend, requestUpdateScheduledSend, requestSendScheduledNow } = - useScheduledMail() - const isInline = compose.placement === "inline" - const isEditingScheduled = compose.editingScheduledId != null - const [showFormatting, setShowFormatting] = useState(false) - const [recipientsFocused, setRecipientsFocused] = useState(false) - const [sendMenuOpen, setSendMenuOpen] = useState(false) - const [isDragOver, setIsDragOver] = useState(false) - const fieldsRef = useRef(null) - const inlineRecipientShellRef = useRef(null) - const subjectInputRef = useRef(null) - const fileInputRef = useRef(null) - const imageInputRef = useRef(null) - - const snapBodyCaretToStartOnHeaderTab = - !isInline && - !compose.threadEmailId && - !compose.threadKind && - !compose.editingScheduledId - - const editor = useEditor({ - immediatelyRender: false, - extensions: [ - StarterKit, - Underline, - Link.configure({ openOnClick: false }), - TextStyle, - Color, - BackgroundColor, - FontFamily, - FontSize, - TextAlign.configure({ types: ["heading", "paragraph"], alignments: ["left", "center", "right", "justify"] }), - SignatureBlock, - ] as Extensions, - content: compose.bodyHtml, - onUpdate: ({ editor: ed }) => { - updateCompose(compose.id, { bodyHtml: ed.getHTML() }) - }, - onFocus: ({ editor: ed, event }) => { - if (!snapBodyCaretToStartOnHeaderTab) return - const rt = event.relatedTarget as Node | null - if (!rt || !fieldsRef.current?.contains(rt)) return - window.requestAnimationFrame(() => { - if (!ed.view.hasFocus()) return - try { - ed.chain().setTextSelection(1).run() - } catch { - /* empty doc edge */ - } - }) - }, - editorProps: { - attributes: { - class: cn( - "prose prose-sm max-w-none px-3 py-2 text-sm text-foreground outline-none focus:outline-none", - isInline - ? "min-h-[200px]" - : isXsSheet - ? "min-h-[min(36vh,280px)]" - : "min-h-[150px]" - ), - }, - }, - }) - - const titleText = compose.subject || "Nouveau message" - const bodyWithoutSig = stripSignature(compose.bodyHtml) - .replace(/

<\/p>/g, "") - .trim() - const hasContent = - compose.subject.trim() !== "" || - compose.to.length > 0 || - compose.cc.length > 0 || - compose.bcc.length > 0 || - compose.attachments.length > 0 || - bodyWithoutSig !== "" - - const handleIdentityChange = useCallback( - (identity: (typeof DEFAULT_IDENTITIES)[number]) => { - if (compose.autoInsertSignature && editor) { - const sigId = identity.defaultSignatureId - const newHtml = insertSignatureHtml(editor.getHTML(), sigId) - editor.commands.setContent(newHtml) - updateCompose(compose.id, { from: identity, bodyHtml: newHtml, signatureId: sigId }) - } else { - updateCompose(compose.id, { from: identity }) - } - }, - [compose.id, compose.autoInsertSignature, editor, updateCompose] - ) - - const handleClose = () => { - const threadInlineDiscard = - isInline && compose.threadEmailId - ? ({ discardThreadReplyDraft: true } as const) - : undefined - if (!hasContent) { - closeCompose(compose.id, threadInlineDiscard) - } else { - updateCompose(compose.id, { savedAt: Date.now() }) - closeCompose(compose.id, threadInlineDiscard) - } - } - - const handleCloseRef = useRef(handleClose) - handleCloseRef.current = handleClose - - useLayoutEffect(() => { - if (!isXsSheet || !bindXsSheetClose) return - bindXsSheetClose(() => { - handleCloseRef.current() - }) - return () => bindXsSheetClose(null) - }, [isXsSheet, bindXsSheetClose, compose.id]) - - const htmlToPreviewText = useCallback((html: string) => { - return html - .replace(/]*>[\s\S]*?<\/style>/gi, " ") - .replace(/<[^>]+>/g, " ") - .replace(/\s+/g, " ") - .trim() - }, []) - - const handleSend = useCallback(() => { - if (compose.to.length === 0) return - const bodyHtml = editor?.getHTML() ?? compose.bodyHtml - const snapshot = cloneComposeForPendingSend({ ...compose, bodyHtml }) - closeCompose(compose.id, { sent: true }) - showPendingSendToast({ - onCommit: async () => {}, - onCancel: () => restoreComposeFromSnapshot(snapshot), - }) - }, [ - closeCompose, - compose, - editor, - restoreComposeFromSnapshot, - ]) - - const submitScheduledSendAt = useCallback( - async (sendAt: Date) => { - if (isEditingScheduled) return - if (compose.to.length === 0) return - setSendMenuOpen(false) - const bodyHtml = editor?.getHTML() ?? compose.bodyHtml - await scheduleSend({ - sendAtIso: sendAt.toISOString(), - to: compose.to.map((c) => ({ name: c.name, email: c.email })), - subject: compose.subject, - previewText: htmlToPreviewText(bodyHtml).slice(0, 500), - bodyHtml, - }) - const whenLabel = sendAt.toLocaleString("fr-FR", { - dateStyle: "medium", - timeStyle: "short", - }) - toast.message(`Ce mail sera envoyé le ${whenLabel}`) - closeCompose(compose.id, { sent: true }) - }, - [ - isEditingScheduled, - compose.bodyHtml, - compose.id, - compose.subject, - compose.to, - closeCompose, - editor, - htmlToPreviewText, - scheduleSend, - ] - ) - - const buildSchedulePayload = useCallback( - (sendAtIso: string): ScheduleSendPayload | null => { - if (compose.to.length === 0) return null - const bodyHtml = editor?.getHTML() ?? compose.bodyHtml - return { - sendAtIso, - to: compose.to.map((c) => ({ name: c.name, email: c.email })), - subject: compose.subject, - previewText: htmlToPreviewText(bodyHtml).slice(0, 500), - bodyHtml, - } - }, - [compose.to, compose.subject, compose.bodyHtml, editor, htmlToPreviewText] - ) - - const saveScheduledEdit = useCallback(async () => { - const id = compose.editingScheduledId - if (!id) return - const iso = - compose.scheduledSendAtIso ?? new Date().toISOString() - const payload = buildSchedulePayload(iso) - if (!payload) return - await requestUpdateScheduledSend(id, payload) - toast.message("Modifications enregistrées") - closeCompose(compose.id) - }, [ - buildSchedulePayload, - closeCompose, - compose.editingScheduledId, - compose.id, - compose.scheduledSendAtIso, - requestUpdateScheduledSend, - ]) - - const sendScheduledFromEditNow = useCallback(async () => { - const id = compose.editingScheduledId - if (!id) return - setSendMenuOpen(false) - const bodyHtml = editor?.getHTML() ?? compose.bodyHtml - const snapshot = cloneComposeForPendingSend({ ...compose, bodyHtml }) - closeCompose(compose.id, { sent: true }) - showPendingSendToast({ - onCommit: async () => { - const schedId = snapshot.editingScheduledId - if (!schedId || snapshot.to.length === 0) return - const iso = snapshot.scheduledSendAtIso ?? new Date().toISOString() - const body = snapshot.bodyHtml - const payload = { - sendAtIso: iso, - to: snapshot.to.map((c) => ({ name: c.name, email: c.email })), - subject: snapshot.subject, - previewText: htmlToPreviewText(body).slice(0, 500), - bodyHtml: body, - } - await requestUpdateScheduledSend(schedId, payload) - await requestSendScheduledNow(schedId) - }, - onCancel: () => restoreComposeFromSnapshot(snapshot), - }) - }, [ - closeCompose, - compose, - editor, - htmlToPreviewText, - requestSendScheduledNow, - requestUpdateScheduledSend, - restoreComposeFromSnapshot, - ]) - - const applyScheduledPlanAt = useCallback( - async (sendAt: Date) => { - const id = compose.editingScheduledId - if (!id) return - setSendMenuOpen(false) - const iso = sendAt.toISOString() - const payload = buildSchedulePayload(iso) - if (!payload) return - await requestUpdateScheduledSend(id, payload) - updateCompose(compose.id, { scheduledSendAtIso: iso }) - const whenLabel = sendAt.toLocaleString("fr-FR", { - dateStyle: "medium", - timeStyle: "short", - }) - toast.message(`Envoi planifié le ${whenLabel}`) - }, - [ - buildSchedulePayload, - compose.editingScheduledId, - compose.id, - requestUpdateScheduledSend, - updateCompose, - ] - ) - - const addFiles = useCallback((files: FileList | File[]) => { - const newAttachments = Array.from(files).map((file) => ({ - id: `att-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - file, - name: file.name, - size: file.size, - type: file.type, - })) - updateCompose(compose.id, { - attachments: [...compose.attachments, ...newAttachments], - }) - }, [compose.id, compose.attachments, updateCompose]) - - const removeAttachment = useCallback((attId: string) => { - updateCompose(compose.id, { - attachments: compose.attachments.filter((a) => a.id !== attId), - }) - }, [compose.id, compose.attachments, updateCompose]) - - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - setIsDragOver(false) - if (e.dataTransfer.files.length > 0) { - addFiles(e.dataTransfer.files) - } - }, [addFiles]) - - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - setIsDragOver(true) - }, []) - - const handleDragLeave = useCallback((e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) { - setIsDragOver(false) - } - }, []) - - const showFromField = recipientsFocused || isXsSheet - - useLayoutEffect(() => { - if (!isInline || !compose.focusToOnMount) return - setRecipientsFocused(true) - }, [isInline, compose.focusToOnMount]) - - useEffect(() => { - if (!recipientsFocused) return - const handleClickOutside = (e: Event) => { - const target = e.target as Node - const root = isInline ? inlineRecipientShellRef.current : fieldsRef.current - if (root && !root.contains(target)) { - const el = e.target as HTMLElement | null - const portal = el?.closest?.( - "[data-radix-popper-content-wrapper], [data-radix-dropdown-menu-content], [data-slot='dropdown-menu-content'], [data-slot='popover-content']" - ) - if (portal) return - setRecipientsFocused(false) - if (compose.showCc && compose.cc.length === 0) { - updateCompose(compose.id, { showCc: false }) - } - if (compose.showBcc && compose.bcc.length === 0) { - updateCompose(compose.id, { showBcc: false }) - } - } - } - document.addEventListener("pointerdown", handleClickOutside) - return () => document.removeEventListener("pointerdown", handleClickOutside) - }, [ - recipientsFocused, - isInline, - compose.showCc, - compose.showBcc, - compose.cc.length, - compose.bcc.length, - compose.id, - updateCompose, - ]) - - useEffect(() => { - if (!editor || editor.isDestroyed) return - const next = compose.bodyHtml - if (editor.getHTML() === next) return - editor.commands.setContent(next, { emitUpdate: false }) - }, [compose.bodyHtml, compose.threadKind, editor]) - - useEffect(() => { - if (!compose.focusSubjectOnMount || isInline) return - const id = window.requestAnimationFrame(() => { - subjectInputRef.current?.focus() - updateCompose(compose.id, { focusSubjectOnMount: false }) - }) - return () => window.cancelAnimationFrame(id) - }, [compose.focusSubjectOnMount, isInline, compose.id, updateCompose]) - - useEffect(() => { - if (!compose.focusBodyOnMount || !editor || editor.isDestroyed) return - let cancelled = false - const outer = window.requestAnimationFrame(() => { - window.requestAnimationFrame(() => { - if (cancelled || !editor || editor.isDestroyed) return - try { - editor.chain().focus().setTextSelection(1).run() - } catch { - editor.chain().focus().run() - } - updateCompose(compose.id, { focusBodyOnMount: false }) - }) - }) - return () => { - cancelled = true - window.cancelAnimationFrame(outer) - } - }, [compose.focusBodyOnMount, compose.id, editor, updateCompose]) - - const clearFocusToMount = useCallback(() => { - updateCompose(compose.id, { focusToOnMount: false }) - }, [compose.id, updateCompose]) - - const ThreadKindIcon = - compose.threadKind === "forward" - ? Forward - : compose.threadKind === "replyAll" - ? ReplyAll - : Reply - - const recipientSummary = - compose.to.length === 0 - ? "Destinataires" - : compose.to.length === 1 && compose.to[0] - ? compose.to[0].name === compose.to[0].email - ? compose.to[0].email - : `${compose.to[0].name} <${compose.to[0].email}>` - : `${compose.to.length} destinataires` - - const showReplyAllInMenu = useMemo( - () => - Boolean( - threadSourceEmail && - collectThreadParticipants(threadSourceEmail).length > 1 - ), - [threadSourceEmail] - ) - - const openInlinePreset = useCallback( - (kind: "reply" | "replyAll" | "forward") => { - if (!threadSourceEmail) return - applyComposePreset( - compose.id, - buildThreadComposePreset(threadSourceEmail, kind) - ) - }, - [threadSourceEmail, applyComposePreset, compose.id] - ) - - const openDockFromInline = useCallback( - (opts?: { focusSubject?: boolean }) => { - setRecipientsFocused(false) - updateCompose(compose.id, { - placement: "dock", - threadEmailId: null, - focusToOnMount: false, - focusBodyOnMount: false, - minimized: false, - maximized: false, - focusSubjectOnMount: Boolean(opts?.focusSubject), - }) - }, - [compose.id, updateCompose] - ) - - const recipientFieldsProps = { - compose, - isInline, - showFromField, - updateCompose, - handleIdentityChange, - clearFocusToMount, - subjectInputRef, - onRecipientsActivate: () => setRecipientsFocused(true), - } - - const modalContent = ( -

- {/* Hidden file inputs */} - { - if (e.target.files && e.target.files.length > 0) { - addFiles(e.target.files) - e.target.value = "" - } - }} - /> - { - if (e.target.files && e.target.files.length > 0) { - addFiles(e.target.files) - e.target.value = "" - } - }} - /> - - {/* Drop overlay */} - {isDragOver ? : null} - {isInline ? ( - setRecipientsFocused(true)} - updateCompose={updateCompose} - recipientFieldsProps={recipientFieldsProps} - fieldsRef={fieldsRef} - inlineRecipientShellRef={inlineRecipientShellRef} - /> - ) : isXsSheet ? ( - - ) : ( - <> - {/* Title bar */} - toggleMinimize(compose.id)} - onMaximize={() => toggleMaximize(compose.id)} - onClose={handleClose} - /> - - )} - - {!isInline && ( -
- -
- )} - - {/* Editor */} -
- -
- - - - {showFormatting ? : null} - - -
- ) - - if (compose.minimized && !isInline && !isXsSheet) { - return ( -
toggleMinimize(compose.id)} - > - - {titleText} - -
- - -
-
- ) - } - - if (compose.maximized && !isInline && !isXsSheet) { - return ( - <> -
toggleMaximize(compose.id)} - /> - {modalContent} - - ) - } - - return modalContent -} - -export function ComposeModalManager() { - const { composeWindows } = useComposeWindows() - const isXs = useIsXs() - - const nonMaximized = composeWindows.filter( - (w) => !w.maximized && w.placement !== "inline" - ) - const maximized = composeWindows.filter((w) => w.maximized && w.placement !== "inline") - - const xsSheetCloseRef = useRef<(() => void) | null>(null) - const bindXsSheetClose = useCallback((fn: (() => void) | null) => { - xsSheetCloseRef.current = fn - }, []) - - /** Une seule fenêtre dock visible en xs : la plus récente (comportement type pile). */ - const xsActiveDock = - isXs && nonMaximized.length > 0 ? nonMaximized[nonMaximized.length - 1] : null - - const handleXsSheetOpenChange = useCallback((open: boolean) => { - if (!open) { - xsSheetCloseRef.current?.() - } - }, []) - - const MODAL_WIDTH = 500 - const MINIMIZED_WIDTH = 280 - const GAP = 12 - const RIGHT_OFFSET = 80 - - const positions = useMemo(() => { - const reversed = [...nonMaximized].reverse() - const result: { id: string; right: number; hidden: boolean }[] = [] - let cursor = RIGHT_OFFSET - for (let i = 0; i < reversed.length; i++) { - const w = reversed[i] - const width = w.minimized ? MINIMIZED_WIDTH : MODAL_WIDTH - result.push({ - id: w.id, - right: cursor, - hidden: i >= 2 && !w.minimized, - }) - cursor += width + GAP - } - return result - }, [nonMaximized]) - - if (isXs) { - return ( - <> - - - - {(xsActiveDock?.subject ?? "").trim() || "Nouveau message"} - - {xsActiveDock ? ( - - ) : null} - - - - {maximized.map((compose) => ( -
- -
- ))} - - ) - } - - return ( - <> - {nonMaximized.map((compose) => { - const pos = positions.find((p) => p.id === compose.id) - if (!pos) return null - return ( -
- -
- ) - })} - - {maximized.map((compose) => ( -
- -
- ))} - - ) -} +export { ComposeWindow, ComposeModalManager } from "./compose" diff --git a/components/gmail/compose/compose-bottom-toolbar.tsx b/components/gmail/compose/compose-bottom-toolbar.tsx new file mode 100644 index 0000000..9cf5fd9 --- /dev/null +++ b/components/gmail/compose/compose-bottom-toolbar.tsx @@ -0,0 +1,533 @@ +"use client" + +import { useState, useCallback } from "react" +import { type Editor } from "@tiptap/react" +import { + ChevronDown, + Paperclip, + Link as LinkIcon, + HardDrive, + Image as ImageIcon, + Lock, + PenTool, + MoreVertical, + Trash2, + Type, + Clock, + Send, +} from "lucide-react" +import { + type ComposeState, + SIGNATURES, + useComposeActions, +} from "@/lib/compose-context" +import { cn, getNextLocalWallClockDate } from "@/lib/utils" +import { + MAIL_COMPOSE_BOTTOM_ICON_BTN, + MAIL_COMPOSE_BOTTOM_ICON_BTN_ACTIVE, + MAIL_COMPOSE_MENU_SELECTED_CLASS, + MAIL_COMPOSE_POPOVER_CLASS, + MAIL_COMPOSE_PRIMARY_SEND_BTN, + MAIL_MENU_SURFACE_CLASS, +} from "@/lib/mail-chrome-classes" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { COMPOSE_PORTAL_Z, insertSignatureHtml } from "./compose-shared" +import { ComposeEmojiButton } from "./compose-emoji-picker" + +function ComposeLinkButton({ + editor, +}: { + editor: Editor | null +}) { + const [open, setOpen] = useState(false) + const [url, setUrl] = useState("") + const [text, setText] = useState("") + + if (!editor) return null + + const isLinkActive = editor.isActive("link") + + const handleToggle = () => { + if (isLinkActive) { + editor.chain().focus().extendMarkRange("link").unsetLink().run() + return + } + setOpen(true) + } + + const handleOpen = (isOpen: boolean) => { + if (isOpen) { + const { from, to, empty } = editor.state.selection + if (isLinkActive) { + const attrs = editor.getAttributes("link") + setUrl(attrs.href || "") + const selectedText = editor.state.doc.textBetween(from, to, " ") + setText(selectedText) + } else if (!empty) { + const selectedText = editor.state.doc.textBetween(from, to, " ") + setText(selectedText) + setUrl("") + } else { + setText("") + setUrl("") + } + } + setOpen(isOpen) + } + + const handleInsert = () => { + if (!url.trim()) return + const href = url.match(/^https?:\/\//) ? url : `https://${url}` + + const { empty } = editor.state.selection + + if (empty && !isLinkActive) { + const displayText = text.trim() || href + editor + .chain() + .focus() + .insertContent(`${displayText}`) + .run() + } else { + if (text.trim() && text.trim() !== editor.state.doc.textBetween( + editor.state.selection.from, + editor.state.selection.to, + " " + )) { + editor + .chain() + .focus() + .deleteSelection() + .insertContent(`${text.trim()}`) + .run() + } else { + editor + .chain() + .focus() + .extendMarkRange("link") + .setLink({ href }) + .run() + } + } + + setOpen(false) + setUrl("") + setText("") + } + + const handleRemoveLink = () => { + editor.chain().focus().extendMarkRange("link").unsetLink().run() + setOpen(false) + setUrl("") + setText("") + } + + return ( + + + + + e.preventDefault()} + > +
+
+ {isLinkActive ? "Modifier le lien" : "Insérer un lien"} +
+
+ + setText(e.target.value)} + placeholder="Texte du lien" + className="h-8 rounded border border-border bg-mail-surface px-2 text-sm text-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring" + /> +
+
+ + setUrl(e.target.value)} + placeholder="https://example.com" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + handleInsert() + } + }} + className="h-8 rounded border border-border bg-mail-surface px-2 text-sm text-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring" + autoFocus + /> +
+
+ {isLinkActive ? ( + + ) : ( + + )} +
+ + +
+
+
+
+
+ ) +} + +export function ComposeSignatureButton({ + editor, + compose, +}: { + editor: Editor | null + compose: ComposeState +}) { + const { updateCompose } = useComposeActions() + + const replaceSignature = useCallback( + (sigId: string | null) => { + if (!editor) return + const newHtml = insertSignatureHtml(editor.getHTML(), sigId) + editor.commands.setContent(newHtml) + updateCompose(compose.id, { bodyHtml: newHtml, signatureId: sigId }) + }, + [editor, compose.id, updateCompose] + ) + + const toggleAutoInsert = useCallback(() => { + const newVal = !compose.autoInsertSignature + updateCompose(compose.id, { autoInsertSignature: newVal }) + if (!newVal) { + replaceSignature(null) + } else { + const sigId = compose.from.defaultSignatureId + if (sigId) replaceSignature(sigId) + } + }, [compose.autoInsertSignature, compose.from.defaultSignatureId, compose.id, updateCompose, replaceSignature]) + + if (!editor) return null + + return ( + + + + + + { + e.preventDefault() + toggleAutoInsert() + }} + className="gap-2" + > + + {compose.autoInsertSignature && } + + Insérer automatiquement + + + replaceSignature(null)} + className={cn("gap-2", !compose.signatureId && MAIL_COMPOSE_MENU_SELECTED_CLASS)} + > + + {!compose.signatureId && } + + Aucune signature + + {SIGNATURES.map((sig) => ( + replaceSignature(sig.id)} + className={cn("gap-2", compose.signatureId === sig.id && MAIL_COMPOSE_MENU_SELECTED_CLASS)} + > + + {compose.signatureId === sig.id && } + + {sig.name} + + ))} + + + ) +} + +export interface ComposeBottomToolbarProps { + compose: ComposeState + editor: Editor | null + isEditingScheduled: boolean + showFormatting: boolean + sendMenuOpen: boolean + setShowFormatting: (v: boolean | ((prev: boolean) => boolean)) => void + setSendMenuOpen: (v: boolean) => void + handleSend: () => void + saveScheduledEdit: () => void | Promise + sendScheduledFromEditNow: () => void | Promise + applyScheduledPlanAt: (sendAt: Date) => void | Promise + submitScheduledSendAt: (sendAt: Date) => void | Promise + handleClose: () => void + fileInputRef: React.RefObject + imageInputRef: React.RefObject +} + +export function ComposeBottomToolbar(props: ComposeBottomToolbarProps) { + const { + compose, + editor, + isEditingScheduled, + showFormatting, + sendMenuOpen, + setShowFormatting, + setSendMenuOpen, + handleSend, + saveScheduledEdit, + sendScheduledFromEditNow, + applyScheduledPlanAt, + submitScheduledSendAt, + handleClose, + fileInputRef, + imageInputRef, + } = props + return ( +
+ {/* Send / save + dropdown */} +
+ {isEditingScheduled ? ( + <> + + + + + + + { + void sendScheduledFromEditNow() + }} + > + + Envoyer maintenant + + + + + Planifier + + + { + void applyScheduledPlanAt( + new Date(Date.now() + 60 * 60 * 1000) + ) + }} + > + + Envoyer dans une heure + + { + void applyScheduledPlanAt( + getNextLocalWallClockDate(9, 0) + ) + }} + > + + Envoyer à 9h + + + + + + + ) : ( + <> + + + + + + + { + void submitScheduledSendAt( + new Date(Date.now() + 60 * 60 * 1000) + ) + }} + > + + Envoyer dans une heure + + { + void submitScheduledSendAt( + getNextLocalWallClockDate(9, 0) + ) + }} + > + + Envoyer à 9h + + setSendMenuOpen(false)}> + + Programmer l'envoi + + + + + )} +
+ + {/* Toolbar icons */} +
+ + + + + + + + + +
+ +
+ + +
+ ) +} diff --git a/components/gmail/compose/compose-emoji-picker.tsx b/components/gmail/compose/compose-emoji-picker.tsx new file mode 100644 index 0000000..f9fc12c --- /dev/null +++ b/components/gmail/compose/compose-emoji-picker.tsx @@ -0,0 +1,79 @@ +"use client" + +import { + useState, + useCallback, + lazy, + Suspense, +} from "react" +import { type Editor } from "@tiptap/react" +import { Smile } from "lucide-react" +import { useTheme } from "next-themes" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import data from "@emoji-mart/data" +import { cn } from "@/lib/utils" +import { MAIL_COMPOSE_BOTTOM_ICON_BTN } from "@/lib/mail-chrome-classes" +import { COMPOSE_PORTAL_Z } from "./compose-shared" + +const LazyPicker = lazy(() => import("@emoji-mart/react")) + +function ComposeEmojiPicker({ onSelect }: { onSelect: (emoji: { native: string }) => void }) { + const { resolvedTheme } = useTheme() + return ( + Chargement…
}> + + + ) +} + +export function ComposeEmojiButton({ + editor, +}: { + editor: Editor | null +}) { + const [open, setOpen] = useState(false) + + const handleSelect = useCallback( + (emoji: { native: string }) => { + editor?.chain().focus().insertContent(emoji.native).run() + setOpen(false) + }, + [editor] + ) + + if (!editor) return null + + return ( + + + + + e.preventDefault()} + > + + + + ) +} diff --git a/components/gmail/compose/compose-formatting-dropdowns.tsx b/components/gmail/compose/compose-formatting-dropdowns.tsx new file mode 100644 index 0000000..357d123 --- /dev/null +++ b/components/gmail/compose/compose-formatting-dropdowns.tsx @@ -0,0 +1,277 @@ +"use client" + +import { useState } from "react" +import { useEditor } from "@tiptap/react" +import { + ChevronDown, + AlignLeft, + AlignCenter, + AlignRight, + AlignJustify, + Palette, + ALargeSmall, + CaseSensitive, +} from "lucide-react" +import { cn } from "@/lib/utils" +import { + MAIL_COMPOSE_MENU_SELECTED_CLASS, + MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE, +} from "@/lib/mail-chrome-classes" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { COMPOSE_PORTAL_Z } from "./compose-shared" + +export const FONT_FAMILIES = [ + { label: "Sans Serif", value: "sans-serif" }, + { label: "Serif", value: "serif" }, + { label: "Monospace", value: "monospace" }, + { label: "Cursive", value: "cursive" }, + { label: "Comic Sans MS", value: "Comic Sans MS, cursive" }, + { label: "Garamond", value: "Garamond, serif" }, + { label: "Georgia", value: "Georgia, serif" }, + { label: "Impact", value: "Impact, sans-serif" }, + { label: "Tahoma", value: "Tahoma, sans-serif" }, + { label: "Trebuchet MS", value: "Trebuchet MS, sans-serif" }, + { label: "Verdana", value: "Verdana, sans-serif" }, +] + +export const FONT_SIZES = [ + { label: "Très petit", value: "10px" }, + { label: "Petit", value: "13px" }, + { label: "Normal", value: "" }, + { label: "Grand", value: "18px" }, + { label: "Très grand", value: "24px" }, + { label: "Énorme", value: "32px" }, +] + +export const TEXT_COLORS = [ + "#000000", "#434343", "#666666", "#999999", "#cccccc", "#efefef", "#f3f3f3", "#ffffff", + "#fb4934", "#fe8019", "#fabd2f", "#b8bb26", "#8ec07c", "#83a598", "#d3869b", "#ebdbb2", + "#cc241d", "#d65d0e", "#d79921", "#98971a", "#689d6a", "#458588", "#b16286", "#a89984", + "#9d0006", "#af3a03", "#b57614", "#79740e", "#427b58", "#076678", "#8f3f71", "#7c6f64", +] + +export function AlignmentDropdown({ + editor, + btnClass, + activeClass, +}: { + editor: NonNullable> + btnClass: string + activeClass: string +}) { + const currentIcon = editor.isActive({ textAlign: "center" }) + ? AlignCenter + : editor.isActive({ textAlign: "right" }) + ? AlignRight + : editor.isActive({ textAlign: "justify" }) + ? AlignJustify + : AlignLeft + const CurrentIcon = currentIcon + + return ( + + + + + + editor.chain().focus().setTextAlign("left").run()} + className={cn(editor.isActive({ textAlign: "left" }) && MAIL_COMPOSE_MENU_SELECTED_CLASS)} + > + Aligner à gauche + + editor.chain().focus().setTextAlign("center").run()} + className={cn(editor.isActive({ textAlign: "center" }) && MAIL_COMPOSE_MENU_SELECTED_CLASS)} + > + Centrer + + editor.chain().focus().setTextAlign("right").run()} + className={cn(editor.isActive({ textAlign: "right" }) && MAIL_COMPOSE_MENU_SELECTED_CLASS)} + > + Aligner à droite + + editor.chain().focus().setTextAlign("justify").run()} + className={cn(editor.isActive({ textAlign: "justify" }) && MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE)} + > + Justifier + + + + ) +} + +export function FontDropdown({ + editor, + btnClass, +}: { + editor: NonNullable> + btnClass: string +}) { + return ( + + + + + + {FONT_FAMILIES.map((f) => ( + editor.chain().focus().setMark("textStyle", { fontFamily: f.value }).run()} + style={{ fontFamily: f.value }} + className={cn( + editor.isActive("textStyle", { fontFamily: f.value }) && MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE + )} + > + {f.label} + + ))} + + + ) +} + +export function FontSizeDropdown({ + editor, + btnClass, +}: { + editor: NonNullable> + btnClass: string +}) { + return ( + + + + + + {FONT_SIZES.map((s) => ( + { + if (s.value) { + editor.chain().focus().setMark("textStyle", { fontSize: s.value }).run() + } else { + editor.chain().focus().setMark("textStyle", { fontSize: null }).removeEmptyTextStyle().run() + } + }} + style={s.value ? { fontSize: s.value } : undefined} + className={cn( + s.value && editor.isActive("textStyle", { fontSize: s.value }) && MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE + )} + > + {s.label} + + ))} + + + ) +} + +export function ColorDropdown({ + editor, + btnClass, +}: { + editor: NonNullable> + btnClass: string +}) { + const [tab, setTab] = useState<"text" | "bg">("text") + + return ( + + + + + e.preventDefault()} + > +
+ + +
+
+ {TEXT_COLORS.map((color) => ( +
+ +
+
+ ) +} diff --git a/components/gmail/compose/compose-formatting-toolbar.tsx b/components/gmail/compose/compose-formatting-toolbar.tsx new file mode 100644 index 0000000..a05268e --- /dev/null +++ b/components/gmail/compose/compose-formatting-toolbar.tsx @@ -0,0 +1,151 @@ +"use client" + +import { type Editor } from "@tiptap/react" +import { + Bold, + Italic, + Underline as UnderlineIcon, + List, + ListOrdered, + Undo, + Redo, + Indent, + Outdent, + RemoveFormatting, +} from "lucide-react" +import { cn } from "@/lib/utils" +import { + MAIL_COMPOSE_TOOLBAR_BTN, + MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE, + MAIL_COMPOSE_TOOLBAR_SEP, +} from "@/lib/mail-chrome-classes" +import { + AlignmentDropdown, + FontDropdown, + FontSizeDropdown, + ColorDropdown, +} from "./compose-formatting-dropdowns" + +export function FormattingToolbar({ + editor, +}: { + editor: Editor | null +}) { + if (!editor) return null + + const btnClass = MAIL_COMPOSE_TOOLBAR_BTN + const activeClass = MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE + const sep = + + return ( +
+ {/* Undo / Redo */} + + + + {sep} + + {/* Font */} + + + {sep} + + {/* Font size */} + + + {sep} + + {/* Bold, Italic, Underline, Colors */} + + + + + + {sep} + + {/* Alignment dropdown, lists, indent/outdent, remove formatting */} + + + + + + +
+ ) +} diff --git a/components/gmail/compose/compose-modal-manager.tsx b/components/gmail/compose/compose-modal-manager.tsx new file mode 100644 index 0000000..4e56ba8 --- /dev/null +++ b/components/gmail/compose/compose-modal-manager.tsx @@ -0,0 +1,115 @@ +"use client" + +import { useRef, useCallback, useMemo } from "react" +import { useIsXs } from "@/hooks/use-xs" +import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet" +import { useComposeWindows } from "@/lib/compose-context" +import { cn } from "@/lib/utils" +import { ComposeWindow } from "@/components/gmail/compose/compose-window" + +export function ComposeModalManager() { + const { composeWindows } = useComposeWindows() + const isXs = useIsXs() + + const nonMaximized = composeWindows.filter( + (w) => !w.maximized && w.placement !== "inline" + ) + const maximized = composeWindows.filter((w) => w.maximized && w.placement !== "inline") + + const xsSheetCloseRef = useRef<(() => void) | null>(null) + const bindXsSheetClose = useCallback((fn: (() => void) | null) => { + xsSheetCloseRef.current = fn + }, []) + + /** Une seule fenêtre dock visible en xs : la plus récente (comportement type pile). */ + const xsActiveDock = + isXs && nonMaximized.length > 0 ? nonMaximized[nonMaximized.length - 1] : null + + const handleXsSheetOpenChange = useCallback((open: boolean) => { + if (!open) { + xsSheetCloseRef.current?.() + } + }, []) + + const MODAL_WIDTH = 500 + const MINIMIZED_WIDTH = 280 + const GAP = 12 + const RIGHT_OFFSET = 80 + + const positions = useMemo(() => { + const reversed = [...nonMaximized].reverse() + const result: { id: string; right: number; hidden: boolean }[] = [] + let cursor = RIGHT_OFFSET + for (let i = 0; i < reversed.length; i++) { + const w = reversed[i] + const width = w.minimized ? MINIMIZED_WIDTH : MODAL_WIDTH + result.push({ + id: w.id, + right: cursor, + hidden: i >= 2 && !w.minimized, + }) + cursor += width + GAP + } + return result + }, [nonMaximized]) + + if (isXs) { + return ( + <> + + + + {(xsActiveDock?.subject ?? "").trim() || "Nouveau message"} + + {xsActiveDock ? ( + + ) : null} + + + + {maximized.map((compose) => ( +
+ +
+ ))} + + ) + } + + return ( + <> + {nonMaximized.map((compose) => { + const pos = positions.find((p) => p.id === compose.id) + if (!pos) return null + return ( +
+ +
+ ) + })} + + {maximized.map((compose) => ( +
+ +
+ ))} + + ) +} diff --git a/components/gmail/compose/compose-toolbar.tsx b/components/gmail/compose/compose-toolbar.tsx index 60800b3..0e5728c 100644 --- a/components/gmail/compose/compose-toolbar.tsx +++ b/components/gmail/compose/compose-toolbar.tsx @@ -1,994 +1,3 @@ -"use client" - -import { - useState, - useCallback, - lazy, - Suspense, -} from "react" -import { useEditor, type Editor } from "@tiptap/react" -import { - ChevronDown, - Paperclip, - Link as LinkIcon, - Smile, - HardDrive, - Image as ImageIcon, - Lock, - PenTool, - MoreVertical, - Trash2, - Bold, - Italic, - Underline as UnderlineIcon, - AlignLeft, - AlignCenter, - AlignRight, - AlignJustify, - List, - ListOrdered, - Undo, - Redo, - Type, - Clock, - Indent, - Outdent, - RemoveFormatting, - Palette, - ALargeSmall, - CaseSensitive, - Send, -} from "lucide-react" -import { - type ComposeState, - SIGNATURES, - useComposeActions, -} from "@/lib/compose-context" -import { cn, getNextLocalWallClockDate } from "@/lib/utils" -import { - MAIL_COMPOSE_BOTTOM_ICON_BTN, - MAIL_COMPOSE_BOTTOM_ICON_BTN_ACTIVE, - MAIL_COMPOSE_MENU_SELECTED_CLASS, - MAIL_COMPOSE_POPOVER_CLASS, - MAIL_COMPOSE_PRIMARY_SEND_BTN, - MAIL_COMPOSE_TOOLBAR_BTN, - MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE, - MAIL_COMPOSE_TOOLBAR_SEP, - MAIL_MENU_SURFACE_CLASS, -} from "@/lib/mail-chrome-classes" -import { useTheme } from "next-themes" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover" -import data from "@emoji-mart/data" -import { COMPOSE_PORTAL_Z, insertSignatureHtml } from "./compose-shared" - -const LazyPicker = lazy(() => import("@emoji-mart/react")) - -function ComposeEmojiPicker({ onSelect }: { onSelect: (emoji: { native: string }) => void }) { - const { resolvedTheme } = useTheme() - return ( - Chargement…
}> - - - ) -} - -function AlignmentDropdown({ - editor, - btnClass, - activeClass, -}: { - editor: NonNullable> - btnClass: string - activeClass: string -}) { - const currentIcon = editor.isActive({ textAlign: "center" }) - ? AlignCenter - : editor.isActive({ textAlign: "right" }) - ? AlignRight - : editor.isActive({ textAlign: "justify" }) - ? AlignJustify - : AlignLeft - const CurrentIcon = currentIcon - - return ( - - - - - - editor.chain().focus().setTextAlign("left").run()} - className={cn(editor.isActive({ textAlign: "left" }) && MAIL_COMPOSE_MENU_SELECTED_CLASS)} - > - Aligner à gauche - - editor.chain().focus().setTextAlign("center").run()} - className={cn(editor.isActive({ textAlign: "center" }) && MAIL_COMPOSE_MENU_SELECTED_CLASS)} - > - Centrer - - editor.chain().focus().setTextAlign("right").run()} - className={cn(editor.isActive({ textAlign: "right" }) && MAIL_COMPOSE_MENU_SELECTED_CLASS)} - > - Aligner à droite - - editor.chain().focus().setTextAlign("justify").run()} - className={cn(editor.isActive({ textAlign: "justify" }) && MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE)} - > - Justifier - - - - ) -} - -const FONT_FAMILIES = [ - { label: "Sans Serif", value: "sans-serif" }, - { label: "Serif", value: "serif" }, - { label: "Monospace", value: "monospace" }, - { label: "Cursive", value: "cursive" }, - { label: "Comic Sans MS", value: "Comic Sans MS, cursive" }, - { label: "Garamond", value: "Garamond, serif" }, - { label: "Georgia", value: "Georgia, serif" }, - { label: "Impact", value: "Impact, sans-serif" }, - { label: "Tahoma", value: "Tahoma, sans-serif" }, - { label: "Trebuchet MS", value: "Trebuchet MS, sans-serif" }, - { label: "Verdana", value: "Verdana, sans-serif" }, -] - -const FONT_SIZES = [ - { label: "Très petit", value: "10px" }, - { label: "Petit", value: "13px" }, - { label: "Normal", value: "" }, - { label: "Grand", value: "18px" }, - { label: "Très grand", value: "24px" }, - { label: "Énorme", value: "32px" }, -] - -const TEXT_COLORS = [ - "#000000", "#434343", "#666666", "#999999", "#cccccc", "#efefef", "#f3f3f3", "#ffffff", - "#fb4934", "#fe8019", "#fabd2f", "#b8bb26", "#8ec07c", "#83a598", "#d3869b", "#ebdbb2", - "#cc241d", "#d65d0e", "#d79921", "#98971a", "#689d6a", "#458588", "#b16286", "#a89984", - "#9d0006", "#af3a03", "#b57614", "#79740e", "#427b58", "#076678", "#8f3f71", "#7c6f64", -] - -function FontDropdown({ - editor, - btnClass, -}: { - editor: NonNullable> - btnClass: string -}) { - return ( - - - - - - {FONT_FAMILIES.map((f) => ( - editor.chain().focus().setMark("textStyle", { fontFamily: f.value }).run()} - style={{ fontFamily: f.value }} - className={cn( - editor.isActive("textStyle", { fontFamily: f.value }) && MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE - )} - > - {f.label} - - ))} - - - ) -} - -function FontSizeDropdown({ - editor, - btnClass, -}: { - editor: NonNullable> - btnClass: string -}) { - return ( - - - - - - {FONT_SIZES.map((s) => ( - { - if (s.value) { - editor.chain().focus().setMark("textStyle", { fontSize: s.value }).run() - } else { - editor.chain().focus().setMark("textStyle", { fontSize: null }).removeEmptyTextStyle().run() - } - }} - style={s.value ? { fontSize: s.value } : undefined} - className={cn( - s.value && editor.isActive("textStyle", { fontSize: s.value }) && MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE - )} - > - {s.label} - - ))} - - - ) -} - -function ColorDropdown({ - editor, - btnClass, -}: { - editor: NonNullable> - btnClass: string -}) { - const [tab, setTab] = useState<"text" | "bg">("text") - - return ( - - - - - e.preventDefault()} - > -
- - -
-
- {TEXT_COLORS.map((color) => ( -
- -
-
- ) -} - -export function FormattingToolbar({ - editor, -}: { - editor: Editor | null -}) { - if (!editor) return null - - const btnClass = MAIL_COMPOSE_TOOLBAR_BTN - const activeClass = MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE - const sep = - - return ( -
- {/* Undo / Redo */} - - - - {sep} - - {/* Font */} - - - {sep} - - {/* Font size */} - - - {sep} - - {/* Bold, Italic, Underline, Colors */} - - - - - - {sep} - - {/* Alignment dropdown, lists, indent/outdent, remove formatting */} - - - - - - -
- ) -} - -function ComposeEmojiButton({ - editor, -}: { - editor: Editor | null -}) { - const [open, setOpen] = useState(false) - - const handleSelect = useCallback( - (emoji: { native: string }) => { - editor?.chain().focus().insertContent(emoji.native).run() - setOpen(false) - }, - [editor] - ) - - if (!editor) return null - - return ( - - - - - e.preventDefault()} - > - - - - ) -} - -function ComposeLinkButton({ - editor, -}: { - editor: Editor | null -}) { - const [open, setOpen] = useState(false) - const [url, setUrl] = useState("") - const [text, setText] = useState("") - - if (!editor) return null - - const isLinkActive = editor.isActive("link") - - const handleToggle = () => { - if (isLinkActive) { - editor.chain().focus().extendMarkRange("link").unsetLink().run() - return - } - setOpen(true) - } - - const handleOpen = (isOpen: boolean) => { - if (isOpen) { - const { from, to, empty } = editor.state.selection - if (isLinkActive) { - const attrs = editor.getAttributes("link") - setUrl(attrs.href || "") - const selectedText = editor.state.doc.textBetween(from, to, " ") - setText(selectedText) - } else if (!empty) { - const selectedText = editor.state.doc.textBetween(from, to, " ") - setText(selectedText) - setUrl("") - } else { - setText("") - setUrl("") - } - } - setOpen(isOpen) - } - - const handleInsert = () => { - if (!url.trim()) return - const href = url.match(/^https?:\/\//) ? url : `https://${url}` - - const { empty } = editor.state.selection - - if (empty && !isLinkActive) { - const displayText = text.trim() || href - editor - .chain() - .focus() - .insertContent(`${displayText}`) - .run() - } else { - if (text.trim() && text.trim() !== editor.state.doc.textBetween( - editor.state.selection.from, - editor.state.selection.to, - " " - )) { - editor - .chain() - .focus() - .deleteSelection() - .insertContent(`${text.trim()}`) - .run() - } else { - editor - .chain() - .focus() - .extendMarkRange("link") - .setLink({ href }) - .run() - } - } - - setOpen(false) - setUrl("") - setText("") - } - - const handleRemoveLink = () => { - editor.chain().focus().extendMarkRange("link").unsetLink().run() - setOpen(false) - setUrl("") - setText("") - } - - return ( - - - - - e.preventDefault()} - > -
-
- {isLinkActive ? "Modifier le lien" : "Insérer un lien"} -
-
- - setText(e.target.value)} - placeholder="Texte du lien" - className="h-8 rounded border border-border bg-mail-surface px-2 text-sm text-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring" - /> -
-
- - setUrl(e.target.value)} - placeholder="https://example.com" - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault() - handleInsert() - } - }} - className="h-8 rounded border border-border bg-mail-surface px-2 text-sm text-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring" - autoFocus - /> -
-
- {isLinkActive ? ( - - ) : ( - - )} -
- - -
-
-
-
-
- ) -} - -export function ComposeSignatureButton({ - editor, - compose, -}: { - editor: Editor | null - compose: ComposeState -}) { - const { updateCompose } = useComposeActions() - - const replaceSignature = useCallback( - (sigId: string | null) => { - if (!editor) return - const newHtml = insertSignatureHtml(editor.getHTML(), sigId) - editor.commands.setContent(newHtml) - updateCompose(compose.id, { bodyHtml: newHtml, signatureId: sigId }) - }, - [editor, compose.id, updateCompose] - ) - - const toggleAutoInsert = useCallback(() => { - const newVal = !compose.autoInsertSignature - updateCompose(compose.id, { autoInsertSignature: newVal }) - if (!newVal) { - replaceSignature(null) - } else { - const sigId = compose.from.defaultSignatureId - if (sigId) replaceSignature(sigId) - } - }, [compose.autoInsertSignature, compose.from.defaultSignatureId, compose.id, updateCompose, replaceSignature]) - - if (!editor) return null - - return ( - - - - - - { - e.preventDefault() - toggleAutoInsert() - }} - className="gap-2" - > - - {compose.autoInsertSignature && } - - Insérer automatiquement - - - replaceSignature(null)} - className={cn("gap-2", !compose.signatureId && MAIL_COMPOSE_MENU_SELECTED_CLASS)} - > - - {!compose.signatureId && } - - Aucune signature - - {SIGNATURES.map((sig) => ( - replaceSignature(sig.id)} - className={cn("gap-2", compose.signatureId === sig.id && MAIL_COMPOSE_MENU_SELECTED_CLASS)} - > - - {compose.signatureId === sig.id && } - - {sig.name} - - ))} - - - ) -} - -export interface ComposeBottomToolbarProps { - compose: ComposeState - editor: Editor | null - isEditingScheduled: boolean - showFormatting: boolean - sendMenuOpen: boolean - setShowFormatting: (v: boolean | ((prev: boolean) => boolean)) => void - setSendMenuOpen: (v: boolean) => void - handleSend: () => void - saveScheduledEdit: () => void | Promise - sendScheduledFromEditNow: () => void | Promise - applyScheduledPlanAt: (sendAt: Date) => void | Promise - submitScheduledSendAt: (sendAt: Date) => void | Promise - handleClose: () => void - fileInputRef: React.RefObject - imageInputRef: React.RefObject -} - -export function ComposeBottomToolbar(props: ComposeBottomToolbarProps) { - const { - compose, - editor, - isEditingScheduled, - showFormatting, - sendMenuOpen, - setShowFormatting, - setSendMenuOpen, - handleSend, - saveScheduledEdit, - sendScheduledFromEditNow, - applyScheduledPlanAt, - submitScheduledSendAt, - handleClose, - fileInputRef, - imageInputRef, - } = props - return ( -
- {/* Send / save + dropdown */} -
- {isEditingScheduled ? ( - <> - - - - - - - { - void sendScheduledFromEditNow() - }} - > - - Envoyer maintenant - - - - - Planifier - - - { - void applyScheduledPlanAt( - new Date(Date.now() + 60 * 60 * 1000) - ) - }} - > - - Envoyer dans une heure - - { - void applyScheduledPlanAt( - getNextLocalWallClockDate(9, 0) - ) - }} - > - - Envoyer à 9h - - - - - - - ) : ( - <> - - - - - - - { - void submitScheduledSendAt( - new Date(Date.now() + 60 * 60 * 1000) - ) - }} - > - - Envoyer dans une heure - - { - void submitScheduledSendAt( - getNextLocalWallClockDate(9, 0) - ) - }} - > - - Envoyer à 9h - - setSendMenuOpen(false)}> - - Programmer l'envoi - - - - - )} -
- - {/* Toolbar icons */} -
- - - - - - - - - -
- -
- - -
- ) -} +export { FormattingToolbar } from "./compose-formatting-toolbar" +export { ComposeBottomToolbar } from "./compose-bottom-toolbar" +export type { ComposeBottomToolbarProps } from "./compose-bottom-toolbar" diff --git a/components/gmail/compose/compose-window.tsx b/components/gmail/compose/compose-window.tsx new file mode 100644 index 0000000..a613d34 --- /dev/null +++ b/components/gmail/compose/compose-window.tsx @@ -0,0 +1,249 @@ +"use client" + +import { EditorContent } from "@tiptap/react" +import { Maximize2, X } from "lucide-react" +import { readCoarsePointerMatches } from "@/hooks/use-touch-nav" +import type { ComposeState } from "@/lib/compose-context" +import type { Email } from "@/lib/email-data" +import { cn } from "@/lib/utils" +import { + MAIL_COMPOSE_TITLEBAR_CLASS, + MAIL_ICON_BTN, +} from "@/lib/mail-chrome-classes" +import { ComposeRecipientFields } from "@/components/gmail/compose/compose-recipients" +import { + ComposeBottomToolbar, + FormattingToolbar, +} from "@/components/gmail/compose/compose-toolbar" +import { + ComposeAttachmentsList, + ComposeDockTitleBar, + ComposeDropOverlay, + ComposeInlineRecipientHeader, + ComposeXsSheetHeader, +} from "@/components/gmail/compose/compose-editor-chrome" +import { useComposeWindow } from "@/components/gmail/compose/use-compose-window" + +export function ComposeWindow({ + compose, + threadSourceEmail = null, + isXsSheet = false, + bindXsSheetClose, +}: { + compose: ComposeState + /** Fil courant : nécessaire pour le menu Répondre / Transférer en inline */ + threadSourceEmail?: Email | null + /** Plein écran dans une bottom sheet (xs) — pas de file ni réduction */ + isXsSheet?: boolean + bindXsSheetClose?: (fn: (() => void) | null) => void +}) { + const { + isInline, + isEditingScheduled, + editor, + titleText, + showFormatting, + setShowFormatting, + recipientsFocused, + setRecipientsFocused, + sendMenuOpen, + setSendMenuOpen, + isDragOver, + fieldsRef, + inlineRecipientShellRef, + fileInputRef, + imageInputRef, + handleClose, + handleSend, + saveScheduledEdit, + sendScheduledFromEditNow, + applyScheduledPlanAt, + submitScheduledSendAt, + addFiles, + removeAttachment, + handleDrop, + handleDragOver, + handleDragLeave, + recipientSummary, + showReplyAllInMenu, + ThreadKindIcon, + openInlinePreset, + openDockFromInline, + recipientFieldsProps, + toggleMinimize, + toggleMaximize, + updateCompose, + } = useComposeWindow(compose, threadSourceEmail, isXsSheet, bindXsSheetClose) + + const modalContent = ( +
+ {/* Hidden file inputs */} + { + if (e.target.files && e.target.files.length > 0) { + addFiles(e.target.files) + e.target.value = "" + } + }} + /> + { + if (e.target.files && e.target.files.length > 0) { + addFiles(e.target.files) + e.target.value = "" + } + }} + /> + + {/* Drop overlay */} + {isDragOver ? : null} + {isInline ? ( + setRecipientsFocused(true)} + updateCompose={updateCompose} + recipientFieldsProps={recipientFieldsProps} + fieldsRef={fieldsRef} + inlineRecipientShellRef={inlineRecipientShellRef} + /> + ) : isXsSheet ? ( + + ) : ( + <> + {/* Title bar */} + toggleMinimize(compose.id)} + onMaximize={() => toggleMaximize(compose.id)} + onClose={handleClose} + /> + + )} + + {!isInline && ( +
+ +
+ )} + + {/* Editor */} +
+ +
+ + + + {showFormatting ? : null} + + +
+ ) + + if (compose.minimized && !isInline && !isXsSheet) { + return ( +
toggleMinimize(compose.id)} + > + + {titleText} + +
+ + +
+
+ ) + } + + if (compose.maximized && !isInline && !isXsSheet) { + return ( + <> +
toggleMaximize(compose.id)} + /> + {modalContent} + + ) + } + + return modalContent +} diff --git a/components/gmail/compose/index.ts b/components/gmail/compose/index.ts new file mode 100644 index 0000000..a4d25d2 --- /dev/null +++ b/components/gmail/compose/index.ts @@ -0,0 +1,2 @@ +export { ComposeWindow } from "./compose-window" +export { ComposeModalManager } from "./compose-modal-manager" diff --git a/components/gmail/compose/use-compose-window.ts b/components/gmail/compose/use-compose-window.ts new file mode 100644 index 0000000..4d1397b --- /dev/null +++ b/components/gmail/compose/use-compose-window.ts @@ -0,0 +1,549 @@ +"use client" + +import { + useState, + useRef, + useEffect, + useLayoutEffect, + useCallback, + useMemo, +} from "react" +import { useEditor } from "@tiptap/react" +import { type Extensions } from "@tiptap/core" +import StarterKit from "@tiptap/starter-kit" +import Underline from "@tiptap/extension-underline" +import Link from "@tiptap/extension-link" +import TextAlign from "@tiptap/extension-text-align" +import { TextStyle, FontFamily, FontSize, BackgroundColor } from "@tiptap/extension-text-style" +import Color from "@tiptap/extension-color" +import { + Reply, + ReplyAll, + Forward, +} from "lucide-react" +import { + type ComposeState, + cloneComposeForPendingSend, + DEFAULT_IDENTITIES, + useComposeActions, +} from "@/lib/compose-context" +import { useScheduledMail } from "@/lib/scheduled-mail-context" +import type { ScheduleSendPayload } from "@/lib/api/scheduled-mail" +import type { Email } from "@/lib/email-data" +import { + buildThreadComposePreset, + collectThreadParticipants, +} from "@/lib/thread-compose-preset" +import { toast } from "sonner" +import { showPendingSendToast } from "@/lib/pending-send-toast" +import { cn } from "@/lib/utils" +import { insertSignatureHtml, SignatureBlock, stripSignature } from "@/components/gmail/compose/compose-shared" + +export function useComposeWindow( + compose: ComposeState, + threadSourceEmail: Email | null = null, + isXsSheet = false, + bindXsSheetClose?: (fn: (() => void) | null) => void +) { + const { + closeCompose, + updateCompose, + applyComposePreset, + toggleMinimize, + toggleMaximize, + restoreComposeFromSnapshot, + } = useComposeActions() + const { scheduleSend, requestUpdateScheduledSend, requestSendScheduledNow } = + useScheduledMail() + const isInline = compose.placement === "inline" + const isEditingScheduled = compose.editingScheduledId != null + const [showFormatting, setShowFormatting] = useState(false) + const [recipientsFocused, setRecipientsFocused] = useState(false) + const [sendMenuOpen, setSendMenuOpen] = useState(false) + const [isDragOver, setIsDragOver] = useState(false) + const fieldsRef = useRef(null) + const inlineRecipientShellRef = useRef(null) + const subjectInputRef = useRef(null) + const fileInputRef = useRef(null) + const imageInputRef = useRef(null) + + const snapBodyCaretToStartOnHeaderTab = + !isInline && + !compose.threadEmailId && + !compose.threadKind && + !compose.editingScheduledId + + const editor = useEditor({ + immediatelyRender: false, + extensions: [ + StarterKit, + Underline, + Link.configure({ openOnClick: false }), + TextStyle, + Color, + BackgroundColor, + FontFamily, + FontSize, + TextAlign.configure({ types: ["heading", "paragraph"], alignments: ["left", "center", "right", "justify"] }), + SignatureBlock, + ] as Extensions, + content: compose.bodyHtml, + onUpdate: ({ editor: ed }) => { + updateCompose(compose.id, { bodyHtml: ed.getHTML() }) + }, + onFocus: ({ editor: ed, event }) => { + if (!snapBodyCaretToStartOnHeaderTab) return + const rt = event.relatedTarget as Node | null + if (!rt || !fieldsRef.current?.contains(rt)) return + window.requestAnimationFrame(() => { + if (!ed.view.hasFocus()) return + try { + ed.chain().setTextSelection(1).run() + } catch { + /* empty doc edge */ + } + }) + }, + editorProps: { + attributes: { + class: cn( + "prose prose-sm max-w-none px-3 py-2 text-sm text-foreground outline-none focus:outline-none", + isInline + ? "min-h-[200px]" + : isXsSheet + ? "min-h-[min(36vh,280px)]" + : "min-h-[150px]" + ), + }, + }, + }) + + const titleText = compose.subject || "Nouveau message" + const bodyWithoutSig = stripSignature(compose.bodyHtml) + .replace(/

<\/p>/g, "") + .trim() + const hasContent = + compose.subject.trim() !== "" || + compose.to.length > 0 || + compose.cc.length > 0 || + compose.bcc.length > 0 || + compose.attachments.length > 0 || + bodyWithoutSig !== "" + + const handleIdentityChange = useCallback( + (identity: (typeof DEFAULT_IDENTITIES)[number]) => { + if (compose.autoInsertSignature && editor) { + const sigId = identity.defaultSignatureId + const newHtml = insertSignatureHtml(editor.getHTML(), sigId) + editor.commands.setContent(newHtml) + updateCompose(compose.id, { from: identity, bodyHtml: newHtml, signatureId: sigId }) + } else { + updateCompose(compose.id, { from: identity }) + } + }, + [compose.id, compose.autoInsertSignature, editor, updateCompose] + ) + + const handleClose = () => { + const threadInlineDiscard = + isInline && compose.threadEmailId + ? ({ discardThreadReplyDraft: true } as const) + : undefined + if (!hasContent) { + closeCompose(compose.id, threadInlineDiscard) + } else { + updateCompose(compose.id, { savedAt: Date.now() }) + closeCompose(compose.id, threadInlineDiscard) + } + } + + const handleCloseRef = useRef(handleClose) + handleCloseRef.current = handleClose + + useLayoutEffect(() => { + if (!isXsSheet || !bindXsSheetClose) return + bindXsSheetClose(() => { + handleCloseRef.current() + }) + return () => bindXsSheetClose(null) + }, [isXsSheet, bindXsSheetClose, compose.id]) + + const htmlToPreviewText = useCallback((html: string) => { + return html + .replace(/]*>[\s\S]*?<\/style>/gi, " ") + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim() + }, []) + + const handleSend = useCallback(() => { + if (compose.to.length === 0) return + const bodyHtml = editor?.getHTML() ?? compose.bodyHtml + const snapshot = cloneComposeForPendingSend({ ...compose, bodyHtml }) + closeCompose(compose.id, { sent: true }) + showPendingSendToast({ + onCommit: async () => {}, + onCancel: () => restoreComposeFromSnapshot(snapshot), + }) + }, [ + closeCompose, + compose, + editor, + restoreComposeFromSnapshot, + ]) + + const submitScheduledSendAt = useCallback( + async (sendAt: Date) => { + if (isEditingScheduled) return + if (compose.to.length === 0) return + setSendMenuOpen(false) + const bodyHtml = editor?.getHTML() ?? compose.bodyHtml + await scheduleSend({ + sendAtIso: sendAt.toISOString(), + to: compose.to.map((c) => ({ name: c.name, email: c.email })), + subject: compose.subject, + previewText: htmlToPreviewText(bodyHtml).slice(0, 500), + bodyHtml, + }) + const whenLabel = sendAt.toLocaleString("fr-FR", { + dateStyle: "medium", + timeStyle: "short", + }) + toast.message(`Ce mail sera envoyé le ${whenLabel}`) + closeCompose(compose.id, { sent: true }) + }, + [ + isEditingScheduled, + compose.bodyHtml, + compose.id, + compose.subject, + compose.to, + closeCompose, + editor, + htmlToPreviewText, + scheduleSend, + ] + ) + + const buildSchedulePayload = useCallback( + (sendAtIso: string): ScheduleSendPayload | null => { + if (compose.to.length === 0) return null + const bodyHtml = editor?.getHTML() ?? compose.bodyHtml + return { + sendAtIso, + to: compose.to.map((c) => ({ name: c.name, email: c.email })), + subject: compose.subject, + previewText: htmlToPreviewText(bodyHtml).slice(0, 500), + bodyHtml, + } + }, + [compose.to, compose.subject, compose.bodyHtml, editor, htmlToPreviewText] + ) + + const saveScheduledEdit = useCallback(async () => { + const id = compose.editingScheduledId + if (!id) return + const iso = + compose.scheduledSendAtIso ?? new Date().toISOString() + const payload = buildSchedulePayload(iso) + if (!payload) return + await requestUpdateScheduledSend(id, payload) + toast.message("Modifications enregistrées") + closeCompose(compose.id) + }, [ + buildSchedulePayload, + closeCompose, + compose.editingScheduledId, + compose.id, + compose.scheduledSendAtIso, + requestUpdateScheduledSend, + ]) + + const sendScheduledFromEditNow = useCallback(async () => { + const id = compose.editingScheduledId + if (!id) return + setSendMenuOpen(false) + const bodyHtml = editor?.getHTML() ?? compose.bodyHtml + const snapshot = cloneComposeForPendingSend({ ...compose, bodyHtml }) + closeCompose(compose.id, { sent: true }) + showPendingSendToast({ + onCommit: async () => { + const schedId = snapshot.editingScheduledId + if (!schedId || snapshot.to.length === 0) return + const iso = snapshot.scheduledSendAtIso ?? new Date().toISOString() + const body = snapshot.bodyHtml + const payload = { + sendAtIso: iso, + to: snapshot.to.map((c) => ({ name: c.name, email: c.email })), + subject: snapshot.subject, + previewText: htmlToPreviewText(body).slice(0, 500), + bodyHtml: body, + } + await requestUpdateScheduledSend(schedId, payload) + await requestSendScheduledNow(schedId) + }, + onCancel: () => restoreComposeFromSnapshot(snapshot), + }) + }, [ + closeCompose, + compose, + editor, + htmlToPreviewText, + requestSendScheduledNow, + requestUpdateScheduledSend, + restoreComposeFromSnapshot, + ]) + + const applyScheduledPlanAt = useCallback( + async (sendAt: Date) => { + const id = compose.editingScheduledId + if (!id) return + setSendMenuOpen(false) + const iso = sendAt.toISOString() + const payload = buildSchedulePayload(iso) + if (!payload) return + await requestUpdateScheduledSend(id, payload) + updateCompose(compose.id, { scheduledSendAtIso: iso }) + const whenLabel = sendAt.toLocaleString("fr-FR", { + dateStyle: "medium", + timeStyle: "short", + }) + toast.message(`Envoi planifié le ${whenLabel}`) + }, + [ + buildSchedulePayload, + compose.editingScheduledId, + compose.id, + requestUpdateScheduledSend, + updateCompose, + ] + ) + + const addFiles = useCallback((files: FileList | File[]) => { + const newAttachments = Array.from(files).map((file) => ({ + id: `att-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + file, + name: file.name, + size: file.size, + type: file.type, + })) + updateCompose(compose.id, { + attachments: [...compose.attachments, ...newAttachments], + }) + }, [compose.id, compose.attachments, updateCompose]) + + const removeAttachment = useCallback((attId: string) => { + updateCompose(compose.id, { + attachments: compose.attachments.filter((a) => a.id !== attId), + }) + }, [compose.id, compose.attachments, updateCompose]) + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragOver(false) + if (e.dataTransfer.files.length > 0) { + addFiles(e.dataTransfer.files) + } + }, [addFiles]) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragOver(true) + }, []) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) { + setIsDragOver(false) + } + }, []) + + const showFromField = recipientsFocused || isXsSheet + + useLayoutEffect(() => { + if (!isInline || !compose.focusToOnMount) return + setRecipientsFocused(true) + }, [isInline, compose.focusToOnMount]) + + useEffect(() => { + if (!recipientsFocused) return + const handleClickOutside = (e: Event) => { + const target = e.target as Node + const root = isInline ? inlineRecipientShellRef.current : fieldsRef.current + if (root && !root.contains(target)) { + const el = e.target as HTMLElement | null + const portal = el?.closest?.( + "[data-radix-popper-content-wrapper], [data-radix-dropdown-menu-content], [data-slot='dropdown-menu-content'], [data-slot='popover-content']" + ) + if (portal) return + setRecipientsFocused(false) + if (compose.showCc && compose.cc.length === 0) { + updateCompose(compose.id, { showCc: false }) + } + if (compose.showBcc && compose.bcc.length === 0) { + updateCompose(compose.id, { showBcc: false }) + } + } + } + document.addEventListener("pointerdown", handleClickOutside) + return () => document.removeEventListener("pointerdown", handleClickOutside) + }, [ + recipientsFocused, + isInline, + compose.showCc, + compose.showBcc, + compose.cc.length, + compose.bcc.length, + compose.id, + updateCompose, + ]) + + useEffect(() => { + if (!editor || editor.isDestroyed) return + const next = compose.bodyHtml + if (editor.getHTML() === next) return + editor.commands.setContent(next, { emitUpdate: false }) + }, [compose.bodyHtml, compose.threadKind, editor]) + + useEffect(() => { + if (!compose.focusSubjectOnMount || isInline) return + const id = window.requestAnimationFrame(() => { + subjectInputRef.current?.focus() + updateCompose(compose.id, { focusSubjectOnMount: false }) + }) + return () => window.cancelAnimationFrame(id) + }, [compose.focusSubjectOnMount, isInline, compose.id, updateCompose]) + + useEffect(() => { + if (!compose.focusBodyOnMount || !editor || editor.isDestroyed) return + let cancelled = false + const outer = window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + if (cancelled || !editor || editor.isDestroyed) return + try { + editor.chain().focus().setTextSelection(1).run() + } catch { + editor.chain().focus().run() + } + updateCompose(compose.id, { focusBodyOnMount: false }) + }) + }) + return () => { + cancelled = true + window.cancelAnimationFrame(outer) + } + }, [compose.focusBodyOnMount, compose.id, editor, updateCompose]) + + const clearFocusToMount = useCallback(() => { + updateCompose(compose.id, { focusToOnMount: false }) + }, [compose.id, updateCompose]) + + const ThreadKindIcon = + compose.threadKind === "forward" + ? Forward + : compose.threadKind === "replyAll" + ? ReplyAll + : Reply + + const recipientSummary = + compose.to.length === 0 + ? "Destinataires" + : compose.to.length === 1 && compose.to[0] + ? compose.to[0].name === compose.to[0].email + ? compose.to[0].email + : `${compose.to[0].name} <${compose.to[0].email}>` + : `${compose.to.length} destinataires` + + const showReplyAllInMenu = useMemo( + () => + Boolean( + threadSourceEmail && + collectThreadParticipants(threadSourceEmail).length > 1 + ), + [threadSourceEmail] + ) + + const openInlinePreset = useCallback( + (kind: "reply" | "replyAll" | "forward") => { + if (!threadSourceEmail) return + applyComposePreset( + compose.id, + buildThreadComposePreset(threadSourceEmail, kind) + ) + }, + [threadSourceEmail, applyComposePreset, compose.id] + ) + + const openDockFromInline = useCallback( + (opts?: { focusSubject?: boolean }) => { + setRecipientsFocused(false) + updateCompose(compose.id, { + placement: "dock", + threadEmailId: null, + focusToOnMount: false, + focusBodyOnMount: false, + minimized: false, + maximized: false, + focusSubjectOnMount: Boolean(opts?.focusSubject), + }) + }, + [compose.id, updateCompose] + ) + + const recipientFieldsProps = { + compose, + isInline, + showFromField, + updateCompose, + handleIdentityChange, + clearFocusToMount, + subjectInputRef, + onRecipientsActivate: () => setRecipientsFocused(true), + } + + return { + compose, + threadSourceEmail, + isXsSheet, + isInline, + isEditingScheduled, + editor, + titleText, + showFormatting, + setShowFormatting, + recipientsFocused, + setRecipientsFocused, + sendMenuOpen, + setSendMenuOpen, + isDragOver, + fieldsRef, + inlineRecipientShellRef, + subjectInputRef, + fileInputRef, + imageInputRef, + showFromField, + handleClose, + handleSend, + saveScheduledEdit, + sendScheduledFromEditNow, + applyScheduledPlanAt, + submitScheduledSendAt, + addFiles, + removeAttachment, + handleDrop, + handleDragOver, + handleDragLeave, + recipientSummary, + showReplyAllInMenu, + ThreadKindIcon, + openInlinePreset, + openDockFromInline, + recipientFieldsProps, + toggleMinimize, + toggleMaximize, + updateCompose, + } +} diff --git a/components/gmail/email-list.tsx b/components/gmail/email-list.tsx index d05b846..e15c7ab 100644 --- a/components/gmail/email-list.tsx +++ b/components/gmail/email-list.tsx @@ -1,4721 +1,2 @@ -"use client" - -import { - startTransition, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, - type ComponentType, - type DragEvent, - type MouseEvent, - type ReactNode, -} from "react" -import { Icon, addCollection } from "@iconify/react" -import { icons as mdiIcons } from "@iconify-json/mdi" -import { attachmentsForEmailList } from "@/lib/attachment-display" -import { resolveParsedCalendarInvitation } from "@/lib/resolve-email-calendar-invitation" -import { VIDEO_CONFERENCE_LOGOS } from "@/lib/calendar-invitation" -import { ensureVcLogosCollection } from "@/lib/register-vc-logos" -import { useEmailDrag } from "@/lib/drag-context" -import { - Star, - ChevronLeft, - ChevronRight, - ChevronUp, - MoreVertical, - RefreshCw, - ChevronDown, - Tag, - Reply, - ReplyAll, - Forward, - Paperclip, - Archive, - Trash2, - Mail, - MailOpen, - Menu, - Clock, - ListTodo, - FolderInput, - VolumeX, - Search, - SquareArrowOutUpRight, - File, - Image as ImageIcon, - ShieldAlert, - ArrowLeft, - Plus, - Send, - Pencil, - CalendarClock, - CalendarX2, - X, - CheckSquare, - Inbox as InboxIcon, - User as UserIcon, -} from "lucide-react" -import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" -import { Input } from "@/components/ui/input" -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuSub, - ContextMenuSubContent, - ContextMenuSubTrigger, - ContextMenuTrigger, -} from "@/components/ui/context-menu" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip" -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" -import { - Empty, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from "@/components/ui/empty" -import { CompactInboxCategoryTabs } from "@/components/gmail/compact-inbox-category-tabs" -import { MailInboxCategoryTabIcons } from "@/components/gmail/mail-inbox-category-tab-icons" -import { cn } from "@/lib/utils" -import { labelPillTextClassForTailwindBgUtility } from "@/lib/label-pill-contrast" -import { - buildLabelTextToNavColorClass, - MailLabelPillStrip, - mailLabelShouldShowInListStrip, -} from "@/components/gmail/mail-label-pills" -import { - emails, - type Email, - type EmailAttachment, -} from "@/lib/email-data" -import { - getThreadMessageCount, - isListRowRead, - isThreadHeadMessage, - readStateTargets, -} from "@/lib/mail-thread" -import { useScheduledMail } from "@/lib/scheduled-mail-context" -import { useMailStore } from "@/lib/stores/mail-store" -import { useScheduledStore } from "@/lib/stores/scheduled-store" -import { usePersistHydrated } from "@/hooks/use-persist-hydrated" -import { useIsMd } from "@/hooks/use-md-breakpoint" -import { threadStoreId } from "@/lib/mail-settings/list-row-id" -import { resolveOpenEmailView } from "@/lib/mail-settings/resolve-open-email" -import { sortEmailsForInbox } from "@/lib/mail-settings/sort-emails" -import { useMailSettingsStore } from "@/lib/stores/mail-settings-store" -import { useActiveAccount } from "@/lib/stores/account-store" -import { useMailSearchStore } from "@/lib/stores/mail-search-store" -import { - MAIL_INBOX_CATEGORY_TAB_CONTENT_DARK_CLASS, - MAIL_MENU_SURFACE_CLASS, - MAIL_MENU_SURFACE_WIDE_CLASS, - MAIL_TOOLBAR_ICON_BTN, -} from "@/lib/mail-chrome-classes" -import { - emailMatchesFolder, - emailMatchesInboxPrimaryTab, - type MailNavFolderMaps, -} from "@/lib/mail-folder-filter" -import { cleanSenderName, resolveSenderEmail } from "@/lib/sender-display" -import { - getMailNavFolderLabel, - inboxTabDisplayLabel, - type FolderTreeNode, - type LabelRowItem, -} from "@/lib/sidebar-nav-data" -import { - mailNavVisitKey, - parseMailNavVisitKey, -} from "@/lib/mail-folder-display" -import { - buildInboxCategoryTabIcons, - inboxTabActiveAccentColor, - resolveEmailInboxCategoryTabs, -} from "@/lib/inbox-category-tabs" -import { - DEFAULT_INBOX_TAB, - INBOX_ALL_TAB, - SEARCH_FOLDER_ID, - inboxTabShowsInactiveMeta, - normalizeInboxTabSegment, -} from "@/lib/mail-url" -import { useSearchParams, useRouter } from "next/navigation" -import { - parseSearchParams, - buildSearchUrl, - DATE_RANGE_OPTIONS, - type SearchParams, -} from "@/lib/mail-search/search-params" -import { filterEmailsBySearchParams } from "@/lib/mail-search/search-engine" -import { MailFolderStackIndicator } from "@/components/gmail/mail-folder-stack-indicator" -import { useSidebarNav, registerNavEmailSync } from "@/lib/sidebar-nav-context" -import { ContactHoverCard } from "./contact-hover-card" -import { EmailLabelPickerBlock } from "@/components/gmail/email-label-picker-block" -import type { CatalogLabelPresence } from "@/components/gmail/email-label-picker-block" -import { resolveLabelPickerVisual } from "@/lib/label-picker-visual" -import { MobileXsBulkSheets } from "@/components/gmail/mobile-xs-bulk-sheets" -import { MailListSwipeRow } from "@/components/gmail/mail-list-swipe-row" -import { - useMoveTargets, - type MoveTarget, -} from "@/components/gmail/move-to-menu-items" -import { EmailView } from "./email-view" -import { MailSearchBar } from "@/components/gmail/mail-search-bar" -import { MailDateText } from "@/components/gmail/mail-date-text" -import { formatMailDetailDate } from "@/lib/mail-date" -import { buildListMailIndex } from "./email-list-row" -import { - useComposeActions, - useComposeDrafts, - type Contact, -} from "@/lib/compose-context" -import { computeFolderUnreadCounts } from "@/lib/mail-nav-metrics" -import { - effectiveLabels, - mergeEmailLabelEdits, - mergeEmailNotSpam, -} from "@/lib/label-edits" -import type { LabelEditState } from "@/lib/stores/mail-store" -import type { MailRouteState } from "@/lib/mail-url" -import { readXsMatches, useIsXs } from "@/hooks/use-xs" -import { useTouchNav } from "@/hooks/use-touch-nav" -import type { MailXsViewChrome } from "@/lib/mail-xs-view-chrome" -import { - buildThreadComposePreset, - withTouchFullscreenComposePreset, -} from "@/lib/thread-compose-preset" - -import { - LABEL_PICKER_EXCLUDE, - applyNavRenameToEdits, - applyNavRemoveLabelToEdits, -} from "@/lib/mail-list/label-actions" -import { EmailListAttachmentRow } from "@/components/gmail/email-list/attachments/email-list-attachment-row" -import { - MoveToDropdownItems, - MoveToContextMenuItems, -} from "@/components/gmail/email-list/move-to-menu-items" -import { MAIL_LIST_ROW_DIVIDER_CLASS } from "@/lib/mail-chrome-classes" -import { - LIST_PAGE_SIZE, - PULL_HOLD_HEIGHT, - PULL_SNAP_BACK_TRANSITION, - REFRESH_SPIN_CLASS, - PULL_ICON_FADE_MS, - PULL_REFRESH_THRESHOLD, - computePullOffset, - computeSpinnerRevealProgress, - type EmailListProps, - collectTreeLabels, - contextMenuTargetIdsForRow, - escapeHtml, - importantSignalIcon, - buildInboxTabBarItems, - inboxTabBadgeCountClass, - inboxTabBadgeDotClass, - CATEGORY_TAB_ICON_CLASS, - listRowCheckboxClass, - listRowQuickHoverTrayToneClass, - formatScheduledDateTimeDisplay, - scheduledIsoToDatetimeLocalValue, - parseDatetimeLocalToIso, -} from "@/components/gmail/email-list/email-list-helpers" - -export function EmailList({ - selectedFolder, - inboxTab, - listPage, - openMailId, - splitView = false, - onToggleSidebar, - onMailRouteNavigate, - onSelectFolder, - onFolderUnreadCountsChange, - onXsViewChromeChange, -}: EmailListProps) { - const isViewMode = openMailId !== null && !splitView - const showSplitReadingPane = splitView && openMailId !== null - const isSearchMode = selectedFolder === SEARCH_FOLDER_ID - const searchRouter = useRouter() - const searchAccount = useActiveAccount() - const setAdvancedOpen = useMailSearchStore((s) => s.setAdvancedOpen) - const urlSearchParams = useSearchParams() - const searchParams = useMemo( - () => (isSearchMode ? parseSearchParams(urlSearchParams) : null), - [isSearchMode, urlSearchParams] - ) - - const setSearchFilter = useCallback( - (patch: Partial) => { - if (!searchParams) return - searchRouter.push(buildSearchUrl({ ...searchParams, ...patch })) - }, - [searchParams, searchRouter] - ) - - const toggleSearchFilter = useCallback( - (key: keyof SearchParams, value: string) => { - if (!searchParams) return - const next = { ...searchParams } - if (key === "has") { - const arr = [...next.has] - if (arr.includes(value)) next.has = arr.filter((v) => v !== value) - else next.has = [...arr, value] - } else if (key === "excludeChats") { - next.excludeChats = !next.excludeChats - } else { - const cur = (next as Record)[key] - ;(next as Record)[key] = cur === value ? "" : value - } - searchRouter.push(buildSearchUrl(next)) - }, - [searchParams, searchRouter] - ) - - const { savedThreadReplyDrafts } = useComposeDrafts() - const { - openCompose, - openComposeWithInitial, - closeAllInlineComposes, - pruneInlineComposesToOpenThread, - } = useComposeActions() - - const { - scheduledEmails, - snoozedEmails, - sentPlaceholderEmails, - requestDeleteScheduled, - requestArchiveScheduled, - requestSnoozeScheduled, - requestToggleReadScheduled, - requestRescheduleScheduled, - requestGetScheduledEditPayload, - requestSendScheduledNow, - requestSnoozeMailboxEmail, - requestRestoreSnoozedToInbox, - } = useScheduledMail() - - const scheduledPersistHydrated = usePersistHydrated(useScheduledStore) - - const allEmails = useMemo( - () => - scheduledPersistHydrated - ? [...emails, ...scheduledEmails, ...snoozedEmails, ...sentPlaceholderEmails] - : emails, - [scheduledPersistHydrated, scheduledEmails, snoozedEmails, sentPlaceholderEmails] - ) - - const emailById = useMemo( - () => new Map(allEmails.map((e) => [e.id, e])), - [allEmails] - ) - - const sidebarNav = useSidebarNav() - const navMaps = useMemo( - () => ({ - folderIdToLabel: sidebarNav.folderIdToLabel, - folderTree: sidebarNav.folderTree, - labelRows: sidebarNav.labelRows, - }), - [sidebarNav.folderIdToLabel, sidebarNav.folderTree, sidebarNav.labelRows] - ) - - const inboxCategoryTabIconsCatalog = useMemo( - () => buildInboxCategoryTabIcons(sidebarNav.labelRows), - [sidebarNav.labelRows] - ) - - const inboxTabBarItems = useMemo( - () => buildInboxTabBarItems(sidebarNav.labelRows), - [sidebarNav.labelRows] - ) - - const listRowLabelBgByTextLower = useMemo( - () => buildLabelTextToNavColorClass(sidebarNav.folderTree, sidebarNav.labelRows), - [sidebarNav.folderTree, sidebarNav.labelRows] - ) - - const [rescheduleTarget, setRescheduleTarget] = useState<{ - id: string - value: string - /** Faux pendant la fermeture du Popover : la barre d’actions reste visible (évite saut d’ancrage). */ - panelOpen: boolean - } | null>(null) - const rescheduleDismissTimeoutsRef = useRef< - Map> - >(new Map()) - - const scheduleReschedulePopoverDismiss = useCallback((rowId: string) => { - const existing = rescheduleDismissTimeoutsRef.current.get(rowId) - if (existing) clearTimeout(existing) - const t = setTimeout(() => { - rescheduleDismissTimeoutsRef.current.delete(rowId) - setRescheduleTarget((p) => (p?.id === rowId ? null : p)) - }, 280) - rescheduleDismissTimeoutsRef.current.set(rowId, t) - }, []) - - useEffect(() => { - const m = rescheduleDismissTimeoutsRef.current - return () => { - for (const t of m.values()) clearTimeout(t) - m.clear() - } - }, []) - - useEffect(() => { - ensureVcLogosCollection() - }, []) - - const [cmScheduledRescheduleValue, setCmScheduledRescheduleValue] = - useState("") - - const handleEditScheduledMail = useCallback( - async (id: string) => { - const payload = await requestGetScheduledEditPayload(id) - if (!payload) return - openComposeWithInitial({ - to: payload.to, - subject: payload.subject, - bodyHtml: payload.bodyHtml, - editingScheduledId: id, - scheduledSendAtIso: payload.sendAtIso, - focusToOnMount: false, - focusBodyOnMount: true, - }) - }, - [requestGetScheduledEditPayload, openComposeWithInitial] - ) - - useEffect(() => { - if (!openMailId) { - closeAllInlineComposes() - } else { - const msg = emailById.get(openMailId) - pruneInlineComposesToOpenThread(msg ? threadStoreId(msg) : openMailId) - } - }, [ - openMailId, - emailById, - closeAllInlineComposes, - pruneInlineComposesToOpenThread, - ]) - - const { beginDrag, registerOnDrop } = useEmailDrag() - const starredEmails = useMailStore((s) => s.starredIds) - const importantEmails = useMailStore((s) => s.importantIds) - const [selectedEmails, setSelectedEmails] = useState([]) - const readOverrides = useMailStore((s) => s.readOverrides) - const conversationMode = useMailSettingsStore((s) => s.conversationMode) - const inboxSort = useMailSettingsStore((s) => s.inboxSort) - const density = useMailSettingsStore((s) => s.density) - const isMd = useIsMd() - const labelEdits = useMailStore((s) => s.labelEdits) - const mailActions = useRef(useMailStore.getState()).current - const setReadOverrides = useCallback( - (updater: (prev: Record) => Record) => { - const current = useMailStore.getState().readOverrides - const next = updater(current) - if (next !== current) mailActions.setReadOverrides(next) - }, - [mailActions] - ) - const setLabelEdits = useCallback( - (updater: (prev: LabelEditState) => LabelEditState) => { - mailActions.setLabelEdits(updater) - }, - [mailActions] - ) - - useEffect(() => { - registerNavEmailSync({ - renameLabel: (from, to) => { - setLabelEdits((prev) => applyNavRenameToEdits(allEmails, prev, from, to)) - }, - removeLabel: (label) => { - setLabelEdits((prev) => applyNavRemoveLabelToEdits(allEmails, prev, label)) - }, - }) - return () => registerNavEmailSync(null) - }, [allEmails]) - const [labelPickerQuery, setLabelPickerQuery] = useState("") - const hiddenEmailIds = useMailStore((s) => s.hiddenEmailIds) - const notSpamEmailIds = useMailStore((s) => s.notSpamEmailIds) - const recentMoveTargets = useMailStore((s) => s.recentMoveTargets) - const rowContextMenuOpenedAtRef = useRef(0) - const contextMenuTargetIdsRef = useRef([]) - const lastSelectionAnchorIdRef = useRef(null) - const [bulkSelectMenuOpen, setBulkSelectMenuOpen] = useState(false) - const [isRefreshing, setIsRefreshing] = useState(false) - const [mobileVisibleCount, setMobileVisibleCount] = useState(LIST_PAGE_SIZE) - const [mobileSelectionMode, setMobileSelectionMode] = useState(false) - const [mobileXsMoreMenuOpen, setMobileXsMoreMenuOpen] = useState(false) - const [mobileXsMoveSheetOpen, setMobileXsMoveSheetOpen] = useState(false) - const [mobileXsLabelSheetOpen, setMobileXsLabelSheetOpen] = useState(false) - const [swipeLabelEmailId, setSwipeLabelEmailId] = useState(null) - const [openSwipeRowId, setOpenSwipeRowId] = useState(null) - const isXs = useIsXs() - const touchNav = useTouchNav() - - const openMobileXsMoveSheet = useCallback(() => { - setMobileXsMoreMenuOpen(false) - window.setTimeout(() => setMobileXsMoveSheetOpen(true), 0) - }, []) - - const handleMobileXsMoveSheetOpenChange = useCallback((open: boolean) => { - setMobileXsMoveSheetOpen(open) - if (!open) { - setMobileSelectionMode(false) - setSelectedEmails([]) - } - }, []) - - const openMobileXsLabelSheet = useCallback(() => { - setMobileXsMoreMenuOpen(false) - setSwipeLabelEmailId(null) - window.setTimeout(() => setMobileXsLabelSheetOpen(true), 0) - }, []) - - const handleLabelSheetOpenChange = useCallback((open: boolean) => { - setMobileXsLabelSheetOpen(open) - if (!open) setSwipeLabelEmailId(null) - }, []) - - const touchListSwipeEnabled = touchNav && !mobileSelectionMode && !isViewMode - - useEffect(() => { - if (!openSwipeRowId) return - const handler = (e: globalThis.TouchEvent) => { - const target = e.target as HTMLElement | null - if (!target) return - const swipeRow = target.closest(`[data-swipe-row-id="${openSwipeRowId}"]`) - if (!swipeRow) setOpenSwipeRowId(null) - } - document.addEventListener("touchstart", handler, { passive: true }) - return () => document.removeEventListener("touchstart", handler) - }, [openSwipeRowId]) - - const listViewportRef = useRef(null) - const pullContentRef = useRef(null) - const pullIconRef = useRef(null) - const pullTouchStartYRef = useRef(0) - const pullActiveRef = useRef(false) - const pullYRef = useRef(0) - const pullRafRef = useRef(null) - const pendingPullYRef = useRef(0) - const seenEmailIdsRaw = useMailStore((s) => s.seenEmailIds) - const seenEmailIds = useMemo(() => new Set(seenEmailIdsRaw), [seenEmailIdsRaw]) - - const markEmailSeen = useCallback((id: string) => { - mailActions.markSeen(id) - }, [mailActions]) - - const folderFilterCtx = useMemo( - () => ({ - starredEmailIds: starredEmails, - importantEmailIds: importantEmails, - }), - [starredEmails, importantEmails] - ) - - const handleRefreshMessages = useCallback(async () => { - if (isRefreshing) return - setIsRefreshing(true) - try { - await new Promise((resolve) => setTimeout(resolve, 900)) - } finally { - setIsRefreshing(false) - } - }, [isRefreshing]) - - const applyPullVisual = useCallback((y: number, animate: boolean) => { - const content = pullContentRef.current - const icon = pullIconRef.current - const transition = animate ? PULL_SNAP_BACK_TRANSITION : "none" - if (content) { - content.style.transition = transition - content.style.transform = `translate3d(0, ${y}px, 0)` - } - if (icon) { - if (y === 0) { - icon.style.transition = animate - ? `opacity ${PULL_ICON_FADE_MS}ms ease-out, transform ${PULL_ICON_FADE_MS}ms ease-out` - : "none" - icon.style.opacity = "0" - icon.style.transform = "rotate(0deg)" - icon.style.removeProperty("animation") - } else { - const progress = computeSpinnerRevealProgress(y) - icon.style.transition = animate - ? `opacity ${PULL_ICON_FADE_MS}ms ease-out, transform ${PULL_ICON_FADE_MS}ms ease-out` - : "none" - icon.style.opacity = String(progress) - icon.style.transform = `rotate(${Math.min(320, progress * 320)}deg)` - } - } - }, []) - - const schedulePullVisual = useCallback( - (y: number) => { - pendingPullYRef.current = y - if (pullRafRef.current != null) return - pullRafRef.current = requestAnimationFrame(() => { - pullRafRef.current = null - applyPullVisual(pendingPullYRef.current, false) - }) - }, - [applyPullVisual] - ) - - const resetPullVisual = useCallback( - (animate: boolean) => { - if (pullRafRef.current != null) { - cancelAnimationFrame(pullRafRef.current) - pullRafRef.current = null - } - pullYRef.current = 0 - pendingPullYRef.current = 0 - applyPullVisual(0, animate) - }, - [applyPullVisual] - ) - - const armPullRefreshSpinner = useCallback(() => { - const icon = pullIconRef.current - if (!icon) return - icon.style.transition = "none" - icon.style.opacity = "1" - icon.style.removeProperty("transform") - icon.style.animation = "spin 0.55s linear infinite" - }, []) - - const releasePull = useCallback(async () => { - if (pullRafRef.current != null) { - cancelAnimationFrame(pullRafRef.current) - pullRafRef.current = null - } - const offset = pullYRef.current - if (offset >= PULL_REFRESH_THRESHOLD) { - pullYRef.current = PULL_HOLD_HEIGHT - applyPullVisual(PULL_HOLD_HEIGHT, false) - armPullRefreshSpinner() - void handleRefreshMessages() - return - } - pullYRef.current = 0 - applyPullVisual(0, true) - }, [applyPullVisual, armPullRefreshSpinner, handleRefreshMessages]) - - useEffect(() => { - if (isViewMode || !isXs || isRefreshing) return - pullYRef.current = 0 - applyPullVisual(0, true) - }, [isRefreshing, isViewMode, isXs, applyPullVisual]) - - const filteredEmails = useMemo(() => { - const hiddenSet = new Set(hiddenEmailIds) - const subtreeIdsCache = new Map() - let visible = allEmails.filter((email) => !hiddenSet.has(email.id)) - const hasLabelEdits = - labelEdits && - (Object.keys(labelEdits.additions).length > 0 || - Object.keys(labelEdits.removals).length > 0) - if (hasLabelEdits || notSpamEmailIds.length > 0) { - visible = visible.map((e) => - mergeEmailNotSpam(mergeEmailLabelEdits(e, labelEdits), notSpamEmailIds) - ) - } - - if (isSearchMode && searchParams) { - return filterEmailsBySearchParams(visible, searchParams, { - starredIds: starredEmails, - importantIds: importantEmails, - }) - } - - let rows = visible.filter((email) => - emailMatchesFolder( - email, - selectedFolder, - folderFilterCtx, - navMaps, - subtreeIdsCache - ) - ) - if (selectedFolder === "inbox") { - const tab = normalizeInboxTabSegment(inboxTab) - if (tab === "primary") { - rows = rows.filter((email) => - emailMatchesInboxPrimaryTab( - email, - folderFilterCtx, - navMaps, - subtreeIdsCache - ) - ) - } else if (tab !== INBOX_ALL_TAB) { - rows = rows.filter( - (email) => - emailMatchesFolder( - email, - "inbox", - folderFilterCtx, - navMaps, - subtreeIdsCache - ) && - emailMatchesFolder( - email, - tab, - folderFilterCtx, - navMaps, - subtreeIdsCache - ) - ) - } - } - return rows - }, [ - selectedFolder, - inboxTab, - hiddenEmailIds, - folderFilterCtx, - labelEdits, - notSpamEmailIds, - allEmails, - navMaps, - isSearchMode, - searchParams, - starredEmails, - importantEmails, - ]) - - const displayListEmails = useMemo(() => { - let rows = filteredEmails - if (conversationMode) { - rows = rows.filter(isThreadHeadMessage) - } - return sortEmailsForInbox( - rows, - inboxSort, - { - readOverrides, - starredIds: starredEmails, - importantIds: importantEmails, - }, - { conversationMode, byId: emailById } - ) - }, [ - filteredEmails, - conversationMode, - inboxSort, - readOverrides, - starredEmails, - importantEmails, - emailById, - ]) - - const inboxCategoryTabLabel = useMemo( - () => - inboxTabDisplayLabel( - inboxTab, - sidebarNav.labelRows, - sidebarNav.folderIdToLabel - ), - [inboxTab, sidebarNav.labelRows, sidebarNav.folderIdToLabel] - ) - - const mobileUnreadCount = useMemo( - () => - displayListEmails.filter( - (e) => !isListRowRead(e, readOverrides, emailById, conversationMode) - ).length, - [displayListEmails, readOverrides, emailById, conversationMode] - ) - - const mobileFolderLabel = useMemo(() => { - if (isSearchMode) return "Résultats de recherche" - const inboxTabNorm = normalizeInboxTabSegment(inboxTab) - return selectedFolder === "inbox" && inboxTabNorm !== "primary" - ? inboxCategoryTabLabel - : getMailNavFolderLabel(selectedFolder, sidebarNav.folderIdToLabel) - }, [ - selectedFolder, - inboxTab, - inboxCategoryTabLabel, - sidebarNav.folderIdToLabel, - isSearchMode, - ]) - - useEffect(() => { - setMobileSelectionMode(false) - setSelectedEmails([]) - }, [selectedFolder, inboxTab]) - - const totalPages = useMemo( - () => Math.max(1, Math.ceil(displayListEmails.length / LIST_PAGE_SIZE)), - [displayListEmails.length] - ) - - const pagedEmails = useMemo(() => { - const start = (listPage - 1) * LIST_PAGE_SIZE - return displayListEmails.slice(start, start + LIST_PAGE_SIZE) - }, [displayListEmails, listPage]) - - const listEmails = useMemo(() => { - if (isXs && !isViewMode) { - return displayListEmails.slice(0, mobileVisibleCount) - } - return pagedEmails - }, [isXs, isViewMode, displayListEmails, mobileVisibleCount, pagedEmails]) - - const listMailIndex = useMemo(() => buildListMailIndex(allEmails), [allEmails]) - - const listRowExtras = useMemo(() => { - const invitationById = new Map< - string, - ReturnType - >() - const attachmentsById = new Map() - const categoryTabsById = new Map< - string, - ReturnType - >() - const subtreeIdsCache = new Map() - const showCategoryTabIcons = - selectedFolder === "inbox" && - normalizeInboxTabSegment(inboxTab) === INBOX_ALL_TAB - - for (const e of listEmails) { - invitationById.set(e.id, resolveParsedCalendarInvitation(e)) - attachmentsById.set(e.id, attachmentsForEmailList(e)) - if (showCategoryTabIcons) { - const tabs = resolveEmailInboxCategoryTabs( - e, - folderFilterCtx, - navMaps, - inboxCategoryTabIconsCatalog, - subtreeIdsCache - ) - if (tabs.length > 0) categoryTabsById.set(e.id, tabs) - } - } - return { invitationById, attachmentsById, categoryTabsById } - }, [ - listEmails, - selectedFolder, - inboxTab, - folderFilterCtx, - navMaps, - inboxCategoryTabIconsCatalog, - ]) - - useEffect(() => { - if (isXs) return - if (listPage > totalPages) { - onMailRouteNavigate({ page: totalPages }) - } - }, [isXs, listPage, totalPages, onMailRouteNavigate]) - - useEffect(() => { - if (isXs && !isViewMode) return - listViewportRef.current?.scrollTo(0, 0) - }, [listPage, selectedFolder, inboxTab, isXs, isViewMode]) - - useEffect(() => { - if (!isXs) return - setMobileVisibleCount(LIST_PAGE_SIZE) - listViewportRef.current?.scrollTo(0, 0) - }, [selectedFolder, inboxTab, isXs]) - - useEffect(() => { - const root = listViewportRef.current - if (!root || !isXs || isViewMode) return - - const onScroll = () => { - if (mobileVisibleCount >= displayListEmails.length) return - const nearBottom = - root.scrollTop + root.clientHeight >= root.scrollHeight - 120 - if (nearBottom) { - setMobileVisibleCount((prev) => - Math.min(prev + LIST_PAGE_SIZE, displayListEmails.length) - ) - } - } - - root.addEventListener("scroll", onScroll, { passive: true }) - return () => root.removeEventListener("scroll", onScroll) - }, [isXs, isViewMode, mobileVisibleCount, displayListEmails.length]) - - useEffect(() => { - const root = listViewportRef.current - if (!root || !isXs || isViewMode) return - - const onTouchStart = (e: TouchEvent) => { - if (root.scrollTop > 0 || isRefreshing) return - pullActiveRef.current = true - pullTouchStartYRef.current = e.touches[0]?.clientY ?? 0 - } - - const onTouchMove = (e: TouchEvent) => { - if (!pullActiveRef.current || isRefreshing) return - const y = e.touches[0]?.clientY ?? 0 - const delta = y - pullTouchStartYRef.current - if (root.scrollTop > 0) { - pullActiveRef.current = false - resetPullVisual(true) - return - } - if (delta <= 0) { - resetPullVisual(true) - return - } - e.preventDefault() - const next = computePullOffset(delta) - pullYRef.current = next - schedulePullVisual(next) - } - - const endPull = () => { - if (!pullActiveRef.current) return - pullActiveRef.current = false - void releasePull() - } - - root.addEventListener("touchstart", onTouchStart, { passive: true }) - root.addEventListener("touchmove", onTouchMove, { passive: false }) - root.addEventListener("touchend", endPull) - root.addEventListener("touchcancel", endPull) - return () => { - if (pullRafRef.current != null) { - cancelAnimationFrame(pullRafRef.current) - pullRafRef.current = null - } - root.removeEventListener("touchstart", onTouchStart) - root.removeEventListener("touchmove", onTouchMove) - root.removeEventListener("touchend", endPull) - root.removeEventListener("touchcancel", endPull) - } - }, [isXs, isViewMode, isRefreshing, releasePull, resetPullVisual, schedulePullVisual]) - - const moveTargets = useMoveTargets({ - folderTree: sidebarNav.folderTree, - recentMoveTargets, - currentFolderId: selectedFolder, - }) - - const collectAllFolderLabels = useCallback((): Set => { - const s = new Set() - const walk = (nodes: FolderTreeNode[]) => { - for (const n of nodes) { - s.add(n.label.toLowerCase()) - if (n.children?.length) walk(n.children) - } - } - walk(sidebarNav.folderTree) - return s - }, [sidebarNav.folderTree]) - - const moveEmailsToTarget = useCallback( - (emailIds: string[], targetId: string) => { - if (emailIds.length === 0) return - const folderLabel = sidebarNav.folderIdToLabel[targetId] - const isSystemTarget = ["inbox", "sent", "drafts", "spam", "trash"].includes(targetId) - const allFolderLabels = collectAllFolderLabels() - - setLabelEdits((prev) => { - const nextAdd = { ...prev.additions } - const nextRem = { ...prev.removals } - - for (const id of emailIds) { - const email = allEmails.find((e) => e.id === id) - const currentLabels = effectiveLabels(email, nextAdd, nextRem) - - if (isSystemTarget) { - if (targetId === "inbox") { - for (const lab of currentLabels) { - if (allFolderLabels.has(lab.toLowerCase())) { - const cur = nextRem[id] ?? [] - if (!cur.some((l) => l.toLowerCase() === lab.toLowerCase())) { - nextRem[id] = [...cur, lab] - } - if (nextAdd[id]?.length) { - nextAdd[id] = nextAdd[id].filter((l) => l.toLowerCase() !== lab.toLowerCase()) - if (nextAdd[id].length === 0) delete nextAdd[id] - } - } - } - } - } else if (folderLabel) { - for (const lab of currentLabels) { - if (allFolderLabels.has(lab.toLowerCase()) && lab.toLowerCase() !== folderLabel.toLowerCase()) { - const cur = nextRem[id] ?? [] - if (!cur.some((l) => l.toLowerCase() === lab.toLowerCase())) { - nextRem[id] = [...cur, lab] - } - if (nextAdd[id]?.length) { - nextAdd[id] = nextAdd[id].filter((l) => l.toLowerCase() !== lab.toLowerCase()) - if (nextAdd[id].length === 0) delete nextAdd[id] - } - } - } - if (!currentLabels.some((l) => l.toLowerCase() === folderLabel.toLowerCase())) { - nextAdd[id] = [...(nextAdd[id] ?? []), folderLabel] - } - if (nextRem[id]?.length) { - nextRem[id] = nextRem[id].filter((l) => l.toLowerCase() !== folderLabel.toLowerCase()) - if (nextRem[id].length === 0) delete nextRem[id] - } - const inboxIdx = currentLabels.findIndex((l) => l.toLowerCase() === "inbox") - if (inboxIdx >= 0 || !email?.labels?.length || email.labels.includes("inbox")) { - const cur = nextRem[id] ?? [] - if (!cur.some((l) => l.toLowerCase() === "inbox")) { - nextRem[id] = [...cur, "inbox"] - } - } - } - } - return { additions: nextAdd, removals: nextRem } - }) - - if (!isSystemTarget || targetId === "inbox") { - mailActions.pushRecentMoveTarget(targetId) - } - - if (isSystemTarget && targetId !== "inbox") { - mailActions.hideEmails(emailIds) - mailActions.pushRecentMoveTarget(targetId) - } - }, - [allEmails, sidebarNav.folderIdToLabel, collectAllFolderLabels, setLabelEdits, mailActions] - ) - - const catalogLabels = useMemo(() => { - const s = new Set() - for (const l of collectTreeLabels(sidebarNav.folderTree)) s.add(l) - for (const row of sidebarNav.labelRows) s.add(row.label) - for (const e of allEmails) { - const eff = mergeEmailNotSpam( - mergeEmailLabelEdits(e, labelEdits), - notSpamEmailIds - ) - for (const lab of eff.labels ?? []) { - if (!LABEL_PICKER_EXCLUDE.has(lab)) s.add(lab) - } - } - return [...s].sort((a, b) => a.localeCompare(b, "fr")) - }, [sidebarNav.folderTree, sidebarNav.labelRows, allEmails, labelEdits, notSpamEmailIds]) - - const resolveLabelVisual = useCallback( - (label: string) => - resolveLabelPickerVisual(label, { - folderTree: sidebarNav.folderTree, - labelRows: sidebarNav.labelRows, - emailLabelToSidebarFolderId: sidebarNav.emailLabelToSidebarFolderId, - }), - [ - sidebarNav.folderTree, - sidebarNav.labelRows, - sidebarNav.emailLabelToSidebarFolderId, - ] - ) - - const resolveLabelCasing = useCallback( - (raw: string) => { - const t = raw.trim() - if (!t) return "" - const hit = catalogLabels.find((c) => c.toLowerCase() === t.toLowerCase()) - return hit ?? t - }, - [catalogLabels] - ) - - const addLabelToEmails = useCallback( - (ids: string[], label: string) => { - const resolved = resolveLabelCasing(label) - if (!resolved || ids.length === 0) return - sidebarNav.ensureLabelRowForLabelText(resolved) - setLabelEdits((prev) => { - const nextAdd = { ...prev.additions } - const nextRem = { ...prev.removals } - for (const id of ids) { - if (nextRem[id]?.length) { - nextRem[id] = nextRem[id].filter( - (x) => x.toLowerCase() !== resolved.toLowerCase() - ) - if (nextRem[id].length === 0) delete nextRem[id] - } - const base = allEmails.find((e) => e.id === id) - const merged = effectiveLabels(base, nextAdd, nextRem) - if (merged.some((x) => x.toLowerCase() === resolved.toLowerCase())) { - continue - } - nextAdd[id] = [...(nextAdd[id] ?? []), resolved] - } - return { additions: nextAdd, removals: nextRem } - }) - }, - [resolveLabelCasing, allEmails, sidebarNav] - ) - - const getCatalogLabelPresence = useCallback( - (ids: string[], catalogLabel: string): CatalogLabelPresence => { - const resolved = resolveLabelCasing(catalogLabel) - if (!resolved || ids.length === 0) return "none" - const lc = resolved.toLowerCase() - let n = 0 - for (const id of ids) { - const e = allEmails.find((x) => x.id === id) - const eff = effectiveLabels(e, labelEdits.additions, labelEdits.removals) - if (eff.some((l) => l.toLowerCase() === lc)) n++ - } - if (n === 0) return "none" - if (n === ids.length) return "all" - return "some" - }, - [allEmails, labelEdits, resolveLabelCasing] - ) - - const toggleLabelOnEmails = useCallback( - (ids: string[], label: string) => { - const resolved = resolveLabelCasing(label) - if (!resolved || ids.length === 0) return - - setLabelEdits((prev) => { - const presence = (id: string) => { - const e = allEmails.find((x) => x.id === id) - if (!e) return false - return effectiveLabels(e, prev.additions, prev.removals).some( - (l) => l.toLowerCase() === resolved.toLowerCase() - ) - } - const allHave = ids.every((id) => presence(id)) - const nextAdd = { ...prev.additions } - const nextRem = { ...prev.removals } - - if (allHave) { - for (const id of ids) { - if (nextAdd[id]?.length) { - const filtered = nextAdd[id].filter( - (l) => l.toLowerCase() !== resolved.toLowerCase() - ) - if (filtered.length) nextAdd[id] = filtered - else delete nextAdd[id] - } - const e = allEmails.find((x) => x.id === id) - if (!e) continue - const still = effectiveLabels(e, nextAdd, nextRem).some( - (l) => l.toLowerCase() === resolved.toLowerCase() - ) - if (still) { - const cur = nextRem[id] ?? [] - if (!cur.some((l) => l.toLowerCase() === resolved.toLowerCase())) { - nextRem[id] = [...cur, resolved] - } - } else if (nextRem[id]?.length) { - const fr = nextRem[id].filter( - (l) => l.toLowerCase() !== resolved.toLowerCase() - ) - if (fr.length) nextRem[id] = fr - else delete nextRem[id] - } - } - } else { - const anyMissing = ids.some((id) => !presence(id)) - if (anyMissing) { - queueMicrotask(() => sidebarNav.ensureLabelRowForLabelText(resolved)) - } - for (const id of ids) { - const e = allEmails.find((x) => x.id === id) - if (!e) continue - const had = effectiveLabels(e, prev.additions, prev.removals).some( - (l) => l.toLowerCase() === resolved.toLowerCase() - ) - if (nextRem[id]?.length) { - const fr = nextRem[id].filter( - (l) => l.toLowerCase() !== resolved.toLowerCase() - ) - if (fr.length) nextRem[id] = fr - else delete nextRem[id] - } - if (!had) { - if (!nextAdd[id]) nextAdd[id] = [] - if (!nextAdd[id].some((l) => l.toLowerCase() === resolved.toLowerCase())) { - nextAdd[id] = [...nextAdd[id], resolved] - } - } - } - } - return { additions: nextAdd, removals: nextRem } - }) - }, - [allEmails, resolveLabelCasing, sidebarNav] - ) - - const folderUnreadCounts = useMemo( - () => - computeFolderUnreadCounts( - allEmails, - folderFilterCtx, - hiddenEmailIds, - readOverrides, - navMaps, - labelEdits, - notSpamEmailIds - ), - [ - folderFilterCtx, - hiddenEmailIds, - readOverrides, - allEmails, - navMaps, - labelEdits, - notSpamEmailIds, - ] - ) - - const pageIds = useMemo(() => listEmails.map((e) => e.id), [listEmails]) - const selectedOnPageCount = useMemo( - () => pageIds.filter((id) => selectedEmails.includes(id)).length, - [pageIds, selectedEmails] - ) - const allPageSelected = pageIds.length > 0 && selectedOnPageCount === pageIds.length - const somePageSelected = selectedOnPageCount > 0 && !allPageSelected - const selectAllChecked: boolean | "indeterminate" = allPageSelected - ? true - : somePageSelected - ? "indeterminate" - : false - - const toggleStar = (id: string) => { - mailActions.toggleStar(id) - } - - const toggleImportant = (id: string) => { - mailActions.toggleImportant(id) - } - - const toggleSelect = (id: string) => { - setSelectedEmails(prev => - prev.includes(id) ? prev.filter(e => e !== id) : [...prev, id] - ) - } - - const selectRangeInclusive = (fromId: string, toId: string) => { - const ids = pageIds - const i0 = ids.indexOf(fromId) - const i1 = ids.indexOf(toId) - if (i0 === -1 || i1 === -1) return - const lo = Math.min(i0, i1) - const hi = Math.max(i0, i1) - const range = ids.slice(lo, hi + 1) - setSelectedEmails((prev) => [...new Set([...prev, ...range])]) - } - - const handleSelectAllChange = (checked: boolean | "indeterminate") => { - if (checked === true) { - setSelectedEmails((prev) => [...new Set([...prev, ...pageIds])]) - } else { - setSelectedEmails((prev) => prev.filter((id) => !pageIds.includes(id))) - } - } - - const mergePageSelection = (subsetOfPageIds: string[]) => { - setSelectedEmails((prev) => { - const outsidePage = prev.filter((id) => !pageIds.includes(id)) - return [...new Set([...outsidePage, ...subsetOfPageIds])] - }) - } - - const effectiveRead = (email: Email) => - readOverrides[email.id] !== undefined ? readOverrides[email.id]! : email.read - - const seenSerialized = useMemo( - () => [...seenEmailIds].sort().join(","), - [seenEmailIds] - ) - - /** Onglets catégories : « nouveaux » + ligne d’expéditeurs = non vus (pas encore aperçus dans la liste), pas non lus. */ - const { unseenInTabById, tabUnseenSenderLineById } = useMemo(() => { - const seen = new Set( - seenSerialized.length > 0 ? seenSerialized.split(",") : [] - ) - const hidden = new Set(hiddenEmailIds) - const visible = allEmails - .filter((email) => !hidden.has(email.id)) - .map((e) => - mergeEmailNotSpam(mergeEmailLabelEdits(e, labelEdits), notSpamEmailIds) - ) - const inboxPool = visible.filter((e) => - emailMatchesFolder(e, "inbox", folderFilterCtx, navMaps) - ) - const counts: Record = {} - const preview: Record = {} - const tabCache = new Map() - for (const tab of inboxTabBarItems) { - const rows = inboxPool.filter((e) => { - if (tab.id === "primary") { - return ( - emailMatchesInboxPrimaryTab(e, folderFilterCtx, navMaps, tabCache) && - !seen.has(e.id) - ) - } - if (tab.id === INBOX_ALL_TAB) { - return !seen.has(e.id) - } - return ( - emailMatchesFolder(e, "inbox", folderFilterCtx, navMaps, tabCache) && - emailMatchesFolder(e, tab.id, folderFilterCtx, navMaps, tabCache) && - !seen.has(e.id) - ) - }) - counts[tab.id] = rows.length - if (inboxTabShowsInactiveMeta(tab.id)) { - const chain: string[] = [] - const used = new Set() - for (const e of rows) { - const n = cleanSenderName(e.sender).trim() - if (!n || used.has(n)) continue - used.add(n) - chain.push(n) - if (chain.length >= 6) break - } - preview[tab.id] = chain.join(", ") - } - } - return { unseenInTabById: counts, tabUnseenSenderLineById: preview } - }, [folderFilterCtx, hiddenEmailIds, labelEdits, seenSerialized, allEmails, navMaps, notSpamEmailIds, inboxTabBarItems]) - - const effectiveStarred = (email: Email) => - starredEmails.includes(email.id) || email.starred - - const selectMenuAll = () => mergePageSelection(pageIds) - const selectMenuNone = () => - setSelectedEmails((prev) => prev.filter((id) => !pageIds.includes(id))) - const selectMenuRead = () => - mergePageSelection( - listEmails.filter((e) => effectiveRead(e)).map((e) => e.id) - ) - const selectMenuUnread = () => - mergePageSelection( - listEmails.filter((e) => !effectiveRead(e)).map((e) => e.id) - ) - const selectMenuStarred = () => - mergePageSelection( - listEmails.filter((e) => effectiveStarred(e)).map((e) => e.id) - ) - const selectMenuUnstarred = () => - mergePageSelection( - listEmails.filter((e) => !effectiveStarred(e)).map((e) => e.id) - ) - - const handleRowCheckboxClickCapture = (id: string, e: MouseEvent) => { - if (e.shiftKey && lastSelectionAnchorIdRef.current != null) { - e.preventDefault() - e.stopPropagation() - selectRangeInclusive(lastSelectionAnchorIdRef.current, id) - lastSelectionAnchorIdRef.current = id - } - } - - const bulkTargetIds = useMemo( - () => pageIds.filter((id) => selectedEmails.includes(id)), - [pageIds, selectedEmails] - ) - const hasUnreadInSelection = useMemo(() => { - for (const id of bulkTargetIds) { - const email = allEmails.find((e) => e.id === id) - if (!email) continue - const isRead = - readOverrides[id] !== undefined ? readOverrides[id]! : email.read - if (!isRead) return true - } - return false - }, [bulkTargetIds, readOverrides, allEmails]) - const showBulkToolbar = bulkTargetIds.length > 0 - - const labelSheetTargetIds = useMemo( - () => (swipeLabelEmailId ? [swipeLabelEmailId] : bulkTargetIds), - [swipeLabelEmailId, bulkTargetIds] - ) - - const clearBulkSelection = (ids: string[]) => { - setSelectedEmails((prev) => prev.filter((id) => !ids.includes(id))) - } - - const bulkHideFromList = (ids: string[]) => { - if (ids.length === 0) return - mailActions.hideEmails(ids) - clearBulkSelection(ids) - } - - const bulkArchive = () => bulkHideFromList(bulkTargetIds) - const bulkDelete = () => bulkHideFromList(bulkTargetIds) - const bulkSpam = () => bulkHideFromList(bulkTargetIds) - - const handleEmailsDroppedOnTarget = useCallback( - (targetId: string, _targetLabel: string, ids: string[]) => { - if (ids.length === 0) return - moveEmailsToTarget(ids, targetId) - setSelectedEmails((prev) => prev.filter((id) => !ids.includes(id))) - }, - [moveEmailsToTarget] - ) - - useEffect(() => { - return registerOnDrop(handleEmailsDroppedOnTarget) - }, [registerOnDrop, handleEmailsDroppedOnTarget]) - - const startRowDrag = useCallback( - (rowId: string, e: DragEvent) => { - if (isXs) return - const inSelection = selectedEmails.includes(rowId) - const ids = - inSelection && bulkTargetIds.length > 0 ? bulkTargetIds : [rowId] - if (e.dataTransfer) { - e.dataTransfer.effectAllowed = "move" - try { - e.dataTransfer.setData("text/plain", ids.join(",")) - } catch { - /* some browsers throw if called outside dragstart context */ - } - const ghost = document.createElement("div") - ghost.style.position = "fixed" - ghost.style.top = "-1000px" - ghost.style.left = "-1000px" - ghost.style.width = "1px" - ghost.style.height = "1px" - ghost.style.opacity = "0" - document.body.appendChild(ghost) - e.dataTransfer.setDragImage(ghost, 0, 0) - window.setTimeout(() => { - if (ghost.parentNode) ghost.parentNode.removeChild(ghost) - }, 0) - } - beginDrag(ids, selectedFolder, e.clientX, e.clientY) - }, - [beginDrag, isXs, selectedEmails, bulkTargetIds, selectedFolder] - ) - - const bulkMarkRead = () => { - if (bulkTargetIds.length === 0) return - setReadOverrides((prev) => { - const next = { ...prev } - for (const id of bulkTargetIds) next[id] = true - return next - }) - } - - const bulkMarkUnread = () => { - if (bulkTargetIds.length === 0) return - setReadOverrides((prev) => { - const next = { ...prev } - for (const id of bulkTargetIds) next[id] = false - return next - }) - } - - const markAllInViewAsRead = useCallback(() => { - setReadOverrides((prev) => { - const next = { ...prev } - for (const e of displayListEmails) { - for (const id of readStateTargets(e, conversationMode)) { - next[id] = true - } - } - return next - }) - }, [displayListEmails, conversationMode]) - - const bulkMoveTo = useCallback( - (targetId: string) => { - if (bulkTargetIds.length === 0) return - moveEmailsToTarget(bulkTargetIds, targetId) - if (targetId !== "inbox") { - setSelectedEmails((prev) => prev.filter((id) => !bulkTargetIds.includes(id))) - } - }, - [bulkTargetIds, moveEmailsToTarget] - ) - - // --- View mode helpers --- - const openEmailView = useMemo(() => { - if (!openMailId) return null - const resolved = resolveOpenEmailView( - openMailId, - allEmails, - conversationMode - ) - if (!resolved) return null - if (resolved.email.labels?.includes("scheduled")) return null - const email = mergeEmailNotSpam( - mergeEmailLabelEdits(resolved.email, labelEdits), - notSpamEmailIds - ) - const threadRoot = mergeEmailNotSpam( - mergeEmailLabelEdits(resolved.threadRoot, labelEdits), - notSpamEmailIds - ) - return { - email, - threadRoot, - isSingleMessageView: resolved.isSingleMessageView, - } - }, [openMailId, labelEdits, allEmails, notSpamEmailIds, conversationMode]) - - const openEmail = openEmailView?.email ?? null - const openEmailThreadRoot = openEmailView?.threadRoot ?? null - const isSingleMessageView = openEmailView?.isSingleMessageView ?? false - - const openMailIndex = useMemo( - () => - openMailId ? displayListEmails.findIndex((e) => e.id === openMailId) : -1, - [openMailId, displayListEmails] - ) - - useEffect(() => { - if (!openMailId) return - const message = emailById.get(openMailId) - if (!message) return - const targets = readStateTargets(message, conversationMode) - for (const id of targets) { - markEmailSeen(id) - } - setReadOverrides((prev) => { - let changed = false - const next = { ...prev } - for (const id of targets) { - if (next[id] === undefined) { - next[id] = true - changed = true - } - } - return changed ? next : prev - }) - }, [openMailId, markEmailSeen, emailById, conversationMode]) - - const navigateToMail = useCallback( - (id: string | null) => { - if (id && splitView) { - const idx = displayListEmails.findIndex((e) => e.id === id) - if (idx >= 0) { - const page = Math.floor(idx / LIST_PAGE_SIZE) + 1 - onMailRouteNavigate({ mailId: id, page }) - return - } - } - onMailRouteNavigate({ mailId: id }) - }, - [splitView, displayListEmails, onMailRouteNavigate] - ) - - useEffect(() => { - if (!openMailId) return - const raw = allEmails.find((e) => e.id === openMailId) - if (raw?.labels?.includes("scheduled")) { - navigateToMail(null) - } - }, [openMailId, allEmails, navigateToMail]) - - const pickAdjacentMailId = useCallback( - (currentId: string) => { - const idx = displayListEmails.findIndex((e) => e.id === currentId) - if (idx < 0) return displayListEmails[0]?.id ?? null - if (idx < displayListEmails.length - 1) return displayListEmails[idx + 1]!.id - if (idx > 0) return displayListEmails[idx - 1]!.id - return null - }, - [displayListEmails] - ) - - const leaveReadingPane = useCallback(() => { - if (!splitView) { - navigateToMail(null) - return - } - if (!openMailId) return - navigateToMail(pickAdjacentMailId(openMailId)) - }, [splitView, openMailId, navigateToMail, pickAdjacentMailId]) - - const goBack = useCallback(() => { - if (splitView) leaveReadingPane() - else navigateToMail(null) - }, [splitView, leaveReadingPane, navigateToMail]) - - const closeViewIfShowingEmail = useCallback( - (emailId: string) => { - if (openMailId === emailId) goBack() - }, - [openMailId, goBack] - ) - - const archiveListRow = useCallback( - (email: Email) => { - if (email.labels?.includes("scheduled")) { - void requestArchiveScheduled(email.id) - } else { - mailActions.hideEmail(email.id) - closeViewIfShowingEmail(email.id) - } - }, - [closeViewIfShowingEmail, mailActions, requestArchiveScheduled] - ) - - const deleteListRow = useCallback( - (email: Email) => { - if (email.labels?.includes("scheduled")) { - void requestDeleteScheduled(email.id) - } else { - mailActions.hideEmail(email.id) - closeViewIfShowingEmail(email.id) - } - }, - [closeViewIfShowingEmail, mailActions, requestDeleteScheduled] - ) - - const openSwipeRowLabelSheet = useCallback((emailId: string) => { - setSwipeLabelEmailId(emailId) - setMobileXsLabelSheetOpen(true) - }, []) - - const restoreSnoozedRowToMailbox = useCallback( - (emailRow: Email) => { - void requestRestoreSnoozedToInbox(emailRow) - if (emailRow.id.startsWith("snz-")) { - const baseId = emailRow.id.slice(4) - if (baseId.length > 0) mailActions.unhideEmail(baseId) - onSelectFolder?.("inbox") - } else { - onSelectFolder?.("scheduled") - } - closeViewIfShowingEmail(emailRow.id) - }, - [ - requestRestoreSnoozedToInbox, - mailActions, - closeViewIfShowingEmail, - onSelectFolder, - ] - ) - - const handleCategoryInboxTabClick = useCallback( - (tabId: string) => { - startTransition(() => { - onMailRouteNavigate({ - inboxTab: tabId, - page: 1, - mailId: null, - }) - }) - }, - [onMailRouteNavigate] - ) - - const handleBreadcrumbNavigate = useCallback( - (visitKey: string) => { - if (visitKey === mailNavVisitKey(selectedFolder, inboxTab)) return - const { folderId, inboxTab: tab } = parseMailNavVisitKey(visitKey) - startTransition(() => { - if (folderId === "inbox" && tab && tab !== DEFAULT_INBOX_TAB) { - onMailRouteNavigate({ - folderId: "inbox", - inboxTab: tab, - page: 1, - mailId: null, - }) - return - } - if (onSelectFolder) { - onSelectFolder(folderId) - return - } - onMailRouteNavigate({ - folderId, - inboxTab: DEFAULT_INBOX_TAB, - page: 1, - mailId: null, - }) - }) - }, - [ - selectedFolder, - inboxTab, - onMailRouteNavigate, - onSelectFolder, - ] - ) - - const goListPrevPage = useCallback(() => { - if (listPage <= 1) return - onMailRouteNavigate({ page: listPage - 1 }) - }, [listPage, onMailRouteNavigate]) - - const goListNextPage = useCallback(() => { - if (listPage >= totalPages) return - onMailRouteNavigate({ page: listPage + 1 }) - }, [listPage, totalPages, onMailRouteNavigate]) - - const goToPrev = useCallback(() => { - if (openMailIndex > 0) { - const id = displayListEmails[openMailIndex - 1]!.id - markEmailSeen(id) - setReadOverrides((prev) => ({ ...prev, [id]: true })) - navigateToMail(id) - } - }, [openMailIndex, displayListEmails, navigateToMail, markEmailSeen]) - - const goToNext = useCallback(() => { - if (openMailIndex >= 0 && openMailIndex < displayListEmails.length - 1) { - const id = displayListEmails[openMailIndex + 1]!.id - markEmailSeen(id) - setReadOverrides((prev) => ({ ...prev, [id]: true })) - navigateToMail(id) - } - }, [openMailIndex, displayListEmails, navigateToMail, markEmailSeen]) - - const handleOpenEmail = useCallback( - (id: string) => { - const em = allEmails.find((e) => e.id === id) - if (em?.labels?.includes("scheduled")) return - markEmailSeen(id) - setReadOverrides((prev) => ({ ...prev, [id]: true })) - navigateToMail(id) - }, - [navigateToMail, markEmailSeen, allEmails] - ) - - const openDraftInCompose = useCallback( - (email: Email) => { - markEmailSeen(email.id) - setReadOverrides((prev) => ({ ...prev, [email.id]: true })) - const to: Contact[] = email.senderEmail - ? [{ name: email.sender.trim(), email: email.senderEmail }] - : [] - const body = - email.body ?? - (email.preview - ? `

${escapeHtml(email.preview)}

` - : "

") - openComposeWithInitial({ - to, - subject: email.subject, - bodyHtml: body, - focusToOnMount: false, - focusBodyOnMount: true, - }) - }, - [markEmailSeen, openComposeWithInitial] - ) - - const handleRowActivate = useCallback( - (email: Email) => { - if (email.labels?.includes("scheduled")) return - if (email.labels?.includes("drafts")) { - openDraftInCompose(email) - return - } - handleOpenEmail(email.id) - }, - [handleOpenEmail, openDraftInCompose] - ) - - const viewModeIsRead = useMemo(() => { - if (!openEmail) return true - return readOverrides[openEmail.id] !== undefined - ? readOverrides[openEmail.id]! - : openEmail.read - }, [openEmail, readOverrides]) - - const afterSingleMessageRemoved = useCallback( - (removedId: string) => { - if (splitView) navigateToMail(pickAdjacentMailId(removedId)) - else navigateToMail(null) - }, - [splitView, navigateToMail, pickAdjacentMailId] - ) - - const singleArchive = useCallback(() => { - if (!openMailId) return - const id = openMailId - mailActions.hideEmail(id) - afterSingleMessageRemoved(id) - }, [openMailId, afterSingleMessageRemoved, mailActions]) - - const singleDelete = useCallback(() => { - if (!openMailId) return - const id = openMailId - mailActions.hideEmail(id) - afterSingleMessageRemoved(id) - }, [openMailId, afterSingleMessageRemoved, mailActions]) - - const singleSpam = useCallback(() => { - if (!openMailId) return - const id = openMailId - mailActions.hideEmail(id) - afterSingleMessageRemoved(id) - }, [openMailId, afterSingleMessageRemoved, mailActions]) - - const singleNotSpam = useCallback(() => { - if (!openMailId) return - const id = openMailId - mailActions.markNotSpam(id) - onSelectFolder?.("inbox") - afterSingleMessageRemoved(id) - }, [openMailId, afterSingleMessageRemoved, onSelectFolder, mailActions]) - - const singleToggleRead = useCallback(() => { - if (!openMailId) return - setReadOverrides((prev) => ({ ...prev, [openMailId]: !viewModeIsRead })) - }, [openMailId, viewModeIsRead]) - - const singleMoveTo = useCallback( - (targetId: string) => { - if (!openMailId) return - moveEmailsToTarget([openMailId], targetId) - const isSystemHide = ["sent", "drafts", "spam", "trash"].includes(targetId) - if (isSystemHide || targetId !== "inbox") { - afterSingleMessageRemoved(openMailId) - } - }, - [openMailId, afterSingleMessageRemoved, moveEmailsToTarget] - ) - - const singleReply = useCallback(() => { - if (!openEmail) return - openComposeWithInitial( - withTouchFullscreenComposePreset(buildThreadComposePreset(openEmail, "reply")) - ) - }, [openEmail, openComposeWithInitial]) - - useEffect(() => { - if (!onXsViewChromeChange) return - if (!isXs || !isViewMode || !openEmail) { - onXsViewChromeChange(null) - return - } - onXsViewChromeChange({ - onArchive: singleArchive, - onReply: singleReply, - moveTargets, - onMoveTo: singleMoveTo, - }) - return () => onXsViewChromeChange(null) - }, [ - onXsViewChromeChange, - isXs, - isViewMode, - openEmail, - singleArchive, - singleReply, - singleMoveTo, - moveTargets, - ]) - - useEffect(() => { - if (!splitView) return - const firstId = displayListEmails[0]?.id ?? null - if (!openMailId) { - if (firstId) navigateToMail(firstId) - return - } - const raw = allEmails.find((e) => e.id === openMailId) - if (raw?.labels?.includes("scheduled")) { - navigateToMail(firstId) - return - } - if (!displayListEmails.some((e) => e.id === openMailId)) { - navigateToMail(firstId) - } - }, [ - splitView, - selectedFolder, - inboxTab, - listPage, - displayListEmails, - openMailId, - navigateToMail, - allEmails, - ]) - - const handleNavigateToLabel = useCallback( - (label: string) => { - const folderId = - sidebarNav.emailLabelToSidebarFolderId[label] ?? label - onSelectFolder?.(folderId) - }, - [onSelectFolder, sidebarNav.emailLabelToSidebarFolderId] - ) - - useEffect(() => { - onFolderUnreadCountsChange?.(folderUnreadCounts) - }, [folderUnreadCounts, onFolderUnreadCountsChange]) - - const listRowsDep = listEmails.map((e) => e.id).join(",") - useLayoutEffect(() => { - if (!splitView || !openMailId) return - const scrollActiveRowIntoView = () => { - const root = listViewportRef.current - if (!root) return - const row = root.querySelector( - `[data-email-row-id="${openMailId}"]` - ) - if (!row) return - row.scrollIntoView({ block: "nearest", behavior: "smooth" }) - } - scrollActiveRowIntoView() - const frame = requestAnimationFrame(scrollActiveRowIntoView) - return () => cancelAnimationFrame(frame) - }, [splitView, openMailId, listPage, listRowsDep]) - - useEffect(() => { - const root = listViewportRef.current - if (!root) return - const obs = new IntersectionObserver( - (entries) => { - for (const en of entries) { - if (!en.isIntersecting) continue - const id = (en.target as HTMLElement).dataset.emailRowId - if (id) markEmailSeen(id) - } - }, - { root, threshold: 0.12, rootMargin: "0px" } - ) - root.querySelectorAll("[data-email-row-id]").forEach((el) => { - obs.observe(el) - }) - return () => obs.disconnect() - }, [listRowsDep, markEmailSeen]) - - // --- keyboard shortcuts for view / split reading pane --- - useEffect(() => { - if (!isViewMode && !showSplitReadingPane) return - const handler = (e: KeyboardEvent) => { - if (e.key === "Escape") { - if (!splitView) goBack() - return - } - if (e.key === "ArrowLeft" || e.key === "k") { - goToPrev() - return - } - if (e.key === "ArrowRight" || e.key === "j") { - goToNext() - return - } - } - window.addEventListener("keydown", handler) - return () => window.removeEventListener("keydown", handler) - }, [isViewMode, showSplitReadingPane, splitView, goBack, goToPrev, goToNext]) - - const dropdownSurfaceClass = MAIL_MENU_SURFACE_CLASS - - const listToolbarMode = splitView || !isViewMode - /** xs + split : icône (+ point si non lus) ; libellé uniquement sur l’onglet actif. */ - const compactInboxTabs = isXs || splitView - const activeInboxTabId = useMemo( - () => normalizeInboxTabSegment(inboxTab), - [inboxTab] - ) - - const openMailToolbar = (showBack: boolean) => ( - - {showBack ? ( - - - - - - Retour à la boîte de réception - - - ) : null} - -
- {openEmail?.spam === true ? ( - <> -
- - -
- - - -
- - - - - - Archiver - - -
- - - -
- - - - - - {viewModeIsRead ? "Marquer comme non lu" : "Marquer comme lu"} - - - - - - - - - - - -
- - ) : ( - <> -
- - - - - - Archiver - - - - - - - - Signaler comme spam - - - - - - - - Supprimer - - -
- - - -
- - - - - - {viewModeIsRead ? "Marquer comme non lu" : "Marquer comme lu"} - - - - - - - - - - - -
- - )} -
-
- ) - - const mailPaginationControls = (mode: "list" | "view") => ( -
- {displayListEmails.length === 0 ? ( - Aucun résultat - ) : mode === "view" ? ( - - {openMailIndex >= 0 ? openMailIndex + 1 : "–"} sur {displayListEmails.length} - - ) : ( - - {(listPage - 1) * LIST_PAGE_SIZE + 1}– - {Math.min(listPage * LIST_PAGE_SIZE, displayListEmails.length)} sur{" "} - {displayListEmails.length} - {totalPages > 1 ? ` · p. ${listPage}/${totalPages}` : null} - - )} - - - - - - {mode === "view" ? "Plus récent" : "Page précédente"} - - - - - - - - {mode === "view" ? "Plus ancien" : "Page suivante"} - - -
- ) - - const mainScrollClass = - "min-h-0 flex-1 overflow-y-auto overflow-x-hidden border-0 bg-mail-surface shadow-none outline-none sm:rounded-b-2xl " + - "[scrollbar-color:#9aa0a6_#ffffff] [scrollbar-width:auto] " + - "[&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar]:border-0 [&::-webkit-scrollbar]:bg-white " + - "[&::-webkit-scrollbar-track]:border-0 [&::-webkit-scrollbar-track]:bg-white [&::-webkit-scrollbar-track]:shadow-none " + - "[&::-webkit-scrollbar-thumb]:rounded-none [&::-webkit-scrollbar-thumb]:border-0 [&::-webkit-scrollbar-thumb]:shadow-none " + - "[&::-webkit-scrollbar-thumb]:bg-[#9aa0a6] hover:[&::-webkit-scrollbar-thumb]:bg-[#5f6368] " + - "[&::-webkit-scrollbar-corner]:border-0 [&::-webkit-scrollbar-corner]:bg-white" - - return ( -
- {/* Mobile xs top bar */} - {!isViewMode && ( -
-
-

- {mobileFolderLabel} -

-

- {displayListEmails.length} message{displayListEmails.length !== 1 ? "s" : ""} - {mobileUnreadCount > 0 && ` · ${mobileUnreadCount} non lu${mobileUnreadCount !== 1 ? "s" : ""}`} -

-
- - - - - - - {showBulkToolbar ? ( - <> - - - Archiver - - - - Supprimer - - - - Signaler comme spam - - - {hasUnreadInSelection ? ( - <> - - Marquer comme lu - - ) : ( - <> - - Marquer comme non lu - - )} - - - { - e.preventDefault() - openMobileXsMoveSheet() - }} - > - - Déplacer vers - - - { - e.preventDefault() - openMobileXsLabelSheet() - }} - > - - Ajouter le libellé - - - - - Ignorer la conversation - - - ) : ( - <> - - - Tout marquer comme lu - - -
- Sélectionnez des messages pour plus d'actions -
- - )} -
-
-
- )} - {/* View-mode xs nav buttons are rendered inside the scroll area below */} - {!isViewMode && touchNav && ( - getCatalogLabelPresence(labelSheetTargetIds, lab)} - onToggleCatalogLabel={(lab) => toggleLabelOnEmails(labelSheetTargetIds, lab)} - onCreateLabel={(lab) => { - addLabelToEmails(labelSheetTargetIds, lab) - setLabelPickerQuery("") - }} - /> - )} - -
-
- {splitView ? ( -
- {onToggleSidebar ? ( - - ) : null} - -
- ) : null} - - {/* Toolbar — relative: scroll lives in sibling below */} -
- - {!splitView && isViewMode ? ( - openMailToolbar(true) - ) : ( - /* ── LIST MODE TOOLBAR (original) ── */ - <> - -
-
- -
- - - -
- - Tous - Aucun - Lus - Non lus - Suivis - - Non suivis - - -
- - {showBulkToolbar ? ( - -
-
- - - - - - Archiver - - - - - - - - Signaler comme spam - - - - - - - - Supprimer - - -
- - - -
- - - - - - {hasUnreadInSelection - ? "Marquer comme lu" - : "Marquer comme non lu"} - - - - - - - - - - - -
- - - - { - if (!open) setLabelPickerQuery("") - }} - > - - - - - - - Mettre en attente - - - - Ajouter à Tasks - - - - - - Ajouter le libellé - - - - getCatalogLabelPresence(bulkTargetIds, lab) - } - onToggleCatalogLabel={(lab) => - toggleLabelOnEmails(bulkTargetIds, lab) - } - onCreateLabel={(lab) => { - addLabelToEmails(bulkTargetIds, lab) - setLabelPickerQuery("") - }} - /> - - - - - Ignorer la conversation - - - - - Ouvrir dans une nouvelle fenêtre - - - -
-
- ) : ( - <> - - - - - - - - - Tout marquer comme lu - - -
- Sélectionnez des messages pour afficher plus d'actions -
-
-
- - )} - - )} - -
- - {listToolbarMode ? mailPaginationControls("list") : null} - {!splitView && !listToolbarMode ? mailPaginationControls("view") : null} -
- - {selectedFolder === "inbox" && ( -
- {listToolbarMode && - (compactInboxTabs ? ( - - ) : ( -
- {inboxTabBarItems.map((tab) => { - const isActive = activeInboxTabId === tab.id - const accentColor = isActive - ? inboxTabActiveAccentColor(tab.id, tab.badgeColor) - : undefined - const unseen = unseenInTabById[tab.id] ?? 0 - const senderLine = tabUnseenSenderLineById[tab.id] ?? "" - const showMeta = - inboxTabShowsInactiveMeta(tab.id) && !isActive && unseen > 0 - const showSenderLine = showMeta && Boolean(senderLine) - const isExpandedTabMeta = showSenderLine - return ( - - ) - })} -
- ))} -
- )} - - {isSearchMode && searchParams && listToolbarMode && ( -
- {/* De dropdown */} - - - - - - setSearchFilter({ from: "" })}> - N'importe qui - - setSearchFilter({ from: searchAccount.email })}> - De moi ({searchAccount.email}) - - - {Array.from(new Set(allEmails.map((e) => e.senderEmail).filter(Boolean))).slice(0, 8).map((addr) => ( - setSearchFilter({ from: addr! })}> - {addr} - - ))} - - - - {/* Date dropdown */} - - - - - - setSearchFilter({ within: "" })}> - Indifférente - - - {DATE_RANGE_OPTIONS.map((opt) => ( - setSearchFilter({ within: opt.value })}> - {opt.label} - - ))} - - - - {/* Contient une pièce jointe */} - - - {/* Exclure les mises à jour d'agenda */} - - - {/* À dropdown */} - - - - - - setSearchFilter({ to: "" })}> - N'importe qui - - setSearchFilter({ to: searchAccount.email })}> - À moi ({searchAccount.email}) - - - - - {/* Non lu */} - - - {/* Recherche avancée */} - -
- )} - -
-
- {listToolbarMode && ( -
- -
- )} -
- {!splitView && isViewMode && openEmail ? ( - /* ── EMAIL VIEW ── */ - <> -
- -
- - - -
-
- { - if (LABEL_PICKER_EXCLUDE.has(lab)) return true - return mailLabelShouldShowInListStrip( - lab, - sidebarNav.emailLabelToSidebarFolderId, - sidebarNav.getNavItemPrefs, - sidebarNav.labelRows - ) - }} - /> - - ) : ( - - <> - {selectedFolder === "scheduled" && ( -
- -

- Les messages de la liste « Envois programmés » seront envoyés à l'heure prévue pour chacun d'eux. -

-
- )} - {displayListEmails.length === 0 ? ( - selectedFolder === "scheduled" ? ( -
-

Aucun message planifié.

-
- ) : isSearchMode && searchParams ? ( - - - - - - - Aucun résultat - - - Pas de résultats pour{" "} - - {searchParams.q || searchParams.hasWords || searchParams.from || searchParams.subject || "votre recherche"} - - {(searchParams.has.length > 0 || searchParams.within || searchParams.from || searchParams.to || searchParams.subject) ? ( - <> avec les filtres choisis - ) : null} - . - - - - ) : ( - - - - - - - Aucun message - - - {selectedFolder === "inbox" ? ( - <> - Aucun message dans l'onglet{" "} - - {inboxCategoryTabLabel} - {" "} - de la boîte de réception. - - ) : ( - <> - Aucun message dans{" "} - - {getMailNavFolderLabel(selectedFolder, sidebarNav.folderIdToLabel)} - - . - - )} - - - - ) - ) : ( -
- {listEmails.map((email) => { - const rowThreadId = threadStoreId(email) - const isStarred = - starredEmails.includes(rowThreadId) || email.starred - const isImportant = - importantEmails.includes(rowThreadId) || email.important - const isSpam = email.spam === true - const isDraft = email.labels?.includes("drafts") === true - const hasThreadReplyDraft = - savedThreadReplyDrafts[rowThreadId] !== undefined - const showDraftBadge = isDraft || hasThreadReplyDraft - const isRead = isListRowRead( - email, - readOverrides, - emailById, - conversationMode - ) - const senderHoverEmail = resolveSenderEmail(email.sender, email.senderEmail) - const threadMessageCount = conversationMode - ? getThreadMessageCount(email) - : 0 - const senderForSearch = email.sender.replace(/\s+/g, " ").trim() - const isSelected = selectedEmails.includes(email.id) - const isSplitActiveRow = - splitView && openMailId === email.id - const hasInvitation = email.hasInvitation === true - const parsedInvitation = - listRowExtras.invitationById.get(email.id) ?? null - const attachmentList = - listRowExtras.attachmentsById.get(email.id) ?? [] - const showAttachmentPills = - attachmentList.length > 0 && (!isMd || density === "default") - const showListPaperclip = - attachmentList.length > 0 && isMd && density !== "default" - const isCompactListRow = isMd && density === "compact" - const listRowPadTop = !showAttachmentPills - ? isCompactListRow - ? "pt-0" - : "pt-1" - : isCompactListRow - ? "pt-0" - : "pt-0.5" - const isScheduled = email.labels?.includes("scheduled") === true - const contextTargetIds = contextMenuTargetIdsForRow( - email.id, - selectedEmails, - selectedFolder, - allEmails - ) - const allContextTargetsScheduled = - contextTargetIds.length > 0 && - contextTargetIds.every((id) => - listMailIndex.scheduledIds.has(id) - ) - const scheduledCtxAnyUnread = - allContextTargetsScheduled && - contextTargetIds.some((id) => { - const em = listMailIndex.emailById.get(id) - if (!em) return false - return !(readOverrides[id] ?? em.read) - }) - const isRescheduleOpenThisRow = - rescheduleTarget?.id === email.id - const spamRowHoverNoArchive = selectedFolder === "spam" - const snoozedFolderRow = selectedFolder === "snoozed" - - return ( - { - if (open) { - rowContextMenuOpenedAtRef.current = Date.now() - setSelectedEmails((prev) => { - const next = contextMenuTargetIdsForRow( - email.id, - prev, - selectedFolder, - allEmails - ) - contextMenuTargetIdsRef.current = [...next] - return next - }) - } else { - setLabelPickerQuery("") - } - }} - > - - { - if (open) setOpenSwipeRowId(email.id) - else if (openSwipeRowId === email.id) setOpenSwipeRowId(null) - }} - onArchive={() => archiveListRow(email)} - onDelete={() => deleteListRow(email)} - onStar={() => toggleStar(email.id)} - onLabel={() => openSwipeRowLabelSheet(email.id)} - > -
startRowDrag(email.id, e)} - onClick={() => { - if (readXsMatches() && mobileSelectionMode) { - toggleSelect(email.id) - lastSelectionAnchorIdRef.current = email.id - return - } - handleRowActivate(email) - }} - className={cn( - "group relative z-0 w-full cursor-pointer pl-3 pr-2 py-2 transition-[background-color,box-shadow] duration-[50ms] ease-out", - !splitView && - "md:flex md:gap-2 md:px-2 md:py-1.5", - !splitView && - (isCompactListRow && !showAttachmentPills - ? "md:items-center" - : "md:items-start"), - isCompactListRow && "md:!py-1 md:text-[13px]", - isSplitActiveRow - ? "z-[1] bg-mail-row-active-split shadow-[inset_3px_0_0_0_#669df6]" - : isSelected - ? "bg-mail-row-selected" - : isRead - ? "bg-mail-row-read" - : "bg-mail-row-unread", - !isSplitActiveRow && - "hover:z-1 hover:shadow-[inset_1px_0_0_#d2d5da,inset_-1px_0_0_#d2d5da,0_4px_10px_-3px_rgba(60,64,67,.16),0_2px_5px_0_rgba(60,64,67,.09)]" - )} - > - {/* Compact < md */} -
- {mobileSelectionMode && ( -
e.stopPropagation()} - onClickCapture={(e) => handleRowCheckboxClickCapture(email.id, e)} - > - { - toggleSelect(email.id) - lastSelectionAnchorIdRef.current = email.id - }} - /> -
- )} -
-
-
e.stopPropagation()} - onClickCapture={(e) => handleRowCheckboxClickCapture(email.id, e)} - > - { - toggleSelect(email.id) - lastSelectionAnchorIdRef.current = email.id - }} - /> -
-
-
- - {isScheduled && ( - - - - )} - {isScheduled ? ( - - À : {email.scheduledToName ?? email.sender} - - ) : ( - - - {showDraftBadge && ( - Brouillon - )} - {email.sender} - - - )} - {threadMessageCount > 1 && ( - - {threadMessageCount} - - )} -
-
- {(parsedInvitation || hasInvitation) && ( - - )} - {attachmentList.length > 0 && ( - - )} - {listRowExtras.categoryTabsById.get(email.id) ? ( - - ) : null} - - {isScheduled ? ( - formatScheduledDateTimeDisplay(email.scheduledSendAt) - ) : ( - - )} - -
-
-
- -
- {email.tag && ( - - {email.tag} - - )} - - - {email.subject} - -
- -
-

- {email.preview} -

- -
-
-
- - {/* Desktop >= md */} -
-
-
e.stopPropagation()} - onClickCapture={(e) => handleRowCheckboxClickCapture(email.id, e)} - > - { - toggleSelect(email.id) - lastSelectionAnchorIdRef.current = email.id - }} - /> -
- -
- - - - {isScheduled && ( - - - - )} -
-
- -
- {isScheduled ? ( - - À : {email.scheduledToName ?? email.sender} - - ) : ( - - - {showDraftBadge && ( - Brouillon - )} - {email.sender} - - - )} - {threadMessageCount > 1 && ( - - {threadMessageCount} - - )} -
- -
-
- {email.tag && ( - - {email.tag} - - )} - - - {email.subject} - - {email.preview} -
- {showAttachmentPills && ( - - )} -
- -
- {isScheduled ? ( -
- - {formatScheduledDateTimeDisplay(email.scheduledSendAt)} - -
- {!spamRowHoverNoArchive && ( - - - - - - Archiver - - - )} - - - - - - Supprimer - - - - - - - - {isRead ? "Marquer comme non lu" : "Marquer comme lu"} - - - - - - - - Mettre en attente - - - { - if (open) { - const pending = - rescheduleDismissTimeoutsRef.current.get( - email.id - ) - if (pending) { - clearTimeout(pending) - rescheduleDismissTimeoutsRef.current.delete( - email.id - ) - } - setRescheduleTarget({ - id: email.id, - value: scheduledIsoToDatetimeLocalValue( - email.scheduledSendAt - ), - panelOpen: true, - }) - } else { - setRescheduleTarget((prev) => - prev?.id === email.id - ? { ...prev, panelOpen: false } - : prev - ) - scheduleReschedulePopoverDismiss(email.id) - } - }} - > - - - - - - - - Reprogrammer - - - e.stopPropagation()} - > -

- Nouvelle date d'envoi -

- - setRescheduleTarget((prev) => - prev?.id === email.id - ? { - ...prev, - value: e.target.value, - panelOpen: true, - } - : prev - ) - } - /> -
- - -
-
-
- - - - - - Modifier le mail - - - - - - - - Envoyer maintenant - - -
-
- ) : ( -
-
- {(parsedInvitation || hasInvitation) && ( - - )} - {listRowExtras.categoryTabsById.get(email.id) ? ( - - ) : null} - {showListPaperclip && ( - - )} - - - -
-
- {!spamRowHoverNoArchive && ( - - - - - - Archiver - - - )} - - - - - - Supprimer - - - - - - - - {isRead ? "Marquer comme non lu" : "Marquer comme lu"} - - - {spamRowHoverNoArchive && ( - - - - - - Boîte de réception - - - )} - {!spamRowHoverNoArchive && - (snoozedFolderRow ? ( - - - - - - {email.id.startsWith("snz-") - ? "Boîte de réception" - : "Planifiés"} - - - ) : ( - - - - - - Mettre en attente - - - ))} -
-
- )} -
-
-
-
-
- - e.preventDefault()} - onPointerDownOutside={(event) => { - const native = event.detail.originalEvent - if ( - native.pointerType === "mouse" && - native.button === 2 && - Date.now() - rowContextMenuOpenedAtRef.current < 450 - ) { - event.preventDefault() - } - }} - className={cn( - cn(MAIL_MENU_SURFACE_WIDE_CLASS, "overflow-visible"), - "[&_[data-slot=context-menu-item]]:gap-3 [&_[data-slot=context-menu-item]]:rounded-none [&_[data-slot=context-menu-item]]:px-3 [&_[data-slot=context-menu-item]]:py-2 [&_[data-slot=context-menu-item]]:text-sm", - "[&_[data-slot=context-menu-item]:focus]:bg-[#f1f3f4] [&_[data-slot=context-menu-item]:focus]:text-[#3c4043]", - "[&_[data-slot=context-menu-sub-trigger]]:gap-3 [&_[data-slot=context-menu-sub-trigger]]:rounded-none [&_[data-slot=context-menu-sub-trigger]]:px-3 [&_[data-slot=context-menu-sub-trigger]]:py-2 [&_[data-slot=context-menu-sub-trigger]]:text-sm", - "[&_[data-slot=context-menu-sub-trigger]:focus]:bg-[#f1f3f4] [&_[data-slot=context-menu-sub-trigger]:focus]:text-[#3c4043]", - "[&_[data-slot=context-menu-separator]]:mx-0 [&_[data-slot=context-menu-separator]]:my-1 [&_[data-slot=context-menu-separator]]:h-px [&_[data-slot=context-menu-separator]]:bg-[#eceff1]", - "[&_[data-slot=context-menu-sub-content]]:min-w-[200px] [&_[data-slot=context-menu-sub-content]]:rounded-lg [&_[data-slot=context-menu-sub-content]]:border [&_[data-slot=context-menu-sub-content]]:border-border [&_[data-slot=context-menu-sub-content]]:bg-popover [&_[data-slot=context-menu-sub-content]]:shadow-lg" - )} - > - {allContextTargetsScheduled ? ( - <> - { - const ids = [...contextMenuTargetIdsRef.current] - void Promise.all( - ids.map((id) => requestArchiveScheduled(id)) - ) - }} - > - - Archiver - - { - const ids = [...contextMenuTargetIdsRef.current] - void Promise.all( - ids.map((id) => requestDeleteScheduled(id)) - ) - }} - > - - Supprimer - - { - const ids = [...contextMenuTargetIdsRef.current] - const markRead = scheduledCtxAnyUnread - setReadOverrides((prev) => { - const next = { ...prev } - for (const id of ids) next[id] = markRead - return next - }) - void Promise.all( - ids.map((id) => - requestToggleReadScheduled(id, markRead) - ) - ) - }} - > - {scheduledCtxAnyUnread ? ( - - ) : ( - - )} - {scheduledCtxAnyUnread - ? "Marquer comme lu" - : "Marquer comme non lu"} - - { - const ids = [...contextMenuTargetIdsRef.current] - void Promise.all( - ids.map((id) => requestSnoozeScheduled(id)) - ) - }} - > - - Mettre en attente - - - { - if (!subOpen) return - const ids = contextMenuTargetIdsRef.current - const first = allEmails.find((e) => e.id === ids[0]) - setCmScheduledRescheduleValue( - scheduledIsoToDatetimeLocalValue( - first?.scheduledSendAt - ) - ) - }} - > - - - Reprogrammer - - -
e.stopPropagation()} - > -

- Nouvelle date d'envoi - {contextTargetIds.length > 1 - ? ` (${contextTargetIds.length} messages)` - : null} -

- - setCmScheduledRescheduleValue(e.target.value) - } - onPointerDown={(e) => e.stopPropagation()} - /> - -
-
-
- 1} - onSelect={() => { - if (contextTargetIds.length !== 1) return - void handleEditScheduledMail(contextTargetIds[0]!) - }} - > - - Modifier le mail - - { - const ids = [...contextMenuTargetIdsRef.current] - void Promise.all( - ids.map((id) => requestSendScheduledNow(id)) - ) - }} - > - - Envoyer maintenant - - - ) : ( - <> - - - Répondre - - - - Répondre à tous - - - - Transférer - - - - Transférer en tant que pièce jointe - - - - - - - Archiver - - - - Supprimer - - { - const newRead = !isRead - const ids = contextMenuTargetIdsRef.current - setReadOverrides((prev) => { - const next = { ...prev } - for (const id of ids) { - next[id] = newRead - } - return next - }) - }} - > - {!isRead ? ( - - ) : ( - - )} - {isRead ? "Marquer comme non lu" : "Marquer comme lu"} - - - - Mettre en attente - - - - Ajouter à Tasks - - - - - - - - Déplacer vers - - - { - moveEmailsToTarget(contextTargetIds, targetId) - if (targetId !== "inbox") { - setSelectedEmails((prev) => prev.filter((id) => !contextTargetIds.includes(id))) - } - }} - /> - - - - - - - Ajouter le libellé - - - - getCatalogLabelPresence(contextTargetIds, lab) - } - onToggleCatalogLabel={(lab) => - toggleLabelOnEmails(contextTargetIds, lab) - } - onCreateLabel={(lab) => { - addLabelToEmails(contextTargetIds, lab) - setLabelPickerQuery("") - }} - /> - - - - - - Ignorer la conversation - - - - - - - - Rech. e-mails de {senderForSearch} - - - - - - - - Ouvrir dans une nouvelle fenêtre - - - )} -
-
- ) - })} -
- )} - -
- )} -
-
- {listToolbarMode ? ( -
- -
- ) : null} -
- - {splitView ? ( - - ) : null} -
- {splitView ? ( -
- {openEmail ? ( - <> -
- {openMailToolbar(false)} -
- {mailPaginationControls("view")} -
-
- { - if (LABEL_PICKER_EXCLUDE.has(lab)) return true - return mailLabelShouldShowInListStrip( - lab, - sidebarNav.emailLabelToSidebarFolderId, - sidebarNav.getNavItemPrefs, - sidebarNav.labelRows - ) - }} - /> -
- - ) : ( - - - - - - - Aucun message sélectionné - - - Choisissez un message dans la liste ou ouvrez une boîte contenant des messages. - - - - )} -
- ) : null} -
- -
- ) -} +export { EmailList } from "@/components/gmail/email-list/email-list" +export type { EmailListProps } from "@/components/gmail/email-list/email-list-helpers" diff --git a/components/gmail/email-list/email-list-body.tsx b/components/gmail/email-list/email-list-body.tsx new file mode 100644 index 0000000..21235a4 --- /dev/null +++ b/components/gmail/email-list/email-list-body.tsx @@ -0,0 +1,274 @@ +"use client" + +import { ChevronLeft, ChevronUp, ChevronDown, RefreshCw } from "lucide-react" +import { Button } from "@/components/ui/button" +import { TooltipProvider } from "@/components/ui/tooltip" +import { cn } from "@/lib/utils" +import { MailFolderStackIndicator } from "@/components/gmail/mail-folder-stack-indicator" +import { mailNavVisitKey } from "@/lib/mail-folder-display" +import { MAIL_LIST_ROW_DIVIDER_CLASS } from "@/lib/mail-chrome-classes" +import { + PULL_HOLD_HEIGHT, + REFRESH_SPIN_CLASS, +} from "@/components/gmail/email-list/email-list-helpers" +import { EmailListRow } from "@/components/gmail/email-list/email-list-row" +import { + EmailListEmpty, + EmailListScheduledBanner, +} from "@/components/gmail/email-list/email-list-empty" +import { EmailListEmailViewPane } from "@/components/gmail/email-list/email-list-email-view-pane" +import type { EmailListData } from "@/components/gmail/email-list/hooks/use-email-list-data" +import type { EmailListLabels } from "@/components/gmail/email-list/hooks/use-email-list-labels" +import type { EmailListSelection } from "@/components/gmail/email-list/hooks/use-email-list-selection" +import type { EmailListReading } from "@/components/gmail/email-list/hooks/use-email-list-reading" + +const MAIN_SCROLL_CLASS = + "min-h-0 flex-1 overflow-y-auto overflow-x-hidden border-0 bg-mail-surface shadow-none outline-none sm:rounded-b-2xl " + + "[scrollbar-color:#9aa0a6_#ffffff] [scrollbar-width:auto] " + + "[&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar]:border-0 [&::-webkit-scrollbar]:bg-white " + + "[&::-webkit-scrollbar-track]:border-0 [&::-webkit-scrollbar-track]:bg-white [&::-webkit-scrollbar-track]:shadow-none " + + "[&::-webkit-scrollbar-thumb]:rounded-none [&::-webkit-scrollbar-thumb]:border-0 [&::-webkit-scrollbar-thumb]:shadow-none " + + "[&::-webkit-scrollbar-thumb]:bg-[#9aa0a6] hover:[&::-webkit-scrollbar-thumb]:bg-[#5f6368] " + + "[&::-webkit-scrollbar-corner]:border-0 [&::-webkit-scrollbar-corner]:bg-white" + +type EmailListBodyProps = { + data: EmailListData + labels: EmailListLabels + selection: EmailListSelection + reading: EmailListReading + onSelectFolder?: (folder: string) => void +} + +export function EmailListBody({ + data, + labels, + selection, + reading, + onSelectFolder, +}: EmailListBodyProps) { + const { + splitView, + isViewMode, + isSearchMode, + selectedFolder, + listToolbarMode, + isRefreshing, + listViewportRef, + pullContentRef, + pullIconRef, + displayListEmails, + listEmails, + inboxCategoryTabLabel, + sidebarNav, + searchParams, + } = data + + const { + openEmail, + openMailIndex, + goBack, + goToPrev, + goToNext, + handleBreadcrumbNavigate, + handleCategoryInboxTabClick, + } = reading + + const rowPropsBase = { + allEmails: data.allEmails, + emailById: data.emailById, + listMailIndex: data.listMailIndex, + listRowExtras: data.listRowExtras, + starredEmails: data.starredEmails, + importantEmails: data.importantEmails, + readOverrides: data.readOverrides, + conversationMode: data.conversationMode, + savedThreadReplyDrafts: data.savedThreadReplyDrafts, + selectedEmails: selection.selectedEmails, + selectedFolder: data.selectedFolder, + splitView: data.splitView, + openMailId: data.openMailId, + isXs: data.isXs, + isMd: data.isMd, + density: data.density, + mobileSelectionMode: selection.mobileSelectionMode, + touchListSwipeEnabled: selection.touchListSwipeEnabled, + openSwipeRowId: selection.openSwipeRowId, + setOpenSwipeRowId: selection.setOpenSwipeRowId, + listRowLabelBgByTextLower: data.listRowLabelBgByTextLower, + sidebarNav: data.sidebarNav, + rescheduleTarget: data.rescheduleTarget, + setRescheduleTarget: data.setRescheduleTarget, + rescheduleDismissTimeoutsRef: data.rescheduleDismissTimeoutsRef, + scheduleReschedulePopoverDismiss: data.scheduleReschedulePopoverDismiss, + rowContextMenuOpenedAtRef: selection.rowContextMenuOpenedAtRef, + contextMenuTargetIdsRef: selection.contextMenuTargetIdsRef, + lastSelectionAnchorIdRef: selection.lastSelectionAnchorIdRef, + setSelectedEmails: selection.setSelectedEmails, + setLabelPickerQuery: data.setLabelPickerQuery, + labelPickerQuery: data.labelPickerQuery, + catalogLabels: labels.catalogLabels, + resolveLabelVisual: labels.resolveLabelVisual, + getCatalogLabelPresence: labels.getCatalogLabelPresence, + toggleLabelOnEmails: labels.toggleLabelOnEmails, + addLabelToEmails: labels.addLabelToEmails, + moveTargets: data.moveTargets, + moveEmailsToTarget: labels.moveEmailsToTarget, + cmScheduledRescheduleValue: data.cmScheduledRescheduleValue, + setCmScheduledRescheduleValue: data.setCmScheduledRescheduleValue, + mailActions: data.mailActions, + setReadOverrides: data.setReadOverrides, + onSelectFolder, + toggleSelect: selection.toggleSelect, + handleRowCheckboxClickCapture: selection.handleRowCheckboxClickCapture, + handleRowActivate: reading.handleRowActivate, + startRowDrag: selection.startRowDrag, + archiveListRow: reading.archiveListRow, + deleteListRow: reading.deleteListRow, + toggleStar: selection.toggleStar, + toggleImportant: selection.toggleImportant, + openSwipeRowLabelSheet: selection.openSwipeRowLabelSheet, + handleNavigateToLabel: reading.handleNavigateToLabel, + handleCategoryInboxTabClick, + closeViewIfShowingEmail: reading.closeViewIfShowingEmail, + restoreSnoozedRowToMailbox: reading.restoreSnoozedRowToMailbox, + handleEditScheduledMail: data.handleEditScheduledMail, + requestArchiveScheduled: data.requestArchiveScheduled, + requestDeleteScheduled: data.requestDeleteScheduled, + requestToggleReadScheduled: data.requestToggleReadScheduled, + requestSnoozeScheduled: data.requestSnoozeScheduled, + requestRescheduleScheduled: data.requestRescheduleScheduled, + requestSendScheduledNow: data.requestSendScheduledNow, + requestSnoozeMailboxEmail: data.requestSnoozeMailboxEmail, + } + + return ( +
+
+ {listToolbarMode && ( +
+ +
+ )} +
+ {!splitView && isViewMode && openEmail ? ( + <> +
+ +
+ + + +
+
+ + + ) : ( + + <> + {selectedFolder === "scheduled" && } + {displayListEmails.length === 0 ? ( + selectedFolder === "scheduled" ? ( + + ) : isSearchMode && searchParams ? ( + + ) : ( + + ) + ) : ( +
+ {listEmails.map((email) => ( + + ))} +
+ )} + +
+ )} +
+
+ {listToolbarMode ? ( +
+ +
+ ) : null} +
+ ) +} + +export type { EmailListBodyProps } diff --git a/components/gmail/email-list/email-list-email-view-pane.tsx b/components/gmail/email-list/email-list-email-view-pane.tsx new file mode 100644 index 0000000..d89a888 --- /dev/null +++ b/components/gmail/email-list/email-list-email-view-pane.tsx @@ -0,0 +1,70 @@ +"use client" + +import { mailLabelShouldShowInListStrip } from "@/components/gmail/mail-label-pills" +import { EmailView } from "@/components/gmail/email-view" +import { LABEL_PICKER_EXCLUDE } from "@/lib/mail-list/label-actions" +import { threadStoreId } from "@/lib/mail-settings/list-row-id" +import type { EmailListData } from "@/components/gmail/email-list/hooks/use-email-list-data" +import type { EmailListReading } from "@/components/gmail/email-list/hooks/use-email-list-reading" +import type { EmailListSelection } from "@/components/gmail/email-list/hooks/use-email-list-selection" + +type EmailListEmailViewPaneProps = { + data: EmailListData + reading: EmailListReading + selection: EmailListSelection +} + +export function EmailListEmailViewPane({ + data, + reading, + selection, +}: EmailListEmailViewPaneProps) { + const { + openEmail, + openEmailThreadRoot, + isSingleMessageView, + handleNavigateToLabel, + singleNotSpam, + } = reading + const { toggleStar } = selection + const { + starredEmails, + listRowLabelBgByTextLower, + sidebarNav, + selectedFolder, + } = data + + if (!openEmail) return null + + return ( + { + if (LABEL_PICKER_EXCLUDE.has(lab)) return true + return mailLabelShouldShowInListStrip( + lab, + sidebarNav.emailLabelToSidebarFolderId, + sidebarNav.getNavItemPrefs, + sidebarNav.labelRows + ) + }} + /> + ) +} + +export type { EmailListEmailViewPaneProps } diff --git a/components/gmail/email-list/email-list-empty.tsx b/components/gmail/email-list/email-list-empty.tsx new file mode 100644 index 0000000..eb5c7e8 --- /dev/null +++ b/components/gmail/email-list/email-list-empty.tsx @@ -0,0 +1,135 @@ +"use client" + +import { Clock, Mail, Search } from "lucide-react" +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty" +import { getMailNavFolderLabel } from "@/lib/sidebar-nav-data" +import type { SearchParams } from "@/lib/mail-search/search-params" + +export type EmailListEmptyProps = { + variant: "scheduled" | "search" | "folder" | "split-pane" + selectedFolder?: string + inboxCategoryTabLabel?: string + folderIdToLabel?: Record + searchParams?: SearchParams | null +} + +export function EmailListEmpty({ + variant, + selectedFolder = "inbox", + inboxCategoryTabLabel = "", + folderIdToLabel = {}, + searchParams = null, +}: EmailListEmptyProps) { + if (variant === "scheduled") { + return ( +
+

Aucun message planifié.

+
+ ) + } + + if (variant === "search" && searchParams) { + return ( + + + + + + + Aucun résultat + + + Pas de résultats pour{" "} + + {searchParams.q || searchParams.hasWords || searchParams.from || searchParams.subject || "votre recherche"} + + {(searchParams.has.length > 0 || searchParams.within || searchParams.from || searchParams.to || searchParams.subject) ? ( + <> avec les filtres choisis + ) : null} + . + + + + ) + } + + if (variant === "split-pane") { + return ( + + + + + + + Aucun message sélectionné + + + Choisissez un message dans la liste ou ouvrez une boîte contenant des messages. + + + + ) + } + + return ( + + + + + + + Aucun message + + + {selectedFolder === "inbox" ? ( + <> + Aucun message dans l'onglet{" "} + + {inboxCategoryTabLabel} + {" "} + de la boîte de réception. + + ) : ( + <> + Aucun message dans{" "} + + {getMailNavFolderLabel(selectedFolder, folderIdToLabel)} + + . + + )} + + + + ) +} + +export function EmailListScheduledBanner() { + return ( +
+ +

+ Les messages de la liste « Envois programmés » seront envoyés à l'heure prévue pour chacun d'eux. +

+
+ ) +} diff --git a/components/gmail/email-list/email-list-layout.tsx b/components/gmail/email-list/email-list-layout.tsx new file mode 100644 index 0000000..fb01f05 --- /dev/null +++ b/components/gmail/email-list/email-list-layout.tsx @@ -0,0 +1,212 @@ +"use client" + +import { Pencil } from "lucide-react" +import { cn } from "@/lib/utils" +import { buildSearchUrl } from "@/lib/mail-search/search-params" +import { MobileXsBulkSheets } from "@/components/gmail/mobile-xs-bulk-sheets" +import { EmailListToolbar } from "@/components/gmail/email-list/email-list-toolbar" +import { EmailListBody } from "@/components/gmail/email-list/email-list-body" +import { EmailListEmailViewPane } from "@/components/gmail/email-list/email-list-email-view-pane" +import { EmailListEmpty } from "@/components/gmail/email-list/email-list-empty" +import type { EmailListProps } from "@/components/gmail/email-list/email-list-helpers" +import type { EmailListData } from "@/components/gmail/email-list/hooks/use-email-list-data" +import type { EmailListLabels } from "@/components/gmail/email-list/hooks/use-email-list-labels" +import type { EmailListSelection } from "@/components/gmail/email-list/hooks/use-email-list-selection" +import type { EmailListReading } from "@/components/gmail/email-list/hooks/use-email-list-reading" + +type EmailListLayoutProps = { + props: EmailListProps + data: EmailListData + labels: EmailListLabels + selection: EmailListSelection + reading: EmailListReading +} + +export function EmailListLayout({ + props, + data, + labels, + selection, + reading, +}: EmailListLayoutProps) { + const { onToggleSidebar } = props + const { + splitView, + isViewMode, + isXs, + touchNav, + openCompose, + } = data + + const { + mobileXsMoveSheetOpen, + mobileXsLabelSheetOpen, + handleMobileXsMoveSheetOpenChange, + handleLabelSheetOpenChange, + labelSheetTargetIds, + bulkMoveTo, + openMobileXsMoveSheet, + openMobileXsLabelSheet, + } = selection + + const { + openEmail, + } = reading + + const toolbarProps = { + isViewMode: data.isViewMode, + splitView: data.splitView, + listToolbarMode: data.listToolbarMode, + compactInboxTabs: data.compactInboxTabs, + isSearchMode: data.isSearchMode, + selectedFolder: data.selectedFolder, + mobileFolderLabel: data.mobileFolderLabel, + displayListEmails: data.displayListEmails, + mobileUnreadCount: data.mobileUnreadCount, + mobileSelectionMode: selection.mobileSelectionMode, + setMobileSelectionMode: selection.setMobileSelectionMode, + setSelectedEmails: selection.setSelectedEmails, + mobileXsMoreMenuOpen: selection.mobileXsMoreMenuOpen, + setMobileXsMoreMenuOpen: selection.setMobileXsMoreMenuOpen, + showBulkToolbar: selection.showBulkToolbar, + bulkSelectMenuOpen: selection.bulkSelectMenuOpen, + setBulkSelectMenuOpen: selection.setBulkSelectMenuOpen, + selectAllChecked: selection.selectAllChecked, + handleSelectAllChange: selection.handleSelectAllChange, + selectMenuAll: selection.selectMenuAll, + selectMenuNone: selection.selectMenuNone, + selectMenuRead: selection.selectMenuRead, + selectMenuUnread: selection.selectMenuUnread, + selectMenuStarred: selection.selectMenuStarred, + selectMenuUnstarred: selection.selectMenuUnstarred, + bulkArchive: selection.bulkArchive, + bulkDelete: selection.bulkDelete, + bulkSpam: selection.bulkSpam, + hasUnreadInSelection: selection.hasUnreadInSelection, + bulkMarkRead: selection.bulkMarkRead, + bulkMarkUnread: selection.bulkMarkUnread, + moveTargets: data.moveTargets, + bulkMoveTo: selection.bulkMoveTo, + labelPickerQuery: data.labelPickerQuery, + setLabelPickerQuery: data.setLabelPickerQuery, + catalogLabels: labels.catalogLabels, + resolveLabelVisual: labels.resolveLabelVisual, + bulkTargetIds: selection.bulkTargetIds, + getCatalogLabelPresence: labels.getCatalogLabelPresence, + toggleLabelOnEmails: labels.toggleLabelOnEmails, + addLabelToEmails: labels.addLabelToEmails, + isRefreshing: data.isRefreshing, + handleManualRefresh: data.handleManualRefresh, + markAllInViewAsRead: data.markAllInViewAsRead, + openMobileXsMoveSheet, + openMobileXsLabelSheet, + listPage: data.listPage, + totalPages: data.totalPages, + openMailIndex: reading.openMailIndex, + goListPrevPage: reading.goListPrevPage, + goListNextPage: reading.goListNextPage, + goToPrev: reading.goToPrev, + goToNext: reading.goToNext, + goBack: reading.goBack, + openEmail: reading.openEmail, + viewModeIsRead: reading.viewModeIsRead, + singleArchive: reading.singleArchive, + singleDelete: reading.singleDelete, + singleNotSpam: reading.singleNotSpam, + singleSpam: reading.singleSpam, + singleToggleRead: reading.singleToggleRead, + singleMoveTo: reading.singleMoveTo, + onToggleSidebar, + inboxTabBarItems: data.inboxTabBarItems, + activeInboxTabId: data.activeInboxTabId, + unseenInTabById: data.unseenInTabById, + tabUnseenSenderLineById: data.tabUnseenSenderLineById, + handleCategoryInboxTabClick: reading.handleCategoryInboxTabClick, + searchParams: data.searchParams, + searchAccount: data.searchAccount, + allEmails: data.allEmails, + setSearchFilter: data.setSearchFilter, + toggleSearchFilter: data.toggleSearchFilter, + setAdvancedOpen: data.setAdvancedOpen, + searchRouter: data.searchRouter, + buildSearchUrl, + } + + return ( +
+ + {!isViewMode && touchNav && ( + labels.getCatalogLabelPresence(labelSheetTargetIds, lab)} + onToggleCatalogLabel={(lab) => labels.toggleLabelOnEmails(labelSheetTargetIds, lab)} + onCreateLabel={(lab) => { + labels.addLabelToEmails(labelSheetTargetIds, lab) + data.setLabelPickerQuery("") + }} + /> + )} + +
+
+ + + + {splitView ? ( + + ) : null} +
+ + {splitView ? ( +
+ {openEmail ? ( + <> + +
+ +
+ + ) : ( + + )} +
+ ) : null} +
+
+ ) +} + +export type { EmailListLayoutProps } diff --git a/components/gmail/email-list/email-list-row.tsx b/components/gmail/email-list/email-list-row.tsx new file mode 100644 index 0000000..4b82c4f --- /dev/null +++ b/components/gmail/email-list/email-list-row.tsx @@ -0,0 +1,1622 @@ +"use client" + +import { memo, type Dispatch, type MouseEvent, type RefObject, type SetStateAction } from "react" +import { Icon } from "@iconify/react" +import { + Archive, + CalendarClock, + Clock, + FolderInput, + Forward, + Inbox as InboxIcon, + ListTodo, + Mail, + MailOpen, + Paperclip, + Pencil, + Reply, + ReplyAll, + Search, + Send, + ShieldAlert, + SquareArrowOutUpRight, + Star, + Tag, + Trash2, + VolumeX, +} from "lucide-react" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, +} from "@/components/ui/context-menu" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { ContactHoverCard } from "@/components/gmail/contact-hover-card" +import { EmailLabelPickerBlock } from "@/components/gmail/email-label-picker-block" +import type { CatalogLabelPresence } from "@/components/gmail/email-label-picker-block" +import { MailInboxCategoryTabIcons } from "@/components/gmail/mail-inbox-category-tab-icons" +import { + MailLabelPillStrip, +} from "@/components/gmail/mail-label-pills" +import { MailListSwipeRow } from "@/components/gmail/mail-list-swipe-row" +import { MailDateText } from "@/components/gmail/mail-date-text" +import { EmailListAttachmentRow } from "@/components/gmail/email-list/attachments/email-list-attachment-row" +import { + MoveToContextMenuItems, +} from "@/components/gmail/email-list/move-to-menu-items" +import type { MoveTarget, MailMoveTargets } from "@/components/gmail/move-to-menu-items" +import { cn } from "@/lib/utils" +import type { Email } from "@/lib/email-data" +import { + getThreadMessageCount, + isListRowRead, +} from "@/lib/mail-thread" +import { threadStoreId } from "@/lib/mail-settings/list-row-id" +import { resolveSenderEmail } from "@/lib/sender-display" +import { VIDEO_CONFERENCE_LOGOS } from "@/lib/calendar-invitation" +import { + MAIL_MENU_SURFACE_CLASS, + MAIL_MENU_SURFACE_WIDE_CLASS, +} from "@/lib/mail-chrome-classes" +import { readXsMatches } from "@/hooks/use-xs" +import type { LabelRowItem, FolderTreeNode } from "@/lib/sidebar-nav-data" +import type { LabelEditState } from "@/lib/stores/mail-store" +import { + contextMenuTargetIdsForRow, + formatScheduledDateTimeDisplay, + importantSignalIcon, + listRowCheckboxClass, + listRowQuickHoverTrayToneClass, + parseDatetimeLocalToIso, + scheduledIsoToDatetimeLocalValue, +} from "@/components/gmail/email-list/email-list-helpers" +import type { ListMailIndex } from "@/components/gmail/email-list/list-mail-index" + +type ListRowExtras = { + invitationById: Map> + attachmentsById: Map + categoryTabsById: Map +} + +export type EmailListRowProps = { + email: Email + allEmails: Email[] + emailById: Map + listMailIndex: ListMailIndex + listRowExtras: ListRowExtras + starredEmails: string[] + importantEmails: string[] + readOverrides: Record + conversationMode: boolean + savedThreadReplyDrafts: Record + selectedEmails: string[] + selectedFolder: string + splitView: boolean + openMailId: string | null + isXs: boolean + isMd: boolean + density: string + mobileSelectionMode: boolean + touchListSwipeEnabled: boolean + openSwipeRowId: string | null + setOpenSwipeRowId: (id: string | null) => void + listRowLabelBgByTextLower: Map + sidebarNav: { + emailLabelToSidebarFolderId: Record + getNavItemPrefs: (id: string) => { messages: string } + labelRows: LabelRowItem[] + folderTree: FolderTreeNode[] + } + rescheduleTarget: { + id: string + value: string + panelOpen: boolean + } | null + setRescheduleTarget: Dispatch> + rescheduleDismissTimeoutsRef: RefObject>> + scheduleReschedulePopoverDismiss: (rowId: string) => void + rowContextMenuOpenedAtRef: RefObject + contextMenuTargetIdsRef: RefObject + lastSelectionAnchorIdRef: RefObject + setSelectedEmails: Dispatch> + setLabelPickerQuery: (q: string) => void + labelPickerQuery: string + catalogLabels: string[] + resolveLabelVisual: (label: string) => ReturnType + getCatalogLabelPresence: (ids: string[], catalogLabel: string) => CatalogLabelPresence + toggleLabelOnEmails: (ids: string[], label: string) => void + addLabelToEmails: (ids: string[], label: string) => void + moveTargets: MailMoveTargets + moveEmailsToTarget: (emailIds: string[], targetId: string) => void + cmScheduledRescheduleValue: string + setCmScheduledRescheduleValue: (v: string) => void + mailActions: { + hideEmail: (id: string) => void + markNotSpam: (id: string) => void + unhideEmail: (id: string) => void + } + setReadOverrides: (updater: (prev: Record) => Record) => void + onSelectFolder?: (folder: string) => void + toggleSelect: (id: string) => void + handleRowCheckboxClickCapture: (id: string, e: MouseEvent) => void + handleRowActivate: (email: Email) => void + startRowDrag: (rowId: string, e: import("react").DragEvent) => void + archiveListRow: (email: Email) => void + deleteListRow: (email: Email) => void + toggleStar: (id: string) => void + toggleImportant: (id: string) => void + openSwipeRowLabelSheet: (emailId: string) => void + handleNavigateToLabel: (label: string) => void + handleCategoryInboxTabClick: (tabId: string) => void + closeViewIfShowingEmail: (emailId: string) => void + restoreSnoozedRowToMailbox: (email: Email) => void + handleEditScheduledMail: (id: string) => void + requestArchiveScheduled: (id: string) => void | Promise + requestDeleteScheduled: (id: string) => void | Promise + requestToggleReadScheduled: (id: string, read: boolean) => void | Promise + requestSnoozeScheduled: (id: string) => void | Promise + requestRescheduleScheduled: (id: string, iso: string) => void | Promise + requestSendScheduledNow: (id: string) => void | Promise + requestSnoozeMailboxEmail: (email: Email) => void | Promise +} + +function EmailListRowInner(props: EmailListRowProps) { + const { + email, + allEmails, + emailById, + listMailIndex, + listRowExtras, + starredEmails, + importantEmails, + readOverrides, + conversationMode, + savedThreadReplyDrafts, + selectedEmails, + selectedFolder, + splitView, + openMailId, + isXs, + isMd, + density, + mobileSelectionMode, + touchListSwipeEnabled, + openSwipeRowId, + setOpenSwipeRowId, + listRowLabelBgByTextLower, + sidebarNav, + rescheduleTarget, + setRescheduleTarget, + rescheduleDismissTimeoutsRef, + scheduleReschedulePopoverDismiss, + rowContextMenuOpenedAtRef, + contextMenuTargetIdsRef, + lastSelectionAnchorIdRef, + setSelectedEmails, + setLabelPickerQuery, + labelPickerQuery, + catalogLabels, + resolveLabelVisual, + getCatalogLabelPresence, + toggleLabelOnEmails, + addLabelToEmails, + moveTargets, + moveEmailsToTarget, + cmScheduledRescheduleValue, + setCmScheduledRescheduleValue, + mailActions, + setReadOverrides, + onSelectFolder, + toggleSelect, + handleRowCheckboxClickCapture, + handleRowActivate, + startRowDrag, + archiveListRow, + deleteListRow, + toggleStar, + toggleImportant, + openSwipeRowLabelSheet, + handleNavigateToLabel, + handleCategoryInboxTabClick, + closeViewIfShowingEmail, + restoreSnoozedRowToMailbox, + handleEditScheduledMail, + requestArchiveScheduled, + requestDeleteScheduled, + requestToggleReadScheduled, + requestSnoozeScheduled, + requestRescheduleScheduled, + requestSendScheduledNow, + requestSnoozeMailboxEmail, + } = props + + + const rowThreadId = threadStoreId(email) + const isStarred = + starredEmails.includes(rowThreadId) || email.starred + const isImportant = + importantEmails.includes(rowThreadId) || email.important + const isSpam = email.spam === true + const isDraft = email.labels?.includes("drafts") === true + const hasThreadReplyDraft = + savedThreadReplyDrafts[rowThreadId] !== undefined + const showDraftBadge = isDraft || hasThreadReplyDraft + const isRead = isListRowRead( + email, + readOverrides, + emailById, + conversationMode + ) + const senderHoverEmail = resolveSenderEmail(email.sender, email.senderEmail) + const threadMessageCount = conversationMode + ? getThreadMessageCount(email) + : 0 + const senderForSearch = email.sender.replace(/\s+/g, " ").trim() + const isSelected = selectedEmails.includes(email.id) + const isSplitActiveRow = + splitView && openMailId === email.id + const hasInvitation = email.hasInvitation === true + const parsedInvitation = + listRowExtras.invitationById.get(email.id) ?? null + const attachmentList = + listRowExtras.attachmentsById.get(email.id) ?? [] + const showAttachmentPills = + attachmentList.length > 0 && (!isMd || density === "default") + const showListPaperclip = + attachmentList.length > 0 && isMd && density !== "default" + const isCompactListRow = isMd && density === "compact" + const listRowPadTop = !showAttachmentPills + ? isCompactListRow + ? "pt-0" + : "pt-1" + : isCompactListRow + ? "pt-0" + : "pt-0.5" + const isScheduled = email.labels?.includes("scheduled") === true + const contextTargetIds = contextMenuTargetIdsForRow( + email.id, + selectedEmails, + selectedFolder, + allEmails + ) + const allContextTargetsScheduled = + contextTargetIds.length > 0 && + contextTargetIds.every((id) => + listMailIndex.scheduledIds.has(id) + ) + const scheduledCtxAnyUnread = + allContextTargetsScheduled && + contextTargetIds.some((id) => { + const em = listMailIndex.emailById.get(id) + if (!em) return false + return !(readOverrides[id] ?? em.read) + }) + const isRescheduleOpenThisRow = + rescheduleTarget?.id === email.id + const spamRowHoverNoArchive = selectedFolder === "spam" + const snoozedFolderRow = selectedFolder === "snoozed" + + return ( + { + if (open) { + rowContextMenuOpenedAtRef.current = Date.now() + setSelectedEmails((prev) => { + const next = contextMenuTargetIdsForRow( + email.id, + prev, + selectedFolder, + allEmails + ) + contextMenuTargetIdsRef.current = [...next] + return next + }) + } else { + setLabelPickerQuery("") + } + }} + > + + { + if (open) setOpenSwipeRowId(email.id) + else if (openSwipeRowId === email.id) setOpenSwipeRowId(null) + }} + onArchive={() => archiveListRow(email)} + onDelete={() => deleteListRow(email)} + onStar={() => toggleStar(email.id)} + onLabel={() => openSwipeRowLabelSheet(email.id)} + > +
startRowDrag(email.id, e)} + onClick={() => { + if (readXsMatches() && mobileSelectionMode) { + toggleSelect(email.id) + lastSelectionAnchorIdRef.current = email.id + return + } + handleRowActivate(email) + }} + className={cn( + "group relative z-0 w-full cursor-pointer pl-3 pr-2 py-2 transition-[background-color,box-shadow] duration-[50ms] ease-out", + !splitView && + "md:flex md:gap-2 md:px-2 md:py-1.5", + !splitView && + (isCompactListRow && !showAttachmentPills + ? "md:items-center" + : "md:items-start"), + isCompactListRow && "md:!py-1 md:text-[13px]", + isSplitActiveRow + ? "z-[1] bg-mail-row-active-split shadow-[inset_3px_0_0_0_#669df6]" + : isSelected + ? "bg-mail-row-selected" + : isRead + ? "bg-mail-row-read" + : "bg-mail-row-unread", + !isSplitActiveRow && + "hover:z-1 hover:shadow-[inset_1px_0_0_#d2d5da,inset_-1px_0_0_#d2d5da,0_4px_10px_-3px_rgba(60,64,67,.16),0_2px_5px_0_rgba(60,64,67,.09)]" + )} + > + {/* Compact < md */} +
+ {mobileSelectionMode && ( +
e.stopPropagation()} + onClickCapture={(e) => handleRowCheckboxClickCapture(email.id, e)} + > + { + toggleSelect(email.id) + lastSelectionAnchorIdRef.current = email.id + }} + /> +
+ )} +
+
+
e.stopPropagation()} + onClickCapture={(e) => handleRowCheckboxClickCapture(email.id, e)} + > + { + toggleSelect(email.id) + lastSelectionAnchorIdRef.current = email.id + }} + /> +
+
+
+ + {isScheduled && ( + + + + )} + {isScheduled ? ( + + À : {email.scheduledToName ?? email.sender} + + ) : ( + + + {showDraftBadge && ( + Brouillon + )} + {email.sender} + + + )} + {threadMessageCount > 1 && ( + + {threadMessageCount} + + )} +
+
+ {(parsedInvitation || hasInvitation) && ( + + )} + {attachmentList.length > 0 && ( + + )} + {listRowExtras.categoryTabsById.get(email.id) ? ( + + ) : null} + + {isScheduled ? ( + formatScheduledDateTimeDisplay(email.scheduledSendAt) + ) : ( + + )} + +
+
+
+ +
+ {email.tag && ( + + {email.tag} + + )} + + + {email.subject} + +
+ +
+

+ {email.preview} +

+ +
+
+
+ + {/* Desktop >= md */} +
+
+
e.stopPropagation()} + onClickCapture={(e) => handleRowCheckboxClickCapture(email.id, e)} + > + { + toggleSelect(email.id) + lastSelectionAnchorIdRef.current = email.id + }} + /> +
+ +
+ + + + {isScheduled && ( + + + + )} +
+
+ +
+ {isScheduled ? ( + + À : {email.scheduledToName ?? email.sender} + + ) : ( + + + {showDraftBadge && ( + Brouillon + )} + {email.sender} + + + )} + {threadMessageCount > 1 && ( + + {threadMessageCount} + + )} +
+ +
+
+ {email.tag && ( + + {email.tag} + + )} + + + {email.subject} + + {email.preview} +
+ {showAttachmentPills && ( + + )} +
+ +
+ {isScheduled ? ( +
+ + {formatScheduledDateTimeDisplay(email.scheduledSendAt)} + +
+ {!spamRowHoverNoArchive && ( + + + + + + Archiver + + + )} + + + + + + Supprimer + + + + + + + + {isRead ? "Marquer comme non lu" : "Marquer comme lu"} + + + + + + + + Mettre en attente + + + { + if (open) { + const pending = + rescheduleDismissTimeoutsRef.current.get( + email.id + ) + if (pending) { + clearTimeout(pending) + rescheduleDismissTimeoutsRef.current.delete( + email.id + ) + } + setRescheduleTarget({ + id: email.id, + value: scheduledIsoToDatetimeLocalValue( + email.scheduledSendAt + ), + panelOpen: true, + }) + } else { + setRescheduleTarget((prev) => + prev?.id === email.id + ? { ...prev, panelOpen: false } + : prev + ) + scheduleReschedulePopoverDismiss(email.id) + } + }} + > + + + + + + + + Reprogrammer + + + e.stopPropagation()} + > +

+ Nouvelle date d'envoi +

+ + setRescheduleTarget((prev) => + prev?.id === email.id + ? { + ...prev, + value: e.target.value, + panelOpen: true, + } + : prev + ) + } + /> +
+ + +
+
+
+ + + + + + Modifier le mail + + + + + + + + Envoyer maintenant + + +
+
+ ) : ( +
+
+ {(parsedInvitation || hasInvitation) && ( + + )} + {listRowExtras.categoryTabsById.get(email.id) ? ( + + ) : null} + {showListPaperclip && ( + + )} + + + +
+
+ {!spamRowHoverNoArchive && ( + + + + + + Archiver + + + )} + + + + + + Supprimer + + + + + + + + {isRead ? "Marquer comme non lu" : "Marquer comme lu"} + + + {spamRowHoverNoArchive && ( + + + + + + Boîte de réception + + + )} + {!spamRowHoverNoArchive && + (snoozedFolderRow ? ( + + + + + + {email.id.startsWith("snz-") + ? "Boîte de réception" + : "Planifiés"} + + + ) : ( + + + + + + Mettre en attente + + + ))} +
+
+ )} +
+
+
+
+
+ + e.preventDefault()} + onPointerDownOutside={(event) => { + const native = event.detail.originalEvent + if ( + native.pointerType === "mouse" && + native.button === 2 && + Date.now() - rowContextMenuOpenedAtRef.current < 450 + ) { + event.preventDefault() + } + }} + className={cn( + cn(MAIL_MENU_SURFACE_WIDE_CLASS, "overflow-visible"), + "[&_[data-slot=context-menu-item]]:gap-3 [&_[data-slot=context-menu-item]]:rounded-none [&_[data-slot=context-menu-item]]:px-3 [&_[data-slot=context-menu-item]]:py-2 [&_[data-slot=context-menu-item]]:text-sm", + "[&_[data-slot=context-menu-item]:focus]:bg-[#f1f3f4] [&_[data-slot=context-menu-item]:focus]:text-[#3c4043]", + "[&_[data-slot=context-menu-sub-trigger]]:gap-3 [&_[data-slot=context-menu-sub-trigger]]:rounded-none [&_[data-slot=context-menu-sub-trigger]]:px-3 [&_[data-slot=context-menu-sub-trigger]]:py-2 [&_[data-slot=context-menu-sub-trigger]]:text-sm", + "[&_[data-slot=context-menu-sub-trigger]:focus]:bg-[#f1f3f4] [&_[data-slot=context-menu-sub-trigger]:focus]:text-[#3c4043]", + "[&_[data-slot=context-menu-separator]]:mx-0 [&_[data-slot=context-menu-separator]]:my-1 [&_[data-slot=context-menu-separator]]:h-px [&_[data-slot=context-menu-separator]]:bg-[#eceff1]", + "[&_[data-slot=context-menu-sub-content]]:min-w-[200px] [&_[data-slot=context-menu-sub-content]]:rounded-lg [&_[data-slot=context-menu-sub-content]]:border [&_[data-slot=context-menu-sub-content]]:border-border [&_[data-slot=context-menu-sub-content]]:bg-popover [&_[data-slot=context-menu-sub-content]]:shadow-lg" + )} + > + {allContextTargetsScheduled ? ( + <> + { + const ids = [...contextMenuTargetIdsRef.current] + void Promise.all( + ids.map((id) => requestArchiveScheduled(id)) + ) + }} + > + + Archiver + + { + const ids = [...contextMenuTargetIdsRef.current] + void Promise.all( + ids.map((id) => requestDeleteScheduled(id)) + ) + }} + > + + Supprimer + + { + const ids = [...contextMenuTargetIdsRef.current] + const markRead = scheduledCtxAnyUnread + setReadOverrides((prev) => { + const next = { ...prev } + for (const id of ids) next[id] = markRead + return next + }) + void Promise.all( + ids.map((id) => + requestToggleReadScheduled(id, markRead) + ) + ) + }} + > + {scheduledCtxAnyUnread ? ( + + ) : ( + + )} + {scheduledCtxAnyUnread + ? "Marquer comme lu" + : "Marquer comme non lu"} + + { + const ids = [...contextMenuTargetIdsRef.current] + void Promise.all( + ids.map((id) => requestSnoozeScheduled(id)) + ) + }} + > + + Mettre en attente + + + { + if (!subOpen) return + const ids = contextMenuTargetIdsRef.current + const first = allEmails.find((e) => e.id === ids[0]) + setCmScheduledRescheduleValue( + scheduledIsoToDatetimeLocalValue( + first?.scheduledSendAt + ) + ) + }} + > + + + Reprogrammer + + +
e.stopPropagation()} + > +

+ Nouvelle date d'envoi + {contextTargetIds.length > 1 + ? ` (${contextTargetIds.length} messages)` + : null} +

+ + setCmScheduledRescheduleValue(e.target.value) + } + onPointerDown={(e) => e.stopPropagation()} + /> + +
+
+
+ 1} + onSelect={() => { + if (contextTargetIds.length !== 1) return + void handleEditScheduledMail(contextTargetIds[0]!) + }} + > + + Modifier le mail + + { + const ids = [...contextMenuTargetIdsRef.current] + void Promise.all( + ids.map((id) => requestSendScheduledNow(id)) + ) + }} + > + + Envoyer maintenant + + + ) : ( + <> + + + Répondre + + + + Répondre à tous + + + + Transférer + + + + Transférer en tant que pièce jointe + + + + + + + Archiver + + + + Supprimer + + { + const newRead = !isRead + const ids = contextMenuTargetIdsRef.current + setReadOverrides((prev) => { + const next = { ...prev } + for (const id of ids) { + next[id] = newRead + } + return next + }) + }} + > + {!isRead ? ( + + ) : ( + + )} + {isRead ? "Marquer comme non lu" : "Marquer comme lu"} + + + + Mettre en attente + + + + Ajouter à Tasks + + + + + + + + Déplacer vers + + + { + moveEmailsToTarget(contextTargetIds, targetId) + if (targetId !== "inbox") { + setSelectedEmails((prev) => prev.filter((id) => !contextTargetIds.includes(id))) + } + }} + /> + + + + + + + Ajouter le libellé + + + + getCatalogLabelPresence(contextTargetIds, lab) + } + onToggleCatalogLabel={(lab) => + toggleLabelOnEmails(contextTargetIds, lab) + } + onCreateLabel={(lab) => { + addLabelToEmails(contextTargetIds, lab) + setLabelPickerQuery("") + }} + /> + + + + + + Ignorer la conversation + + + + + + + + Rech. e-mails de {senderForSearch} + + + + + + + + Ouvrir dans une nouvelle fenêtre + + + )} +
+
+ ) +} + +export const EmailListRow = memo(EmailListRowInner) diff --git a/components/gmail/email-list/email-list-toolbar.tsx b/components/gmail/email-list/email-list-toolbar.tsx new file mode 100644 index 0000000..feffa3e --- /dev/null +++ b/components/gmail/email-list/email-list-toolbar.tsx @@ -0,0 +1,1344 @@ +"use client" + +import { Icon } from "@iconify/react" +import { + Archive, + ArrowLeft, + CalendarX2, + ChevronDown, + ChevronLeft, + ChevronRight, + CheckSquare, + Clock, + FolderInput, + ListTodo, + Mail, + MailOpen, + Menu, + MoreVertical, + Paperclip, + RefreshCw, + Search, + Send, + ShieldAlert, + SquareArrowOutUpRight, + Tag, + Trash2, + User as UserIcon, + VolumeX, + X, +} from "lucide-react" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { CompactInboxCategoryTabs } from "@/components/gmail/compact-inbox-category-tabs" +import { EmailLabelPickerBlock } from "@/components/gmail/email-label-picker-block" +import type { CatalogLabelPresence } from "@/components/gmail/email-label-picker-block" +import { MailSearchBar } from "@/components/gmail/mail-search-bar" +import { + MoveToDropdownItems, +} from "@/components/gmail/email-list/move-to-menu-items" +import type { MailMoveTargets } from "@/components/gmail/move-to-menu-items" +import { cn } from "@/lib/utils" +import type { Email } from "@/lib/email-data" +import { + MAIL_INBOX_CATEGORY_TAB_CONTENT_DARK_CLASS, + MAIL_MENU_SURFACE_CLASS, +} from "@/lib/mail-chrome-classes" +import { + DATE_RANGE_OPTIONS, + type SearchParams, +} from "@/lib/mail-search/search-params" +import { inboxTabActiveAccentColor } from "@/lib/inbox-category-tabs" +import { inboxTabShowsInactiveMeta } from "@/lib/mail-url" +import { + CATEGORY_TAB_ICON_CLASS, + inboxTabBadgeCountClass, + inboxTabBadgeDotClass, + REFRESH_SPIN_CLASS, +} from "@/components/gmail/email-list/email-list-helpers" +import { LIST_PAGE_SIZE } from "@/components/gmail/email-list/email-list-helpers" + +export type EmailListToolbarProps = { + isViewMode: boolean + splitView: boolean + listToolbarMode: boolean + compactInboxTabs: boolean + isSearchMode: boolean + selectedFolder: string + mobileFolderLabel: string + displayListEmails: Email[] + mobileUnreadCount: number + mobileSelectionMode: boolean + setMobileSelectionMode: (v: boolean | ((p: boolean) => boolean)) => void + setSelectedEmails: (v: string[] | ((p: string[]) => string[])) => void + mobileXsMoreMenuOpen: boolean + setMobileXsMoreMenuOpen: (v: boolean) => void + showBulkToolbar: boolean + bulkSelectMenuOpen: boolean + setBulkSelectMenuOpen: (v: boolean) => void + selectAllChecked: boolean | "indeterminate" + handleSelectAllChange: (checked: boolean | "indeterminate") => void + selectMenuAll: () => void + selectMenuNone: () => void + selectMenuRead: () => void + selectMenuUnread: () => void + selectMenuStarred: () => void + selectMenuUnstarred: () => void + bulkArchive: () => void + bulkDelete: () => void + bulkSpam: () => void + hasUnreadInSelection: boolean + bulkMarkRead: () => void + bulkMarkUnread: () => void + moveTargets: MailMoveTargets + bulkMoveTo: (targetId: string) => void + labelPickerQuery: string + setLabelPickerQuery: (q: string) => void + catalogLabels: string[] + resolveLabelVisual: (label: string) => ReturnType + bulkTargetIds: string[] + getCatalogLabelPresence: (ids: string[], catalogLabel: string) => CatalogLabelPresence + toggleLabelOnEmails: (ids: string[], label: string) => void + addLabelToEmails: (ids: string[], label: string) => void + isRefreshing: boolean + handleManualRefresh: () => void + markAllInViewAsRead: () => void + openMobileXsMoveSheet: () => void + openMobileXsLabelSheet: () => void + listPage: number + totalPages: number + openMailIndex: number + goListPrevPage: () => void + goListNextPage: () => void + goToPrev: () => void + goToNext: () => void + goBack: () => void + openEmail: Email | null + viewModeIsRead: boolean + singleArchive: () => void + singleDelete: () => void + singleNotSpam: () => void + singleSpam: () => void + singleToggleRead: () => void + singleMoveTo: (targetId: string) => void + onToggleSidebar?: () => void + inboxTabBarItems: Array<{ id: string; label: string; icon: string; badgeColor: string }> + activeInboxTabId: string + unseenInTabById: Record + tabUnseenSenderLineById: Record + handleCategoryInboxTabClick: (tabId: string) => void + searchParams: SearchParams | null + searchAccount: { email: string } + allEmails: Email[] + setSearchFilter: (patch: Partial) => void + toggleSearchFilter: (key: keyof SearchParams, value: string) => void + setAdvancedOpen: (open: boolean) => void + searchRouter: { push: (url: string) => void } + buildSearchUrl: (params: SearchParams) => string + variant?: "list" | "reading-pane" + part?: "mobile" | "list" | "all" +} + +export function EmailListToolbar(props: EmailListToolbarProps) { + const { + isViewMode, + splitView, + listToolbarMode, + compactInboxTabs, + isSearchMode, + selectedFolder, + mobileFolderLabel, + displayListEmails, + mobileUnreadCount, + mobileSelectionMode, + setMobileSelectionMode, + setSelectedEmails, + mobileXsMoreMenuOpen, + setMobileXsMoreMenuOpen, + showBulkToolbar, + bulkSelectMenuOpen, + setBulkSelectMenuOpen, + selectAllChecked, + handleSelectAllChange, + selectMenuAll, + selectMenuNone, + selectMenuRead, + selectMenuUnread, + selectMenuStarred, + selectMenuUnstarred, + bulkArchive, + bulkDelete, + bulkSpam, + hasUnreadInSelection, + bulkMarkRead, + bulkMarkUnread, + moveTargets, + bulkMoveTo, + labelPickerQuery, + setLabelPickerQuery, + catalogLabels, + resolveLabelVisual, + bulkTargetIds, + getCatalogLabelPresence, + toggleLabelOnEmails, + addLabelToEmails, + isRefreshing, + handleManualRefresh, + markAllInViewAsRead, + openMobileXsMoveSheet, + openMobileXsLabelSheet, + listPage, + totalPages, + openMailIndex, + goListPrevPage, + goListNextPage, + goToPrev, + goToNext, + goBack, + openEmail, + viewModeIsRead, + singleArchive, + singleDelete, + singleNotSpam, + singleSpam, + singleToggleRead, + singleMoveTo, + onToggleSidebar, + inboxTabBarItems, + activeInboxTabId, + unseenInTabById, + tabUnseenSenderLineById, + handleCategoryInboxTabClick, + searchParams, + searchAccount, + allEmails, + setSearchFilter, + toggleSearchFilter, + setAdvancedOpen, + searchRouter, + buildSearchUrl, + variant = "list", + part = "all", + } = props + + const dropdownSurfaceClass = MAIL_MENU_SURFACE_CLASS + + const openMailToolbar = (showBack: boolean) => ( + + {showBack ? ( + + + + + + Retour à la boîte de réception + + + ) : null} + +
+ {openEmail?.spam === true ? ( + <> +
+ + +
+ + + +
+ + + + + + Archiver + + +
+ + + +
+ + + + + + {viewModeIsRead ? "Marquer comme non lu" : "Marquer comme lu"} + + + + + + + + + + + +
+ + ) : ( + <> +
+ + + + + + Archiver + + + + + + + + Signaler comme spam + + + + + + + + Supprimer + + +
+ + + +
+ + + + + + {viewModeIsRead ? "Marquer comme non lu" : "Marquer comme lu"} + + + + + + + + + + + +
+ + )} +
+
+) + +const mailPaginationControls = (mode: "list" | "view") => ( +
+ {displayListEmails.length === 0 ? ( + Aucun résultat + ) : mode === "view" ? ( + + {openMailIndex >= 0 ? openMailIndex + 1 : "–"} sur {displayListEmails.length} + + ) : ( + + {(listPage - 1) * LIST_PAGE_SIZE + 1}– + {Math.min(listPage * LIST_PAGE_SIZE, displayListEmails.length)} sur{" "} + {displayListEmails.length} + {totalPages > 1 ? ` · p. ${listPage}/${totalPages}` : null} + + )} + + + + + + {mode === "view" ? "Plus récent" : "Page précédente"} + + + + + + + + {mode === "view" ? "Plus ancien" : "Page suivante"} + + +
+) + + if (variant === "reading-pane") { + return ( +
+ {openMailToolbar(false)} +
+ {mailPaginationControls("view")} +
+ ) + } + + return ( + <> +{part !== "list" && !isViewMode && ( +
+
+

+ {mobileFolderLabel} +

+

+ {displayListEmails.length} message{displayListEmails.length !== 1 ? "s" : ""} + {mobileUnreadCount > 0 && ` · ${mobileUnreadCount} non lu${mobileUnreadCount !== 1 ? "s" : ""}`} +

+
+ + + + + + + {showBulkToolbar ? ( + <> + + + Archiver + + + + Supprimer + + + + Signaler comme spam + + + {hasUnreadInSelection ? ( + <> + + Marquer comme lu + + ) : ( + <> + + Marquer comme non lu + + )} + + + { + e.preventDefault() + openMobileXsMoveSheet() + }} + > + + Déplacer vers + + + { + e.preventDefault() + openMobileXsLabelSheet() + }} + > + + Ajouter le libellé + + + + + Ignorer la conversation + + + ) : ( + <> + + + Tout marquer comme lu + + +
+ Sélectionnez des messages pour plus d'actions +
+ + )} +
+
+
+)} +{part !== "mobile" && ( + <> +{splitView ? ( +
+ {onToggleSidebar ? ( + + ) : null} + +
+) : null} +{/* Toolbar — relative: scroll lives in sibling below */} +
+ + {!splitView && isViewMode ? ( + openMailToolbar(true) + ) : ( + /* ── LIST MODE TOOLBAR (original) ── */ + <> + +
+
+ +
+ + + +
+ + Tous + Aucun + Lus + Non lus + Suivis + + Non suivis + + +
+ + {showBulkToolbar ? ( + +
+
+ + + + + + Archiver + + + + + + + + Signaler comme spam + + + + + + + + Supprimer + + +
+ + + +
+ + + + + + {hasUnreadInSelection + ? "Marquer comme lu" + : "Marquer comme non lu"} + + + + + + + + + + + +
+ + + + { + if (!open) setLabelPickerQuery("") + }} + > + + + + + + + Mettre en attente + + + + Ajouter à Tasks + + + + + + Ajouter le libellé + + + + getCatalogLabelPresence(bulkTargetIds, lab) + } + onToggleCatalogLabel={(lab) => + toggleLabelOnEmails(bulkTargetIds, lab) + } + onCreateLabel={(lab) => { + addLabelToEmails(bulkTargetIds, lab) + setLabelPickerQuery("") + }} + /> + + + + + Ignorer la conversation + + + + + Ouvrir dans une nouvelle fenêtre + + + +
+
+ ) : ( + <> + + + + + + + + + Tout marquer comme lu + + +
+ Sélectionnez des messages pour afficher plus d'actions +
+
+
+ + )} + + )} + +
+ + {listToolbarMode ? mailPaginationControls("list") : null} + {!splitView && !listToolbarMode ? mailPaginationControls("view") : null} +
+ +{selectedFolder === "inbox" && ( +
+ {listToolbarMode && + (compactInboxTabs ? ( + + ) : ( +
+ {inboxTabBarItems.map((tab) => { + const isActive = activeInboxTabId === tab.id + const accentColor = isActive + ? inboxTabActiveAccentColor(tab.id, tab.badgeColor) + : undefined + const unseen = unseenInTabById[tab.id] ?? 0 + const senderLine = tabUnseenSenderLineById[tab.id] ?? "" + const showMeta = + inboxTabShowsInactiveMeta(tab.id) && !isActive && unseen > 0 + const showSenderLine = showMeta && Boolean(senderLine) + const isExpandedTabMeta = showSenderLine + return ( + + ) + })} +
+ ))} +
+)} + +{isSearchMode && searchParams && listToolbarMode && ( +
+ {/* De dropdown */} + + + + + + setSearchFilter({ from: "" })}> + N'importe qui + + setSearchFilter({ from: searchAccount.email })}> + De moi ({searchAccount.email}) + + + {Array.from(new Set(allEmails.map((e) => e.senderEmail).filter(Boolean))).slice(0, 8).map((addr) => ( + setSearchFilter({ from: addr! })}> + {addr} + + ))} + + + + {/* Date dropdown */} + + + + + + setSearchFilter({ within: "" })}> + Indifférente + + + {DATE_RANGE_OPTIONS.map((opt) => ( + setSearchFilter({ within: opt.value })}> + {opt.label} + + ))} + + + + {/* Contient une pièce jointe */} + + + {/* Exclure les mises à jour d'agenda */} + + + {/* À dropdown */} + + + + + + setSearchFilter({ to: "" })}> + N'importe qui + + setSearchFilter({ to: searchAccount.email })}> + À moi ({searchAccount.email}) + + + + + {/* Non lu */} + + + {/* Recherche avancée */} + +
+)} + +)} + + ) +} diff --git a/components/gmail/email-list/email-list.tsx b/components/gmail/email-list/email-list.tsx new file mode 100644 index 0000000..3662097 --- /dev/null +++ b/components/gmail/email-list/email-list.tsx @@ -0,0 +1,27 @@ +"use client" + +import type { EmailListProps } from "@/components/gmail/email-list/email-list-helpers" +import { useEmailListData } from "@/components/gmail/email-list/hooks/use-email-list-data" +import { useEmailListLabels } from "@/components/gmail/email-list/hooks/use-email-list-labels" +import { useEmailListSelection } from "@/components/gmail/email-list/hooks/use-email-list-selection" +import { useEmailListReading } from "@/components/gmail/email-list/hooks/use-email-list-reading" +import { EmailListLayout } from "@/components/gmail/email-list/email-list-layout" + +export function EmailList(props: EmailListProps) { + const data = useEmailListData(props) + const labels = useEmailListLabels(data) + const selection = useEmailListSelection(data, labels) + const reading = useEmailListReading(props, data, labels) + + return ( + + ) +} + +export type { EmailListProps } from "@/components/gmail/email-list/email-list-helpers" diff --git a/components/gmail/email-list/hooks/use-email-list-data.ts b/components/gmail/email-list/hooks/use-email-list-data.ts new file mode 100644 index 0000000..2ff5034 --- /dev/null +++ b/components/gmail/email-list/hooks/use-email-list-data.ts @@ -0,0 +1,785 @@ +"use client" + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react" +import { useSearchParams, useRouter } from "next/navigation" +import { buildLabelTextToNavColorClass } from "@/components/gmail/mail-label-pills" +import { emails } from "@/lib/email-data" +import { + isListRowRead, + isThreadHeadMessage, + readStateTargets, +} from "@/lib/mail-thread" +import { useScheduledMail } from "@/lib/scheduled-mail-context" +import { useMailStore } from "@/lib/stores/mail-store" +import { useScheduledStore } from "@/lib/stores/scheduled-store" +import { usePersistHydrated } from "@/hooks/use-persist-hydrated" +import { useIsMd } from "@/hooks/use-md-breakpoint" +import { sortEmailsForInbox } from "@/lib/mail-settings/sort-emails" +import { useMailSettingsStore } from "@/lib/stores/mail-settings-store" +import { useActiveAccount } from "@/lib/stores/account-store" +import { useMailSearchStore } from "@/lib/stores/mail-search-store" +import { + emailMatchesFolder, + emailMatchesInboxPrimaryTab, + type MailNavFolderMaps, +} from "@/lib/mail-folder-filter" +import { + getMailNavFolderLabel, + inboxTabDisplayLabel, +} from "@/lib/sidebar-nav-data" +import { buildInboxCategoryTabIcons } from "@/lib/inbox-category-tabs" +import { + INBOX_ALL_TAB, + SEARCH_FOLDER_ID, + inboxTabShowsInactiveMeta, + normalizeInboxTabSegment, +} from "@/lib/mail-url" +import { + parseSearchParams, + buildSearchUrl, + type SearchParams, +} from "@/lib/mail-search/search-params" +import { filterEmailsBySearchParams } from "@/lib/mail-search/search-engine" +import { useSidebarNav, registerNavEmailSync } from "@/lib/sidebar-nav-context" +import { useMoveTargets } from "@/components/gmail/move-to-menu-items" +import { buildListMailIndex } from "@/components/gmail/email-list/list-mail-index" +import { + useComposeActions, + useComposeDrafts, +} from "@/lib/compose-context" +import { computeFolderUnreadCounts } from "@/lib/mail-nav-metrics" +import { + mergeEmailLabelEdits, + mergeEmailNotSpam, +} from "@/lib/label-edits" +import type { LabelEditState } from "@/lib/stores/mail-store" +import { useIsXs } from "@/hooks/use-xs" +import { useTouchNav } from "@/hooks/use-touch-nav" +import { + applyNavRenameToEdits, + applyNavRemoveLabelToEdits, +} from "@/lib/mail-list/label-actions" +import { + LIST_PAGE_SIZE, + type EmailListProps, + buildInboxTabBarItems, +} from "@/components/gmail/email-list/email-list-helpers" +import { useMailListPullRefresh } from "@/hooks/use-mail-list-pull-refresh" +import { ensureVcLogosCollection } from "@/lib/register-vc-logos" +import { attachmentsForEmailList } from "@/lib/attachment-display" +import { resolveParsedCalendarInvitation } from "@/lib/resolve-email-calendar-invitation" +import { resolveEmailInboxCategoryTabs } from "@/lib/inbox-category-tabs" +import type { Email, EmailAttachment } from "@/lib/email-data" +import { cleanSenderName } from "@/lib/sender-display" +import { threadStoreId } from "@/lib/mail-settings/list-row-id" + +export function useEmailListData({ + selectedFolder, + inboxTab, + listPage, + openMailId, + splitView = false, + onMailRouteNavigate, + onFolderUnreadCountsChange, +}: EmailListProps) { + const isViewMode = openMailId !== null && !splitView + const showSplitReadingPane = splitView && openMailId !== null + const isSearchMode = selectedFolder === SEARCH_FOLDER_ID + const searchRouter = useRouter() + const searchAccount = useActiveAccount() + const setAdvancedOpen = useMailSearchStore((s) => s.setAdvancedOpen) + const urlSearchParams = useSearchParams() + const searchParams = useMemo( + () => (isSearchMode ? parseSearchParams(urlSearchParams) : null), + [isSearchMode, urlSearchParams] + ) + + const setSearchFilter = useCallback( + (patch: Partial) => { + if (!searchParams) return + searchRouter.push(buildSearchUrl({ ...searchParams, ...patch })) + }, + [searchParams, searchRouter] + ) + + const toggleSearchFilter = useCallback( + (key: keyof SearchParams, value: string) => { + if (!searchParams) return + const next = { ...searchParams } + if (key === "has") { + const arr = [...next.has] + if (arr.includes(value)) next.has = arr.filter((v) => v !== value) + else next.has = [...arr, value] + } else if (key === "excludeChats") { + next.excludeChats = !next.excludeChats + } else { + const cur = (next as Record)[key] + ;(next as Record)[key] = cur === value ? "" : value + } + searchRouter.push(buildSearchUrl(next)) + }, + [searchParams, searchRouter] + ) + + const { savedThreadReplyDrafts } = useComposeDrafts() + const { + openCompose, + openComposeWithInitial, + closeAllInlineComposes, + pruneInlineComposesToOpenThread, + } = useComposeActions() + + const { + scheduledEmails, + snoozedEmails, + sentPlaceholderEmails, + requestDeleteScheduled, + requestArchiveScheduled, + requestSnoozeScheduled, + requestToggleReadScheduled, + requestRescheduleScheduled, + requestGetScheduledEditPayload, + requestSendScheduledNow, + requestSnoozeMailboxEmail, + requestRestoreSnoozedToInbox, + } = useScheduledMail() + + const scheduledPersistHydrated = usePersistHydrated(useScheduledStore) + + const allEmails = useMemo( + () => + scheduledPersistHydrated + ? [...emails, ...scheduledEmails, ...snoozedEmails, ...sentPlaceholderEmails] + : emails, + [scheduledPersistHydrated, scheduledEmails, snoozedEmails, sentPlaceholderEmails] + ) + + const emailById = useMemo( + () => new Map(allEmails.map((e) => [e.id, e])), + [allEmails] + ) + + const sidebarNav = useSidebarNav() + const navMaps = useMemo( + () => ({ + folderIdToLabel: sidebarNav.folderIdToLabel, + folderTree: sidebarNav.folderTree, + labelRows: sidebarNav.labelRows, + }), + [sidebarNav.folderIdToLabel, sidebarNav.folderTree, sidebarNav.labelRows] + ) + + const inboxCategoryTabIconsCatalog = useMemo( + () => buildInboxCategoryTabIcons(sidebarNav.labelRows), + [sidebarNav.labelRows] + ) + + const inboxTabBarItems = useMemo( + () => buildInboxTabBarItems(sidebarNav.labelRows), + [sidebarNav.labelRows] + ) + + const listRowLabelBgByTextLower = useMemo( + () => buildLabelTextToNavColorClass(sidebarNav.folderTree, sidebarNav.labelRows), + [sidebarNav.folderTree, sidebarNav.labelRows] + ) + + const [rescheduleTarget, setRescheduleTarget] = useState<{ + id: string + value: string + panelOpen: boolean + } | null>(null) + const rescheduleDismissTimeoutsRef = useRef< + Map> + >(new Map()) + + const scheduleReschedulePopoverDismiss = useCallback((rowId: string) => { + const existing = rescheduleDismissTimeoutsRef.current.get(rowId) + if (existing) clearTimeout(existing) + const t = setTimeout(() => { + rescheduleDismissTimeoutsRef.current.delete(rowId) + setRescheduleTarget((p) => (p?.id === rowId ? null : p)) + }, 280) + rescheduleDismissTimeoutsRef.current.set(rowId, t) + }, []) + + useEffect(() => { + const m = rescheduleDismissTimeoutsRef.current + return () => { + for (const t of m.values()) clearTimeout(t) + m.clear() + } + }, []) + + useEffect(() => { + ensureVcLogosCollection() + }, []) + + const [cmScheduledRescheduleValue, setCmScheduledRescheduleValue] = + useState("") + + const handleEditScheduledMail = useCallback( + async (id: string) => { + const payload = await requestGetScheduledEditPayload(id) + if (!payload) return + openComposeWithInitial({ + to: payload.to, + subject: payload.subject, + bodyHtml: payload.bodyHtml, + editingScheduledId: id, + scheduledSendAtIso: payload.sendAtIso, + focusToOnMount: false, + focusBodyOnMount: true, + }) + }, + [requestGetScheduledEditPayload, openComposeWithInitial] + ) + + useEffect(() => { + if (!openMailId) { + closeAllInlineComposes() + } else { + const msg = emailById.get(openMailId) + pruneInlineComposesToOpenThread(msg ? threadStoreId(msg) : openMailId) + } + }, [ + openMailId, + emailById, + closeAllInlineComposes, + pruneInlineComposesToOpenThread, + ]) + + const starredEmails = useMailStore((s) => s.starredIds) + const importantEmails = useMailStore((s) => s.importantIds) + const readOverrides = useMailStore((s) => s.readOverrides) + const conversationMode = useMailSettingsStore((s) => s.conversationMode) + const inboxSort = useMailSettingsStore((s) => s.inboxSort) + const density = useMailSettingsStore((s) => s.density) + const isMd = useIsMd() + const labelEdits = useMailStore((s) => s.labelEdits) + const mailActions = useRef(useMailStore.getState()).current + const setReadOverrides = useCallback( + (updater: (prev: Record) => Record) => { + const current = useMailStore.getState().readOverrides + const next = updater(current) + if (next !== current) mailActions.setReadOverrides(next) + }, + [mailActions] + ) + const setLabelEdits = useCallback( + (updater: (prev: LabelEditState) => LabelEditState) => { + mailActions.setLabelEdits(updater) + }, + [mailActions] + ) + + useEffect(() => { + registerNavEmailSync({ + renameLabel: (from, to) => { + setLabelEdits((prev) => applyNavRenameToEdits(allEmails, prev, from, to)) + }, + removeLabel: (label) => { + setLabelEdits((prev) => applyNavRemoveLabelToEdits(allEmails, prev, label)) + }, + }) + return () => registerNavEmailSync(null) + }, [allEmails, setLabelEdits]) + + const [labelPickerQuery, setLabelPickerQuery] = useState("") + const hiddenEmailIds = useMailStore((s) => s.hiddenEmailIds) + const notSpamEmailIds = useMailStore((s) => s.notSpamEmailIds) + const recentMoveTargets = useMailStore((s) => s.recentMoveTargets) + const [mobileVisibleCount, setMobileVisibleCount] = useState(LIST_PAGE_SIZE) + const isXs = useIsXs() + const touchNav = useTouchNav() + + const seenEmailIdsRaw = useMailStore((s) => s.seenEmailIds) + const seenEmailIds = useMemo(() => new Set(seenEmailIdsRaw), [seenEmailIdsRaw]) + + const handleRefreshMessages = useCallback(async () => { + await new Promise((resolve) => setTimeout(resolve, 900)) + }, []) + + const { + isRefreshing, + setIsRefreshing, + listViewportRef, + pullContentRef, + pullIconRef, + } = useMailListPullRefresh({ + enabled: isXs && !isViewMode, + isViewMode, + onRefresh: handleRefreshMessages, + }) + + const handleManualRefresh = useCallback(async () => { + if (isRefreshing) return + setIsRefreshing(true) + try { + await handleRefreshMessages() + } finally { + setIsRefreshing(false) + } + }, [isRefreshing, handleRefreshMessages, setIsRefreshing]) + + const markEmailSeen = useCallback((id: string) => { + mailActions.markSeen(id) + }, [mailActions]) + + const folderFilterCtx = useMemo( + () => ({ + starredEmailIds: starredEmails, + importantEmailIds: importantEmails, + }), + [starredEmails, importantEmails] + ) + + const filteredEmails = useMemo(() => { + const hiddenSet = new Set(hiddenEmailIds) + const subtreeIdsCache = new Map() + let visible = allEmails.filter((email) => !hiddenSet.has(email.id)) + const hasLabelEdits = + labelEdits && + (Object.keys(labelEdits.additions).length > 0 || + Object.keys(labelEdits.removals).length > 0) + if (hasLabelEdits || notSpamEmailIds.length > 0) { + visible = visible.map((e) => + mergeEmailNotSpam(mergeEmailLabelEdits(e, labelEdits), notSpamEmailIds) + ) + } + + if (isSearchMode && searchParams) { + return filterEmailsBySearchParams(visible, searchParams, { + starredIds: starredEmails, + importantIds: importantEmails, + }) + } + + let rows = visible.filter((email) => + emailMatchesFolder( + email, + selectedFolder, + folderFilterCtx, + navMaps, + subtreeIdsCache + ) + ) + if (selectedFolder === "inbox") { + const tab = normalizeInboxTabSegment(inboxTab) + if (tab === "primary") { + rows = rows.filter((email) => + emailMatchesInboxPrimaryTab( + email, + folderFilterCtx, + navMaps, + subtreeIdsCache + ) + ) + } else if (tab !== INBOX_ALL_TAB) { + rows = rows.filter( + (email) => + emailMatchesFolder( + email, + "inbox", + folderFilterCtx, + navMaps, + subtreeIdsCache + ) && + emailMatchesFolder( + email, + tab, + folderFilterCtx, + navMaps, + subtreeIdsCache + ) + ) + } + } + return rows + }, [ + selectedFolder, + inboxTab, + hiddenEmailIds, + folderFilterCtx, + labelEdits, + notSpamEmailIds, + allEmails, + navMaps, + isSearchMode, + searchParams, + starredEmails, + importantEmails, + ]) + + const displayListEmails = useMemo(() => { + let rows = filteredEmails + if (conversationMode) { + rows = rows.filter(isThreadHeadMessage) + } + return sortEmailsForInbox( + rows, + inboxSort, + { + readOverrides, + starredIds: starredEmails, + importantIds: importantEmails, + }, + { conversationMode, byId: emailById } + ) + }, [ + filteredEmails, + conversationMode, + inboxSort, + readOverrides, + starredEmails, + importantEmails, + emailById, + ]) + + const inboxCategoryTabLabel = useMemo( + () => + inboxTabDisplayLabel( + inboxTab, + sidebarNav.labelRows, + sidebarNav.folderIdToLabel + ), + [inboxTab, sidebarNav.labelRows, sidebarNav.folderIdToLabel] + ) + + const mobileUnreadCount = useMemo( + () => + displayListEmails.filter( + (e) => !isListRowRead(e, readOverrides, emailById, conversationMode) + ).length, + [displayListEmails, readOverrides, emailById, conversationMode] + ) + + const mobileFolderLabel = useMemo(() => { + if (isSearchMode) return "Résultats de recherche" + const inboxTabNorm = normalizeInboxTabSegment(inboxTab) + return selectedFolder === "inbox" && inboxTabNorm !== "primary" + ? inboxCategoryTabLabel + : getMailNavFolderLabel(selectedFolder, sidebarNav.folderIdToLabel) + }, [ + selectedFolder, + inboxTab, + inboxCategoryTabLabel, + sidebarNav.folderIdToLabel, + isSearchMode, + ]) + + const totalPages = useMemo( + () => Math.max(1, Math.ceil(displayListEmails.length / LIST_PAGE_SIZE)), + [displayListEmails.length] + ) + + const pagedEmails = useMemo(() => { + const start = (listPage - 1) * LIST_PAGE_SIZE + return displayListEmails.slice(start, start + LIST_PAGE_SIZE) + }, [displayListEmails, listPage]) + + const listEmails = useMemo(() => { + if (isXs && !isViewMode) { + return displayListEmails.slice(0, mobileVisibleCount) + } + return pagedEmails + }, [isXs, isViewMode, displayListEmails, mobileVisibleCount, pagedEmails]) + + const listMailIndex = useMemo(() => buildListMailIndex(allEmails), [allEmails]) + + const listRowExtras = useMemo(() => { + const invitationById = new Map< + string, + ReturnType + >() + const attachmentsById = new Map() + const categoryTabsById = new Map< + string, + ReturnType + >() + const subtreeIdsCache = new Map() + const showCategoryTabIcons = + selectedFolder === "inbox" && + normalizeInboxTabSegment(inboxTab) === INBOX_ALL_TAB + + for (const e of listEmails) { + invitationById.set(e.id, resolveParsedCalendarInvitation(e)) + attachmentsById.set(e.id, attachmentsForEmailList(e)) + if (showCategoryTabIcons) { + const tabs = resolveEmailInboxCategoryTabs( + e, + folderFilterCtx, + navMaps, + inboxCategoryTabIconsCatalog, + subtreeIdsCache + ) + if (tabs.length > 0) categoryTabsById.set(e.id, tabs) + } + } + return { invitationById, attachmentsById, categoryTabsById } + }, [ + listEmails, + selectedFolder, + inboxTab, + folderFilterCtx, + navMaps, + inboxCategoryTabIconsCatalog, + ]) + + useEffect(() => { + if (isXs) return + if (listPage > totalPages) { + onMailRouteNavigate({ page: totalPages }) + } + }, [isXs, listPage, totalPages, onMailRouteNavigate]) + + useEffect(() => { + if (isXs && !isViewMode) return + listViewportRef.current?.scrollTo(0, 0) + }, [listPage, selectedFolder, inboxTab, isXs, isViewMode, listViewportRef]) + + useEffect(() => { + if (!isXs) return + setMobileVisibleCount(LIST_PAGE_SIZE) + listViewportRef.current?.scrollTo(0, 0) + }, [selectedFolder, inboxTab, isXs, listViewportRef]) + + useEffect(() => { + const root = listViewportRef.current + if (!root || !isXs || isViewMode) return + + const onScroll = () => { + if (mobileVisibleCount >= displayListEmails.length) return + const nearBottom = + root.scrollTop + root.clientHeight >= root.scrollHeight - 120 + if (nearBottom) { + setMobileVisibleCount((prev) => + Math.min(prev + LIST_PAGE_SIZE, displayListEmails.length) + ) + } + } + + root.addEventListener("scroll", onScroll, { passive: true }) + return () => root.removeEventListener("scroll", onScroll) + }, [isXs, isViewMode, mobileVisibleCount, displayListEmails.length, listViewportRef]) + + const moveTargets = useMoveTargets({ + folderTree: sidebarNav.folderTree, + recentMoveTargets, + currentFolderId: selectedFolder, + }) + + const folderUnreadCounts = useMemo( + () => + computeFolderUnreadCounts( + allEmails, + folderFilterCtx, + hiddenEmailIds, + readOverrides, + navMaps, + labelEdits, + notSpamEmailIds + ), + [ + folderFilterCtx, + hiddenEmailIds, + readOverrides, + allEmails, + navMaps, + labelEdits, + notSpamEmailIds, + ] + ) + + const seenSerialized = useMemo( + () => [...seenEmailIds].sort().join(","), + [seenEmailIds] + ) + + const { unseenInTabById, tabUnseenSenderLineById } = useMemo(() => { + const seen = new Set( + seenSerialized.length > 0 ? seenSerialized.split(",") : [] + ) + const hidden = new Set(hiddenEmailIds) + const visible = allEmails + .filter((email) => !hidden.has(email.id)) + .map((e) => + mergeEmailNotSpam(mergeEmailLabelEdits(e, labelEdits), notSpamEmailIds) + ) + const inboxPool = visible.filter((e) => + emailMatchesFolder(e, "inbox", folderFilterCtx, navMaps) + ) + const counts: Record = {} + const preview: Record = {} + const tabCache = new Map() + for (const tab of inboxTabBarItems) { + const rows = inboxPool.filter((e) => { + if (tab.id === "primary") { + return ( + emailMatchesInboxPrimaryTab(e, folderFilterCtx, navMaps, tabCache) && + !seen.has(e.id) + ) + } + if (tab.id === INBOX_ALL_TAB) { + return !seen.has(e.id) + } + return ( + emailMatchesFolder(e, "inbox", folderFilterCtx, navMaps, tabCache) && + emailMatchesFolder(e, tab.id, folderFilterCtx, navMaps, tabCache) && + !seen.has(e.id) + ) + }) + counts[tab.id] = rows.length + if (inboxTabShowsInactiveMeta(tab.id)) { + const chain: string[] = [] + const used = new Set() + for (const e of rows) { + const n = cleanSenderName(e.sender).trim() + if (!n || used.has(n)) continue + used.add(n) + chain.push(n) + if (chain.length >= 6) break + } + preview[tab.id] = chain.join(", ") + } + } + return { unseenInTabById: counts, tabUnseenSenderLineById: preview } + }, [folderFilterCtx, hiddenEmailIds, labelEdits, seenSerialized, allEmails, navMaps, notSpamEmailIds, inboxTabBarItems]) + + useEffect(() => { + onFolderUnreadCountsChange?.(folderUnreadCounts) + }, [folderUnreadCounts, onFolderUnreadCountsChange]) + + const listToolbarMode = splitView || !isViewMode + const compactInboxTabs = isXs || splitView + const activeInboxTabId = useMemo( + () => normalizeInboxTabSegment(inboxTab), + [inboxTab] + ) + + const pageIds = useMemo(() => listEmails.map((e) => e.id), [listEmails]) + const listRowsDep = listEmails.map((e) => e.id).join(",") + + const effectiveRead = useCallback( + (email: Email) => + readOverrides[email.id] !== undefined ? readOverrides[email.id]! : email.read, + [readOverrides] + ) + + const effectiveStarred = useCallback( + (email: Email) => + starredEmails.includes(email.id) || email.starred, + [starredEmails] + ) + + const markAllInViewAsRead = useCallback(() => { + setReadOverrides((prev) => { + const next = { ...prev } + for (const e of displayListEmails) { + for (const id of readStateTargets(e, conversationMode)) { + next[id] = true + } + } + return next + }) + }, [displayListEmails, conversationMode, setReadOverrides]) + + return { + selectedFolder, + inboxTab, + listPage, + openMailId, + splitView, + isViewMode, + showSplitReadingPane, + isSearchMode, + searchRouter, + searchAccount, + setAdvancedOpen, + searchParams, + setSearchFilter, + toggleSearchFilter, + savedThreadReplyDrafts, + openCompose, + openComposeWithInitial, + allEmails, + emailById, + sidebarNav, + navMaps, + inboxCategoryTabIconsCatalog, + inboxTabBarItems, + listRowLabelBgByTextLower, + rescheduleTarget, + setRescheduleTarget, + rescheduleDismissTimeoutsRef, + scheduleReschedulePopoverDismiss, + cmScheduledRescheduleValue, + setCmScheduledRescheduleValue, + handleEditScheduledMail, + starredEmails, + importantEmails, + readOverrides, + conversationMode, + inboxSort, + density, + isMd, + labelEdits, + mailActions, + setReadOverrides, + setLabelEdits, + labelPickerQuery, + setLabelPickerQuery, + hiddenEmailIds, + notSpamEmailIds, + recentMoveTargets, + mobileVisibleCount, + isXs, + touchNav, + seenEmailIds, + isRefreshing, + listViewportRef, + pullContentRef, + pullIconRef, + handleManualRefresh, + markEmailSeen, + folderFilterCtx, + filteredEmails, + displayListEmails, + inboxCategoryTabLabel, + mobileUnreadCount, + mobileFolderLabel, + totalPages, + pagedEmails, + listEmails, + listMailIndex, + listRowExtras, + moveTargets, + folderUnreadCounts, + unseenInTabById, + tabUnseenSenderLineById, + listToolbarMode, + compactInboxTabs, + activeInboxTabId, + pageIds, + listRowsDep, + effectiveRead, + effectiveStarred, + markAllInViewAsRead, + requestDeleteScheduled, + requestArchiveScheduled, + requestSnoozeScheduled, + requestToggleReadScheduled, + requestRescheduleScheduled, + requestSendScheduledNow, + requestSnoozeMailboxEmail, + requestRestoreSnoozedToInbox, + } +} + +export type EmailListData = ReturnType diff --git a/components/gmail/email-list/hooks/use-email-list-labels.ts b/components/gmail/email-list/hooks/use-email-list-labels.ts new file mode 100644 index 0000000..9def58b --- /dev/null +++ b/components/gmail/email-list/hooks/use-email-list-labels.ts @@ -0,0 +1,290 @@ +"use client" + +import { useCallback, useMemo } from "react" +import type { CatalogLabelPresence } from "@/components/gmail/email-label-picker-block" +import { resolveLabelPickerVisual } from "@/lib/label-picker-visual" +import { + effectiveLabels, + mergeEmailLabelEdits, + mergeEmailNotSpam, +} from "@/lib/label-edits" +import type { FolderTreeNode } from "@/lib/sidebar-nav-data" +import { + LABEL_PICKER_EXCLUDE, +} from "@/lib/mail-list/label-actions" +import { + collectTreeLabels, +} from "@/components/gmail/email-list/email-list-helpers" +import type { EmailListData } from "@/components/gmail/email-list/hooks/use-email-list-data" + +export function useEmailListLabels(data: EmailListData) { + const { + allEmails, + sidebarNav, + labelEdits, + notSpamEmailIds, + setLabelEdits, + mailActions, + } = data + + const collectAllFolderLabels = useCallback((): Set => { + const s = new Set() + const walk = (nodes: FolderTreeNode[]) => { + for (const n of nodes) { + s.add(n.label.toLowerCase()) + if (n.children?.length) walk(n.children) + } + } + walk(sidebarNav.folderTree) + return s + }, [sidebarNav.folderTree]) + + const moveEmailsToTarget = useCallback( + (emailIds: string[], targetId: string) => { + if (emailIds.length === 0) return + const folderLabel = sidebarNav.folderIdToLabel[targetId] + const isSystemTarget = ["inbox", "sent", "drafts", "spam", "trash"].includes(targetId) + const allFolderLabels = collectAllFolderLabels() + + setLabelEdits((prev) => { + const nextAdd = { ...prev.additions } + const nextRem = { ...prev.removals } + + for (const id of emailIds) { + const email = allEmails.find((e) => e.id === id) + const currentLabels = effectiveLabels(email, nextAdd, nextRem) + + if (isSystemTarget) { + if (targetId === "inbox") { + for (const lab of currentLabels) { + if (allFolderLabels.has(lab.toLowerCase())) { + const cur = nextRem[id] ?? [] + if (!cur.some((l) => l.toLowerCase() === lab.toLowerCase())) { + nextRem[id] = [...cur, lab] + } + if (nextAdd[id]?.length) { + nextAdd[id] = nextAdd[id].filter((l) => l.toLowerCase() !== lab.toLowerCase()) + if (nextAdd[id].length === 0) delete nextAdd[id] + } + } + } + } + } else if (folderLabel) { + for (const lab of currentLabels) { + if (allFolderLabels.has(lab.toLowerCase()) && lab.toLowerCase() !== folderLabel.toLowerCase()) { + const cur = nextRem[id] ?? [] + if (!cur.some((l) => l.toLowerCase() === lab.toLowerCase())) { + nextRem[id] = [...cur, lab] + } + if (nextAdd[id]?.length) { + nextAdd[id] = nextAdd[id].filter((l) => l.toLowerCase() !== lab.toLowerCase()) + if (nextAdd[id].length === 0) delete nextAdd[id] + } + } + } + if (!currentLabels.some((l) => l.toLowerCase() === folderLabel.toLowerCase())) { + nextAdd[id] = [...(nextAdd[id] ?? []), folderLabel] + } + if (nextRem[id]?.length) { + nextRem[id] = nextRem[id].filter((l) => l.toLowerCase() !== folderLabel.toLowerCase()) + if (nextRem[id].length === 0) delete nextRem[id] + } + const inboxIdx = currentLabels.findIndex((l) => l.toLowerCase() === "inbox") + if (inboxIdx >= 0 || !email?.labels?.length || email.labels.includes("inbox")) { + const cur = nextRem[id] ?? [] + if (!cur.some((l) => l.toLowerCase() === "inbox")) { + nextRem[id] = [...cur, "inbox"] + } + } + } + } + return { additions: nextAdd, removals: nextRem } + }) + + if (!isSystemTarget || targetId === "inbox") { + mailActions.pushRecentMoveTarget(targetId) + } + + if (isSystemTarget && targetId !== "inbox") { + mailActions.hideEmails(emailIds) + mailActions.pushRecentMoveTarget(targetId) + } + }, + [allEmails, sidebarNav.folderIdToLabel, collectAllFolderLabels, setLabelEdits, mailActions] + ) + + const catalogLabels = useMemo(() => { + const s = new Set() + for (const l of collectTreeLabels(sidebarNav.folderTree)) s.add(l) + for (const row of sidebarNav.labelRows) s.add(row.label) + for (const e of allEmails) { + const eff = mergeEmailNotSpam( + mergeEmailLabelEdits(e, labelEdits), + notSpamEmailIds + ) + for (const lab of eff.labels ?? []) { + if (!LABEL_PICKER_EXCLUDE.has(lab)) s.add(lab) + } + } + return [...s].sort((a, b) => a.localeCompare(b, "fr")) + }, [sidebarNav.folderTree, sidebarNav.labelRows, allEmails, labelEdits, notSpamEmailIds]) + + const resolveLabelVisual = useCallback( + (label: string) => + resolveLabelPickerVisual(label, { + folderTree: sidebarNav.folderTree, + labelRows: sidebarNav.labelRows, + emailLabelToSidebarFolderId: sidebarNav.emailLabelToSidebarFolderId, + }), + [ + sidebarNav.folderTree, + sidebarNav.labelRows, + sidebarNav.emailLabelToSidebarFolderId, + ] + ) + + const resolveLabelCasing = useCallback( + (raw: string) => { + const t = raw.trim() + if (!t) return "" + const hit = catalogLabels.find((c) => c.toLowerCase() === t.toLowerCase()) + return hit ?? t + }, + [catalogLabels] + ) + + const addLabelToEmails = useCallback( + (ids: string[], label: string) => { + const resolved = resolveLabelCasing(label) + if (!resolved || ids.length === 0) return + sidebarNav.ensureLabelRowForLabelText(resolved) + setLabelEdits((prev) => { + const nextAdd = { ...prev.additions } + const nextRem = { ...prev.removals } + for (const id of ids) { + if (nextRem[id]?.length) { + nextRem[id] = nextRem[id].filter( + (x) => x.toLowerCase() !== resolved.toLowerCase() + ) + if (nextRem[id].length === 0) delete nextRem[id] + } + const base = allEmails.find((e) => e.id === id) + const merged = effectiveLabels(base, nextAdd, nextRem) + if (merged.some((x) => x.toLowerCase() === resolved.toLowerCase())) { + continue + } + nextAdd[id] = [...(nextAdd[id] ?? []), resolved] + } + return { additions: nextAdd, removals: nextRem } + }) + }, + [resolveLabelCasing, allEmails, sidebarNav, setLabelEdits] + ) + + const getCatalogLabelPresence = useCallback( + (ids: string[], catalogLabel: string): CatalogLabelPresence => { + const resolved = resolveLabelCasing(catalogLabel) + if (!resolved || ids.length === 0) return "none" + const lc = resolved.toLowerCase() + let n = 0 + for (const id of ids) { + const e = allEmails.find((x) => x.id === id) + const eff = effectiveLabels(e, labelEdits.additions, labelEdits.removals) + if (eff.some((l) => l.toLowerCase() === lc)) n++ + } + if (n === 0) return "none" + if (n === ids.length) return "all" + return "some" + }, + [allEmails, labelEdits, resolveLabelCasing] + ) + + const toggleLabelOnEmails = useCallback( + (ids: string[], label: string) => { + const resolved = resolveLabelCasing(label) + if (!resolved || ids.length === 0) return + + setLabelEdits((prev) => { + const presence = (id: string) => { + const e = allEmails.find((x) => x.id === id) + if (!e) return false + return effectiveLabels(e, prev.additions, prev.removals).some( + (l) => l.toLowerCase() === resolved.toLowerCase() + ) + } + const allHave = ids.every((id) => presence(id)) + const nextAdd = { ...prev.additions } + const nextRem = { ...prev.removals } + + if (allHave) { + for (const id of ids) { + if (nextAdd[id]?.length) { + const filtered = nextAdd[id].filter( + (l) => l.toLowerCase() !== resolved.toLowerCase() + ) + if (filtered.length) nextAdd[id] = filtered + else delete nextAdd[id] + } + const e = allEmails.find((x) => x.id === id) + if (!e) continue + const still = effectiveLabels(e, nextAdd, nextRem).some( + (l) => l.toLowerCase() === resolved.toLowerCase() + ) + if (still) { + const cur = nextRem[id] ?? [] + if (!cur.some((l) => l.toLowerCase() === resolved.toLowerCase())) { + nextRem[id] = [...cur, resolved] + } + } else if (nextRem[id]?.length) { + const fr = nextRem[id].filter( + (l) => l.toLowerCase() !== resolved.toLowerCase() + ) + if (fr.length) nextRem[id] = fr + else delete nextRem[id] + } + } + } else { + const anyMissing = ids.some((id) => !presence(id)) + if (anyMissing) { + queueMicrotask(() => sidebarNav.ensureLabelRowForLabelText(resolved)) + } + for (const id of ids) { + const e = allEmails.find((x) => x.id === id) + if (!e) continue + const had = effectiveLabels(e, prev.additions, prev.removals).some( + (l) => l.toLowerCase() === resolved.toLowerCase() + ) + if (nextRem[id]?.length) { + const fr = nextRem[id].filter( + (l) => l.toLowerCase() !== resolved.toLowerCase() + ) + if (fr.length) nextRem[id] = fr + else delete nextRem[id] + } + if (!had) { + if (!nextAdd[id]) nextAdd[id] = [] + if (!nextAdd[id].some((l) => l.toLowerCase() === resolved.toLowerCase())) { + nextAdd[id] = [...nextAdd[id], resolved] + } + } + } + } + return { additions: nextAdd, removals: nextRem } + }) + }, + [allEmails, resolveLabelCasing, sidebarNav, setLabelEdits] + ) + + return { + collectAllFolderLabels, + moveEmailsToTarget, + catalogLabels, + resolveLabelVisual, + resolveLabelCasing, + addLabelToEmails, + toggleLabelOnEmails, + getCatalogLabelPresence, + } +} + +export type EmailListLabels = ReturnType diff --git a/components/gmail/email-list/hooks/use-email-list-reading.ts b/components/gmail/email-list/hooks/use-email-list-reading.ts new file mode 100644 index 0000000..c7c8ba3 --- /dev/null +++ b/components/gmail/email-list/hooks/use-email-list-reading.ts @@ -0,0 +1,564 @@ +"use client" + +import { + startTransition, + useCallback, + useEffect, + useLayoutEffect, + useMemo, +} from "react" +import type { Email } from "@/lib/email-data" +import { readStateTargets } from "@/lib/mail-thread" +import { threadStoreId } from "@/lib/mail-settings/list-row-id" +import { resolveOpenEmailView } from "@/lib/mail-settings/resolve-open-email" +import { + mergeEmailLabelEdits, + mergeEmailNotSpam, +} from "@/lib/label-edits" +import { + DEFAULT_INBOX_TAB, +} from "@/lib/mail-url" +import { + mailNavVisitKey, + parseMailNavVisitKey, +} from "@/lib/mail-folder-display" +import { + LIST_PAGE_SIZE, + escapeHtml, +} from "@/components/gmail/email-list/email-list-helpers" +import type { Contact } from "@/lib/compose-context" +import { + buildThreadComposePreset, + withTouchFullscreenComposePreset, +} from "@/lib/thread-compose-preset" +import type { EmailListProps } from "@/components/gmail/email-list/email-list-helpers" +import type { EmailListData } from "@/components/gmail/email-list/hooks/use-email-list-data" +import type { EmailListLabels } from "@/components/gmail/email-list/hooks/use-email-list-labels" + +export function useEmailListReading( + props: EmailListProps, + data: EmailListData, + labels: EmailListLabels +) { + const { + onMailRouteNavigate, + onSelectFolder, + onXsViewChromeChange, + } = props + + const { + openMailId, + splitView, + isViewMode, + showSplitReadingPane, + isXs, + allEmails, + emailById, + displayListEmails, + listPage, + listRowsDep, + listViewportRef, + conversationMode, + labelEdits, + notSpamEmailIds, + readOverrides, + setReadOverrides, + markEmailSeen, + mailActions, + moveTargets, + selectedFolder, + inboxTab, + openComposeWithInitial, + } = data + + const { moveEmailsToTarget } = labels + + const openEmailView = useMemo(() => { + if (!openMailId) return null + const resolved = resolveOpenEmailView( + openMailId, + allEmails, + conversationMode + ) + if (!resolved) return null + if (resolved.email.labels?.includes("scheduled")) return null + const email = mergeEmailNotSpam( + mergeEmailLabelEdits(resolved.email, labelEdits), + notSpamEmailIds + ) + const threadRoot = mergeEmailNotSpam( + mergeEmailLabelEdits(resolved.threadRoot, labelEdits), + notSpamEmailIds + ) + return { + email, + threadRoot, + isSingleMessageView: resolved.isSingleMessageView, + } + }, [openMailId, labelEdits, allEmails, notSpamEmailIds, conversationMode]) + + const openEmail = openEmailView?.email ?? null + const openEmailThreadRoot = openEmailView?.threadRoot ?? null + const isSingleMessageView = openEmailView?.isSingleMessageView ?? false + + const openMailIndex = useMemo( + () => + openMailId ? displayListEmails.findIndex((e) => e.id === openMailId) : -1, + [openMailId, displayListEmails] + ) + + useEffect(() => { + if (!openMailId) return + const message = emailById.get(openMailId) + if (!message) return + const targets = readStateTargets(message, conversationMode) + for (const id of targets) { + markEmailSeen(id) + } + setReadOverrides((prev) => { + let changed = false + const next = { ...prev } + for (const id of targets) { + if (next[id] === undefined) { + next[id] = true + changed = true + } + } + return changed ? next : prev + }) + }, [openMailId, markEmailSeen, emailById, conversationMode, setReadOverrides]) + + const navigateToMail = useCallback( + (id: string | null) => { + if (id && splitView) { + const idx = displayListEmails.findIndex((e) => e.id === id) + if (idx >= 0) { + const page = Math.floor(idx / LIST_PAGE_SIZE) + 1 + onMailRouteNavigate({ mailId: id, page }) + return + } + } + onMailRouteNavigate({ mailId: id }) + }, + [splitView, displayListEmails, onMailRouteNavigate] + ) + + useEffect(() => { + if (!openMailId) return + const raw = allEmails.find((e) => e.id === openMailId) + if (raw?.labels?.includes("scheduled")) { + navigateToMail(null) + } + }, [openMailId, allEmails, navigateToMail]) + + const pickAdjacentMailId = useCallback( + (currentId: string) => { + const idx = displayListEmails.findIndex((e) => e.id === currentId) + if (idx < 0) return displayListEmails[0]?.id ?? null + if (idx < displayListEmails.length - 1) return displayListEmails[idx + 1]!.id + if (idx > 0) return displayListEmails[idx - 1]!.id + return null + }, + [displayListEmails] + ) + + const leaveReadingPane = useCallback(() => { + if (!splitView) { + navigateToMail(null) + return + } + if (!openMailId) return + navigateToMail(pickAdjacentMailId(openMailId)) + }, [splitView, openMailId, navigateToMail, pickAdjacentMailId]) + + const goBack = useCallback(() => { + if (splitView) leaveReadingPane() + else navigateToMail(null) + }, [splitView, leaveReadingPane, navigateToMail]) + + const closeViewIfShowingEmail = useCallback( + (emailId: string) => { + if (openMailId === emailId) goBack() + }, + [openMailId, goBack] + ) + + const archiveListRow = useCallback( + (email: Email) => { + if (email.labels?.includes("scheduled")) { + void data.requestArchiveScheduled(email.id) + } else { + mailActions.hideEmail(email.id) + closeViewIfShowingEmail(email.id) + } + }, + [closeViewIfShowingEmail, mailActions, data] + ) + + const deleteListRow = useCallback( + (email: Email) => { + if (email.labels?.includes("scheduled")) { + void data.requestDeleteScheduled(email.id) + } else { + mailActions.hideEmail(email.id) + closeViewIfShowingEmail(email.id) + } + }, + [closeViewIfShowingEmail, mailActions, data] + ) + + const restoreSnoozedRowToMailbox = useCallback( + (emailRow: Email) => { + void data.requestRestoreSnoozedToInbox(emailRow) + if (emailRow.id.startsWith("snz-")) { + const baseId = emailRow.id.slice(4) + if (baseId.length > 0) mailActions.unhideEmail(baseId) + onSelectFolder?.("inbox") + } else { + onSelectFolder?.("scheduled") + } + closeViewIfShowingEmail(emailRow.id) + }, + [ + data, + mailActions, + closeViewIfShowingEmail, + onSelectFolder, + ] + ) + + const handleCategoryInboxTabClick = useCallback( + (tabId: string) => { + startTransition(() => { + onMailRouteNavigate({ + inboxTab: tabId, + page: 1, + mailId: null, + }) + }) + }, + [onMailRouteNavigate] + ) + + const handleBreadcrumbNavigate = useCallback( + (visitKey: string) => { + if (visitKey === mailNavVisitKey(selectedFolder, inboxTab)) return + const { folderId, inboxTab: tab } = parseMailNavVisitKey(visitKey) + startTransition(() => { + if (folderId === "inbox" && tab && tab !== DEFAULT_INBOX_TAB) { + onMailRouteNavigate({ + folderId: "inbox", + inboxTab: tab, + page: 1, + mailId: null, + }) + return + } + if (onSelectFolder) { + onSelectFolder(folderId) + return + } + onMailRouteNavigate({ + folderId, + inboxTab: DEFAULT_INBOX_TAB, + page: 1, + mailId: null, + }) + }) + }, + [ + selectedFolder, + inboxTab, + onMailRouteNavigate, + onSelectFolder, + ] + ) + + const goListPrevPage = useCallback(() => { + if (listPage <= 1) return + onMailRouteNavigate({ page: listPage - 1 }) + }, [listPage, onMailRouteNavigate]) + + const goListNextPage = useCallback(() => { + if (listPage >= data.totalPages) return + onMailRouteNavigate({ page: listPage + 1 }) + }, [listPage, data.totalPages, onMailRouteNavigate]) + + const goToPrev = useCallback(() => { + if (openMailIndex > 0) { + const id = displayListEmails[openMailIndex - 1]!.id + markEmailSeen(id) + setReadOverrides((prev) => ({ ...prev, [id]: true })) + navigateToMail(id) + } + }, [openMailIndex, displayListEmails, navigateToMail, markEmailSeen, setReadOverrides]) + + const goToNext = useCallback(() => { + if (openMailIndex >= 0 && openMailIndex < displayListEmails.length - 1) { + const id = displayListEmails[openMailIndex + 1]!.id + markEmailSeen(id) + setReadOverrides((prev) => ({ ...prev, [id]: true })) + navigateToMail(id) + } + }, [openMailIndex, displayListEmails, navigateToMail, markEmailSeen, setReadOverrides]) + + const handleOpenEmail = useCallback( + (id: string) => { + const em = allEmails.find((e) => e.id === id) + if (em?.labels?.includes("scheduled")) return + markEmailSeen(id) + setReadOverrides((prev) => ({ ...prev, [id]: true })) + navigateToMail(id) + }, + [navigateToMail, markEmailSeen, allEmails, setReadOverrides] + ) + + const openDraftInCompose = useCallback( + (email: Email) => { + markEmailSeen(email.id) + setReadOverrides((prev) => ({ ...prev, [email.id]: true })) + const to: Contact[] = email.senderEmail + ? [{ name: email.sender.trim(), email: email.senderEmail }] + : [] + const body = + email.body ?? + (email.preview + ? `

${escapeHtml(email.preview)}

` + : "

") + openComposeWithInitial({ + to, + subject: email.subject, + bodyHtml: body, + focusToOnMount: false, + focusBodyOnMount: true, + }) + }, + [markEmailSeen, openComposeWithInitial, setReadOverrides] + ) + + const handleRowActivate = useCallback( + (email: Email) => { + if (email.labels?.includes("scheduled")) return + if (email.labels?.includes("drafts")) { + openDraftInCompose(email) + return + } + handleOpenEmail(email.id) + }, + [handleOpenEmail, openDraftInCompose] + ) + + const viewModeIsRead = useMemo(() => { + if (!openEmail) return true + return readOverrides[openEmail.id] !== undefined + ? readOverrides[openEmail.id]! + : openEmail.read + }, [openEmail, readOverrides]) + + const afterSingleMessageRemoved = useCallback( + (removedId: string) => { + if (splitView) navigateToMail(pickAdjacentMailId(removedId)) + else navigateToMail(null) + }, + [splitView, navigateToMail, pickAdjacentMailId] + ) + + const singleArchive = useCallback(() => { + if (!openMailId) return + const id = openMailId + mailActions.hideEmail(id) + afterSingleMessageRemoved(id) + }, [openMailId, afterSingleMessageRemoved, mailActions]) + + const singleDelete = useCallback(() => { + if (!openMailId) return + const id = openMailId + mailActions.hideEmail(id) + afterSingleMessageRemoved(id) + }, [openMailId, afterSingleMessageRemoved, mailActions]) + + const singleSpam = useCallback(() => { + if (!openMailId) return + const id = openMailId + mailActions.hideEmail(id) + afterSingleMessageRemoved(id) + }, [openMailId, afterSingleMessageRemoved, mailActions]) + + const singleNotSpam = useCallback(() => { + if (!openMailId) return + const id = openMailId + mailActions.markNotSpam(id) + onSelectFolder?.("inbox") + afterSingleMessageRemoved(id) + }, [openMailId, afterSingleMessageRemoved, onSelectFolder, mailActions]) + + const singleToggleRead = useCallback(() => { + if (!openMailId) return + setReadOverrides((prev) => ({ ...prev, [openMailId]: !viewModeIsRead })) + }, [openMailId, viewModeIsRead, setReadOverrides]) + + const singleMoveTo = useCallback( + (targetId: string) => { + if (!openMailId) return + moveEmailsToTarget([openMailId], targetId) + const isSystemHide = ["sent", "drafts", "spam", "trash"].includes(targetId) + if (isSystemHide || targetId !== "inbox") { + afterSingleMessageRemoved(openMailId) + } + }, + [openMailId, afterSingleMessageRemoved, moveEmailsToTarget] + ) + + const singleReply = useCallback(() => { + if (!openEmail) return + openComposeWithInitial( + withTouchFullscreenComposePreset(buildThreadComposePreset(openEmail, "reply")) + ) + }, [openEmail, openComposeWithInitial]) + + useEffect(() => { + if (!onXsViewChromeChange) return + if (!isXs || !isViewMode || !openEmail) { + onXsViewChromeChange(null) + return + } + onXsViewChromeChange({ + onArchive: singleArchive, + onReply: singleReply, + moveTargets, + onMoveTo: singleMoveTo, + }) + return () => onXsViewChromeChange(null) + }, [ + onXsViewChromeChange, + isXs, + isViewMode, + openEmail, + singleArchive, + singleReply, + singleMoveTo, + moveTargets, + ]) + + useEffect(() => { + if (!splitView) return + const firstId = displayListEmails[0]?.id ?? null + if (!openMailId) { + if (firstId) navigateToMail(firstId) + return + } + const raw = allEmails.find((e) => e.id === openMailId) + if (raw?.labels?.includes("scheduled")) { + navigateToMail(firstId) + return + } + if (!displayListEmails.some((e) => e.id === openMailId)) { + navigateToMail(firstId) + } + }, [ + splitView, + selectedFolder, + inboxTab, + listPage, + displayListEmails, + openMailId, + navigateToMail, + allEmails, + ]) + + const handleNavigateToLabel = useCallback( + (label: string) => { + const folderId = + data.sidebarNav.emailLabelToSidebarFolderId[label] ?? label + onSelectFolder?.(folderId) + }, + [onSelectFolder, data.sidebarNav.emailLabelToSidebarFolderId] + ) + + useLayoutEffect(() => { + if (!splitView || !openMailId) return + const scrollActiveRowIntoView = () => { + const root = listViewportRef.current + if (!root) return + const row = root.querySelector( + `[data-email-row-id="${openMailId}"]` + ) + if (!row) return + row.scrollIntoView({ block: "nearest", behavior: "smooth" }) + } + scrollActiveRowIntoView() + const frame = requestAnimationFrame(scrollActiveRowIntoView) + return () => cancelAnimationFrame(frame) + }, [splitView, openMailId, listPage, listRowsDep, listViewportRef]) + + useEffect(() => { + const root = listViewportRef.current + if (!root) return + const obs = new IntersectionObserver( + (entries) => { + for (const en of entries) { + if (!en.isIntersecting) continue + const id = (en.target as HTMLElement).dataset.emailRowId + if (id) markEmailSeen(id) + } + }, + { root, threshold: 0.12, rootMargin: "0px" } + ) + root.querySelectorAll("[data-email-row-id]").forEach((el) => { + obs.observe(el) + }) + return () => obs.disconnect() + }, [listRowsDep, markEmailSeen, listViewportRef]) + + useEffect(() => { + if (!isViewMode && !showSplitReadingPane) return + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") { + if (!splitView) goBack() + return + } + if (e.key === "ArrowLeft" || e.key === "k") { + goToPrev() + return + } + if (e.key === "ArrowRight" || e.key === "j") { + goToNext() + return + } + } + window.addEventListener("keydown", handler) + return () => window.removeEventListener("keydown", handler) + }, [isViewMode, showSplitReadingPane, splitView, goBack, goToPrev, goToNext]) + + return { + openEmail, + openEmailThreadRoot, + isSingleMessageView, + openMailIndex, + navigateToMail, + goBack, + closeViewIfShowingEmail, + archiveListRow, + deleteListRow, + restoreSnoozedRowToMailbox, + handleCategoryInboxTabClick, + handleBreadcrumbNavigate, + goListPrevPage, + goListNextPage, + goToPrev, + goToNext, + handleOpenEmail, + handleRowActivate, + viewModeIsRead, + singleArchive, + singleDelete, + singleSpam, + singleNotSpam, + singleToggleRead, + singleMoveTo, + singleReply, + handleNavigateToLabel, + } +} + +export type EmailListReading = ReturnType diff --git a/components/gmail/email-list/hooks/use-email-list-selection.ts b/components/gmail/email-list/hooks/use-email-list-selection.ts new file mode 100644 index 0000000..30a4145 --- /dev/null +++ b/components/gmail/email-list/hooks/use-email-list-selection.ts @@ -0,0 +1,337 @@ +"use client" + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type DragEvent, + type MouseEvent, +} from "react" +import { useEmailDrag } from "@/lib/drag-context" +import type { EmailListData } from "@/components/gmail/email-list/hooks/use-email-list-data" +import type { EmailListLabels } from "@/components/gmail/email-list/hooks/use-email-list-labels" + +export function useEmailListSelection( + data: EmailListData, + labels: EmailListLabels +) { + const { + selectedFolder, + isViewMode, + isXs, + touchNav, + pageIds, + listEmails, + effectiveRead, + effectiveStarred, + readOverrides, + allEmails, + setReadOverrides, + mailActions, + } = data + + const { moveEmailsToTarget } = labels + + const { beginDrag, registerOnDrop } = useEmailDrag() + + const [selectedEmails, setSelectedEmails] = useState([]) + const rowContextMenuOpenedAtRef = useRef(0) + const contextMenuTargetIdsRef = useRef([]) + const lastSelectionAnchorIdRef = useRef(null) + const [bulkSelectMenuOpen, setBulkSelectMenuOpen] = useState(false) + const [mobileSelectionMode, setMobileSelectionMode] = useState(false) + const [mobileXsMoreMenuOpen, setMobileXsMoreMenuOpen] = useState(false) + const [mobileXsMoveSheetOpen, setMobileXsMoveSheetOpen] = useState(false) + const [mobileXsLabelSheetOpen, setMobileXsLabelSheetOpen] = useState(false) + const [swipeLabelEmailId, setSwipeLabelEmailId] = useState(null) + const [openSwipeRowId, setOpenSwipeRowId] = useState(null) + + const touchListSwipeEnabled = touchNav && !mobileSelectionMode && !isViewMode + + useEffect(() => { + setMobileSelectionMode(false) + setSelectedEmails([]) + }, [selectedFolder, data.inboxTab]) + + useEffect(() => { + if (!openSwipeRowId) return + const handler = (e: globalThis.TouchEvent) => { + const target = e.target as HTMLElement | null + if (!target) return + const swipeRow = target.closest(`[data-swipe-row-id="${openSwipeRowId}"]`) + if (!swipeRow) setOpenSwipeRowId(null) + } + document.addEventListener("touchstart", handler, { passive: true }) + return () => document.removeEventListener("touchstart", handler) + }, [openSwipeRowId]) + + const openMobileXsMoveSheet = useCallback(() => { + setMobileXsMoreMenuOpen(false) + window.setTimeout(() => setMobileXsMoveSheetOpen(true), 0) + }, []) + + const handleMobileXsMoveSheetOpenChange = useCallback((open: boolean) => { + setMobileXsMoveSheetOpen(open) + if (!open) { + setMobileSelectionMode(false) + setSelectedEmails([]) + } + }, []) + + const openMobileXsLabelSheet = useCallback(() => { + setMobileXsMoreMenuOpen(false) + setSwipeLabelEmailId(null) + window.setTimeout(() => setMobileXsLabelSheetOpen(true), 0) + }, []) + + const handleLabelSheetOpenChange = useCallback((open: boolean) => { + setMobileXsLabelSheetOpen(open) + if (!open) setSwipeLabelEmailId(null) + }, []) + + const selectedOnPageCount = useMemo( + () => pageIds.filter((id) => selectedEmails.includes(id)).length, + [pageIds, selectedEmails] + ) + const allPageSelected = pageIds.length > 0 && selectedOnPageCount === pageIds.length + const somePageSelected = selectedOnPageCount > 0 && !allPageSelected + const selectAllChecked: boolean | "indeterminate" = allPageSelected + ? true + : somePageSelected + ? "indeterminate" + : false + + const toggleStar = (id: string) => { + mailActions.toggleStar(id) + } + + const toggleImportant = (id: string) => { + mailActions.toggleImportant(id) + } + + const toggleSelect = (id: string) => { + setSelectedEmails(prev => + prev.includes(id) ? prev.filter(e => e !== id) : [...prev, id] + ) + } + + const selectRangeInclusive = (fromId: string, toId: string) => { + const ids = pageIds + const i0 = ids.indexOf(fromId) + const i1 = ids.indexOf(toId) + if (i0 === -1 || i1 === -1) return + const lo = Math.min(i0, i1) + const hi = Math.max(i0, i1) + const range = ids.slice(lo, hi + 1) + setSelectedEmails((prev) => [...new Set([...prev, ...range])]) + } + + const handleSelectAllChange = (checked: boolean | "indeterminate") => { + if (checked === true) { + setSelectedEmails((prev) => [...new Set([...prev, ...pageIds])]) + } else { + setSelectedEmails((prev) => prev.filter((id) => !pageIds.includes(id))) + } + } + + const mergePageSelection = (subsetOfPageIds: string[]) => { + setSelectedEmails((prev) => { + const outsidePage = prev.filter((id) => !pageIds.includes(id)) + return [...new Set([...outsidePage, ...subsetOfPageIds])] + }) + } + + const selectMenuAll = () => mergePageSelection(pageIds) + const selectMenuNone = () => + setSelectedEmails((prev) => prev.filter((id) => !pageIds.includes(id))) + const selectMenuRead = () => + mergePageSelection( + listEmails.filter((e) => effectiveRead(e)).map((e) => e.id) + ) + const selectMenuUnread = () => + mergePageSelection( + listEmails.filter((e) => !effectiveRead(e)).map((e) => e.id) + ) + const selectMenuStarred = () => + mergePageSelection( + listEmails.filter((e) => effectiveStarred(e)).map((e) => e.id) + ) + const selectMenuUnstarred = () => + mergePageSelection( + listEmails.filter((e) => !effectiveStarred(e)).map((e) => e.id) + ) + + const handleRowCheckboxClickCapture = (id: string, e: MouseEvent) => { + if (e.shiftKey && lastSelectionAnchorIdRef.current != null) { + e.preventDefault() + e.stopPropagation() + selectRangeInclusive(lastSelectionAnchorIdRef.current, id) + lastSelectionAnchorIdRef.current = id + } + } + + const bulkTargetIds = useMemo( + () => pageIds.filter((id) => selectedEmails.includes(id)), + [pageIds, selectedEmails] + ) + const hasUnreadInSelection = useMemo(() => { + for (const id of bulkTargetIds) { + const email = allEmails.find((e) => e.id === id) + if (!email) continue + const isRead = + readOverrides[id] !== undefined ? readOverrides[id]! : email.read + if (!isRead) return true + } + return false + }, [bulkTargetIds, readOverrides, allEmails]) + const showBulkToolbar = bulkTargetIds.length > 0 + + const labelSheetTargetIds = useMemo( + () => (swipeLabelEmailId ? [swipeLabelEmailId] : bulkTargetIds), + [swipeLabelEmailId, bulkTargetIds] + ) + + const clearBulkSelection = (ids: string[]) => { + setSelectedEmails((prev) => prev.filter((id) => !ids.includes(id))) + } + + const bulkHideFromList = (ids: string[]) => { + if (ids.length === 0) return + mailActions.hideEmails(ids) + clearBulkSelection(ids) + } + + const bulkArchive = () => bulkHideFromList(bulkTargetIds) + const bulkDelete = () => bulkHideFromList(bulkTargetIds) + const bulkSpam = () => bulkHideFromList(bulkTargetIds) + + const handleEmailsDroppedOnTarget = useCallback( + (targetId: string, _targetLabel: string, ids: string[]) => { + if (ids.length === 0) return + moveEmailsToTarget(ids, targetId) + setSelectedEmails((prev) => prev.filter((id) => !ids.includes(id))) + }, + [moveEmailsToTarget] + ) + + useEffect(() => { + return registerOnDrop(handleEmailsDroppedOnTarget) + }, [registerOnDrop, handleEmailsDroppedOnTarget]) + + const startRowDrag = useCallback( + (rowId: string, e: DragEvent) => { + if (isXs) return + const inSelection = selectedEmails.includes(rowId) + const ids = + inSelection && bulkTargetIds.length > 0 ? bulkTargetIds : [rowId] + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = "move" + try { + e.dataTransfer.setData("text/plain", ids.join(",")) + } catch { + /* some browsers throw if called outside dragstart context */ + } + const ghost = document.createElement("div") + ghost.style.position = "fixed" + ghost.style.top = "-1000px" + ghost.style.left = "-1000px" + ghost.style.width = "1px" + ghost.style.height = "1px" + ghost.style.opacity = "0" + document.body.appendChild(ghost) + e.dataTransfer.setDragImage(ghost, 0, 0) + window.setTimeout(() => { + if (ghost.parentNode) ghost.parentNode.removeChild(ghost) + }, 0) + } + beginDrag(ids, selectedFolder, e.clientX, e.clientY) + }, + [beginDrag, isXs, selectedEmails, bulkTargetIds, selectedFolder] + ) + + const bulkMarkRead = () => { + if (bulkTargetIds.length === 0) return + setReadOverrides((prev) => { + const next = { ...prev } + for (const id of bulkTargetIds) next[id] = true + return next + }) + } + + const bulkMarkUnread = () => { + if (bulkTargetIds.length === 0) return + setReadOverrides((prev) => { + const next = { ...prev } + for (const id of bulkTargetIds) next[id] = false + return next + }) + } + + const bulkMoveTo = useCallback( + (targetId: string) => { + if (bulkTargetIds.length === 0) return + moveEmailsToTarget(bulkTargetIds, targetId) + if (targetId !== "inbox") { + setSelectedEmails((prev) => prev.filter((id) => !bulkTargetIds.includes(id))) + } + }, + [bulkTargetIds, moveEmailsToTarget] + ) + + const openSwipeRowLabelSheet = useCallback((emailId: string) => { + setSwipeLabelEmailId(emailId) + setMobileXsLabelSheetOpen(true) + }, []) + + return { + selectedEmails, + setSelectedEmails, + rowContextMenuOpenedAtRef, + contextMenuTargetIdsRef, + lastSelectionAnchorIdRef, + bulkSelectMenuOpen, + setBulkSelectMenuOpen, + mobileSelectionMode, + setMobileSelectionMode, + mobileXsMoreMenuOpen, + setMobileXsMoreMenuOpen, + mobileXsMoveSheetOpen, + mobileXsLabelSheetOpen, + swipeLabelEmailId, + openSwipeRowId, + setOpenSwipeRowId, + touchListSwipeEnabled, + openMobileXsMoveSheet, + handleMobileXsMoveSheetOpenChange, + openMobileXsLabelSheet, + handleLabelSheetOpenChange, + selectAllChecked, + handleSelectAllChange, + selectMenuAll, + selectMenuNone, + selectMenuRead, + selectMenuUnread, + selectMenuStarred, + selectMenuUnstarred, + toggleStar, + toggleImportant, + toggleSelect, + handleRowCheckboxClickCapture, + bulkTargetIds, + hasUnreadInSelection, + showBulkToolbar, + labelSheetTargetIds, + bulkArchive, + bulkDelete, + bulkSpam, + bulkMarkRead, + bulkMarkUnread, + bulkMoveTo, + startRowDrag, + openSwipeRowLabelSheet, + } +} + +export type EmailListSelection = ReturnType diff --git a/components/gmail/email-list/index.tsx b/components/gmail/email-list/index.tsx index bf24c0c..e15c7ab 100644 --- a/components/gmail/email-list/index.tsx +++ b/components/gmail/email-list/index.tsx @@ -1 +1,2 @@ -export { EmailList } from "@/components/gmail/email-list" +export { EmailList } from "@/components/gmail/email-list/email-list" +export type { EmailListProps } from "@/components/gmail/email-list/email-list-helpers" diff --git a/components/gmail/email-list-row.tsx b/components/gmail/email-list/list-mail-index.ts similarity index 97% rename from components/gmail/email-list-row.tsx rename to components/gmail/email-list/list-mail-index.ts index 66e367f..618418f 100644 --- a/components/gmail/email-list-row.tsx +++ b/components/gmail/email-list/list-mail-index.ts @@ -4,7 +4,7 @@ import { useCallback } from "react" import type { Email } from "@/lib/email-data" import { useMailStore } from "@/lib/stores/mail-store" -type ListMailIndex = { +export type ListMailIndex = { emailById: Map scheduledIds: Set } diff --git a/components/gmail/email-view.tsx b/components/gmail/email-view.tsx index d69ce67..78441ea 100644 --- a/components/gmail/email-view.tsx +++ b/components/gmail/email-view.tsx @@ -8,17 +8,7 @@ import { useState, type CSSProperties, } from "react" -import { - Star, - Reply, - ReplyAll, - Forward, - Info, - HardDrive, - File, - FileText, - Image as ImageIcon, -} from "lucide-react" +import { Star, Reply, ReplyAll, Forward } from "lucide-react" import { Tooltip, TooltipContent, @@ -31,19 +21,8 @@ import { cleanSenderName, senderInitial, } from "@/lib/sender-display" -import { MailDateText } from "@/components/gmail/mail-date-text" -import type { - Email, - ConversationMessage, - EmailAttachment, - EmailAttachmentKind, -} from "@/lib/email-data" +import type { Email, EmailAttachment } from "@/lib/email-data" import type { FolderTreeNode, LabelRowItem } from "@/lib/sidebar-nav-data" -import { - attachmentPreviewTooltip, - resolveAttachmentKind, - shouldUseAttachmentPillsInPreview, -} from "@/lib/attachment-display" import { useComposeActions, useComposeDrafts, @@ -61,23 +40,17 @@ import { openConversationPrint } from "@/lib/print-conversation" import { resolveParsedCalendarInvitation } from "@/lib/resolve-email-calendar-invitation" import { ComposeWindow } from "@/components/gmail/compose-modal" import { CalendarInvitationPreview } from "@/components/gmail/calendar-invitation-preview" -import { ContactHoverCard } from "./contact-hover-card" import { EmailViewSubjectHeader } from "./email-view/email-view-header" -import { EmailViewMessageToolbar } from "./email-view/email-view-toolbar" import { - MAIL_MESSAGE_HOVER_CLASS, MAIL_PREVIEW_SCROLL_CLASS, MAIL_REPLY_BAR_CLASS, MAIL_REPLY_BUTTON_CLASS, - MAIL_TOOLTIP_CONTENT_CLASS, } from "@/lib/mail-chrome-classes" -import { useTheme } from "next-themes" import { - emailPreviewBaseCss, - emailPreviewDarkOverrideCss, - emailPreviewLightOverrideCss, - preprocessEmailHtmlForTheme, -} from "@/lib/email-preview-dark-styles" + CollapsedMessage, + ExpandedMessage, + SpamWhyBanner, +} from "@/components/gmail/email-view/email-view-messages" interface EmailViewProps { email: Email @@ -101,400 +74,6 @@ interface EmailViewProps { isSingleMessageView?: boolean } -const EMAIL_PREVIEW_IFRAME_STYLE: React.CSSProperties = { - display: "block", - background: "transparent", -} - -function documentIsDark(): boolean { - return document.documentElement.classList.contains("dark") -} - -/* ── Sandboxed iframe for HTML body ── */ - -function SandboxedContent({ - html, - isSpam, -}: { - html: string - isSpam: boolean -}) { - const iframeRef = useRef(null) - const [height, setHeight] = useState(120) - - const sandboxValue = isSpam - ? "allow-same-origin" - : "allow-same-origin allow-popups" - - const { resolvedTheme } = useTheme() - - const injectContent = useCallback(() => { - const iframe = iframeRef.current - if (!iframe) return - - const doc = iframe.contentDocument - if (!doc) return - - const cspMeta = isSpam - ? `` - : `` - - const isDark = documentIsDark() - const processedHtml = preprocessEmailHtmlForTheme(html, isDark) - const themeOverrides = isDark - ? emailPreviewDarkOverrideCss() - : emailPreviewLightOverrideCss() - - doc.open() - doc.write(` - - - - ${cspMeta} - - -${processedHtml} -`) - doc.close() - - const resizeObserver = new ResizeObserver(() => { - const body = iframe.contentDocument?.body - if (body) { - setHeight(Math.max(60, body.scrollHeight + 2)) - } - }) - - if (doc.body) { - resizeObserver.observe(doc.body) - setHeight(Math.max(60, doc.body.scrollHeight + 2)) - } - - return () => resizeObserver.disconnect() - }, [html, isSpam, resolvedTheme]) - - useEffect(() => { - const cleanup = injectContent() - return () => cleanup?.() - }, [injectContent]) - - return ( -