From 88d8ab283dc24a47761f1f978cc629bec93368d8 Mon Sep 17 00:00:00 2001 From: R3D347HR4Y Date: Fri, 15 May 2026 17:40:17 +0200 Subject: [PATCH] First commit, not a small one --- .gitignore | 16 + app/apple-icon.png | Bin 0 -> 23130 bytes app/globals.css | 221 + app/icon.png | Bin 0 -> 2036 bytes app/layout.tsx | 28 + app/mail/[[...segments]]/page.tsx | 4 + app/mail/layout.tsx | 9 + app/mail/mail-app-shell.tsx | 209 + app/page.tsx | 17 + components.json | 21 + .../gmail/calendar-invitation-preview.tsx | 156 + components/gmail/compose-modal.tsx | 2217 ++++++++++ components/gmail/contact-hover-card.tsx | 168 + components/gmail/email-label-picker-block.tsx | 124 + components/gmail/email-list.tsx | 3833 +++++++++++++++++ components/gmail/email-view.tsx | 1003 +++++ components/gmail/header.tsx | 169 + .../gmail/mail-folder-stack-indicator.tsx | 94 + components/gmail/mail-label-pills.tsx | 266 ++ components/gmail/mobile-bottom-bar.tsx | 90 + components/gmail/mobile-xs-bulk-sheets.tsx | 202 + components/gmail/move-drag-indicator.tsx | 81 + components/gmail/move-to-menu-items.tsx | 85 + components/gmail/right-panel.tsx | 24 + components/gmail/sidebar.tsx | 2109 +++++++++ components/theme-provider.tsx | 11 + components/ui/accordion.tsx | 66 + components/ui/alert-dialog.tsx | 157 + components/ui/alert.tsx | 66 + components/ui/aspect-ratio.tsx | 11 + components/ui/avatar.tsx | 53 + components/ui/badge.tsx | 46 + components/ui/breadcrumb.tsx | 109 + components/ui/button-group.tsx | 83 + components/ui/button.tsx | 60 + components/ui/calendar.tsx | 213 + components/ui/card.tsx | 92 + components/ui/carousel.tsx | 241 ++ components/ui/chart.tsx | 351 ++ components/ui/checkbox.tsx | 38 + components/ui/collapsible.tsx | 33 + components/ui/command.tsx | 184 + components/ui/context-menu.tsx | 277 ++ components/ui/dialog.tsx | 143 + components/ui/drawer.tsx | 135 + components/ui/dropdown-menu.tsx | 259 ++ components/ui/empty.tsx | 104 + components/ui/field.tsx | 244 ++ components/ui/form.tsx | 167 + components/ui/hover-card.tsx | 44 + components/ui/input-group.tsx | 169 + components/ui/input-otp.tsx | 77 + components/ui/input.tsx | 26 + components/ui/item.tsx | 193 + components/ui/kbd.tsx | 28 + components/ui/label.tsx | 24 + components/ui/menubar.tsx | 276 ++ components/ui/navigation-menu.tsx | 166 + components/ui/pagination.tsx | 127 + components/ui/popover.tsx | 48 + components/ui/progress.tsx | 31 + components/ui/radio-group.tsx | 45 + components/ui/resizable.tsx | 56 + components/ui/scroll-area.tsx | 58 + components/ui/select.tsx | 185 + components/ui/separator.tsx | 28 + components/ui/sheet.tsx | 139 + components/ui/sidebar.tsx | 726 ++++ components/ui/skeleton.tsx | 13 + components/ui/slider.tsx | 59 + components/ui/sonner.tsx | 25 + components/ui/spinner.tsx | 16 + components/ui/switch.tsx | 29 + components/ui/table.tsx | 116 + components/ui/tabs.tsx | 66 + components/ui/textarea.tsx | 18 + components/ui/toast.tsx | 129 + components/ui/toaster.tsx | 35 + components/ui/toggle-group.tsx | 73 + components/ui/toggle.tsx | 47 + components/ui/tooltip.tsx | 61 + components/ui/use-mobile.tsx | 19 + components/ui/use-toast.ts | 191 + components/ultimail-logo.tsx | 47 + hooks/use-mobile.ts | 19 + hooks/use-toast.ts | 191 + hooks/use-xs.ts | 18 + lib/api/scheduled-mail.ts | 213 + lib/attachment-display.ts | 80 + lib/calendar-invitation.ts | 300 ++ lib/compose-context.tsx | 705 +++ lib/demo-calendar-invitation-emails.ts | 290 ++ lib/drag-context.tsx | 276 ++ lib/email-data.ts | 682 +++ lib/iconify-logos-vc-subset.json | 1 + lib/label-edits.ts | 39 + lib/label-pill-contrast.ts | 214 + lib/mail-folder-display.ts | 82 + lib/mail-folder-filter.ts | 136 + lib/mail-nav-icons.tsx | 66 + lib/mail-nav-metrics.ts | 120 + lib/mail-url.ts | 110 + lib/pending-send-toast.tsx | 177 + lib/print-conversation.ts | 174 + lib/register-vc-logos.ts | 33 + lib/resolve-email-calendar-invitation.ts | 34 + lib/scheduled-mail-context.tsx | 76 + lib/sender-display.ts | 28 + lib/sidebar-nav-context.tsx | 242 ++ lib/sidebar-nav-data.ts | 102 + lib/sidebar-nav-folder-ids.ts | 138 + lib/sidebar-nav-maps.ts | 89 + lib/stores/hydration.ts | 16 + lib/stores/mail-store.ts | 187 + lib/stores/nav-store.ts | 392 ++ lib/stores/scheduled-store.ts | 191 + lib/thread-compose-preset.ts | 262 ++ lib/utils.ts | 26 + next-env.d.ts | 6 + next.config.mjs | 11 + package.json | 96 + pnpm-lock.yaml | 3830 ++++++++++++++++ postcss.config.mjs | 8 + public/admin-mark.svg | 15 + public/agenda-mark.svg | 18 + public/apple-icon.png | Bin 0 -> 2626 bytes public/brand/ultimail-header-icon.png | Bin 0 -> 10904 bytes public/brand/ultimail-mark.jpg | Bin 0 -> 4147 bytes public/brand/ultimail-mark.png | Bin 0 -> 5408 bytes public/brand/ultimail-original.png | Bin 0 -> 802329 bytes public/brand/ultimail-wordmark-horizontal.jpg | Bin 0 -> 17055 bytes public/brand/ultimail-wordmark-horizontal.png | Bin 0 -> 21982 bytes public/brand/ultimail-wordmark-horizontal.svg | 40 + public/brand/ultimail-wordmark-stacked.jpg | Bin 0 -> 19792 bytes public/brand/ultimail-wordmark-stacked.png | Bin 0 -> 26492 bytes public/brand/ultimail-wordmark-stacked.svg | 82 + public/brand/utlimail-original.png | Bin 0 -> 802329 bytes public/compte-mark.svg | 5 + public/ground-news-mark.svg | 1 + public/icon-dark-32x32.png | Bin 0 -> 585 bytes public/icon-light-32x32.png | Bin 0 -> 566 bytes public/mistral-mark.svg | 15 + public/openstreetmap-mark.svg | 14 + public/photos-mark.svg | 18 + public/placeholder-logo.png | Bin 0 -> 568 bytes public/placeholder-logo.svg | 1 + public/placeholder-user.jpg | Bin 0 -> 1635 bytes public/placeholder.jpg | Bin 0 -> 1064 bytes public/placeholder.svg | 1 + public/qwant-mark.svg | 6 + public/ultidrive-mark.svg | 9 + public/ultimail-mark.svg | 11 + public/ultimeet-mark.svg | 19 + scripts/emit-ultimail-header-icon.mjs | 232 + scripts/rasterize-ultimail-brand.mjs | 47 + scripts/vectorize-ultimail-brand.mjs | 184 + styles/globals.css | 125 + tsconfig.json | 41 + tsconfig.tsbuildinfo | 1 + 159 files changed, 28224 insertions(+) create mode 100644 .gitignore create mode 100644 app/apple-icon.png create mode 100644 app/globals.css create mode 100644 app/icon.png create mode 100644 app/layout.tsx create mode 100644 app/mail/[[...segments]]/page.tsx create mode 100644 app/mail/layout.tsx create mode 100644 app/mail/mail-app-shell.tsx create mode 100644 app/page.tsx create mode 100644 components.json create mode 100644 components/gmail/calendar-invitation-preview.tsx create mode 100644 components/gmail/compose-modal.tsx create mode 100644 components/gmail/contact-hover-card.tsx create mode 100644 components/gmail/email-label-picker-block.tsx create mode 100644 components/gmail/email-list.tsx create mode 100644 components/gmail/email-view.tsx create mode 100644 components/gmail/header.tsx create mode 100644 components/gmail/mail-folder-stack-indicator.tsx create mode 100644 components/gmail/mail-label-pills.tsx create mode 100644 components/gmail/mobile-bottom-bar.tsx create mode 100644 components/gmail/mobile-xs-bulk-sheets.tsx create mode 100644 components/gmail/move-drag-indicator.tsx create mode 100644 components/gmail/move-to-menu-items.tsx create mode 100644 components/gmail/right-panel.tsx create mode 100644 components/gmail/sidebar.tsx create mode 100644 components/theme-provider.tsx create mode 100644 components/ui/accordion.tsx create mode 100644 components/ui/alert-dialog.tsx create mode 100644 components/ui/alert.tsx create mode 100644 components/ui/aspect-ratio.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/breadcrumb.tsx create mode 100644 components/ui/button-group.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/calendar.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/carousel.tsx create mode 100644 components/ui/chart.tsx create mode 100644 components/ui/checkbox.tsx create mode 100644 components/ui/collapsible.tsx create mode 100644 components/ui/command.tsx create mode 100644 components/ui/context-menu.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/drawer.tsx create mode 100644 components/ui/dropdown-menu.tsx create mode 100644 components/ui/empty.tsx create mode 100644 components/ui/field.tsx create mode 100644 components/ui/form.tsx create mode 100644 components/ui/hover-card.tsx create mode 100644 components/ui/input-group.tsx create mode 100644 components/ui/input-otp.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/item.tsx create mode 100644 components/ui/kbd.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/menubar.tsx create mode 100644 components/ui/navigation-menu.tsx create mode 100644 components/ui/pagination.tsx create mode 100644 components/ui/popover.tsx create mode 100644 components/ui/progress.tsx create mode 100644 components/ui/radio-group.tsx create mode 100644 components/ui/resizable.tsx create mode 100644 components/ui/scroll-area.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/sheet.tsx create mode 100644 components/ui/sidebar.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/slider.tsx create mode 100644 components/ui/sonner.tsx create mode 100644 components/ui/spinner.tsx create mode 100644 components/ui/switch.tsx create mode 100644 components/ui/table.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 components/ui/toast.tsx create mode 100644 components/ui/toaster.tsx create mode 100644 components/ui/toggle-group.tsx create mode 100644 components/ui/toggle.tsx create mode 100644 components/ui/tooltip.tsx create mode 100644 components/ui/use-mobile.tsx create mode 100644 components/ui/use-toast.ts create mode 100644 components/ultimail-logo.tsx create mode 100644 hooks/use-mobile.ts create mode 100644 hooks/use-toast.ts create mode 100644 hooks/use-xs.ts create mode 100644 lib/api/scheduled-mail.ts create mode 100644 lib/attachment-display.ts create mode 100644 lib/calendar-invitation.ts create mode 100644 lib/compose-context.tsx create mode 100644 lib/demo-calendar-invitation-emails.ts create mode 100644 lib/drag-context.tsx create mode 100644 lib/email-data.ts create mode 100644 lib/iconify-logos-vc-subset.json create mode 100644 lib/label-edits.ts create mode 100644 lib/label-pill-contrast.ts create mode 100644 lib/mail-folder-display.ts create mode 100644 lib/mail-folder-filter.ts create mode 100644 lib/mail-nav-icons.tsx create mode 100644 lib/mail-nav-metrics.ts create mode 100644 lib/mail-url.ts create mode 100644 lib/pending-send-toast.tsx create mode 100644 lib/print-conversation.ts create mode 100644 lib/register-vc-logos.ts create mode 100644 lib/resolve-email-calendar-invitation.ts create mode 100644 lib/scheduled-mail-context.tsx create mode 100644 lib/sender-display.ts create mode 100644 lib/sidebar-nav-context.tsx create mode 100644 lib/sidebar-nav-data.ts create mode 100644 lib/sidebar-nav-folder-ids.ts create mode 100644 lib/sidebar-nav-maps.ts create mode 100644 lib/stores/hydration.ts create mode 100644 lib/stores/mail-store.ts create mode 100644 lib/stores/nav-store.ts create mode 100644 lib/stores/scheduled-store.ts create mode 100644 lib/thread-compose-preset.ts create mode 100644 lib/utils.ts create mode 100644 next-env.d.ts create mode 100644 next.config.mjs create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 postcss.config.mjs create mode 100644 public/admin-mark.svg create mode 100644 public/agenda-mark.svg create mode 100644 public/apple-icon.png create mode 100644 public/brand/ultimail-header-icon.png create mode 100644 public/brand/ultimail-mark.jpg create mode 100644 public/brand/ultimail-mark.png create mode 100644 public/brand/ultimail-original.png create mode 100644 public/brand/ultimail-wordmark-horizontal.jpg create mode 100644 public/brand/ultimail-wordmark-horizontal.png create mode 100644 public/brand/ultimail-wordmark-horizontal.svg create mode 100644 public/brand/ultimail-wordmark-stacked.jpg create mode 100644 public/brand/ultimail-wordmark-stacked.png create mode 100644 public/brand/ultimail-wordmark-stacked.svg create mode 100644 public/brand/utlimail-original.png create mode 100644 public/compte-mark.svg create mode 100644 public/ground-news-mark.svg create mode 100644 public/icon-dark-32x32.png create mode 100644 public/icon-light-32x32.png create mode 100644 public/mistral-mark.svg create mode 100644 public/openstreetmap-mark.svg create mode 100644 public/photos-mark.svg create mode 100644 public/placeholder-logo.png create mode 100644 public/placeholder-logo.svg create mode 100644 public/placeholder-user.jpg create mode 100644 public/placeholder.jpg create mode 100644 public/placeholder.svg create mode 100644 public/qwant-mark.svg create mode 100644 public/ultidrive-mark.svg create mode 100644 public/ultimail-mark.svg create mode 100644 public/ultimeet-mark.svg create mode 100644 scripts/emit-ultimail-header-icon.mjs create mode 100644 scripts/rasterize-ultimail-brand.mjs create mode 100644 scripts/vectorize-ultimail-brand.mjs create mode 100644 styles/globals.css create mode 100644 tsconfig.json create mode 100644 tsconfig.tsbuildinfo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f81b183 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# v0 sandbox internal files +__v0_runtime_loader.js +__v0_devtools.tsx +__v0_jsx-dev-runtime.ts +.snowflake/ +.v0-trash/ +.vercel/ + +# Environment variables +.env*.local +.env + +# Common ignores +node_modules +.next/ +.DS_Store \ No newline at end of file diff --git a/app/apple-icon.png b/app/apple-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c059766d4950e6c79dd34929a74bc37b83749cb4 GIT binary patch literal 23130 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 N002ovPDHLkV1jhdfnWdt literal 0 HcmV?d00001 diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..0d35be1 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,221 @@ +@import 'tailwindcss'; +@import 'tw-animate-css'; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); + /** Fond chrome (layout mail : header, rails, sidebar). */ + --app-canvas: #fafbfc; +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.145 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.145 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.985 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.396 0.141 25.723); + --destructive-foreground: oklch(0.637 0.237 25.331); + --border: oklch(0.269 0 0); + --input: oklch(0.269 0 0); + --ring: oklch(0.439 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(0.269 0 0); + --sidebar-ring: oklch(0.439 0 0); +} + +@theme inline { + --font-sans: 'Geist', 'Geist Fallback'; + --font-mono: 'Geist Mono', 'Geist Mono Fallback'; + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --color-app-canvas: var(--app-canvas); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + + /* Main sur tout contrôle cliquable (boutons natifs, liens, rôles Radix). Les menus passent en pointer via composants UI. */ + button:not(:disabled):not([disabled]), + a[href], + summary, + label[for] { + cursor: pointer; + } + + [role='button']:not([aria-disabled='true']), + [role='tab']:not([aria-disabled='true']), + [role='menuitem']:not([aria-disabled='true']):not([data-disabled]), + [role='menuitemcheckbox']:not([aria-disabled='true']):not([data-disabled]), + [role='menuitemradio']:not([aria-disabled='true']):not([data-disabled]), + [role='option']:not([aria-disabled='true']):not([data-disabled]), + [role='combobox']:not([aria-disabled='true']), + [role='switch']:not([aria-disabled='true']), + [role='radio']:not([aria-disabled='true']) { + cursor: pointer; + } + + button:disabled, + button[disabled], + [role='button'][aria-disabled='true'], + [role='tab'][aria-disabled='true'], + [role='menuitem'][aria-disabled='true'], + [role='menuitem'][data-disabled], + [role='option'][aria-disabled='true'], + [role='option'][data-disabled], + [role='combobox'][aria-disabled='true'] { + cursor: not-allowed; + } +} + +/* Tiptap rich-text editor */ +.tiptap { + outline: none; +} +.tiptap p { + margin: 0; +} +.tiptap ul, +.tiptap ol { + padding-left: 1.5rem; + margin: 0.25rem 0; +} +.tiptap ul { + list-style-type: disc; +} +.tiptap ol { + list-style-type: decimal; +} +.tiptap h1 { + font-size: 1.5rem; + font-weight: 700; + margin: 0.5rem 0 0.25rem; +} +.tiptap h2 { + font-size: 1.25rem; + font-weight: 600; + margin: 0.5rem 0 0.25rem; +} +.tiptap h3 { + font-size: 1.1rem; + font-weight: 600; + margin: 0.25rem 0; +} +.tiptap blockquote { + border-left: 3px solid #dadce0; + padding-left: 0.75rem; + margin: 0.5rem 0; + color: #5f6368; +} +.tiptap a { + color: #1a73e8; + text-decoration: underline; +} +.tiptap code { + background-color: #f1f3f4; + border-radius: 3px; + padding: 0.1rem 0.3rem; + font-size: 0.875em; +} +.tiptap pre { + background-color: #f1f3f4; + border-radius: 4px; + padding: 0.75rem; + margin: 0.5rem 0; + overflow-x: auto; +} +.tiptap pre code { + background: none; + padding: 0; +} diff --git a/app/icon.png b/app/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..7242607e4fc1aba8bdf4a9f80d3d531f3ca8de7e GIT binary patch literal 2036 zcmV!-HYxV-0BgI&QE6AFuv2YRs!%y9CDa0{j=QRQFwh)Dr99M*`M(S)J)e$t?<+keGEZ&nQur5Z$%L^o9 z****rhXCt^d9)fj$qnH7W$;W$rakeoKwKCl3&RK#7fLj#aX_xfgaDJv zDv;(5n&v>53Z&d?{6h#slH8 zps|a9=xBIgb7JKyZl;kX^==v1$lq zdSKnILwDR-JLmN~qh4%;R{JhkAjA*?ifZsZLEkR0_vCkM+YH<;414TsTn?guv4v-`^Rp9j?G1l0FS#1OQ8h=Pt zldi7=x!&V0P<@tZefvX?dR3hruGU%6Z;tZO-R$%8;kM|U(l4tD5)aFUfN% zE^E}pSIQFDY(fAv{`q*%5=_-}(DyBeaVUXj-1$9j2djo*Ua3@0Q9?Pz@WV44fP3G< z#I}fPm7Pb$sMcw{iukUG1{P3c~R>ny+xWb3T z8FL_}W+=$5N)4w?L)85p4Wf2xBPMrsRA_^})<4tTFEWt5402@7hVgkh$NUk6m&0z>nl)7r|Fdym~$TQ zX2kX0vuE6bDPLTf?y~RdcMiM$1IIn1m|HV^*l=uIjxoE@91?>@HVF^DpuuqqkPh2} zmoHy_&h&n$`!sgW!@BX|Y}@mF@9#X13^ws)-@)#qZ!|9Zt|P4OMyTE``YJl10&R7! z#R&^&n_p1jqy>h%c_(k2iw!ltmVebvkS)#Oovjg0tzBn$O-k?%oKrb8{0QGqzuj$` zoE*~~-t2k*52S$=WI|@%h|xApPrr9P}9Gl zVO#&=hVuU8hW!4lhNa4t4Ke*|8y1|aYz_F^&KB3Mk1z5&8yV~NFB!|m4mGdsBHWH# zXqI*3`FpSErnzuw%O6#p&WF)E4a29WGT(MhrI$|6fU?gLr_R}<x56iLv{-6CTaobtSFWes6j__mjka=HGJ z^1wp%Kq9O=p2)u=9jGzeA8$QddLcBE%LV4fF8xIpOmyi)*I7)8-+rxs>g|7j0>mp1 SxCKc70000) { + return ( + + + {children} + {process.env.NODE_ENV === 'production' && } + + + ) +} diff --git a/app/mail/[[...segments]]/page.tsx b/app/mail/[[...segments]]/page.tsx new file mode 100644 index 0000000..ced238c --- /dev/null +++ b/app/mail/[[...segments]]/page.tsx @@ -0,0 +1,4 @@ +/** Route catch-all : toute l'interface est rendue par `app/mail/layout.tsx` pour conserver le state React entre changements d'URL. */ +export default function MailSegmentsPage() { + return null +} diff --git a/app/mail/layout.tsx b/app/mail/layout.tsx new file mode 100644 index 0000000..2476dd0 --- /dev/null +++ b/app/mail/layout.tsx @@ -0,0 +1,9 @@ +import { MailAppShell } from "./mail-app-shell" + +export default function MailLayout({ + children, +}: { + children: React.ReactNode +}) { + return {children} +} diff --git a/app/mail/mail-app-shell.tsx b/app/mail/mail-app-shell.tsx new file mode 100644 index 0000000..90abba5 --- /dev/null +++ b/app/mail/mail-app-shell.tsx @@ -0,0 +1,209 @@ +"use client" + +import { + Suspense, + useCallback, + useEffect, + useMemo, + useState, + type CSSProperties, +} from "react" +import dynamic from "next/dynamic" +import { useIsXs } from "@/hooks/use-xs" +import { Toaster } from "sonner" +import { useRouter, usePathname } from "next/navigation" +import { Sidebar } from "@/components/gmail/sidebar" +import { Header } from "@/components/gmail/header" +import { EmailList } from "@/components/gmail/email-list" +import { RightPanel } from "@/components/gmail/right-panel" +import { EmailDragProvider } from "@/lib/drag-context" +import { MoveDragIndicator } from "@/components/gmail/move-drag-indicator" +import { ComposeProvider } from "@/lib/compose-context" +import { ScheduledMailProvider } from "@/lib/scheduled-mail-context" +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" + +const MobileBottomBar = dynamic( + () => + import("@/components/gmail/mobile-bottom-bar").then( + (m) => m.MobileBottomBar + ), + { ssr: false } +) +import { + parseMailSegments, + buildMailPath, + DEFAULT_INBOX_TAB, + type MailRouteState, +} from "@/lib/mail-url" + +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 segments = useMemo(() => segmentsFromPathname(pathname), [pathname]) + const route = useMemo(() => parseMailSegments(segments), [segments]) + + const isXs = useIsXs() + const pushRecentFolderVisit = useMailStore((s) => s.pushRecentFolderVisit) + const [sidebarCollapsed, setSidebarCollapsed] = useState(false) + + useEffect(() => { + if (isXs) setSidebarCollapsed(true) + }, [isXs]) + + useEffect(() => { + pushRecentFolderVisit(mailNavVisitKey(route.folderId, route.inboxTab)) + }, [route.folderId, route.inboxTab, pushRecentFolderVisit]) + const [folderUnreadCounts, setFolderUnreadCounts] = useState< + Record + >({}) + + 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, + } + router.push(buildMailPath(next), { scroll: false }) + }, + [router, route] + ) + + const handleSelectFolder = useCallback( + (id: string) => { + navigateRoute({ + folderId: id, + inboxTab: DEFAULT_INBOX_TAB, + page: 1, + mailId: null, + }) + if (isXs) setSidebarCollapsed(true) + }, + [navigateRoute, isXs] + ) + + return ( + + navigateRoute({ + folderId: nextFolderId, + inboxTab: DEFAULT_INBOX_TAB, + page: 1, + mailId: null, + }) + } + > +
+ {!isXs && ( +
setSidebarCollapsed(!sidebarCollapsed)} + /> + )} +
+ {isXs && !sidebarCollapsed && ( +
+ + ) +} + +export function MailAppShell({ + children: _routeOutlet, +}: { + children: React.ReactNode +}) { + return ( + + + + +
+
+
+ } + > + + + + + + + + + ) +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..8d120ca --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,17 @@ +import { redirect } from "next/navigation" + +type HomeSearchParams = Promise<{ mail?: string | string[] }> + +export default async function Home({ + searchParams, +}: { + searchParams: HomeSearchParams +}) { + const sp = await searchParams + const raw = sp.mail + const mail = Array.isArray(raw) ? raw[0] : raw + if (mail && mail.length > 0) { + redirect(`/mail/inbox/message/${encodeURIComponent(mail)}`) + } + redirect("/mail/inbox") +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..4ee62ee --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/components/gmail/calendar-invitation-preview.tsx b/components/gmail/calendar-invitation-preview.tsx new file mode 100644 index 0000000..3fec3a8 --- /dev/null +++ b/components/gmail/calendar-invitation-preview.tsx @@ -0,0 +1,156 @@ +"use client" + +import { useMemo, useState } from "react" +import { Icon } from "@iconify/react" +import { ThumbsDown, ThumbsUp, Users, MoreVertical } from "lucide-react" +import { + VIDEO_CONFERENCE_LOGOS, + formatInvitationAttendeeLine, + formatInvitationTimeChip, + type ParsedCalendarInvitation, +} from "@/lib/calendar-invitation" +import { ensureVcLogosCollection } from "@/lib/register-vc-logos" +import { cn } from "@/lib/utils" + +function attendeeDisplayList(inv: ParsedCalendarInvitation): { + organizerLine?: string + othersLine?: string +} { + const orgEmail = inv.organizer?.email + const organizerLine = + orgEmail || inv.organizer?.name + ? `${orgEmail ?? inv.organizer?.name} – Organisateur` + : undefined + + const others = inv.attendees.filter((a) => { + const e = a.email?.toLowerCase() + return e && e !== orgEmail?.toLowerCase() + }) + const line = formatInvitationAttendeeLine( + others.length > 0 ? others : inv.attendees, + 4 + ) + return { + organizerLine, + othersLine: line || undefined, + } +} + +const RSVP_BTN = + "rounded-full bg-[#1a73e8] px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-[#1557b0]" + +const RSVP_SECONDARY = + "rounded-full border border-[#dadce0] bg-[#e8f0fe] px-4 py-2 text-sm font-medium text-[#1a73e8] transition-colors hover:bg-[#d2e3fc]" + +export function CalendarInvitationPreview({ + invitation, + className, +}: { + invitation: ParsedCalendarInvitation + className?: string +}) { + ensureVcLogosCollection() + + const timeChip = useMemo( + () => formatInvitationTimeChip(invitation.start, invitation.end), + [invitation.start, invitation.end] + ) + + const { organizerLine, othersLine } = useMemo( + () => attendeeDisplayList(invitation), + [invitation] + ) + + const confIcon = VIDEO_CONFERENCE_LOGOS[invitation.conferenceProvider] + + const [rsvp, setRsvp] = useState(null) + + return ( +
+
+
+
+ + {timeChip} +
+

+ {invitation.summary} +

+ {organizerLine && ( +

{organizerLine}

+ )} + {othersLine && ( +

+ + {othersLine} +

+ )} +
+ +
+
+ +
+
+

Dans votre agenda

+

Aucun autre événement à cette date

+
+
+
+ +
+ {(["Oui", "Non", "Peut-être"] as const).map((label) => ( + + ))} + + +
+ +
+ D’après cet e-mail +
+ Correct ? + + +
+
+
+ ) +} diff --git a/components/gmail/compose-modal.tsx b/components/gmail/compose-modal.tsx new file mode 100644 index 0000000..92cba0a --- /dev/null +++ b/components/gmail/compose-modal.tsx @@ -0,0 +1,2217 @@ +"use client" + +import { + useState, + useRef, + useEffect, + useLayoutEffect, + useCallback, + useMemo, + lazy, + Suspense, +} from "react" +import { useEditor, EditorContent } from "@tiptap/react" +import { Editor, Node as TipTapNode, mergeAttributes, 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 { + 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, +} from "lucide-react" +import { + type ComposeState, + type Contact, + cloneComposeForPendingSend, + DEFAULT_IDENTITIES, + MOCK_CONTACTS, + SIGNATURES, + useCompose, +} 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, getNextLocalWallClockDate } from "@/lib/utils" +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" + +const LazyPicker = lazy(() => import("@emoji-mart/react")) + +function EmojiPicker({ onSelect }: { onSelect: (emoji: { native: string }) => void }) { + 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}
` +} + +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" }) && "bg-[#e8eaed]")} + > + Aligner à gauche + + editor.chain().focus().setTextAlign("center").run()} + className={cn(editor.isActive({ textAlign: "center" }) && "bg-[#e8eaed]")} + > + Centrer + + editor.chain().focus().setTextAlign("right").run()} + className={cn(editor.isActive({ textAlign: "right" }) && "bg-[#e8eaed]")} + > + 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-[#dadce0] bg-white px-2 text-sm text-[#202124] outline-none focus:border-[#1a73e8] focus:ring-1 focus:ring-[#1a73e8]" + /> +
+
+ + setUrl(e.target.value)} + placeholder="https://example.com" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + handleInsert() + } + }} + className="h-8 rounded border border-[#dadce0] bg-white px-2 text-sm text-[#202124] outline-none focus:border-[#1a73e8] focus:ring-1 focus:ring-[#1a73e8]" + autoFocus + /> +
+
+ {isLinkActive ? ( + + ) : ( + + )} +
+ + +
+
+
+
+
+ ) +} + +function SignatureButton({ + editor, + compose, +}: { + editor: Editor | null + compose: ComposeState +}) { + const { updateCompose } = useCompose() + + 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 && "bg-[#e8eaed]")} + > + + {!compose.signatureId && } + + Aucune signature + + {SIGNATURES.map((sig) => ( + replaceSignature(sig.id)} + className={cn("gap-2", compose.signatureId === sig.id && "bg-[#e8eaed]")} + > + + {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]" + /> +
+ + )} + + ) +} + +export function ComposeWindow({ + compose, + threadSourceEmail = null, +}: { + compose: ComposeState + /** Fil courant : nécessaire pour le menu Répondre / Transférer en inline */ + threadSourceEmail?: Email | null +}) { + const { + closeCompose, + updateCompose, + applyComposePreset, + toggleMinimize, + toggleMaximize, + restoreComposeFromSnapshot, + } = useCompose() + 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-[#202124] outline-none focus:outline-none", + isInline ? "min-h-[200px]" : "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 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 + + useLayoutEffect(() => { + if (!isInline || !compose.focusToOnMount) return + setRecipientsFocused(true) + }, [isInline, compose.focusToOnMount]) + + useEffect(() => { + if (!recipientsFocused) return + const handleClickOutside = (e: MouseEvent) => { + const root = isInline ? inlineRecipientShellRef.current : fieldsRef.current + if (root && !root.contains(e.target as Node)) { + const portal = (e.target as HTMLElement)?.closest?.("[data-radix-popper-content-wrapper]") + 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("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", 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 && ( +
+
+ +

Déposer les fichiers ici

+
+
+ )} + {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} + + +
+
+ +
+
+ ) : ( + <> + {/* Title bar */} +
toggleMinimize(compose.id)} + > + + {titleText} + +
+ + + +
+
+ + )} + + {!isInline && ( +
+ +
+ )} + + {/* Editor */} +
+ +
+ + {/* 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 && } + + {/* 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 */} +
+ + + + + + + + + +
+ +
+ + +
+
+ ) + + if (compose.minimized && !isInline) { + return ( +
toggleMinimize(compose.id)} + > + + {titleText} + +
+ + +
+
+ ) + } + + if (compose.maximized && !isInline) { + return ( + <> +
toggleMaximize(compose.id)} + /> + {modalContent} + + ) + } + + return modalContent +} + +export function ComposeModalManager() { + const { composeWindows } = useCompose() + + const nonMaximized = composeWindows.filter( + (w) => !w.maximized && w.placement !== "inline" + ) + const maximized = composeWindows.filter((w) => w.maximized && w.placement !== "inline") + + 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]) + + 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/contact-hover-card.tsx b/components/gmail/contact-hover-card.tsx new file mode 100644 index 0000000..d4dcca5 --- /dev/null +++ b/components/gmail/contact-hover-card.tsx @@ -0,0 +1,168 @@ +"use client" + +import type { MouseEvent, ReactNode } from "react" +import { useEffect, useState } from "react" +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" +import { + avatarColor, + cleanSenderName, + resolveSenderEmail, + senderInitial, +} from "@/lib/sender-display" +import { + Calendar, + ExternalLink, + Mail, + MessageSquare, + UserPlus, + Video, +} from "lucide-react" +import { useCompose } from "@/lib/compose-context" + +export interface ContactHoverCardProps { + /** Champ expéditeur brut (liste, conversation, etc.) */ + displayName: string + email?: string + children: ReactNode + className?: string + onTriggerClick?: (e: MouseEvent) => void + align?: "start" | "center" | "end" + side?: "top" | "right" | "bottom" | "left" +} + +export function ContactHoverCard({ + displayName, + email: emailOverride, + children, + className, + onTriggerClick, + align = "start", + side = "bottom", +}: ContactHoverCardProps) { + const { openComposeWithInitial } = useCompose() + const [open, setOpen] = useState(false) + const name = cleanSenderName(displayName) + const email = resolveSenderEmail(displayName, emailOverride) + const color = avatarColor(name) + + useEffect(() => { + if (!open) return + const close = () => setOpen(false) + const opts: AddEventListenerOptions = { capture: true, passive: true } + window.addEventListener("scroll", close, opts) + window.addEventListener("wheel", close, opts) + window.addEventListener("touchmove", close, opts) + return () => { + window.removeEventListener("scroll", close, opts) + window.removeEventListener("wheel", close, opts) + window.removeEventListener("touchmove", close, opts) + } + }, [open]) + + return ( + + + { + onTriggerClick?.(e) + }} + > + {children} + + + +
+
+
+ {senderInitial(name)} +
+
+

{name}

+

{email}

+
+ +
+
+ +
+ + + + +
+ +
+ +
+
+
+ ) +} diff --git a/components/gmail/email-label-picker-block.tsx b/components/gmail/email-label-picker-block.tsx new file mode 100644 index 0000000..118863b --- /dev/null +++ b/components/gmail/email-label-picker-block.tsx @@ -0,0 +1,124 @@ +"use client" + +import type { ComponentType, ReactNode } from "react" +import { Check, Minus, Plus } from "lucide-react" +import { Input } from "@/components/ui/input" +import { cn } from "@/lib/utils" + +export type CatalogLabelPresence = "none" | "some" | "all" + +export type LabelPickerItemComponent = ComponentType<{ + children: ReactNode + onSelect?: (event: Event) => void + className?: string +}> + +function LabelPickerCheckboxVisual({ + checked, +}: { + checked: boolean | "indeterminate" +}) { + return ( + + {checked === true ? ( + + ) : checked === "indeterminate" ? ( + + ) : null} + + ) +} + +export function EmailLabelPickerBlock({ + query, + onQueryChange, + catalogLabels, + Item, + getLabelPresence, + onToggleCatalogLabel, + onCreateLabel, + listClassName, +}: { + query: string + onQueryChange: (v: string) => void + catalogLabels: string[] + Item: LabelPickerItemComponent + getLabelPresence: (label: string) => CatalogLabelPresence + onToggleCatalogLabel: (label: string) => void + onCreateLabel: (label: string) => void + listClassName?: string +}) { + const q = query.trim().toLowerCase() + const filtered = catalogLabels.filter( + (l) => q.length === 0 || l.toLowerCase().includes(q) + ) + const trimmed = query.trim() + const hasExact = catalogLabels.some( + (l) => l.toLowerCase() === trimmed.toLowerCase() + ) + const canCreate = trimmed.length > 0 && !hasExact + + return ( + <> +
e.stopPropagation()} + > + onQueryChange(e.target.value)} + placeholder="Rechercher ou créer un libellé…" + className="h-8 border-[#dadce0] text-sm shadow-none" + autoComplete="off" + onPointerDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + /> +
+
+ {canCreate ? ( + { + e.preventDefault() + onCreateLabel(trimmed) + }} + > + + + Créer le libellé « {trimmed} » + + + ) : null} + {filtered.map((label) => { + const presence = getLabelPresence(label) + const boxChecked: boolean | "indeterminate" = + presence === "all" ? true : presence === "some" ? "indeterminate" : false + return ( + { + e.preventDefault() + onToggleCatalogLabel(label) + }} + > + + {label} + + ) + })} + {filtered.length === 0 && !canCreate ? ( +
+ Aucun libellé correspondant +
+ ) : null} +
+ + ) +} diff --git a/components/gmail/email-list.tsx b/components/gmail/email-list.tsx new file mode 100644 index 0000000..c7d6fa9 --- /dev/null +++ b/components/gmail/email-list.tsx @@ -0,0 +1,3833 @@ +"use client" + +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type ComponentType, + type DragEvent, + type ElementType, + type MouseEvent, + type ReactNode, + type SVGProps, +} 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, + MoreVertical, + RefreshCw, + ChevronDown, + Tag, + Users, + Info, + MessageSquare, + Reply, + ReplyAll, + Forward, + Paperclip, + Archive, + Trash2, + Mail, + MailOpen, + Clock, + ListTodo, + FolderInput, + VolumeX, + Search, + SquareArrowOutUpRight, + File, + Image as ImageIcon, + ShieldAlert, + ArrowLeft, + Plus, + Send, + Pencil, + CalendarClock, + X, + CheckSquare, +} 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 { cn } from "@/lib/utils" +import { + buildLabelTextToNavColorClass, + MailLabelPillStrip, +} from "@/components/gmail/mail-label-pills" +import { emails, type Email, type EmailAttachment } from "@/lib/email-data" +import { useScheduledMail } from "@/lib/scheduled-mail-context" +import { useMailStore } from "@/lib/stores/mail-store" +import { + emailMatchesFolder, + type MailNavFolderMaps, +} from "@/lib/mail-folder-filter" +import { cleanSenderName, resolveSenderEmail } from "@/lib/sender-display" +import { getMailNavFolderLabel, type FolderTreeNode } from "@/lib/sidebar-nav-data" +import { mailNavVisitKey } from "@/lib/mail-folder-display" +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 { MobileXsBulkSheets } from "@/components/gmail/mobile-xs-bulk-sheets" +import { + useMoveTargets, + type MoveTarget, +} from "@/components/gmail/move-to-menu-items" +import { EmailView } from "./email-view" +import { useCompose, type Contact } from "@/lib/compose-context" +import { computeFolderUnreadCounts } from "@/lib/mail-nav-metrics" +import { + effectiveLabels, + mergeEmailLabelEdits, +} from "@/lib/label-edits" +import type { LabelEditState } from "@/lib/stores/mail-store" +import type { MailRouteState } from "@/lib/mail-url" +import { useIsXs } from "@/hooks/use-xs" + +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 "—" + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return "—" + return d.toLocaleString("fr-FR", { dateStyle: "medium", timeStyle: "short" }) +} + +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 TabBadgeTone = "green" | "blue" | "orange" | "purple" + +interface CategoryTab { + id: string + label: string + icon: ElementType + badgeTone?: TabBadgeTone +} + +const categoryTabs: CategoryTab[] = [ + { id: "primary", label: "Principale", icon: Inbox, badgeTone: "blue" }, + { + id: "promotions", + label: "Promotions", + icon: Tag, + badgeTone: "green", + }, + { + id: "social", + label: "Réseaux sociaux", + icon: Users, + badgeTone: "blue", + }, + { + id: "updates", + label: "Notifications", + icon: Info, + badgeTone: "orange", + }, + { id: "forums", label: "Forums", icon: MessageSquare, badgeTone: "purple" }, +] + +const CATEGORY_TAB_ICON_STROKE = 2.5 + +function categoryBadgeClass(tone: TabBadgeTone) { + return cn( + "shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium leading-none text-white", + tone === "green" && "bg-[#1e8e3e]", + tone === "blue" && "bg-[#0b57d0]", + tone === "orange" && "bg-[#e8710a]", + tone === "purple" && "bg-[#9334e6]" + ) +} + +function categoryBadgeDotClass(tone: TabBadgeTone) { + return cn( + "absolute -right-0.5 -top-0.5 size-2 rounded-full ring-2 ring-white", + tone === "green" && "bg-[#1e8e3e]", + tone === "blue" && "bg-[#0b57d0]", + tone === "orange" && "bg-[#e8710a]", + tone === "purple" && "bg-[#9334e6]" + ) +} + +function Inbox({ className, strokeWidth = CATEGORY_TAB_ICON_STROKE, ...props }: SVGProps) { + return ( + + + + + ) +} + +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 + onMailRouteNavigate: (patch: Partial) => void + onSelectFolder?: (folder: string) => void + onFolderUnreadCountsChange?: (counts: Record) => 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]" + ) +} + +export function EmailList({ + selectedFolder, + inboxTab, + listPage, + openMailId, + onMailRouteNavigate, + onSelectFolder, + onFolderUnreadCountsChange, +}: EmailListProps) { + const isViewMode = openMailId !== null + + const { + openComposeWithInitial, + closeAllInlineComposes, + pruneInlineComposesToOpenThread, + savedThreadReplyDrafts, + } = useCompose() + + const { + scheduledEmails, + snoozedEmails, + sentPlaceholderEmails, + requestDeleteScheduled, + requestArchiveScheduled, + requestSnoozeScheduled, + requestToggleReadScheduled, + requestRescheduleScheduled, + requestGetScheduledEditPayload, + requestSendScheduledNow, + } = useScheduledMail() + + const allEmails = useMemo( + () => [...emails, ...scheduledEmails, ...snoozedEmails, ...sentPlaceholderEmails], + [scheduledEmails, snoozedEmails, sentPlaceholderEmails] + ) + + const sidebarNav = useSidebarNav() + const navMaps = useMemo( + () => ({ + folderIdToLabel: sidebarNav.folderIdToLabel, + folderTree: sidebarNav.folderTree, + }), + [sidebarNav.folderIdToLabel, sidebarNav.folderTree] + ) + + 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 { + pruneInlineComposesToOpenThread(openMailId) + } + }, [ + openMailId, + 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 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 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 isXs = useIsXs() + + 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) + window.setTimeout(() => setMobileXsLabelSheetOpen(true), 0) + }, []) + 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 visible = allEmails + .filter((email) => !hiddenEmailIds.includes(email.id)) + .map((e) => mergeEmailLabelEdits(e, labelEdits)) + let rows = visible.filter((email) => + emailMatchesFolder(email, selectedFolder, folderFilterCtx, navMaps) + ) + if (selectedFolder === "inbox") { + rows = rows.filter((email) => email.category === inboxTab) + } + return rows + }, [ + selectedFolder, + inboxTab, + hiddenEmailIds, + folderFilterCtx, + labelEdits, + allEmails, + navMaps, + ]) + + const inboxCategoryTabLabel = useMemo( + () => categoryTabs.find((t) => t.id === inboxTab)?.label ?? inboxTab, + [inboxTab] + ) + + const mobileUnreadCount = useMemo( + () => filteredEmails.filter((e) => !(readOverrides[e.id] ?? e.read)).length, + [filteredEmails, readOverrides] + ) + + const mobileFolderLabel = useMemo( + () => + selectedFolder === "inbox" && inboxTab !== "primary" + ? inboxCategoryTabLabel + : getMailNavFolderLabel(selectedFolder, sidebarNav.folderIdToLabel), + [selectedFolder, inboxTab, inboxCategoryTabLabel, sidebarNav.folderIdToLabel] + ) + + useEffect(() => { + setMobileSelectionMode(false) + setSelectedEmails([]) + }, [selectedFolder, inboxTab]) + + const totalPages = useMemo( + () => Math.max(1, Math.ceil(filteredEmails.length / LIST_PAGE_SIZE)), + [filteredEmails.length] + ) + + const pagedEmails = useMemo(() => { + const start = (listPage - 1) * LIST_PAGE_SIZE + return filteredEmails.slice(start, start + LIST_PAGE_SIZE) + }, [filteredEmails, listPage]) + + const listEmails = useMemo(() => { + if (isXs && !isViewMode) { + return filteredEmails.slice(0, mobileVisibleCount) + } + return pagedEmails + }, [isXs, isViewMode, filteredEmails, mobileVisibleCount, pagedEmails]) + + 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 >= filteredEmails.length) return + const nearBottom = + root.scrollTop + root.clientHeight >= root.scrollHeight - 120 + if (nearBottom) { + setMobileVisibleCount((prev) => + Math.min(prev + LIST_PAGE_SIZE, filteredEmails.length) + ) + } + } + + root.addEventListener("scroll", onScroll, { passive: true }) + return () => root.removeEventListener("scroll", onScroll) + }, [isXs, isViewMode, mobileVisibleCount, filteredEmails.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 = mergeEmailLabelEdits(e, labelEdits) + 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]) + + 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 + ), + [folderFilterCtx, hiddenEmailIds, readOverrides, allEmails, navMaps, labelEdits] + ) + + 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) => mergeEmailLabelEdits(e, labelEdits)) + const inboxPool = visible.filter((e) => + emailMatchesFolder(e, "inbox", folderFilterCtx, navMaps) + ) + const counts: Record = {} + const preview: Record = {} + for (const tab of categoryTabs) { + const rows = inboxPool.filter( + (e) => e.category === tab.id && !seen.has(e.id) + ) + counts[tab.id] = rows.length + 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]) + + 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 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 filteredEmails) next[e.id] = true + return next + }) + }, [filteredEmails]) + + 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 openEmail = useMemo(() => { + if (!openMailId) return null + const raw = allEmails.find((e) => e.id === openMailId) ?? null + if (!raw) return null + if (raw.labels?.includes("scheduled")) return null + return mergeEmailLabelEdits(raw, labelEdits) + }, [openMailId, labelEdits, allEmails]) + const openMailIndex = useMemo( + () => (openMailId ? filteredEmails.findIndex((e) => e.id === openMailId) : -1), + [openMailId, filteredEmails] + ) + + useEffect(() => { + if (!openMailId) return + markEmailSeen(openMailId) + setReadOverrides((prev) => + prev[openMailId] !== undefined ? prev : { ...prev, [openMailId]: true } + ) + }, [openMailId, markEmailSeen]) + + const navigateToMail = useCallback( + (id: string | null) => { + onMailRouteNavigate({ mailId: id }) + }, + [onMailRouteNavigate] + ) + + useEffect(() => { + if (!openMailId) return + const raw = allEmails.find((e) => e.id === openMailId) + if (raw?.labels?.includes("scheduled")) { + navigateToMail(null) + } + }, [openMailId, allEmails, navigateToMail]) + + const goBack = useCallback(() => navigateToMail(null), [navigateToMail]) + + const handleCategoryInboxTabClick = useCallback( + (tabId: string) => { + onMailRouteNavigate({ + inboxTab: tabId, + page: 1, + mailId: null, + }) + }, + [onMailRouteNavigate] + ) + + 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 = filteredEmails[openMailIndex - 1]!.id + markEmailSeen(id) + setReadOverrides((prev) => ({ ...prev, [id]: true })) + navigateToMail(id) + } + }, [openMailIndex, filteredEmails, navigateToMail, markEmailSeen]) + + const goToNext = useCallback(() => { + if (openMailIndex >= 0 && openMailIndex < filteredEmails.length - 1) { + const id = filteredEmails[openMailIndex + 1]!.id + markEmailSeen(id) + setReadOverrides((prev) => ({ ...prev, [id]: true })) + navigateToMail(id) + } + }, [openMailIndex, filteredEmails, 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 singleArchive = useCallback(() => { + if (!openMailId) return + mailActions.hideEmail(openMailId) + goBack() + }, [openMailId, goBack, mailActions]) + + const singleDelete = useCallback(() => { + if (!openMailId) return + mailActions.hideEmail(openMailId) + goBack() + }, [openMailId, goBack, mailActions]) + + const singleSpam = useCallback(() => { + if (!openMailId) return + mailActions.hideEmail(openMailId) + goBack() + }, [openMailId, goBack, mailActions]) + + const singleNotSpam = useCallback(() => { + if (!openMailId) return + mailActions.hideEmail(openMailId) + onSelectFolder?.("inbox") + goBack() + }, [openMailId, goBack, 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") { + goBack() + } + }, + [openMailId, goBack, moveEmailsToTarget] + ) + + 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(",") + 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 mode --- + useEffect(() => { + if (!isViewMode) return + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") { 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, goBack, goToPrev, goToNext]) + + const dropdownSurfaceClass = + "min-w-[220px] rounded-lg border border-[#dadce0] bg-white p-0 py-1 text-[#3c4043] shadow-lg [&_[data-slot=dropdown-menu-item]]:gap-3 [&_[data-slot=dropdown-menu-item]]:rounded-none [&_[data-slot=dropdown-menu-item]]:px-3 [&_[data-slot=dropdown-menu-item]]:py-2 [&_[data-slot=dropdown-menu-item]]:text-sm [&_[data-slot=dropdown-menu-item]:focus]:bg-[#f1f3f4] [&_[data-slot=dropdown-menu-sub-trigger]]:gap-3 [&_[data-slot=dropdown-menu-sub-trigger]]:rounded-none [&_[data-slot=dropdown-menu-sub-trigger]]:px-3 [&_[data-slot=dropdown-menu-sub-trigger]]:py-2 [&_[data-slot=dropdown-menu-sub-trigger]]:text-sm [&_[data-slot=dropdown-menu-sub-trigger]:focus]:bg-[#f1f3f4] [&_[data-slot=dropdown-menu-sub-content]]:min-w-[200px] [&_[data-slot=dropdown-menu-sub-content]]:rounded-lg [&_[data-slot=dropdown-menu-sub-content]]:border [&_[data-slot=dropdown-menu-sub-content]]:border-[#dadce0] [&_[data-slot=dropdown-menu-sub-content]]:bg-white [&_[data-slot=dropdown-menu-sub-content]]:p-0 [&_[data-slot=dropdown-menu-sub-content]]:py-1 [&_[data-slot=dropdown-menu-sub-content]]:shadow-lg [&_[data-slot=dropdown-menu-separator]]:mx-0 [&_[data-slot=dropdown-menu-separator]]:my-1 [&_[data-slot=dropdown-menu-separator]]:bg-[#eceff1]" + + const mainScrollClass = + "min-h-0 flex-1 overflow-y-auto overflow-x-hidden rounded-b-2xl border-0 bg-white shadow-none outline-none " + + "[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 */} + {isXs && !isViewMode && ( +
+
+

+ {mobileFolderLabel} +

+

+ {filteredEmails.length} message{filteredEmails.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 +
+ + )} +
+
+ getCatalogLabelPresence(bulkTargetIds, lab)} + onToggleCatalogLabel={(lab) => toggleLabelOnEmails(bulkTargetIds, lab)} + onCreateLabel={(lab) => { + addLabelToEmails(bulkTargetIds, lab) + setLabelPickerQuery("") + }} + /> +
+ )} + + {/* Toolbar — relative: scroll lives in sibling below */} +
+ + {isViewMode ? ( + /* ── VIEW MODE TOOLBAR ── */ + + + + + + + Retour à la boîte de réception + + + +
+ {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"} + + + + + + + + + + + +
+ + )} +
+
+ ) : ( + /* ── 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 +
+
+
+ + )} + + )} + +
+ + {/* Pagination — liste (pages) ou vue message (position dans le filtre) */} +
+ {filteredEmails.length === 0 ? ( + Aucun résultat + ) : isViewMode ? ( + + {openMailIndex >= 0 ? openMailIndex + 1 : "–"} sur {filteredEmails.length} + + ) : ( + + {(listPage - 1) * LIST_PAGE_SIZE + 1}– + {Math.min(listPage * LIST_PAGE_SIZE, filteredEmails.length)} sur{" "} + {filteredEmails.length} + {totalPages > 1 ? ` · p. ${listPage}/${totalPages}` : null} + + )} + + + + + + {isViewMode ? "Plus récent" : "Page précédente"} + + + + + + + + {isViewMode ? "Plus ancien" : "Page suivante"} + + +
+
+ + {selectedFolder === "inbox" && ( +
+ {!isViewMode && ( +
+ {categoryTabs.map((tab) => { + const isActive = inboxTab === tab.id + const isPrimaryTab = tab.id === "primary" + const unseen = unseenInTabById[tab.id] ?? 0 + const senderLine = tabUnseenSenderLineById[tab.id] ?? "" + const showMeta = + !isPrimaryTab && !isActive && unseen > 0 + const showSenderLine = showMeta && Boolean(senderLine) + const isExpandedTabMeta = showSenderLine + return ( + + ) + })} +
+ )} +
+ )} + +
+ {isXs && !isViewMode ? ( +
+ +
+ ) : null} +
+ {isViewMode && openEmail ? ( + /* ── EMAIL VIEW ── */ + { + if (LABEL_PICKER_EXCLUDE.has(lab)) return true + const fid = sidebarNav.emailLabelToSidebarFolderId[lab] + if (!fid) return true + return sidebarNav.getNavItemPrefs(fid).messages !== "hide" + }} + /> + ) : ( + + <> + {selectedFolder === "scheduled" && ( +
+ +

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

+
+ )} + {filteredEmails.length === 0 ? ( + selectedFolder === "scheduled" ? ( +
+

Aucun message planifié.

+
+ ) : ( + + + + + + + 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 isStarred = starredEmails.includes(email.id) || email.starred + const isImportant = importantEmails.includes(email.id) || email.important + const isSpam = email.spam === true + const isDraft = email.labels?.includes("drafts") === true + const hasThreadReplyDraft = + savedThreadReplyDrafts[email.id] !== undefined + const showDraftBadge = isDraft || hasThreadReplyDraft + const isRead = + readOverrides[email.id] !== undefined ? readOverrides[email.id]! : email.read + const senderHoverEmail = resolveSenderEmail(email.sender, email.senderEmail) + const senderForSearch = email.sender.replace(/\s+/g, " ").trim() + const isSelected = selectedEmails.includes(email.id) + const hasInvitation = email.hasInvitation === true + const parsedInvitation = resolveParsedCalendarInvitation(email) + const attachmentList = attachmentsForEmailList(email) + const isScheduled = email.labels?.includes("scheduled") === true + const contextTargetIds = contextMenuTargetIdsForRow( + email.id, + selectedEmails, + selectedFolder, + allEmails + ) + const allContextTargetsScheduled = + contextTargetIds.length > 0 && + contextTargetIds.every((id) => + allEmails.some( + (e) => e.id === id && e.labels?.includes("scheduled") + ) + ) + const scheduledCtxAnyUnread = + allContextTargetsScheduled && + contextTargetIds.some((id) => { + const em = allEmails.find((e) => e.id === id) + if (!em) return false + return !(readOverrides[id] ?? em.read) + }) + const isRescheduleOpenThisRow = + rescheduleTarget?.id === email.id + + return ( + { + if (open) { + rowContextMenuOpenedAtRef.current = Date.now() + setSelectedEmails((prev) => { + const next = contextMenuTargetIdsForRow( + email.id, + prev, + selectedFolder, + allEmails + ) + contextMenuTargetIdsRef.current = [...next] + return next + }) + } else { + setLabelPickerQuery("") + } + }} + > + +
startRowDrag(email.id, e)} + onClick={() => { + if (isXs && 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-150 md:flex md:items-start md:gap-2 md:px-2 md:py-1.5", + isSelected + ? "bg-[#e8f0fe]" + : isRead + ? "bg-[#f5f5f5]" + : "bg-white", + "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 */} +
+ {isXs && mobileSelectionMode && ( +
e.stopPropagation()} + onClickCapture={(e) => handleRowCheckboxClickCapture(email.id, e)} + > + { + toggleSelect(email.id) + lastSelectionAnchorIdRef.current = email.id + }} + /> +
+ )} +
+
+ {!isXs && ( +
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} + + + )} + {email.participantCount != null && email.participantCount > 1 && ( + + {email.participantCount} + + )} +
+
+ {(parsedInvitation || hasInvitation) && ( + + )} + {attachmentList.length > 0 && ( + + )} + + {isScheduled + ? formatScheduledDateTimeDisplay(email.scheduledSendAt) + : email.date} + +
+
+
+ +
+ {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} + + + )} + {email.participantCount && email.participantCount > 1 && ( + {email.participantCount} + )} +
+ +
+
+ {email.tag && ( + + {email.tag} + + )} + + + {email.subject} + + {email.preview} +
+ {attachmentList.length > 0 && ( + + )} +
+ +
+ {isScheduled ? ( +
+ + {formatScheduledDateTimeDisplay(email.scheduledSendAt)} + +
+ + + + + + 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) && ( + + )} + + {email.date} + +
+ )} +
+
+
+
+ + 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( + "min-w-[280px] overflow-visible rounded-lg border border-[#dadce0] bg-white p-0 py-1 text-[#3c4043] shadow-lg", + "[&_[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-[#dadce0] [&_[data-slot=context-menu-sub-content]]:bg-white [&_[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 + + + )} +
+
+ ) + })} +
+ )} + +
+ )} + {!isXs && !isViewMode ? ( +
+ +
+ ) : null} +
+
+
+ ) +} diff --git a/components/gmail/email-view.tsx b/components/gmail/email-view.tsx new file mode 100644 index 0000000..92bc63c --- /dev/null +++ b/components/gmail/email-view.tsx @@ -0,0 +1,1003 @@ +"use client" + +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { + Star, + Reply, + ReplyAll, + Forward, + Smile, + 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, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { cn } from "@/lib/utils" +import { + avatarColor, + cleanSenderName, + senderInitial, +} from "@/lib/sender-display" +import type { + Email, + ConversationMessage, + EmailAttachment, + EmailAttachmentKind, +} from "@/lib/email-data" +import { + attachmentPreviewTooltip, + resolveAttachmentKind, + shouldUseAttachmentPillsInPreview, +} from "@/lib/attachment-display" +import { + useCompose, + DEFAULT_IDENTITIES, + type ThreadComposeKind, + savedThreadDraftToComposePreset, +} from "@/lib/compose-context" +import { buildThreadComposePreset } from "@/lib/thread-compose-preset" +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 { MailLabelPillStrip } from "./mail-label-pills" + +interface EmailViewProps { + email: Email + onToggleStar: (id: string) => void + isStarred: boolean + onNavigateToLabel?: (label: string) => void + /** Message spam : bannière + pastille sujet ; bouton « non-spam » */ + onNotSpam?: () => void + /** Si défini, les pastilles libellé dont la fonction retourne false sont masquées (préférences barre latérale). */ + showLabelChip?: (label: string) => boolean + labelBgByText?: Map + emailLabelToSidebarFolderId?: Record + getNavItemPrefs?: (id: string) => { messages: string } +} + +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 = + "min-w-[280px] rounded-lg border border-[#dadce0] bg-white p-0 py-1 text-[#3c4043] shadow-lg [&_[data-slot=dropdown-menu-item]]:gap-3 [&_[data-slot=dropdown-menu-item]]:rounded-none [&_[data-slot=dropdown-menu-item]]:px-3 [&_[data-slot=dropdown-menu-item]]:py-2 [&_[data-slot=dropdown-menu-item]]:text-sm [&_[data-slot=dropdown-menu-item]:focus]:bg-[#f1f3f4] [&_[data-slot=dropdown-menu-separator]]:mx-0 [&_[data-slot=dropdown-menu-separator]]:my-1 [&_[data-slot=dropdown-menu-separator]]:bg-[#eceff1]" + +/* ── 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 injectContent = useCallback(() => { + const iframe = iframeRef.current + if (!iframe) return + + const doc = iframe.contentDocument + if (!doc) return + + const cspMeta = isSpam + ? `` + : `` + + doc.open() + doc.write(` + + + + ${cspMeta} + + +${html} +`) + 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]) + + useEffect(() => { + const cleanup = injectContent() + return () => cleanup?.() + }, [injectContent]) + + return ( +