From 8551150ffee2b9ac1984997cd272c965924834f9 Mon Sep 17 00:00:00 2001 From: R3D347HR4Y Date: Wed, 20 May 2026 16:01:08 +0200 Subject: [PATCH] before split 2 --- app/globals.css | 73 +- app/mail/mail-app-shell.tsx | 75 +- components/gmail/compose-modal.tsx | 1667 +---------------- .../gmail/compose/compose-editor-chrome.tsx | 356 ++++ .../gmail/compose/compose-recipients.tsx | 405 ++++ components/gmail/compose/compose-shared.ts | 36 + components/gmail/compose/compose-toolbar.tsx | 994 ++++++++++ .../gmail/contacts/contact-form-view.tsx | 2 +- components/gmail/email-list.tsx | 454 +---- .../attachments/email-list-attachment-row.tsx | 88 + .../attachments/list-attachment-chip.tsx | 23 + .../gmail/email-list/email-list-helpers.ts | 145 ++ components/gmail/email-list/index.tsx | 1 + .../gmail/email-list/move-to-menu-items.tsx | 127 ++ components/gmail/email-view.tsx | 391 +--- .../gmail/email-view/email-view-header.tsx | 183 ++ .../gmail/email-view/email-view-toolbar.tsx | 297 +++ components/gmail/mail-search-bar.tsx | 123 +- components/gmail/mobile-search-overlay.tsx | 209 ++- components/gmail/sidebar.tsx | 761 +------- components/gmail/sidebar/category-nav-row.tsx | 297 +++ .../gmail/sidebar/sidebar-nav-constants.ts | 78 + .../gmail/sidebar/sidebar-nav-primitives.tsx | 294 +++ hooks/use-mail-list-pull-refresh.ts | 133 ++ hooks/use-mail-route.ts | 51 + hooks/use-sidebar-nav-drag.ts | 103 + lib/mail-chrome-classes.ts | 137 ++ lib/mail-list/label-actions.ts | 71 + lib/mail-search/navigate.ts | 41 + lib/sidebar-folder-tree-utils.ts | 28 + lib/stores/README.md | 20 + lib/stores/mail-ui-store.ts | 28 + tsconfig.tsbuildinfo | 2 +- 33 files changed, 4422 insertions(+), 3271 deletions(-) create mode 100644 components/gmail/compose/compose-editor-chrome.tsx create mode 100644 components/gmail/compose/compose-recipients.tsx create mode 100644 components/gmail/compose/compose-shared.ts create mode 100644 components/gmail/compose/compose-toolbar.tsx create mode 100644 components/gmail/email-list/attachments/email-list-attachment-row.tsx create mode 100644 components/gmail/email-list/attachments/list-attachment-chip.tsx create mode 100644 components/gmail/email-list/email-list-helpers.ts create mode 100644 components/gmail/email-list/index.tsx create mode 100644 components/gmail/email-list/move-to-menu-items.tsx create mode 100644 components/gmail/email-view/email-view-header.tsx create mode 100644 components/gmail/email-view/email-view-toolbar.tsx create mode 100644 components/gmail/sidebar/category-nav-row.tsx create mode 100644 components/gmail/sidebar/sidebar-nav-constants.ts create mode 100644 components/gmail/sidebar/sidebar-nav-primitives.tsx create mode 100644 hooks/use-mail-list-pull-refresh.ts create mode 100644 hooks/use-mail-route.ts create mode 100644 hooks/use-sidebar-nav-drag.ts create mode 100644 lib/mail-list/label-actions.ts create mode 100644 lib/mail-search/navigate.ts create mode 100644 lib/sidebar-folder-tree-utils.ts create mode 100644 lib/stores/README.md create mode 100644 lib/stores/mail-ui-store.ts diff --git a/app/globals.css b/app/globals.css index 445a36a..b24fdcb 100644 --- a/app/globals.css +++ b/app/globals.css @@ -58,6 +58,11 @@ --mail-nav-hover: #f1f3f4; --mail-nav-drop: #fef7cd; --mail-invitation: #e8f0fe; + --mail-list-divider: #eceff1; + --mail-list-chip-border: #dadce0; + --mail-list-chip-text: #3c4043; + --mail-list-chip-muted: #f1f3f4; + --mail-row-checkbox-border: #c2c2c2; } .dark { @@ -81,6 +86,11 @@ --mail-nav-hover: #3c4043; --mail-nav-drop: #4a4428; --mail-invitation: #2d3a4d; + --mail-list-divider: #3c4043; + --mail-list-chip-border: #5f6368; + --mail-list-chip-text: #e8eaed; + --mail-list-chip-muted: #3c4043; + --mail-row-checkbox-border: #9aa0a6; --background: oklch(0.145 0 0); --foreground: oklch(0.985 0 0); --card: oklch(0.145 0 0); @@ -161,6 +171,11 @@ --color-mail-border: var(--mail-border); --color-mail-border-subtle: var(--mail-border-subtle); --color-mail-invitation: var(--mail-invitation); + --color-mail-list-divider: var(--mail-list-divider); + --color-mail-list-chip-border: var(--mail-list-chip-border); + --color-mail-list-chip-text: var(--mail-list-chip-text); + --color-mail-list-chip-muted: var(--mail-list-chip-muted); + --color-mail-row-checkbox-border: var(--mail-row-checkbox-border); } @layer base { @@ -352,11 +367,8 @@ html::before { inset: 0; z-index: -1; pointer-events: none; + background: var(--mail-bg-layer, none); background-color: var(--mail-bg-fallback, transparent); - background-image: var(--mail-bg-layer, none); - background-size: cover; - background-position: center; - background-repeat: no-repeat; opacity: 0; transition: opacity 0.25s ease; } @@ -365,6 +377,10 @@ html[data-mail-background]:not([data-mail-background='none'])::before { opacity: 1; } +html[data-mail-background]:not([data-mail-background='none']) body { + background-color: transparent !important; +} + html[data-mail-background]:not([data-mail-background='none']) .ultimail-app { background-color: transparent !important; } @@ -413,6 +429,28 @@ html[data-mail-background]:not([data-mail-background='none']) background-color: var(--mail-invitation); } +/** + * Sidebar overlay (touch / xs) — fond opaque. + * Nom hors préfixe bg-* pour éviter qu’un utility Tailwind écrase la règle. + */ +.ultimail-app .mail-sidebar-overlay-panel { + background-color: #ffffff; +} + +html.dark .ultimail-app .mail-sidebar-overlay-panel { + background-color: var(--background); +} + +html[data-mail-background]:not([data-mail-background='none']) .ultimail-app .mail-sidebar-overlay-panel { + background-color: #ffffff !important; +} + +html.dark[data-mail-background]:not([data-mail-background='none']) + .ultimail-app + .mail-sidebar-overlay-panel { + background-color: var(--background) !important; +} + /* ── Mail : mode sombre (surcharges ciblées dans le shell) ── */ html.dark .ultimail-app { color-scheme: dark; @@ -520,6 +558,33 @@ html.dark [data-slot='context-menu-separator'] { background-color: var(--border) !important; } +/* Recherche avancée — champs (sheet xs + panneau dropdown desktop) */ +html.dark :where([data-mail-mobile-search], [data-mail-search-advanced]) + :where([data-slot='input'], [data-slot='select-trigger']) { + background-color: var(--mail-surface-muted) !important; + border: 1px solid var(--mail-border) !important; + color: var(--mail-text) !important; +} + +/* Priorité sur .ultimail-app input { border-color: mail-border-subtle } */ +html.dark .ultimail-app [data-mail-search-advanced] + :where([data-slot='input'], [data-slot='select-trigger']) { + background-color: var(--mail-surface-muted) !important; + border: 1px solid var(--mail-border) !important; + color: var(--mail-text) !important; +} + +html.dark :where([data-mail-mobile-search], [data-mail-search-advanced]) + :where([data-slot='checkbox']) { + background-color: var(--mail-surface-muted) !important; + border: 1.5px solid var(--mail-row-checkbox-border) !important; +} + +html.dark .ultimail-app [data-mail-search-advanced] :where([data-slot='checkbox']) { + background-color: var(--mail-surface-muted) !important; + border: 1.5px solid var(--mail-row-checkbox-border) !important; +} + html.dark .ultimail-app :where(.hover\:bg-gray-50:hover, .hover\:bg-gray-100:hover) { background-color: var(--mail-nav-hover) !important; } diff --git a/app/mail/mail-app-shell.tsx b/app/mail/mail-app-shell.tsx index cb57ba4..1ba8a6e 100644 --- a/app/mail/mail-app-shell.tsx +++ b/app/mail/mail-app-shell.tsx @@ -5,17 +5,17 @@ import { useCallback, useEffect, useLayoutEffect, - useMemo, useState, } from "react" import { useIsXs } from "@/hooks/use-xs" import { readTouchNavMatches, useTouchNav } from "@/hooks/use-touch-nav" import { useMailSplitView } from "@/hooks/use-mail-split-view" +import { useMailRoute } from "@/hooks/use-mail-route" import { MobileBottomBar } from "@/components/gmail/mobile-bottom-bar" import { MobileSearchOverlay } from "@/components/gmail/mobile-search-overlay" import type { MailXsViewChrome } from "@/lib/mail-xs-view-chrome" import { MailToaster } from "@/components/gmail/mail-toaster" -import { useRouter, usePathname, useSearchParams } from "next/navigation" +import { useRouter } from "next/navigation" import { Sidebar } from "@/components/gmail/sidebar" import { Header } from "@/components/gmail/header" import { EmailList } from "@/components/gmail/email-list" @@ -29,76 +29,45 @@ import { ComposeModalManager } from "@/components/gmail/compose-modal" import { SidebarNavProvider } from "@/lib/sidebar-nav-context" import { mailNavVisitKey } from "@/lib/mail-folder-display" import { useMailStore } from "@/lib/stores/mail-store" -import { - parseMailSegments, - buildMailPath, - DEFAULT_INBOX_TAB, - type MailRouteState, -} from "@/lib/mail-url" +import { useMailUiStore } from "@/lib/stores/mail-ui-store" +import { DEFAULT_INBOX_TAB } from "@/lib/mail-url" import { cn } from "@/lib/utils" import { ThemeProvider } from "@/components/theme-provider" import { MailThemeApplier } from "@/components/gmail/mail-theme-applier" import { QuickSettingsRoot } from "@/components/gmail/quick-settings/quick-settings-root" -function segmentsFromPathname(pathname: string | null): string[] | undefined { - if (!pathname?.startsWith("/mail")) return undefined - const rest = pathname.slice("/mail".length).replace(/^\//, "") - if (!rest) return [] - return rest.split("/").filter(Boolean) -} - function MailAppInner() { const router = useRouter() - const pathname = usePathname() - const currentSearchParams = useSearchParams() - const segments = useMemo(() => segmentsFromPathname(pathname), [pathname]) - const route = useMemo(() => parseMailSegments(segments), [segments]) + const { route, navigateRoute, searchParams: currentSearchParams } = + useMailRoute() const isXs = useIsXs() const touchNav = useTouchNav() const splitView = useMailSplitView() const pushRecentFolderVisit = useMailStore((s) => s.pushRecentFolderVisit) - /** Start closed so narrow viewports match SSR/CSS before JS runs; desktop opens in layout. */ - const [sidebarCollapsed, setSidebarCollapsed] = useState(true) + + const sidebarCollapsed = useMailUiStore((s) => s.sidebarCollapsed) + const setSidebarCollapsed = useMailUiStore((s) => s.setSidebarCollapsed) + const mobileSearchOpen = useMailUiStore((s) => s.mobileSearchOpen) + const setMobileSearchOpen = useMailUiStore((s) => s.setMobileSearchOpen) + const folderUnreadCounts = useMailUiStore((s) => s.folderUnreadCounts) + const setFolderUnreadCounts = useMailUiStore((s) => s.setFolderUnreadCounts) + + const [xsViewChrome, setXsViewChrome] = useState(null) useLayoutEffect(() => { if (!readTouchNavMatches()) setSidebarCollapsed(false) - }, []) + }, [setSidebarCollapsed]) useEffect(() => { if (isXs) setSidebarCollapsed(true) - }, [isXs]) + }, [isXs, setSidebarCollapsed]) useEffect(() => { if (route.folderId !== "search") { pushRecentFolderVisit(mailNavVisitKey(route.folderId, route.inboxTab)) } }, [route.folderId, route.inboxTab, pushRecentFolderVisit]) - const [folderUnreadCounts, setFolderUnreadCounts] = useState< - Record - >({}) - const [xsViewChrome, setXsViewChrome] = useState(null) - const [mobileSearchOpen, setMobileSearchOpen] = useState(false) - - const navigateRoute = useCallback( - (patch: Partial) => { - const next: MailRouteState = { - folderId: patch.folderId ?? route.folderId, - inboxTab: - patch.inboxTab !== undefined && patch.inboxTab !== null - ? patch.inboxTab - : route.inboxTab, - page: patch.page !== undefined ? patch.page : route.page, - mailId: patch.mailId !== undefined ? patch.mailId : route.mailId, - } - let url = buildMailPath(next) - if (next.folderId === "search" && currentSearchParams.toString()) { - url += `?${currentSearchParams.toString()}` - } - router.push(url, { scroll: false }) - }, - [router, route, currentSearchParams] - ) const handleSelectFolder = useCallback( (id: string) => { @@ -110,7 +79,7 @@ function MailAppInner() { }) if (readTouchNavMatches()) setSidebarCollapsed(true) }, - [navigateRoute] + [navigateRoute, setSidebarCollapsed] ) return ( @@ -181,7 +150,9 @@ function MailAppInner() { listPage={route.page} openMailId={route.mailId} splitView={splitView} - onToggleSidebar={() => setSidebarCollapsed((c) => !c)} + onToggleSidebar={() => + useMailUiStore.getState().toggleSidebarCollapsed() + } onMailRouteNavigate={navigateRoute} onSelectFolder={handleSelectFolder} onFolderUnreadCountsChange={setFolderUnreadCounts} @@ -202,7 +173,9 @@ function MailAppInner() { {!splitView ? ( setSidebarCollapsed((c) => !c)} + onToggleSidebar={() => + useMailUiStore.getState().toggleSidebarCollapsed() + } xsViewChrome={xsViewChrome} onOpenSearch={() => setMobileSearchOpen(true)} searchQuery={route.folderId === "search" ? (currentSearchParams.get("q") ?? "") : ""} diff --git a/components/gmail/compose-modal.tsx b/components/gmail/compose-modal.tsx index fd4efc3..7248f5b 100644 --- a/components/gmail/compose-modal.tsx +++ b/components/gmail/compose-modal.tsx @@ -7,14 +7,12 @@ import { useLayoutEffect, useCallback, useMemo, - lazy, - Suspense, } 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 { Editor, Node as TipTapNode, mergeAttributes, type Extensions } from "@tiptap/core" +import { type Extensions } from "@tiptap/core" import StarterKit from "@tiptap/starter-kit" import Underline from "@tiptap/extension-underline" import Link from "@tiptap/extension-link" @@ -22,52 +20,17 @@ import TextAlign from "@tiptap/extension-text-align" import { TextStyle, FontFamily, FontSize, BackgroundColor } from "@tiptap/extension-text-style" import Color from "@tiptap/extension-color" import { - Maximize2, - Minimize2, - X, - 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, Reply, ReplyAll, Forward, - SquareArrowOutUpRight, - Pencil, - Send, + Maximize2, + Minimize2, + X, } from "lucide-react" import { type ComposeState, - type Contact, cloneComposeForPendingSend, DEFAULT_IDENTITIES, - MOCK_CONTACTS, - SIGNATURES, useComposeActions, useComposeWindows, } from "@/lib/compose-context" @@ -80,1149 +43,24 @@ import { } from "@/lib/thread-compose-preset" import { toast } from "sonner" import { showPendingSendToast } from "@/lib/pending-send-toast" -import { cn, getNextLocalWallClockDate } from "@/lib/utils" +import { cn } from "@/lib/utils" import { - MAIL_COMPOSE_MENU_SELECTED_CLASS, - MAIL_COMPOSE_POPOVER_CLASS, MAIL_COMPOSE_TITLEBAR_CLASS, MAIL_ICON_BTN, - MAIL_MENU_SURFACE_CLASS, } from "@/lib/mail-chrome-classes" -import { useTheme } from "next-themes" +import { ComposeRecipientFields } from "@/components/gmail/compose/compose-recipients" import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" + ComposeBottomToolbar, + FormattingToolbar, +} from "@/components/gmail/compose/compose-toolbar" import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover" -import data from "@emoji-mart/data" - -const LazyPicker = lazy(() => import("@emoji-mart/react")) - -function EmojiPicker({ onSelect }: { onSelect: (emoji: { native: string }) => void }) { - const { resolvedTheme } = useTheme() - return ( - Chargement…}> - - - ) -} - -const SignatureBlock = TipTapNode.create({ - name: "signatureBlock", - group: "block", - content: "block+", - defining: true, - isolating: true, - - parseHTML() { - return [{ tag: 'div[id="ultimail-signature"]' }] - }, - - renderHTML({ HTMLAttributes }) { - return ["div", mergeAttributes(HTMLAttributes, { id: "ultimail-signature" }), 0] - }, -}) - -const SIG_REGEX = /
[\s\S]*<\/div>/ - -function stripSignature(html: string) { - return html.replace(SIG_REGEX, "") -} - -function insertSignatureHtml(html: string, sigId: string | null) { - const sig = sigId ? SIGNATURES.find((s) => s.id === sigId) : null - const clean = stripSignature(html) - if (!sig) return clean - return clean + `

--

${sig.html}
` -} - -/** Menus/popovers Radix default z-50 ; compose sheet content uses z-61+. */ -const COMPOSE_PORTAL_Z = "z-[100]" - -const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ - -function RecipientField({ - label, - contacts, - onChange, - placeholder, - onActivate, - autoFocus, - onAutoFocusDone, -}: { - label: string - contacts: Contact[] - onChange: (contacts: Contact[]) => void - placeholder?: string - onActivate?: () => void - autoFocus?: boolean - onAutoFocusDone?: () => void -}) { - const [inputValue, setInputValue] = useState("") - const [showSuggestions, setShowSuggestions] = useState(false) - const [selectedSuggestionIdx, setSelectedSuggestionIdx] = useState(0) - const inputRef = useRef(null) - const containerRef = useRef(null) - - const suggestions = useMemo(() => { - if (!inputValue.trim()) return [] - const q = inputValue.toLowerCase() - return MOCK_CONTACTS.filter( - (c) => - !contacts.some((existing) => existing.email === c.email) && - (c.name.toLowerCase().includes(q) || c.email.toLowerCase().includes(q)) - ).slice(0, 6) - }, [inputValue, contacts]) - - useEffect(() => { - setSelectedSuggestionIdx(0) - }, [suggestions.length]) - - useEffect(() => { - if (!autoFocus) return - const id = window.requestAnimationFrame(() => { - inputRef.current?.focus() - onAutoFocusDone?.() - }) - return () => window.cancelAnimationFrame(id) - }, [autoFocus, onAutoFocusDone]) - - const addContact = useCallback( - (contact: Contact) => { - if (!contacts.some((c) => c.email === contact.email)) { - onChange([...contacts, contact]) - } - setInputValue("") - setShowSuggestions(false) - }, - [contacts, onChange] - ) - - const tryAddRawEmail = useCallback( - (raw: string) => { - const trimmed = raw.trim().replace(/,$/, "") - if (!trimmed) return - const matchedContact = MOCK_CONTACTS.find( - (c) => c.email.toLowerCase() === trimmed.toLowerCase() - ) - if (matchedContact) { - addContact(matchedContact) - } else if (EMAIL_REGEX.test(trimmed)) { - addContact({ name: trimmed, email: trimmed }) - } - }, - [addContact] - ) - - const removeContact = useCallback( - (email: string) => { - onChange(contacts.filter((c) => c.email !== email)) - }, - [contacts, onChange] - ) - - const handleKeyDown = (e: React.KeyboardEvent) => { - if ( - (e.key === "Enter" || e.key === "Tab" || e.key === "," || e.key === " ") && - inputValue.trim() - ) { - e.preventDefault() - if (showSuggestions && suggestions.length > 0) { - addContact(suggestions[selectedSuggestionIdx]) - } else { - tryAddRawEmail(inputValue) - } - return - } - if (e.key === "Backspace" && !inputValue && contacts.length > 0) { - onChange(contacts.slice(0, -1)) - return - } - if (showSuggestions && suggestions.length > 0) { - if (e.key === "ArrowDown") { - e.preventDefault() - setSelectedSuggestionIdx((i) => - i < suggestions.length - 1 ? i + 1 : 0 - ) - } else if (e.key === "ArrowUp") { - e.preventDefault() - setSelectedSuggestionIdx((i) => - i > 0 ? i - 1 : suggestions.length - 1 - ) - } - } - if (e.key === "Escape") { - setShowSuggestions(false) - } - } - - const getInitials = (name: string) => { - const parts = name.split(" ").filter(Boolean) - return parts.length >= 2 - ? (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() - : (parts[0]?.[0] ?? "").toUpperCase() - } - - const pillColors = [ - "bg-blue-600", - "bg-purple-600", - "bg-emerald-600", - "bg-amber-600", - "bg-rose-600", - "bg-teal-600", - "bg-indigo-600", - ] - - const getColor = (email: string) => { - let hash = 0 - for (let i = 0; i < email.length; i++) { - hash = email.charCodeAt(i) + ((hash << 5) - hash) - } - return pillColors[Math.abs(hash) % pillColors.length] - } - - return ( -
-
{ - inputRef.current?.focus() - onActivate?.() - }} - > - {label} - {contacts.map((c) => ( - - - {getInitials(c.name)} - - - {c.name === c.email ? c.email : c.name} - - - - ))} - { - setInputValue(e.target.value) - setShowSuggestions(true) - }} - onKeyDown={handleKeyDown} - onFocus={() => { - setShowSuggestions(true) - onActivate?.() - }} - onBlur={() => { - setTimeout(() => { - setShowSuggestions(false) - if (inputValue.trim()) tryAddRawEmail(inputValue) - }, 200) - }} - placeholder={contacts.length === 0 ? placeholder : undefined} - className="min-w-[120px] flex-1 border-none bg-transparent py-1 text-sm text-[#202124] outline-none placeholder:text-[#80868b]" - /> -
- {showSuggestions && suggestions.length > 0 && ( -
- {suggestions.map((s, idx) => ( - - ))} -
- )} -
- ) -} - -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" }) && "bg-[#e8eaed]")} - > - 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 }) && "bg-[#e8eaed]" - )} - > - {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 }) && "bg-[#e8eaed]" - )} - > - {s.label} - - ))} - - - ) -} - -function ColorDropdown({ - editor, - btnClass, -}: { - editor: NonNullable> - btnClass: string -}) { - const [tab, setTab] = useState<"text" | "bg">("text") - - return ( - - - - - e.preventDefault()} - > -
- - -
-
- {TEXT_COLORS.map((color) => ( -
- -
-
- ) -} - -function FormattingToolbar({ - editor, -}: { - editor: Editor | null -}) { - if (!editor) return null - - const btnClass = - "flex h-7 w-7 items-center justify-center rounded hover:bg-[#f1f3f4] text-[#5f6368] transition-colors disabled:opacity-40" - const activeClass = "bg-[#e8eaed] text-[#202124]" - const sep = - - return ( -
- {/* Undo / Redo */} - - - - {sep} - - {/* Font */} - - - {sep} - - {/* Font size */} - - - {sep} - - {/* Bold, Italic, Underline, Colors */} - - - - - - {sep} - - {/* Alignment dropdown, lists, indent/outdent, remove formatting */} - - - - - - -
- ) -} - -function EmojiButton({ - 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 LinkButton({ - 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 ? ( - - ) : ( - - )} -
- - -
-
-
-
-
- ) -} - -function SignatureButton({ - 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} - - ))} - - - ) -} - -interface ComposeRecipientFieldsProps { - compose: ComposeState - isInline: boolean - showFromField: boolean - updateCompose: (id: string, patch: Partial) => void - handleIdentityChange: (identity: (typeof DEFAULT_IDENTITIES)[number]) => void - clearFocusToMount: () => void - subjectInputRef: React.RefObject - onRecipientsActivate: () => void -} - -function ComposeRecipientFields({ - compose, - isInline, - showFromField, - updateCompose, - handleIdentityChange, - clearFocusToMount, - subjectInputRef, - onRecipientsActivate, -}: ComposeRecipientFieldsProps) { - const dockNewMessageTabOrder = - !isInline && !compose.threadEmailId && !compose.threadKind - const forwardDockSkipSubjectTab = - !isInline && compose.threadKind === "forward" - - return ( - <> - {showFromField && ( -
- De - - - - - - {DEFAULT_IDENTITIES.map((id) => ( - handleIdentityChange(id)} - > -
- {id.name} - - {id.email} - -
-
- ))} -
-
-
- )} - {showFromField && !isInline && ( -
- )} - -
-
- 0 ? "À" : "Destinataires"} - contacts={compose.to} - onChange={(to) => updateCompose(compose.id, { to })} - onActivate={onRecipientsActivate} - autoFocus={Boolean(compose.focusToOnMount)} - onAutoFocusDone={clearFocusToMount} - /> -
- {showFromField && (!compose.showCc || !compose.showBcc) && ( -
- {!compose.showCc && ( - - )} - {!compose.showBcc && ( - - )} -
- )} -
- {!isInline &&
} - - {compose.showCc && ( - <> - updateCompose(compose.id, { cc })} - /> - {!isInline &&
} - - )} - - {compose.showBcc && ( - <> - updateCompose(compose.id, { bcc })} - /> - {!isInline &&
} - - )} - - {!isInline && ( - <> - - updateCompose(compose.id, { subject: e.target.value }) - } - placeholder="Objet" - tabIndex={forwardDockSkipSubjectTab ? -1 : undefined} - className="h-8 w-full border-none bg-transparent px-3 text-sm text-[#202124] outline-none placeholder:text-[#80868b]" - /> -
- - )} - - ) -} + 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, @@ -1299,7 +137,7 @@ export function ComposeWindow({ editorProps: { attributes: { class: cn( - "prose prose-sm max-w-none px-3 py-2 text-sm text-[#202124] outline-none focus:outline-none", + "prose prose-sm max-w-none px-3 py-2 text-sm text-foreground outline-none focus:outline-none", isInline ? "min-h-[200px]" : isXsSheet @@ -1746,223 +584,42 @@ export function ComposeWindow({ /> {/* Drop overlay */} - {isDragOver && ( -
-
- -

Déposer les fichiers ici

-
-
- )} + {isDragOver ? : null} {isInline ? ( -
-
- - - - - e.preventDefault()} - > - openInlinePreset("reply")} - > - - Répondre - - {showReplyAllInMenu ? ( - openInlinePreset("replyAll")} - > - - Répondre à tous - - ) : null} - openInlinePreset("forward")} - > - - Transférer - - - openDockFromInline({ focusSubject: true })}> - - Modifier l'objet - - openDockFromInline()}> - - Ouvrir une fenêtre de réponse - - - - - - - {!recipientsFocused && (!compose.showCc || !compose.showBcc) ? ( -
- {!compose.showCc ? ( - - ) : null} - {!compose.showBcc ? ( - - ) : null} -
- ) : null} - - -
-
- -
-
+ setRecipientsFocused(true)} + updateCompose={updateCompose} + recipientFieldsProps={recipientFieldsProps} + fieldsRef={fieldsRef} + inlineRecipientShellRef={inlineRecipientShellRef} + /> ) : isXsSheet ? ( -
- - {titleText} - - -
+ ) : ( <> {/* Title bar */} -
toggleMinimize(compose.id)} - > - - {titleText} - -
- - - -
-
+ toggleMinimize(compose.id)} + onMaximize={() => toggleMaximize(compose.id)} + onClose={handleClose} + /> )} {!isInline && ( -
- -
+
+ +
)} {/* Editor */} @@ -1970,222 +627,30 @@ export function ComposeWindow({
- {/* Attachments */} - {compose.attachments.length > 0 && ( -
- {compose.attachments.map((att) => ( -
- {att.type.startsWith("image/") ? ( - - ) : ( - - )} - - {att.name} - - - {att.size < 1024 - ? `${att.size} o` - : att.size < 1048576 - ? `${(att.size / 1024).toFixed(1)} Ko` - : `${(att.size / 1048576).toFixed(1)} Mo`} - - -
- ))} -
- )} + - {/* Formatting toolbar (toggle) */} - {showFormatting && } + {showFormatting ? : null} - {/* Bottom toolbar */} -
- {/* 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-editor-chrome.tsx b/components/gmail/compose/compose-editor-chrome.tsx new file mode 100644 index 0000000..217ff98 --- /dev/null +++ b/components/gmail/compose/compose-editor-chrome.tsx @@ -0,0 +1,356 @@ +"use client" + +import type { RefObject } from "react" +import { + ChevronDown, + Forward, + Maximize2, + Minimize2, + Paperclip, + Pencil, + Reply, + ReplyAll, + SquareArrowOutUpRight, + X, + Image as ImageIcon, +} from "lucide-react" +import type { ComposeState } from "@/lib/compose-context" +import type { Email } from "@/lib/email-data" +import { cn } from "@/lib/utils" +import { + MAIL_COMPOSE_DROP_ZONE_CLASS, + MAIL_COMPOSE_TITLEBAR_CLASS, + MAIL_ICON_BTN, +} from "@/lib/mail-chrome-classes" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { COMPOSE_PORTAL_Z } from "./compose-shared" +import type { ComposeRecipientFieldsProps } from "./compose-recipients" +import { ComposeRecipientFields } from "./compose-recipients" + +export function ComposeDropOverlay() { + return ( +
+
+ +

Déposer les fichiers ici

+
+
+ ) +} + +export interface ComposeInlineRecipientHeaderProps { + compose: ComposeState + threadSourceEmail: Email | null + recipientSummary: string + recipientsFocused: boolean + showReplyAllInMenu: boolean + ThreadKindIcon: typeof Reply + onOpenInlinePreset: (kind: "reply" | "replyAll" | "forward") => void + onOpenDockFromInline: (opts?: { focusSubject?: boolean }) => void + onActivateRecipients: () => void + updateCompose: (id: string, patch: Partial) => void + recipientFieldsProps: ComposeRecipientFieldsProps + fieldsRef: RefObject + inlineRecipientShellRef: RefObject +} + +export function ComposeInlineRecipientHeader({ + compose, + threadSourceEmail, + recipientSummary, + recipientsFocused, + showReplyAllInMenu, + ThreadKindIcon, + onOpenInlinePreset, + onOpenDockFromInline, + onActivateRecipients, + updateCompose, + recipientFieldsProps, + fieldsRef, + inlineRecipientShellRef, +}: ComposeInlineRecipientHeaderProps) { + return ( +
+
+ + + + + e.preventDefault()} + > + onOpenInlinePreset("reply")} + > + + Répondre + + {showReplyAllInMenu ? ( + onOpenInlinePreset("replyAll")} + > + + Répondre à tous + + ) : null} + onOpenInlinePreset("forward")} + > + + Transférer + + + onOpenDockFromInline({ focusSubject: true })}> + + Modifier l'objet + + onOpenDockFromInline()}> + + Ouvrir une fenêtre de réponse + + + + + + + {!recipientsFocused && (!compose.showCc || !compose.showBcc) ? ( +
+ {!compose.showCc ? ( + + ) : null} + {!compose.showBcc ? ( + + ) : null} +
+ ) : null} + + +
+
+ +
+
+ ) +} + +export function ComposeXsSheetHeader({ + titleText, + onClose, +}: { + titleText: string + onClose: () => void +}) { + return ( +
+ + {titleText} + + +
+ ) +} + +export function ComposeDockTitleBar({ + titleText, + maximized, + onMinimize, + onMaximize, + onClose, +}: { + titleText: string + maximized: boolean + onMinimize: () => void + onMaximize: () => void + onClose: () => void +}) { + return ( +
+ + {titleText} + +
+ + + +
+
+ ) +} + +export function ComposeAttachmentsList({ + attachments, + onRemove, +}: { + attachments: ComposeState["attachments"] + onRemove: (attId: string) => void +}) { + if (attachments.length === 0) return null + + return ( +
+ {attachments.map((att) => ( +
+ {att.type.startsWith("image/") ? ( + + ) : ( + + )} + + {att.name} + + + {att.size < 1024 + ? `${att.size} o` + : att.size < 1048576 + ? `${(att.size / 1024).toFixed(1)} Ko` + : `${(att.size / 1048576).toFixed(1)} Mo`} + + +
+ ))} +
+ ) +} diff --git a/components/gmail/compose/compose-recipients.tsx b/components/gmail/compose/compose-recipients.tsx new file mode 100644 index 0000000..aba0020 --- /dev/null +++ b/components/gmail/compose/compose-recipients.tsx @@ -0,0 +1,405 @@ +"use client" + +import { + useState, + useRef, + useEffect, + useCallback, + useMemo, + type RefObject, +} from "react" +import { ChevronDown, X } from "lucide-react" +import { + type ComposeState, + type Contact, + DEFAULT_IDENTITIES, + MOCK_CONTACTS, +} from "@/lib/compose-context" +import { cn } from "@/lib/utils" +import { + MAIL_COMPOSE_CONTACT_PILL_CLASS, + MAIL_COMPOSE_RECIPIENT_DIVIDER, + MAIL_COMPOSE_SUGGESTION_HOVER, + MAIL_COMPOSE_SUGGESTION_SELECTED, +} from "@/lib/mail-chrome-classes" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { COMPOSE_PORTAL_Z, EMAIL_REGEX } from "./compose-shared" + +function RecipientField({ + label, + contacts, + onChange, + placeholder, + onActivate, + autoFocus, + onAutoFocusDone, +}: { + label: string + contacts: Contact[] + onChange: (contacts: Contact[]) => void + placeholder?: string + onActivate?: () => void + autoFocus?: boolean + onAutoFocusDone?: () => void +}) { + const [inputValue, setInputValue] = useState("") + const [showSuggestions, setShowSuggestions] = useState(false) + const [selectedSuggestionIdx, setSelectedSuggestionIdx] = useState(0) + const inputRef = useRef(null) + const containerRef = useRef(null) + + const suggestions = useMemo(() => { + if (!inputValue.trim()) return [] + const q = inputValue.toLowerCase() + return MOCK_CONTACTS.filter( + (c) => + !contacts.some((existing) => existing.email === c.email) && + (c.name.toLowerCase().includes(q) || c.email.toLowerCase().includes(q)) + ).slice(0, 6) + }, [inputValue, contacts]) + + useEffect(() => { + setSelectedSuggestionIdx(0) + }, [suggestions.length]) + + useEffect(() => { + if (!autoFocus) return + const id = window.requestAnimationFrame(() => { + inputRef.current?.focus() + onAutoFocusDone?.() + }) + return () => window.cancelAnimationFrame(id) + }, [autoFocus, onAutoFocusDone]) + + const addContact = useCallback( + (contact: Contact) => { + if (!contacts.some((c) => c.email === contact.email)) { + onChange([...contacts, contact]) + } + setInputValue("") + setShowSuggestions(false) + }, + [contacts, onChange] + ) + + const tryAddRawEmail = useCallback( + (raw: string) => { + const trimmed = raw.trim().replace(/,$/, "") + if (!trimmed) return + const matchedContact = MOCK_CONTACTS.find( + (c) => c.email.toLowerCase() === trimmed.toLowerCase() + ) + if (matchedContact) { + addContact(matchedContact) + } else if (EMAIL_REGEX.test(trimmed)) { + addContact({ name: trimmed, email: trimmed }) + } + }, + [addContact] + ) + + const removeContact = useCallback( + (email: string) => { + onChange(contacts.filter((c) => c.email !== email)) + }, + [contacts, onChange] + ) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if ( + (e.key === "Enter" || e.key === "Tab" || e.key === "," || e.key === " ") && + inputValue.trim() + ) { + e.preventDefault() + if (showSuggestions && suggestions.length > 0) { + addContact(suggestions[selectedSuggestionIdx]) + } else { + tryAddRawEmail(inputValue) + } + return + } + if (e.key === "Backspace" && !inputValue && contacts.length > 0) { + onChange(contacts.slice(0, -1)) + return + } + if (showSuggestions && suggestions.length > 0) { + if (e.key === "ArrowDown") { + e.preventDefault() + setSelectedSuggestionIdx((i) => + i < suggestions.length - 1 ? i + 1 : 0 + ) + } else if (e.key === "ArrowUp") { + e.preventDefault() + setSelectedSuggestionIdx((i) => + i > 0 ? i - 1 : suggestions.length - 1 + ) + } + } + if (e.key === "Escape") { + setShowSuggestions(false) + } + } + + const getInitials = (name: string) => { + const parts = name.split(" ").filter(Boolean) + return parts.length >= 2 + ? (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() + : (parts[0]?.[0] ?? "").toUpperCase() + } + + const pillColors = [ + "bg-blue-600", + "bg-purple-600", + "bg-emerald-600", + "bg-amber-600", + "bg-rose-600", + "bg-teal-600", + "bg-indigo-600", + ] + + const getColor = (email: string) => { + let hash = 0 + for (let i = 0; i < email.length; i++) { + hash = email.charCodeAt(i) + ((hash << 5) - hash) + } + return pillColors[Math.abs(hash) % pillColors.length] + } + + return ( +
+
{ + inputRef.current?.focus() + onActivate?.() + }} + > + {label} + {contacts.map((c) => ( + + + {getInitials(c.name)} + + + {c.name === c.email ? c.email : c.name} + + + + ))} + { + setInputValue(e.target.value) + setShowSuggestions(true) + }} + onKeyDown={handleKeyDown} + onFocus={() => { + setShowSuggestions(true) + onActivate?.() + }} + onBlur={() => { + setTimeout(() => { + setShowSuggestions(false) + if (inputValue.trim()) tryAddRawEmail(inputValue) + }, 200) + }} + placeholder={contacts.length === 0 ? placeholder : undefined} + className="min-w-[120px] flex-1 border-none bg-transparent py-1 text-sm text-foreground outline-none placeholder:text-muted-foreground" + /> +
+ {showSuggestions && suggestions.length > 0 && ( +
+ {suggestions.map((s, idx) => ( + + ))} +
+ )} +
+ ) +} + +export interface ComposeRecipientFieldsProps { + compose: ComposeState + isInline: boolean + showFromField: boolean + updateCompose: (id: string, patch: Partial) => void + handleIdentityChange: (identity: (typeof DEFAULT_IDENTITIES)[number]) => void + clearFocusToMount: () => void + subjectInputRef: RefObject + onRecipientsActivate: () => void +} + +export function ComposeRecipientFields({ + compose, + isInline, + showFromField, + updateCompose, + handleIdentityChange, + clearFocusToMount, + subjectInputRef, + onRecipientsActivate, +}: ComposeRecipientFieldsProps) { + const dockNewMessageTabOrder = + !isInline && !compose.threadEmailId && !compose.threadKind + const forwardDockSkipSubjectTab = + !isInline && compose.threadKind === "forward" + + return ( + <> + {showFromField && ( +
+ De + + + + + + {DEFAULT_IDENTITIES.map((id) => ( + handleIdentityChange(id)} + > +
+ {id.name} + {id.email} +
+
+ ))} +
+
+
+ )} + {showFromField && !isInline &&
} + +
+
+ 0 ? "À" : "Destinataires"} + contacts={compose.to} + onChange={(to) => updateCompose(compose.id, { to })} + onActivate={onRecipientsActivate} + autoFocus={Boolean(compose.focusToOnMount)} + onAutoFocusDone={clearFocusToMount} + /> +
+ {showFromField && (!compose.showCc || !compose.showBcc) && ( +
+ {!compose.showCc && ( + + )} + {!compose.showBcc && ( + + )} +
+ )} +
+ {!isInline &&
} + + {compose.showCc && ( + <> + updateCompose(compose.id, { cc })} + /> + {!isInline &&
} + + )} + + {compose.showBcc && ( + <> + updateCompose(compose.id, { bcc })} + /> + {!isInline &&
} + + )} + + {!isInline && ( + <> + + updateCompose(compose.id, { subject: e.target.value }) + } + placeholder="Objet" + tabIndex={forwardDockSkipSubjectTab ? -1 : undefined} + className="h-8 w-full border-none bg-transparent px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground" + /> +
+ + )} + + ) +} diff --git a/components/gmail/compose/compose-shared.ts b/components/gmail/compose/compose-shared.ts new file mode 100644 index 0000000..b270026 --- /dev/null +++ b/components/gmail/compose/compose-shared.ts @@ -0,0 +1,36 @@ +import { Node as TipTapNode, mergeAttributes } from "@tiptap/core" +import { SIGNATURES } from "@/lib/compose-context" + +/** Menus/popovers Radix default z-50 ; compose sheet content uses z-61+. */ +export const COMPOSE_PORTAL_Z = "z-[100]" + +export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +export const SignatureBlock = TipTapNode.create({ + name: "signatureBlock", + group: "block", + content: "block+", + defining: true, + isolating: true, + + parseHTML() { + return [{ tag: 'div[id="ultimail-signature"]' }] + }, + + renderHTML({ HTMLAttributes }) { + return ["div", mergeAttributes(HTMLAttributes, { id: "ultimail-signature" }), 0] + }, +}) + +const SIG_REGEX = /
[\s\S]*<\/div>/ + +export function stripSignature(html: string) { + return html.replace(SIG_REGEX, "") +} + +export function insertSignatureHtml(html: string, sigId: string | null) { + const sig = sigId ? SIGNATURES.find((s) => s.id === sigId) : null + const clean = stripSignature(html) + if (!sig) return clean + return clean + `

--

${sig.html}
` +} diff --git a/components/gmail/compose/compose-toolbar.tsx b/components/gmail/compose/compose-toolbar.tsx new file mode 100644 index 0000000..60800b3 --- /dev/null +++ b/components/gmail/compose/compose-toolbar.tsx @@ -0,0 +1,994 @@ +"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 */} +
+ + + + + + + + + +
+ +
+ + +
+ ) +} diff --git a/components/gmail/contacts/contact-form-view.tsx b/components/gmail/contacts/contact-form-view.tsx index ec38964..8df6f32 100644 --- a/components/gmail/contacts/contact-form-view.tsx +++ b/components/gmail/contacts/contact-form-view.tsx @@ -213,7 +213,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) { birthday: { day: undefined, month: undefined, year: undefined }, notes: "", labels: [], - }, { shouldDirty: true }) + }) clearCreateDraft() }, [mode, createDraft, reset, clearCreateDraft]) diff --git a/components/gmail/email-list.tsx b/components/gmail/email-list.tsx index 3d43e97..d05b846 100644 --- a/components/gmail/email-list.tsx +++ b/components/gmail/email-list.tsx @@ -206,423 +206,41 @@ import { withTouchFullscreenComposePreset, } from "@/lib/thread-compose-preset" -addCollection(mdiIcons) - -const LIST_PAGE_SIZE = 50 -const PULL_HOLD_HEIGHT = 48 -const PULL_REFRESH_THRESHOLD = 56 -const PULL_REFRESH_MAX = 112 -const PULL_SNAP_BACK_TRANSITION = - "transform 0.24s cubic-bezier(0.32, 0.72, 0, 1)" -const REFRESH_SPIN_CLASS = "animate-[spin_0.55s_linear_infinite]" -const PULL_ICON_FADE_MS = 120 -/** Tirage (px) avant que le spinner ne devienne visible. */ -const PULL_SPINNER_REVEAL_OFFSET = 26 - -function computePullOffset(delta: number): number { - if (delta <= 0) return 0 - const damped = delta * 0.48 - const capped = Math.min(PULL_REFRESH_MAX, damped) - const ratio = capped / PULL_REFRESH_MAX - return capped * (1 - ratio * 0.12) -} - -function computeSpinnerRevealProgress(y: number): number { - if (y <= PULL_SPINNER_REVEAL_OFFSET) return 0 - const range = Math.max(1, PULL_REFRESH_THRESHOLD - PULL_SPINNER_REVEAL_OFFSET) - return Math.min(1, ((y - PULL_SPINNER_REVEAL_OFFSET) / range) * 1.35) -} - -/** Libellés système qu’on ne propose pas dans « Ajouter le libellé ». */ -const LABEL_PICKER_EXCLUDE = new Set(["inbox", "sent", "drafts", "spam", "starred"]) - -function collectTreeLabels(nodes: FolderTreeNode[]): string[] { - const out: string[] = [] - for (const n of nodes) { - out.push(n.label) - if (n.children?.length) out.push(...collectTreeLabels(n.children)) - } - return out -} - -function formatScheduledDateTimeDisplay(iso: string | undefined): string { - if (!iso) return "—" - return formatMailDetailDate(iso) -} - -function scheduledIsoToDatetimeLocalValue(iso: string | undefined): string { - if (!iso) return "" - const d = new Date(iso) - if (Number.isNaN(d.getTime())) return "" - const p = (n: number) => String(n).padStart(2, "0") - return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}` -} - -function parseDatetimeLocalToIso(value: string): string | null { - const d = new Date(value) - if (Number.isNaN(d.getTime())) return null - return d.toISOString() -} - -/** Cibles du clic droit : sélection courante ou ligne seule ; en Planifié, seulement les ids réellement planifiés. */ -function contextMenuTargetIdsForRow( - emailId: string, - selectedEmails: string[], - selectedFolder: string, - pool: Email[] -): string[] { - const raw = selectedEmails.includes(emailId) ? selectedEmails : [emailId] - if (selectedFolder !== "scheduled") return raw - const onlyScheduled = raw.filter((id) => - pool.some((e) => e.id === id && e.labels?.includes("scheduled")) - ) - return onlyScheduled.length > 0 ? onlyScheduled : [emailId] -} - -function applyNavRenameToEdits( - pool: Email[], - prev: LabelEditState, - from: string, - to: string -): LabelEditState { - const lcFrom = from.toLowerCase() - const toTrim = to.trim() - if (!toTrim) return prev - const nextAdd = { ...prev.additions } - const nextRem = { ...prev.removals } - for (const e of pool) { - const id = e.id - const eff = effectiveLabels(e, prev.additions, prev.removals) - if (!eff.some((l) => l.toLowerCase() === lcFrom)) continue - const wanted = eff.map((l) => (l.toLowerCase() === lcFrom ? toTrim : l)) - delete nextAdd[id] - delete nextRem[id] - const base = e.labels ?? [] - const removals = base.filter( - (b) => !wanted.some((w) => w.toLowerCase() === b.toLowerCase()) - ) - const additions = wanted.filter( - (w) => !base.some((b) => b.toLowerCase() === w.toLowerCase()) - ) - if (removals.length) nextRem[id] = removals - if (additions.length) nextAdd[id] = additions - } - return { additions: nextAdd, removals: nextRem } -} - -function applyNavRemoveLabelToEdits( - pool: Email[], - prev: LabelEditState, - label: string -): LabelEditState { - const lc = label.toLowerCase() - const nextAdd = { ...prev.additions } - const nextRem = { ...prev.removals } - for (const e of pool) { - const id = e.id - const eff = effectiveLabels(e, prev.additions, prev.removals) - if (!eff.some((l) => l.toLowerCase() === lc)) continue - const wanted = eff.filter((l) => l.toLowerCase() !== lc) - delete nextAdd[id] - delete nextRem[id] - const base = e.labels ?? [] - const removals = base.filter( - (b) => !wanted.some((w) => w.toLowerCase() === b.toLowerCase()) - ) - const additions = wanted.filter( - (w) => !base.some((b) => b.toLowerCase() === w.toLowerCase()) - ) - if (removals.length) nextRem[id] = removals - if (additions.length) nextAdd[id] = additions - } - return { additions: nextAdd, removals: nextRem } -} - - -function escapeHtml(text: string): string { - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) -} - -function importantSignalIcon(isSpam: boolean, isImportant: boolean): string { - if (isSpam) return "mdi:flag-outline" - if (isImportant) return "mdi:label-variant" - return "mdi:label-variant-outline" -} - -type InboxTabBarItem = { - id: string - label: string - icon: string - badgeColor: string -} - -function buildInboxTabBarItems(labelRows: readonly LabelRowItem[]): InboxTabBarItem[] { - return [ - ...buildInboxCategoryTabIcons(labelRows), - { - id: INBOX_ALL_TAB, - label: "Tous les messages", - icon: "mdi:inbox", - badgeColor: "bg-[#0b57d0]", - }, - ] -} - -function inboxTabBadgeCountClass(badgeColor: string) { - return cn( - "shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium leading-none", - badgeColor, - labelPillTextClassForTailwindBgUtility(badgeColor) - ) -} - -function inboxTabBadgeDotClass(badgeColor: string) { - return cn( - "absolute -right-0.5 -top-0.5 size-2 rounded-full ring-2 ring-mail-surface", - badgeColor - ) -} - -const CATEGORY_TAB_ICON_CLASS = "h-4 w-4 shrink-0" -function ListAttachmentChip({ att }: { att: EmailAttachment }) { - return ( - - {att.kind === "pdf" ? ( - - ) : att.kind === "image" ? ( - - ) : ( - - )} - {att.name} - - ) -} - -function EmailListAttachmentRow({ - emailId, - attachments, -}: { - emailId: string - attachments: EmailAttachment[] -}) { - const containerRef = useRef(null) - const measureRef = useRef(null) - const [collapsed, setCollapsed] = useState(false) - const attachSig = attachments.map((a) => `${a.name}\u0001${a.kind ?? ""}`).join("\u0002") - - const updateCollapsed = useCallback(() => { - const container = containerRef.current - const measure = measureRef.current - if (!container || !measure || attachments.length <= 1) { - setCollapsed(false) - return - } - const available = container.clientWidth - const needed = measure.scrollWidth - setCollapsed(needed > available + 1) - }, [attachSig, attachments.length]) - - useLayoutEffect(() => { - updateCollapsed() - }, [updateCollapsed]) - - useEffect(() => { - const el = containerRef.current - if (!el || typeof ResizeObserver === "undefined") return - const ro = new ResizeObserver(() => updateCollapsed()) - ro.observe(el) - return () => ro.disconnect() - }, [updateCollapsed]) - - const othersLabel = - attachments.length - 1 === 1 ? "1 autre" : `${attachments.length - 1} autres` - const othersTitle = attachments - .slice(1) - .map((a) => a.name) - .join(", ") - - return ( -
- {attachments.length > 1 && ( -
- {attachments.map((att, idx) => ( - - ))} -
- )} -
- {collapsed && attachments.length > 1 ? ( - <> - - - {othersLabel} - - - ) : ( - attachments.map((att, idx) => ( - - )) - )} -
-
- ) -} - -function MoveToDropdownItems({ - targets, - onMoveTo, -}: { - targets: { recents: MoveTarget[]; system: MoveTarget[]; folders: MoveTarget[] } - onMoveTo: (targetId: string) => void -}) { - return ( - <> - {targets.recents.length > 0 && ( - <> -
- Récents -
- {targets.recents.map((t) => ( - onMoveTo(t.id)}> - - {t.icon} - - - {t.label} - - ))} - - - )} - {targets.system.map((t) => ( - onMoveTo(t.id)}> - {t.icon} - {t.label} - - ))} - {targets.folders.length > 0 && ( - <> - -
- Dossiers -
- {targets.folders.map((t) => ( - onMoveTo(t.id)} - style={{ paddingLeft: `${12 + t.depth * 16}px` }} - > - {t.icon} - {t.label} - - ))} - - )} - - ) -} - -function MoveToContextMenuItems({ - targets, - onMoveTo, -}: { - targets: { recents: MoveTarget[]; system: MoveTarget[]; folders: MoveTarget[] } - onMoveTo: (targetId: string) => void -}) { - return ( - <> - {targets.recents.length > 0 && ( - <> -
- Récents -
- {targets.recents.map((t) => ( - onMoveTo(t.id)}> - - {t.icon} - - - {t.label} - - ))} - - - )} - {targets.system.map((t) => ( - onMoveTo(t.id)}> - {t.icon} - {t.label} - - ))} - {targets.folders.length > 0 && ( - <> - -
- Dossiers -
- {targets.folders.map((t) => ( - onMoveTo(t.id)} - style={{ paddingLeft: `${12 + t.depth * 16}px` }} - > - {t.icon} - {t.label} - - ))} - - )} - - ) -} - -interface EmailListProps { - selectedFolder: string - /** Onglet catégories (boîte de réception), depuis l’URL. */ - inboxTab: string - /** Page de liste (1-based), depuis l’URL. */ - listPage: number - openMailId: string | null - /** md+ split pane: list left, reading pane right (tablet landscape or user setting). */ - splitView?: boolean - onToggleSidebar?: () => void - onMailRouteNavigate: (patch: Partial) => void - onSelectFolder?: (folder: string) => void - onFolderUnreadCountsChange?: (counts: Record) => void - /** Barre basse xs en lecture d’un message. */ - onXsViewChromeChange?: (chrome: MailXsViewChrome | null) => void -} - -function listRowCheckboxClass(circular: boolean) { - return cn( - "size-4 min-h-4 min-w-4 shrink-0 border-[1.5px] border-[#c2c2c2] bg-transparent shadow-none dark:bg-transparent focus-visible:ring-[#c2c2c2]/30 data-[state=checked]:border-[#1a73e8] data-[state=checked]:bg-[#1a73e8] data-[state=checked]:text-white", - circular ? "rounded-full" : "rounded-[2.5px]" - ) -} - -function listRowQuickHoverTrayToneClass(isSelected: boolean, isRead: boolean) { - return isSelected - ? "bg-mail-row-selected" - : isRead - ? "bg-mail-row-read" - : "bg-mail-row-unread" -} +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, @@ -3629,7 +3247,7 @@ export function EmailList({ ) : (
diff --git a/components/gmail/email-list/attachments/email-list-attachment-row.tsx b/components/gmail/email-list/attachments/email-list-attachment-row.tsx new file mode 100644 index 0000000..f55064c --- /dev/null +++ b/components/gmail/email-list/attachments/email-list-attachment-row.tsx @@ -0,0 +1,88 @@ +"use client" + +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react" +import type { EmailAttachment } from "@/lib/email-data" +import { ListAttachmentChip } from "./list-attachment-chip" + +export function EmailListAttachmentRow({ + emailId, + attachments, +}: { + emailId: string + attachments: EmailAttachment[] +}) { + const containerRef = useRef(null) + const measureRef = useRef(null) + const [collapsed, setCollapsed] = useState(false) + const attachSig = attachments.map((a) => `${a.name}\u0001${a.kind ?? ""}`).join("\u0002") + + const updateCollapsed = useCallback(() => { + const container = containerRef.current + const measure = measureRef.current + if (!container || !measure || attachments.length <= 1) { + setCollapsed(false) + return + } + const available = container.clientWidth + const needed = measure.scrollWidth + setCollapsed(needed > available + 1) + }, [attachSig, attachments.length]) + + useLayoutEffect(() => { + updateCollapsed() + }, [updateCollapsed]) + + useEffect(() => { + const el = containerRef.current + if (!el || typeof ResizeObserver === "undefined") return + const ro = new ResizeObserver(() => updateCollapsed()) + ro.observe(el) + return () => ro.disconnect() + }, [updateCollapsed]) + + const othersLabel = + attachments.length - 1 === 1 ? "1 autre" : `${attachments.length - 1} autres` + const othersTitle = attachments + .slice(1) + .map((a) => a.name) + .join(", ") + + return ( +
+ {attachments.length > 1 && ( +
+ {attachments.map((att, idx) => ( + + ))} +
+ )} +
+ {collapsed && attachments.length > 1 ? ( + <> + + + {othersLabel} + + + ) : ( + attachments.map((att, idx) => ( + + )) + )} +
+
+ ) +} diff --git a/components/gmail/email-list/attachments/list-attachment-chip.tsx b/components/gmail/email-list/attachments/list-attachment-chip.tsx new file mode 100644 index 0000000..03d4634 --- /dev/null +++ b/components/gmail/email-list/attachments/list-attachment-chip.tsx @@ -0,0 +1,23 @@ +"use client" + +import { File, Image as ImageIcon } from "lucide-react" +import type { EmailAttachment } from "@/lib/email-data" + +export function ListAttachmentChip({ att }: { att: EmailAttachment }) { + return ( + + {att.kind === "pdf" ? ( + + ) : att.kind === "image" ? ( + + ) : ( + + )} + {att.name} + + ) +} diff --git a/components/gmail/email-list/email-list-helpers.ts b/components/gmail/email-list/email-list-helpers.ts new file mode 100644 index 0000000..c1c58b8 --- /dev/null +++ b/components/gmail/email-list/email-list-helpers.ts @@ -0,0 +1,145 @@ +import { cn } from "@/lib/utils" +import { labelPillTextClassForTailwindBgUtility } from "@/lib/label-pill-contrast" +import { buildInboxCategoryTabIcons } from "@/lib/inbox-category-tabs" + +type InboxTabBarItem = { + id: string + label: string + icon: string + badgeColor: string +} +import { INBOX_ALL_TAB } from "@/lib/mail-url" +import type { Email } from "@/lib/email-data" +import type { FolderTreeNode, LabelRowItem } from "@/lib/sidebar-nav-data" +import type { MailRouteState } from "@/lib/mail-url" +import { formatMailDetailDate } from "@/lib/mail-date" +import { + MAIL_LIST_ROW_CHECKBOX_CIRCULAR_CLASS, + MAIL_LIST_ROW_CHECKBOX_SQUARE_CLASS, +} from "@/lib/mail-chrome-classes" + +export const LIST_PAGE_SIZE = 50 + +export { + PULL_HOLD_HEIGHT, + PULL_REFRESH_THRESHOLD, + PULL_REFRESH_MAX, + PULL_SNAP_BACK_TRANSITION, + REFRESH_SPIN_CLASS, + PULL_ICON_FADE_MS, + PULL_SPINNER_REVEAL_OFFSET, + computePullOffset, + computeSpinnerRevealProgress, +} from "@/hooks/use-mail-list-pull-refresh" + +export interface EmailListProps { + selectedFolder: string + inboxTab: string + listPage: number + openMailId: string | null + splitView?: boolean + onToggleSidebar?: () => void + onMailRouteNavigate: (patch: Partial) => void + onSelectFolder?: (folder: string) => void + onFolderUnreadCountsChange?: (counts: Record) => void + onXsViewChromeChange?: (chrome: import("@/lib/mail-xs-view-chrome").MailXsViewChrome | null) => void +} + +export function collectTreeLabels(nodes: FolderTreeNode[]): string[] { + const out: string[] = [] + for (const n of nodes) { + out.push(n.label) + if (n.children?.length) out.push(...collectTreeLabels(n.children)) + } + return out +} + +export function formatScheduledDateTimeDisplay(iso: string | undefined): string { + if (!iso) return "—" + return formatMailDetailDate(iso) +} + +export function scheduledIsoToDatetimeLocalValue(iso: string | undefined): string { + if (!iso) return "" + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return "" + const p = (n: number) => String(n).padStart(2, "0") + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}` +} + +export function parseDatetimeLocalToIso(value: string): string | null { + const d = new Date(value) + if (Number.isNaN(d.getTime())) return null + return d.toISOString() +} + +export function contextMenuTargetIdsForRow( + emailId: string, + selectedEmails: string[], + selectedFolder: string, + pool: Email[] +): string[] { + const raw = selectedEmails.includes(emailId) ? selectedEmails : [emailId] + if (selectedFolder !== "scheduled") return raw + const onlyScheduled = raw.filter((id) => + pool.some((e) => e.id === id && e.labels?.includes("scheduled")) + ) + return onlyScheduled.length > 0 ? onlyScheduled : [emailId] +} + +export function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) +} + +export function importantSignalIcon(isSpam: boolean, isImportant: boolean): string { + if (isSpam) return "mdi:flag-outline" + if (isImportant) return "mdi:label-variant" + return "mdi:label-variant-outline" +} + +export function buildInboxTabBarItems(labelRows: readonly LabelRowItem[]): InboxTabBarItem[] { + return [ + ...buildInboxCategoryTabIcons(labelRows), + { + id: INBOX_ALL_TAB, + label: "Tous les messages", + icon: "mdi:inbox", + badgeColor: "bg-[#0b57d0]", + }, + ] +} + +export function inboxTabBadgeCountClass(badgeColor: string) { + return cn( + "shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium leading-none", + badgeColor, + labelPillTextClassForTailwindBgUtility(badgeColor) + ) +} + +export function inboxTabBadgeDotClass(badgeColor: string) { + return cn( + "absolute -right-0.5 -top-0.5 size-2 rounded-full ring-2 ring-mail-surface", + badgeColor + ) +} + +export const CATEGORY_TAB_ICON_CLASS = "h-4 w-4 shrink-0" + +export function listRowCheckboxClass(circular: boolean) { + return circular + ? MAIL_LIST_ROW_CHECKBOX_CIRCULAR_CLASS + : MAIL_LIST_ROW_CHECKBOX_SQUARE_CLASS +} + +export function listRowQuickHoverTrayToneClass(isSelected: boolean, isRead: boolean) { + return isSelected + ? "bg-mail-row-selected" + : isRead + ? "bg-mail-row-read" + : "bg-mail-row-unread" +} diff --git a/components/gmail/email-list/index.tsx b/components/gmail/email-list/index.tsx new file mode 100644 index 0000000..bf24c0c --- /dev/null +++ b/components/gmail/email-list/index.tsx @@ -0,0 +1 @@ +export { EmailList } from "@/components/gmail/email-list" diff --git a/components/gmail/email-list/move-to-menu-items.tsx b/components/gmail/email-list/move-to-menu-items.tsx new file mode 100644 index 0000000..527286c --- /dev/null +++ b/components/gmail/email-list/move-to-menu-items.tsx @@ -0,0 +1,127 @@ +"use client" + +import type { ReactNode } from "react" +import { Clock } from "lucide-react" +import { + ContextMenuItem, + ContextMenuSeparator, +} from "@/components/ui/context-menu" +import { + DropdownMenuItem, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu" +import type { MoveTarget } from "@/components/gmail/move-to-menu-items" + +export type MoveToTargets = { + recents: MoveTarget[] + system: MoveTarget[] + folders: MoveTarget[] +} + +function MoveToSectionHeader({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ) +} + +export function MoveToDropdownItems({ + targets, + onMoveTo, +}: { + targets: MoveToTargets + onMoveTo: (targetId: string) => void +}) { + return ( + <> + {targets.recents.length > 0 && ( + <> + Récents + {targets.recents.map((t) => ( + onMoveTo(t.id)}> + + {t.icon} + + + {t.label} + + ))} + + + )} + {targets.system.map((t) => ( + onMoveTo(t.id)}> + {t.icon} + {t.label} + + ))} + {targets.folders.length > 0 && ( + <> + + Dossiers + {targets.folders.map((t) => ( + onMoveTo(t.id)} + style={{ paddingLeft: `${12 + t.depth * 16}px` }} + > + {t.icon} + {t.label} + + ))} + + )} + + ) +} + +export function MoveToContextMenuItems({ + targets, + onMoveTo, +}: { + targets: MoveToTargets + onMoveTo: (targetId: string) => void +}) { + return ( + <> + {targets.recents.length > 0 && ( + <> + Récents + {targets.recents.map((t) => ( + onMoveTo(t.id)}> + + {t.icon} + + + {t.label} + + ))} + + + )} + {targets.system.map((t) => ( + onMoveTo(t.id)}> + {t.icon} + {t.label} + + ))} + {targets.folders.length > 0 && ( + <> + + Dossiers + {targets.folders.map((t) => ( + onMoveTo(t.id)} + style={{ paddingLeft: `${12 + t.depth * 16}px` }} + > + {t.icon} + {t.label} + + ))} + + )} + + ) +} diff --git a/components/gmail/email-view.tsx b/components/gmail/email-view.tsx index d70cae2..d69ce67 100644 --- a/components/gmail/email-view.tsx +++ b/components/gmail/email-view.tsx @@ -13,36 +13,12 @@ import { Reply, ReplyAll, Forward, - MoreVertical, - Printer, - ExternalLink, - ChevronDown, Info, - TriangleAlert, - Trash2, - Mail, - Ban, - ShieldAlert, - Fish, - Flag, - SlidersHorizontal, - Languages, - Download, - Code2, - MessageCircleWarning, HardDrive, File, FileText, Image as ImageIcon, } from "lucide-react" -import { Button } from "@/components/ui/button" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" import { Tooltip, TooltipContent, @@ -86,11 +62,9 @@ import { resolveParsedCalendarInvitation } from "@/lib/resolve-email-calendar-in import { ComposeWindow } from "@/components/gmail/compose-modal" import { CalendarInvitationPreview } from "@/components/gmail/calendar-invitation-preview" import { ContactHoverCard } from "./contact-hover-card" -import { MailLabelPillStrip } from "./mail-label-pills" +import { EmailViewSubjectHeader } from "./email-view/email-view-header" +import { EmailViewMessageToolbar } from "./email-view/email-view-toolbar" import { - MAIL_ICON_BTN, - MAIL_INVITATION_CARD_CLASS, - MAIL_MENU_SURFACE_WIDE_CLASS, MAIL_MESSAGE_HOVER_CLASS, MAIL_PREVIEW_SCROLL_CLASS, MAIL_REPLY_BAR_CLASS, @@ -102,7 +76,6 @@ import { emailPreviewBaseCss, emailPreviewDarkOverrideCss, emailPreviewLightOverrideCss, - emailPreviewSubjectCss, preprocessEmailHtmlForTheme, } from "@/lib/email-preview-dark-styles" @@ -128,19 +101,6 @@ interface EmailViewProps { isSingleMessageView?: boolean } -const LABEL_DISPLAY_NAMES: Record = { - inbox: "Boîte de réception", - starred: "Suivis", - snoozed: "En attente", - important: "Important", - sent: "Messages envoyés", - drafts: "Brouillons", - spam: "Spam", - trash: "Corbeille", -} - -const MESSAGE_MORE_MENU_CLASS = MAIL_MENU_SURFACE_WIDE_CLASS - const EMAIL_PREVIEW_IFRAME_STYLE: React.CSSProperties = { display: "block", background: "transparent", @@ -232,45 +192,6 @@ function SandboxedContent({ ) } -/* ── Sandboxed iframe for subject ── */ - -function SandboxedSubject({ text }: { text: string }) { - const iframeRef = useRef(null) - const { resolvedTheme } = useTheme() - - useEffect(() => { - const iframe = iframeRef.current - if (!iframe) return - const doc = iframe.contentDocument - if (!doc) return - - const isDark = documentIsDark() - - doc.open() - doc.write(` - - - - - - -${text.replace(//g, ">")} -`) - doc.close() - }, [text, resolvedTheme]) - - return ( -