ultisuite-client/components/gmail/email-list/email-list-toolbar.tsx
R3D347HR4Y 5304790ed5
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat(auth): enhance session management and identity provider settings
- Added SessionGuard component to manage session expiration and online status.
- Updated AuthProvider to streamline session fetching and handling.
- Introduced IdentityProvidersSection for managing OAuth, SAML, and LDAP identity providers.
- Implemented identity provider guides for easier configuration.
- Enhanced mail settings with infinite scroll option for improved user experience.
- Updated global styles and layout components for better consistency across the application.
2026-06-09 09:36:46 +02:00

1397 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { useEffect, useState } from "react"
import { Icon } from "@iconify/react"
import {
Archive,
ArrowLeft,
CalendarX2,
ChevronDown,
ChevronLeft,
ChevronRight,
CheckSquare,
Clock,
FolderInput,
ListTodo,
Mail,
MailOpen,
Menu,
MoreVertical,
Paperclip,
RefreshCw,
Search,
Send,
ShieldAlert,
SquareArrowOutUpRight,
Tag,
Trash2,
User as UserIcon,
VolumeX,
X,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { CompactInboxCategoryTabs } from "@/components/gmail/compact-inbox-category-tabs"
import { EmailLabelPickerBlock } from "@/components/gmail/email-label-picker-block"
import type { CatalogLabelPresence } from "@/components/gmail/email-label-picker-block"
import { MailSearchBar } from "@/components/gmail/mail-search-bar"
import {
MoveToDropdownItems,
} from "@/components/gmail/email-list/move-to-menu-items"
import type { MailMoveTargets } from "@/components/gmail/move-to-menu-items"
import { cn } from "@/lib/utils"
import type { Email } from "@/lib/email-data"
import {
MAIL_INBOX_CATEGORY_TAB_CONTENT_DARK_CLASS,
MAIL_MENU_SURFACE_CLASS,
} from "@/lib/mail-chrome-classes"
import {
DATE_RANGE_OPTIONS,
type SearchParams,
} from "@/lib/mail-search/search-params"
import { inboxTabActiveAccentColor } from "@/lib/inbox-category-tabs"
import { inboxTabShowsInactiveMeta } from "@/lib/mail-url"
import {
CATEGORY_TAB_ICON_CLASS,
inboxTabBadgeCountClass,
inboxTabBadgeDotClass,
REFRESH_SPIN_CLASS,
} from "@/components/gmail/email-list/email-list-helpers"
import {
LIST_PAGE_SIZE_OPTIONS,
type ListPageSize,
} from "@/lib/mail-list-page-size"
export type EmailListToolbarProps = {
isViewMode: boolean
splitView: boolean
listToolbarMode: boolean
compactInboxTabs: boolean
isSearchMode: boolean
selectedFolder: string
mobileFolderLabel: string
displayListEmails: Email[]
mobileUnreadCount: number
mobileSelectionMode: boolean
setMobileSelectionMode: (v: boolean | ((p: boolean) => boolean)) => void
setSelectedEmails: (v: string[] | ((p: string[]) => string[])) => void
mobileXsMoreMenuOpen: boolean
setMobileXsMoreMenuOpen: (v: boolean) => void
showBulkToolbar: boolean
bulkSelectMenuOpen: boolean
setBulkSelectMenuOpen: (v: boolean) => void
selectAllChecked: boolean | "indeterminate"
handleSelectAllChange: (checked: boolean | "indeterminate") => void
selectMenuAll: () => void
selectMenuNone: () => void
selectMenuRead: () => void
selectMenuUnread: () => void
selectMenuStarred: () => void
selectMenuUnstarred: () => void
bulkArchive: () => void
bulkDelete: () => void
bulkSpam: () => void
hasUnreadInSelection: boolean
bulkMarkRead: () => void
bulkMarkUnread: () => void
moveTargets: MailMoveTargets
bulkMoveTo: (targetId: string) => void
labelPickerQuery: string
setLabelPickerQuery: (q: string) => void
catalogLabels: string[]
resolveLabelVisual: (label: string) => ReturnType<typeof import("@/lib/label-picker-visual").resolveLabelPickerVisual>
bulkTargetIds: string[]
getCatalogLabelPresence: (ids: string[], catalogLabel: string) => CatalogLabelPresence
toggleLabelOnEmails: (ids: string[], label: string) => void
addLabelToEmails: (ids: string[], label: string) => void
isRefreshing: boolean
handleManualRefresh: () => void
markAllInViewAsRead: () => void
openMobileXsMoveSheet: () => void
openMobileXsLabelSheet: () => void
listPage: number
totalPages: number
paginationTotal?: number
listPageSize: number
paginationRangeStart: number
paginationRangeEnd: number
infiniteScroll: boolean
onListPageSizeChange: (size: ListPageSize) => void
openMailIndex: number
goListPrevPage: () => void
goListNextPage: () => void
goToPrev: () => void
goToNext: () => void
goBack: () => void
openEmail: Email | null
viewModeIsRead: boolean
singleArchive: () => void
singleDelete: () => void
singleNotSpam: () => void
singleSpam: () => void
singleToggleRead: () => void
singleMoveTo: (targetId: string) => void
onToggleSidebar?: () => void
inboxTabBarItems: Array<{ id: string; label: string; icon: string; badgeColor: string }>
activeInboxTabId: string
unseenInTabById: Record<string, number>
tabUnseenSenderLineById: Record<string, string>
handleCategoryInboxTabClick: (tabId: string) => void
searchParams: SearchParams | null
searchAccount: { email: string } | null
allEmails: Email[]
setSearchFilter: (patch: Partial<SearchParams>) => void
toggleSearchFilter: (key: keyof SearchParams, value: string) => void
setAdvancedOpen: (open: boolean) => void
searchRouter: { push: (url: string) => void }
buildSearchUrl: (params: SearchParams) => string
variant?: "list" | "reading-pane"
part?: "mobile" | "list" | "all"
}
export function EmailListToolbar(props: EmailListToolbarProps) {
const {
isViewMode,
splitView,
listToolbarMode,
compactInboxTabs,
isSearchMode,
selectedFolder,
mobileFolderLabel,
displayListEmails,
mobileUnreadCount,
mobileSelectionMode,
setMobileSelectionMode,
setSelectedEmails,
mobileXsMoreMenuOpen,
setMobileXsMoreMenuOpen,
showBulkToolbar,
bulkSelectMenuOpen,
setBulkSelectMenuOpen,
selectAllChecked,
handleSelectAllChange,
selectMenuAll,
selectMenuNone,
selectMenuRead,
selectMenuUnread,
selectMenuStarred,
selectMenuUnstarred,
bulkArchive,
bulkDelete,
bulkSpam,
hasUnreadInSelection,
bulkMarkRead,
bulkMarkUnread,
moveTargets,
bulkMoveTo,
labelPickerQuery,
setLabelPickerQuery,
catalogLabels,
resolveLabelVisual,
bulkTargetIds,
getCatalogLabelPresence,
toggleLabelOnEmails,
addLabelToEmails,
isRefreshing,
handleManualRefresh,
markAllInViewAsRead,
openMobileXsMoveSheet,
openMobileXsLabelSheet,
listPage,
totalPages,
paginationTotal,
listPageSize,
paginationRangeStart,
paginationRangeEnd,
infiniteScroll,
onListPageSizeChange,
openMailIndex,
goListPrevPage,
goListNextPage,
goToPrev,
goToNext,
goBack,
openEmail,
viewModeIsRead,
singleArchive,
singleDelete,
singleNotSpam,
singleSpam,
singleToggleRead,
singleMoveTo,
onToggleSidebar,
inboxTabBarItems,
activeInboxTabId,
unseenInTabById,
tabUnseenSenderLineById,
handleCategoryInboxTabClick,
searchParams,
searchAccount,
allEmails,
setSearchFilter,
toggleSearchFilter,
setAdvancedOpen,
searchRouter,
buildSearchUrl,
variant = "list",
part = "all",
} = props
const [countsMounted, setCountsMounted] = useState(false)
useEffect(() => setCountsMounted(true), [])
const dropdownSurfaceClass = MAIL_MENU_SURFACE_CLASS
const openMailToolbar = (showBack: boolean) => (
<>
{showBack ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label="Retour à la boîte de réception"
onClick={goBack}
>
<ArrowLeft className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Retour à la boîte de réception
</TooltipContent>
</Tooltip>
) : null}
<div className="flex min-w-0 flex-wrap items-center gap-0.5 pl-1">
{openEmail?.spam === true ? (
<>
<div className="flex min-w-0 shrink-0 flex-wrap items-center gap-0.5">
<Button
type="button"
variant="ghost"
size="sm"
className="h-9 shrink-0 px-2.5 text-sm font-medium text-[#444746] hover:bg-[#f1f3f4]"
onClick={singleDelete}
>
Supprimer définitivement
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-9 shrink-0 px-2.5 text-sm font-medium text-[#444746] hover:bg-[#f1f3f4]"
onClick={singleNotSpam}
>
Non-spam
</Button>
</div>
<span className="mx-1 h-6 w-px shrink-0 bg-[#dadce0]" aria-hidden />
<div className="flex shrink-0 items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label="Archiver"
onClick={singleArchive}
>
<Archive className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Archiver
</TooltipContent>
</Tooltip>
</div>
<span className="mx-1 h-6 w-px shrink-0 bg-[#dadce0]" aria-hidden />
<div className="flex min-w-0 shrink-0 items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label={
viewModeIsRead ? "Marquer comme non lu" : "Marquer comme lu"
}
onClick={singleToggleRead}
>
{viewModeIsRead ? (
<Mail className="h-[18px] w-[18px]" strokeWidth={1.5} />
) : (
<MailOpen className="h-[18px] w-[18px]" strokeWidth={1.5} />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{viewModeIsRead ? "Marquer comme non lu" : "Marquer comme lu"}
</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-9 w-9 shrink-0 gap-1 px-0 text-[#444746] hover:bg-[#f1f3f4] lg:h-9 lg:w-auto lg:px-2"
aria-label="Déplacer vers"
>
<FolderInput
className="h-[18px] w-[18px] shrink-0"
strokeWidth={1.5}
/>
<span className="hidden max-w-32 truncate lg:inline">
Déplacer vers
</span>
<ChevronDown className="hidden h-3.5 w-3.5 shrink-0 opacity-70 lg:block" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={cn(dropdownSurfaceClass, "max-h-80 overflow-y-auto")}
>
<MoveToDropdownItems targets={moveTargets} onMoveTo={singleMoveTo} />
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
) : (
<>
<div className="flex shrink-0 items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label="Archiver"
onClick={singleArchive}
>
<Archive className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Archiver
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label="Signaler comme spam"
onClick={singleSpam}
>
<ShieldAlert className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Signaler comme spam
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label="Supprimer"
onClick={singleDelete}
>
<Trash2 className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Supprimer
</TooltipContent>
</Tooltip>
</div>
<span className="mx-1 h-6 w-px shrink-0 bg-[#dadce0]" aria-hidden />
<div className="flex min-w-0 shrink-0 items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label={
viewModeIsRead ? "Marquer comme non lu" : "Marquer comme lu"
}
onClick={singleToggleRead}
>
{viewModeIsRead ? (
<Mail className="h-[18px] w-[18px]" strokeWidth={1.5} />
) : (
<MailOpen className="h-[18px] w-[18px]" strokeWidth={1.5} />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{viewModeIsRead ? "Marquer comme non lu" : "Marquer comme lu"}
</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-9 w-9 shrink-0 gap-1 px-0 text-[#444746] hover:bg-[#f1f3f4] lg:h-9 lg:w-auto lg:px-2"
aria-label="Déplacer vers"
>
<FolderInput
className="h-[18px] w-[18px] shrink-0"
strokeWidth={1.5}
/>
<span className="hidden max-w-32 truncate lg:inline">
Déplacer vers
</span>
<ChevronDown className="hidden h-3.5 w-3.5 shrink-0 opacity-70 lg:block" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={cn(dropdownSurfaceClass, "max-h-80 overflow-y-auto")}
>
<MoveToDropdownItems targets={moveTargets} onMoveTo={singleMoveTo} />
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
)}
</div>
</>
)
const mailPaginationControls = (mode: "list" | "view") => {
const totalCount =
paginationTotal ??
(mode === "view" ? displayListEmails.length : paginationRangeEnd)
return (
<div
className={cn(
"flex shrink-0 items-center gap-2 whitespace-nowrap text-sm text-gray-600",
mode === "list" && "max-sm:hidden sm:flex"
)}
>
{displayListEmails.length === 0 ? (
<span>Aucun résultat</span>
) : mode === "view" ? (
<span className="hidden sm:inline">
{openMailIndex >= 0 ? openMailIndex + 1 : ""} sur {displayListEmails.length}
</span>
) : (
<span className="inline-flex items-center gap-1">
{paginationRangeStart} à{" "}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="rounded px-0.5 font-medium text-foreground underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Choisir le nombre de messages par page"
>
{paginationRangeEnd}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className={dropdownSurfaceClass}>
{LIST_PAGE_SIZE_OPTIONS.map((size) => (
<DropdownMenuItem
key={size}
onSelect={() => onListPageSizeChange(size)}
className={cn(size === listPageSize && "font-medium")}
>
{size} par page
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>{" "}
sur {totalCount}
</span>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-9 w-9",
mode === "view" && openMailIndex > 0
? "text-gray-600"
: mode === "list" && listPage > 1
? "text-gray-600"
: "text-gray-400"
)}
disabled={mode === "view" ? openMailIndex <= 0 : listPage <= 1}
onClick={mode === "view" ? goToPrev : goListPrevPage}
aria-label={mode === "view" ? "Plus récent" : "Page précédente"}
>
<ChevronLeft className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{mode === "view" ? "Plus récent" : "Page précédente"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-9 w-9",
mode === "view" && openMailIndex < displayListEmails.length - 1
? "text-gray-600"
: mode === "list" && listPage < totalPages
? "text-gray-600"
: "text-gray-400"
)}
disabled={
mode === "view"
? openMailIndex >= displayListEmails.length - 1
: listPage >= totalPages
}
onClick={mode === "view" ? goToNext : goListNextPage}
aria-label={mode === "view" ? "Plus ancien" : "Page suivante"}
>
<ChevronRight className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{mode === "view" ? "Plus ancien" : "Page suivante"}
</TooltipContent>
</Tooltip>
</div>
)
}
if (variant === "reading-pane") {
return (
<div className="relative z-20 flex shrink-0 min-h-12 items-start gap-2 border-b border-gray-200 py-1.5 pl-2 pr-4">
{openMailToolbar(false)}
<div className="flex-1" />
{mailPaginationControls("view")}
</div>
)
}
return (
<>
{part !== "list" && !isViewMode && (
<div className="relative z-20 flex shrink-0 items-center gap-2 border-b border-border bg-mail-surface px-4 py-2.5 sm:hidden">
<div className="min-w-0 flex-1">
<h1 className="truncate text-base font-semibold text-[#1f1f1f] leading-tight">
{mobileFolderLabel}
</h1>
<p className="text-xs text-[#5f6368] leading-snug">
{countsMounted ? (
<>
{displayListEmails.length} message
{displayListEmails.length !== 1 ? "s" : ""}
{mobileUnreadCount > 0 &&
` · ${mobileUnreadCount} non lu${mobileUnreadCount !== 1 ? "s" : ""}`}
</>
) : (
"…"
)}
</p>
</div>
<Button
type="button"
variant="ghost"
size={mobileSelectionMode ? "icon" : "sm"}
className={cn(
"shrink-0 text-[#444746]",
mobileSelectionMode
? "size-9 rounded-full border border-gray-200 bg-white/80 shadow-md backdrop-blur hover:bg-white"
: "h-9 min-h-9 gap-1.5 rounded-full border border-gray-200 bg-white/80 px-3 text-xs font-medium shadow-md backdrop-blur hover:bg-white"
)}
onClick={() => {
setMobileSelectionMode((p) => !p)
if (mobileSelectionMode) setSelectedEmails([])
}}
aria-label={mobileSelectionMode ? "Annuler la sélection" : "Sélection"}
>
{mobileSelectionMode ? (
<X className="size-[18px]" strokeWidth={1.5} />
) : (
<>
<CheckSquare className="size-4" strokeWidth={1.5} />
<span>Sélection</span>
</>
)}
</Button>
<DropdownMenu
open={mobileXsMoreMenuOpen}
onOpenChange={setMobileXsMoreMenuOpen}
>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-9 shrink-0 rounded-full border border-gray-200 bg-white/80 text-[#444746] shadow-md backdrop-blur hover:bg-white"
aria-label="Plus d'actions"
>
<MoreVertical className="size-[18px]" strokeWidth={1.5} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={4}
className={cn(dropdownSurfaceClass, "min-w-[260px]")}
>
{showBulkToolbar ? (
<>
<DropdownMenuItem onSelect={bulkArchive}>
<Archive className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
Archiver
</DropdownMenuItem>
<DropdownMenuItem onSelect={bulkDelete}>
<Trash2 className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
Supprimer
</DropdownMenuItem>
<DropdownMenuItem onSelect={bulkSpam}>
<ShieldAlert className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
Signaler comme spam
</DropdownMenuItem>
<DropdownMenuItem
onSelect={hasUnreadInSelection ? bulkMarkRead : bulkMarkUnread}
>
{hasUnreadInSelection ? (
<>
<MailOpen className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
Marquer comme lu
</>
) : (
<>
<Mail className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
Marquer comme non lu
</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
openMobileXsMoveSheet()
}}
>
<FolderInput className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
<span className="flex-1">Déplacer vers</span>
<ChevronRight className="ml-auto size-4 text-[#5f6368]" strokeWidth={1.5} />
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
openMobileXsLabelSheet()
}}
>
<Tag className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
<span className="flex-1">Ajouter le libellé</span>
<ChevronRight className="ml-auto size-4 text-[#5f6368]" strokeWidth={1.5} />
</DropdownMenuItem>
<DropdownMenuItem>
<VolumeX className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
Ignorer la conversation
</DropdownMenuItem>
</>
) : (
<>
<DropdownMenuItem onSelect={markAllInViewAsRead}>
<MailOpen className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
Tout marquer comme lu
</DropdownMenuItem>
<DropdownMenuSeparator />
<div
className="px-3 py-2 text-sm leading-snug text-[#5f6368] select-none"
role="note"
>
Sélectionnez des messages pour plus d&apos;actions
</div>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{part !== "mobile" && (
<>
{splitView ? (
<div className="flex max-sm:hidden shrink-0 items-center gap-2 border-b border-border bg-mail-surface px-2 py-2">
{onToggleSidebar ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-9 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label="Ouvrir le menu"
onClick={onToggleSidebar}
>
<Menu className="size-5" strokeWidth={1.5} />
</Button>
) : null}
<MailSearchBar compact className="min-w-0 flex-1" />
</div>
) : null}
{/* Toolbar — relative: scroll lives in sibling below */}
<div
className={cn(
"relative z-20 flex shrink-0 min-h-12 gap-2 border-b border-border bg-mail-surface py-1.5 pl-2 pr-4",
splitView ? "rounded-none" : "sm:rounded-t-2xl",
isViewMode ? "items-start" : "items-center",
(isViewMode ? !listToolbarMode : true) && "max-sm:hidden"
)}
>
{!splitView && isViewMode ? (
openMailToolbar(true)
) : (
/* ── LIST MODE TOOLBAR (original) ── */
<>
<DropdownMenu
open={bulkSelectMenuOpen}
onOpenChange={setBulkSelectMenuOpen}
>
<div
className={cn(
"flex items-center overflow-hidden rounded-md border pr-0 transition-[background-color,box-shadow,border-color]",
bulkSelectMenuOpen
? "border-[#dadce0] bg-[#f1f3f4] shadow-sm"
: "border-transparent"
)}
>
<div className="flex h-9 shrink-0 items-center pl-1 pr-0.5 md:pl-0">
<Checkbox
checked={selectAllChecked}
onCheckedChange={handleSelectAllChange}
className="size-4 min-h-4 min-w-4 shrink-0 rounded-[2.5px] 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 data-[state=indeterminate]:border-[#1a73e8] data-[state=indeterminate]:bg-[#1a73e8] data-[state=indeterminate]:text-white"
/>
</div>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"h-9 w-7 shrink-0 rounded-none p-0 text-[#5f6368]",
bulkSelectMenuOpen
? "border-l border-[#dadce0] hover:bg-[#e8eaed]"
: "hover:bg-[#f1f3f4]"
)}
aria-label="Options de sélection"
>
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent
align="start"
sideOffset={4}
className={cn(dropdownSurfaceClass, "min-w-[180px]")}
>
<DropdownMenuItem onSelect={selectMenuAll}>Tous</DropdownMenuItem>
<DropdownMenuItem onSelect={selectMenuNone}>Aucun</DropdownMenuItem>
<DropdownMenuItem onSelect={selectMenuRead}>Lus</DropdownMenuItem>
<DropdownMenuItem onSelect={selectMenuUnread}>Non lus</DropdownMenuItem>
<DropdownMenuItem onSelect={selectMenuStarred}>Suivis</DropdownMenuItem>
<DropdownMenuItem onSelect={selectMenuUnstarred}>
Non suivis
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{showBulkToolbar ? (
<>
<div className="flex min-w-0 items-center gap-0.5 pl-1">
<div className="flex shrink-0 items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label="Archiver"
onClick={bulkArchive}
>
<Archive className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Archiver
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label="Signaler comme spam"
onClick={bulkSpam}
>
<ShieldAlert className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Signaler comme spam
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label="Supprimer"
onClick={bulkDelete}
>
<Trash2 className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Supprimer
</TooltipContent>
</Tooltip>
</div>
<span
className="mx-1 h-6 w-px shrink-0 bg-[#dadce0]"
aria-hidden
/>
<div className="flex min-w-0 shrink-0 items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label={
hasUnreadInSelection
? "Marquer comme lu"
: "Marquer comme non lu"
}
onClick={() =>
hasUnreadInSelection ? bulkMarkRead() : bulkMarkUnread()
}
>
{hasUnreadInSelection ? (
<MailOpen className="h-[18px] w-[18px]" strokeWidth={1.5} />
) : (
<Mail className="h-[18px] w-[18px]" strokeWidth={1.5} />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{hasUnreadInSelection
? "Marquer comme lu"
: "Marquer comme non lu"}
</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-9 w-9 shrink-0 gap-1 px-0 text-[#444746] hover:bg-[#f1f3f4] lg:h-9 lg:w-auto lg:px-2"
aria-label="Déplacer vers"
>
<FolderInput className="h-[18px] w-[18px] shrink-0" strokeWidth={1.5} />
<span className="hidden max-w-32 truncate lg:inline">
Déplacer vers
</span>
<ChevronDown className="hidden h-3.5 w-3.5 shrink-0 opacity-70 lg:block" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={cn(dropdownSurfaceClass, "max-h-80 overflow-y-auto")}
>
<MoveToDropdownItems targets={moveTargets} onMoveTo={bulkMoveTo} />
</DropdownMenuContent>
</DropdownMenu>
</div>
<span
className="mx-1 h-6 w-px shrink-0 bg-[#dadce0]"
aria-hidden
/>
<DropdownMenu
onOpenChange={(open) => {
if (!open) setLabelPickerQuery("")
}}
>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label="Plus d'actions"
>
<MoreVertical className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={cn(
dropdownSurfaceClass,
/* Sous-menus Radix restent dans ce nœud : overflow-auto les clippe */
"overflow-visible"
)}
>
<DropdownMenuItem>
<Clock className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
Mettre en attente
</DropdownMenuItem>
<DropdownMenuItem>
<ListTodo className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
Ajouter à Tasks
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger className="[&>svg:last-child]:text-[#5f6368]">
<Tag className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
Ajouter le libellé
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
className={cn(
dropdownSurfaceClass,
"z-[100] flex max-h-72 min-w-[260px] flex-col overflow-hidden p-0 py-0"
)}
>
<EmailLabelPickerBlock
query={labelPickerQuery}
onQueryChange={setLabelPickerQuery}
catalogLabels={catalogLabels}
resolveLabelVisual={resolveLabelVisual}
Item={DropdownMenuItem}
getLabelPresence={(lab) =>
getCatalogLabelPresence(bulkTargetIds, lab)
}
onToggleCatalogLabel={(lab) =>
toggleLabelOnEmails(bulkTargetIds, lab)
}
onCreateLabel={(lab) => {
addLabelToEmails(bulkTargetIds, lab)
setLabelPickerQuery("")
}}
/>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem>
<VolumeX className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
Ignorer la conversation
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<SquareArrowOutUpRight
className="size-[18px] text-[#5f6368]"
strokeWidth={1.5}
/>
Ouvrir dans une nouvelle fenêtre
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
) : (
<>
<Button
type="button"
variant="ghost"
size="icon"
className="hidden h-9 w-9 text-gray-600 sm:inline-flex"
aria-label="Rafraîchir"
aria-busy={isRefreshing}
disabled={isRefreshing}
onClick={() => void handleManualRefresh()}
>
<RefreshCw
className={cn(
"h-4 w-4",
isRefreshing && REFRESH_SPIN_CLASS
)}
/>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label="Plus d'actions"
>
<MoreVertical
className="h-[18px] w-[18px]"
strokeWidth={1.5}
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
sideOffset={4}
className={cn(dropdownSurfaceClass, "min-w-[260px]")}
>
<DropdownMenuItem onSelect={markAllInViewAsRead}>
<MailOpen
className="size-[18px] text-[#5f6368]"
strokeWidth={1.5}
/>
Tout marquer comme lu
</DropdownMenuItem>
<DropdownMenuSeparator />
<div
className="px-3 py-2 text-sm leading-snug text-[#5f6368] select-none"
role="note"
>
Sélectionnez des messages pour afficher plus d&apos;actions
</div>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</>
)}
<div className="flex-1" />
{listToolbarMode && !infiniteScroll ? mailPaginationControls("list") : null}
{!splitView && !listToolbarMode ? mailPaginationControls("view") : null}
</div>
{selectedFolder === "inbox" && (
<div className="relative z-10 w-full shrink-0 bg-mail-surface after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:z-0 after:h-px after:bg-border">
{listToolbarMode &&
(compactInboxTabs ? (
<CompactInboxCategoryTabs
tabs={inboxTabBarItems}
activeTabId={activeInboxTabId}
unseenInTabById={unseenInTabById}
onTabClick={handleCategoryInboxTabClick}
/>
) : (
<div
className="grid w-full min-w-0 max-w-[1260px]"
style={{
gridTemplateColumns: `repeat(${inboxTabBarItems.length}, minmax(0, 1fr))`,
}}
>
{inboxTabBarItems.map((tab) => {
const isActive = activeInboxTabId === tab.id
const accentColor = isActive
? inboxTabActiveAccentColor(tab.id, tab.badgeColor)
: undefined
const unseen = unseenInTabById[tab.id] ?? 0
const senderLine = tabUnseenSenderLineById[tab.id] ?? ""
const showMeta =
inboxTabShowsInactiveMeta(tab.id) && !isActive && unseen > 0
const showSenderLine = showMeta && Boolean(senderLine)
const isExpandedTabMeta = showSenderLine
return (
<button
key={tab.id}
type="button"
aria-label={tab.label}
aria-current={isActive ? "true" : undefined}
onClick={() => handleCategoryInboxTabClick(tab.id)}
style={
accentColor
? { boxShadow: `inset 0 -3px 0 0 ${accentColor}` }
: undefined
}
className={cn(
"relative z-[1] flex cursor-pointer transition-colors",
"min-w-0 w-full overflow-hidden max-sm:min-h-10 max-sm:items-center max-sm:justify-center",
"sm:min-h-14 sm:items-center sm:py-2 sm:text-left",
!isActive && "hover:bg-[#f1f3f4]"
)}
>
<>
<div className="flex h-10 w-full items-center justify-center sm:hidden">
<div className="relative inline-flex shrink-0">
<Icon
icon={tab.icon}
className={cn(
CATEGORY_TAB_ICON_CLASS,
MAIL_INBOX_CATEGORY_TAB_CONTENT_DARK_CLASS,
!isActive && "text-[#5f6368]"
)}
style={accentColor ? { color: accentColor } : undefined}
aria-hidden
/>
{showMeta && unseen > 0 ? (
<span
className={inboxTabBadgeDotClass(tab.badgeColor)}
aria-hidden
/>
) : null}
</div>
</div>
<div className="hidden min-w-0 flex-1 items-center gap-2 mx-2 sm:mx-3 sm:flex">
<Icon
icon={tab.icon}
className={cn(
CATEGORY_TAB_ICON_CLASS,
"self-center",
MAIL_INBOX_CATEGORY_TAB_CONTENT_DARK_CLASS,
!isActive && "text-[#5f6368]"
)}
style={accentColor ? { color: accentColor } : undefined}
aria-hidden
/>
<div className="flex min-w-0 w-0 flex-1 flex-col gap-px">
<div
className={cn(
"flex min-w-0 items-center gap-1.5",
isExpandedTabMeta && "min-h-5"
)}
>
<span
className={cn(
"min-w-0 flex-1 truncate text-[13px] font-semibold leading-tight",
MAIL_INBOX_CATEGORY_TAB_CONTENT_DARK_CLASS,
!isActive && "text-[#3c4043]"
)}
style={accentColor ? { color: accentColor } : undefined}
>
{tab.label}
</span>
{showMeta && unseen > 0 ? (
<span className={inboxTabBadgeCountClass(tab.badgeColor)}>
{unseen}
<span className="hidden md:inline">
{" "}
{unseen === 1 ? "nouveau" : "nouveaux"}
</span>
</span>
) : null}
</div>
{isExpandedTabMeta ? (
<span
className={cn(
"block min-h-4 min-w-0 truncate text-[11px] leading-snug text-[#5f6368]",
MAIL_INBOX_CATEGORY_TAB_CONTENT_DARK_CLASS
)}
>
{senderLine}
</span>
) : null}
</div>
</div>
</>
</button>
)
})}
</div>
))}
</div>
)}
{isSearchMode && searchParams && listToolbarMode && (
<div className="flex w-full shrink-0 items-center gap-1.5 overflow-x-auto border-b border-[#dadce0] bg-mail-surface px-3 py-1.5 text-xs dark:border-gray-700">
{/* De dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"flex shrink-0 items-center gap-1 rounded-full border px-2.5 py-1 transition-colors",
searchParams.from
? "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
: "border-[#dadce0] text-[#5f6368] hover:bg-[#f1f3f4] dark:border-gray-600 dark:text-gray-400"
)}
>
<UserIcon className="size-3" strokeWidth={2} />
De{searchParams.from ? ` : ${searchParams.from}` : ""}
<ChevronDown className="size-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={MAIL_MENU_SURFACE_CLASS}>
<DropdownMenuItem onSelect={() => setSearchFilter({ from: "" })}>
N&apos;importe qui
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setSearchFilter({ from: searchAccount?.email ?? "" })}>
De moi ({searchAccount?.email})
</DropdownMenuItem>
<DropdownMenuSeparator />
{Array.from(new Set(allEmails.map((e) => e.senderEmail).filter(Boolean))).slice(0, 8).map((addr) => (
<DropdownMenuItem key={addr} onSelect={() => setSearchFilter({ from: addr! })}>
{addr}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Date dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"flex shrink-0 items-center gap-1 rounded-full border px-2.5 py-1 transition-colors",
searchParams.within
? "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
: "border-[#dadce0] text-[#5f6368] hover:bg-[#f1f3f4] dark:border-gray-600 dark:text-gray-400"
)}
>
<Clock className="size-3" strokeWidth={2} />
{searchParams.within
? DATE_RANGE_OPTIONS.find((o) => o.value === searchParams.within)?.label ?? searchParams.within
: "Indifférente"}
<ChevronDown className="size-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={MAIL_MENU_SURFACE_CLASS}>
<DropdownMenuItem onSelect={() => setSearchFilter({ within: "" })}>
Indifférente
</DropdownMenuItem>
<DropdownMenuSeparator />
{DATE_RANGE_OPTIONS.map((opt) => (
<DropdownMenuItem key={opt.value} onSelect={() => setSearchFilter({ within: opt.value })}>
{opt.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Contient une pièce jointe */}
<button
type="button"
onClick={() => toggleSearchFilter("has", "attachment")}
className={cn(
"flex shrink-0 items-center gap-1 rounded-full border px-2.5 py-1 transition-colors",
searchParams.has.includes("attachment")
? "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
: "border-[#dadce0] text-[#5f6368] hover:bg-[#f1f3f4] dark:border-gray-600 dark:text-gray-400"
)}
>
<Paperclip className="size-3" strokeWidth={2} />
Pièces jointes
</button>
{/* Exclure les mises à jour d'agenda */}
<button
type="button"
onClick={() => toggleSearchFilter("excludeChats", "true")}
className={cn(
"flex shrink-0 items-center gap-1 rounded-full border px-2.5 py-1 transition-colors",
searchParams.excludeChats
? "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
: "border-[#dadce0] text-[#5f6368] hover:bg-[#f1f3f4] dark:border-gray-600 dark:text-gray-400"
)}
>
<CalendarX2 className="size-3" strokeWidth={2} />
Exclure les mises à jour d&apos;agenda
</button>
{/* À dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"flex shrink-0 items-center gap-1 rounded-full border px-2.5 py-1 transition-colors",
searchParams.to
? "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
: "border-[#dadce0] text-[#5f6368] hover:bg-[#f1f3f4] dark:border-gray-600 dark:text-gray-400"
)}
>
<Send className="size-3" strokeWidth={2} />
À{searchParams.to ? ` : ${searchParams.to}` : ""}
<ChevronDown className="size-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={MAIL_MENU_SURFACE_CLASS}>
<DropdownMenuItem onSelect={() => setSearchFilter({ to: "" })}>
N&apos;importe qui
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setSearchFilter({ to: searchAccount?.email ?? "" })}>
À moi ({searchAccount?.email})
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Non lu */}
<button
type="button"
onClick={() => {
if (!searchParams) return
const next = { ...searchParams }
if (next.q.includes("is:unread")) {
next.q = next.q.replace(/\s*is:unread\s*/g, "").trim()
} else {
next.q = (next.q + " is:unread").trim()
}
searchRouter.push(buildSearchUrl(next))
}}
className={cn(
"flex shrink-0 items-center gap-1 rounded-full border px-2.5 py-1 transition-colors",
searchParams.q.includes("is:unread")
? "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
: "border-[#dadce0] text-[#5f6368] hover:bg-[#f1f3f4] dark:border-gray-600 dark:text-gray-400"
)}
>
<MailOpen className="size-3" strokeWidth={2} />
Non lu
</button>
{/* Recherche avancée */}
<button
type="button"
onClick={() => setAdvancedOpen(true)}
className="ml-auto shrink-0 px-2 py-1 text-xs font-medium text-[#1a73e8] hover:text-[#1765cc] dark:text-blue-400"
>
Recherche avancée
</button>
</div>
)}
</>
)}
</>
)
}