ultisuite-client/components/gmail/email-list.tsx
2026-05-15 17:40:17 +02:00

3834 lines
152 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 {
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 quon 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
}
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<SVGSVGElement>) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
</svg>
)
}
function ListAttachmentChip({ att }: { att: EmailAttachment }) {
return (
<span className="inline-flex max-w-[min(100%,280px)] min-w-0 shrink items-center gap-1.5 rounded-full border border-[#dadce0] bg-transparent px-2.5 py-1 text-[13px] leading-snug text-[#3c4043]">
{att.kind === "pdf" ? (
<File className="size-4 shrink-0 fill-[#d93025]" strokeWidth={0} aria-hidden />
) : att.kind === "image" ? (
<ImageIcon
className="size-4 shrink-0 text-[#5f6368] [&_circle]:fill-none [&_path]:fill-none [&_path]:stroke-current [&_rect]:fill-current [&_rect]:opacity-[0.32]"
strokeWidth={1.5}
aria-hidden
/>
) : (
<File className="size-4 shrink-0 fill-[#5f6368]" strokeWidth={0} aria-hidden />
)}
<span className="min-w-0 truncate">{att.name}</span>
</span>
)
}
function EmailListAttachmentRow({
emailId,
attachments,
}: {
emailId: string
attachments: EmailAttachment[]
}) {
const containerRef = useRef<HTMLDivElement>(null)
const measureRef = useRef<HTMLDivElement>(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 (
<div ref={containerRef} className="relative min-w-0 w-full">
{attachments.length > 1 && (
<div
ref={measureRef}
className="pointer-events-none invisible absolute left-0 top-0 z-[-1] flex w-max flex-nowrap gap-1.5"
aria-hidden
>
{attachments.map((att, idx) => (
<ListAttachmentChip key={`${emailId}-m-${idx}`} att={att} />
))}
</div>
)}
<div className="flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden">
{collapsed && attachments.length > 1 ? (
<>
<ListAttachmentChip att={attachments[0]!} />
<span
className="shrink-0 rounded-full border border-[#dadce0] bg-[#f1f3f4] px-2.5 py-1 text-[13px] leading-snug text-[#5f6368]"
title={othersTitle}
>
{othersLabel}
</span>
</>
) : (
attachments.map((att, idx) => (
<ListAttachmentChip key={`${emailId}-v-${idx}`} att={att} />
))
)}
</div>
</div>
)
}
function MoveToDropdownItems({
targets,
onMoveTo,
}: {
targets: { recents: MoveTarget[]; system: MoveTarget[]; folders: MoveTarget[] }
onMoveTo: (targetId: string) => void
}) {
return (
<>
{targets.recents.length > 0 && (
<>
<div className="px-3 py-1.5 text-[11px] font-medium uppercase tracking-wide text-[#5f6368]">
Récents
</div>
{targets.recents.map((t) => (
<DropdownMenuItem key={`recent-${t.id}`} onSelect={() => onMoveTo(t.id)}>
<span className="flex items-center gap-2">
{t.icon}
<Clock className="size-3 shrink-0 text-[#9aa0a6]" strokeWidth={1.5} />
</span>
{t.label}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</>
)}
{targets.system.map((t) => (
<DropdownMenuItem key={t.id} onSelect={() => onMoveTo(t.id)}>
{t.icon}
{t.label}
</DropdownMenuItem>
))}
{targets.folders.length > 0 && (
<>
<DropdownMenuSeparator />
<div className="px-3 py-1.5 text-[11px] font-medium uppercase tracking-wide text-[#5f6368]">
Dossiers
</div>
{targets.folders.map((t) => (
<DropdownMenuItem
key={t.id}
onSelect={() => onMoveTo(t.id)}
style={{ paddingLeft: `${12 + t.depth * 16}px` }}
>
{t.icon}
{t.label}
</DropdownMenuItem>
))}
</>
)}
</>
)
}
function MoveToContextMenuItems({
targets,
onMoveTo,
}: {
targets: { recents: MoveTarget[]; system: MoveTarget[]; folders: MoveTarget[] }
onMoveTo: (targetId: string) => void
}) {
return (
<>
{targets.recents.length > 0 && (
<>
<div className="px-3 py-1.5 text-[11px] font-medium uppercase tracking-wide text-[#5f6368]">
Récents
</div>
{targets.recents.map((t) => (
<ContextMenuItem key={`recent-${t.id}`} onSelect={() => onMoveTo(t.id)}>
<span className="flex items-center gap-2">
{t.icon}
<Clock className="size-3 shrink-0 text-[#9aa0a6]" strokeWidth={1.5} />
</span>
{t.label}
</ContextMenuItem>
))}
<ContextMenuSeparator />
</>
)}
{targets.system.map((t) => (
<ContextMenuItem key={t.id} onSelect={() => onMoveTo(t.id)}>
{t.icon}
{t.label}
</ContextMenuItem>
))}
{targets.folders.length > 0 && (
<>
<ContextMenuSeparator />
<div className="px-3 py-1.5 text-[11px] font-medium uppercase tracking-wide text-[#5f6368]">
Dossiers
</div>
{targets.folders.map((t) => (
<ContextMenuItem
key={t.id}
onSelect={() => onMoveTo(t.id)}
style={{ paddingLeft: `${12 + t.depth * 16}px` }}
>
{t.icon}
{t.label}
</ContextMenuItem>
))}
</>
)}
</>
)
}
interface EmailListProps {
selectedFolder: string
/** Onglet catégories (boîte de réception), depuis lURL. */
inboxTab: string
/** Page de liste (1-based), depuis lURL. */
listPage: number
openMailId: string | null
onMailRouteNavigate: (patch: Partial<MailRouteState>) => void
onSelectFolder?: (folder: string) => void
onFolderUnreadCountsChange?: (counts: Record<string, number>) => 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<MailNavFolderMaps>(
() => ({
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 dactions reste visible (évite saut dancrage). */
panelOpen: boolean
} | null>(null)
const rescheduleDismissTimeoutsRef = useRef<
Map<string, ReturnType<typeof setTimeout>>
>(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<string[]>([])
const readOverrides = useMailStore((s) => s.readOverrides)
const labelEdits = useMailStore((s) => s.labelEdits)
const mailActions = useRef(useMailStore.getState()).current
const setReadOverrides = useCallback(
(updater: (prev: Record<string, boolean>) => Record<string, boolean>) => {
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<string[]>([])
const lastSelectionAnchorIdRef = useRef<string | null>(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<HTMLDivElement>(null)
const pullContentRef = useRef<HTMLDivElement>(null)
const pullIconRef = useRef<SVGSVGElement>(null)
const pullTouchStartYRef = useRef(0)
const pullActiveRef = useRef(false)
const pullYRef = useRef(0)
const pullRafRef = useRef<number | null>(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<string> => {
const s = new Set<string>()
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<string>()
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 dexpé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<string, number> = {}
const preview: Record<string, string> = {}
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<string>()
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<HTMLDivElement>) => {
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
? `<p style="color:#5f6368">${escapeHtml(email.preview)}</p>`
: "<p></p>")
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<HTMLElement>("[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 (
<div className="flex h-full min-h-0 flex-1 flex-col">
{/* Mobile xs top bar */}
{isXs && !isViewMode && (
<div className="relative z-20 flex shrink-0 items-center gap-2 border-b border-gray-200 bg-white px-4 py-2.5">
<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">
{filteredEmails.length} message{filteredEmails.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"
: "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"
)}
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"
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>
<MobileXsBulkSheets
moveSheetOpen={mobileXsMoveSheetOpen}
onMoveSheetOpenChange={handleMobileXsMoveSheetOpenChange}
labelSheetOpen={mobileXsLabelSheetOpen}
onLabelSheetOpenChange={setMobileXsLabelSheetOpen}
labelPickerQuery={labelPickerQuery}
onLabelPickerQueryChange={setLabelPickerQuery}
catalogLabels={catalogLabels}
moveTargets={moveTargets}
onMoveTo={bulkMoveTo}
getLabelPresence={(lab) => getCatalogLabelPresence(bulkTargetIds, lab)}
onToggleCatalogLabel={(lab) => toggleLabelOnEmails(bulkTargetIds, lab)}
onCreateLabel={(lab) => {
addLabelToEmails(bulkTargetIds, lab)
setLabelPickerQuery("")
}}
/>
</div>
)}
{/* Toolbar — relative: scroll lives in sibling below */}
<div
className={cn(
"relative z-20 flex shrink-0 min-h-12 gap-2 rounded-t-2xl border-b border-gray-200 bg-white py-1.5 pl-2 pr-4",
isViewMode ? "items-start" : "items-center",
isXs && !isViewMode && "hidden"
)}
>
{isViewMode ? (
/* ── VIEW MODE TOOLBAR ── */
<TooltipProvider delayDuration={400}>
<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>
<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>
</TooltipProvider>
) : (
/* ── 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-white shadow-none dark:bg-white 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 ? (
<TooltipProvider delayDuration={400}>
<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}
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>
</TooltipProvider>
) : (
<>
<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 handleRefreshMessages()}
>
<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" />
{/* Pagination — liste (pages) ou vue message (position dans le filtre) */}
<div
className={cn(
"flex shrink-0 items-center gap-2 whitespace-nowrap text-sm text-gray-600",
!isViewMode && isXs && "hidden sm:flex"
)}
>
{filteredEmails.length === 0 ? (
<span>Aucun résultat</span>
) : isViewMode ? (
<span className="hidden sm:inline">
{openMailIndex >= 0 ? openMailIndex + 1 : ""} sur {filteredEmails.length}
</span>
) : (
<span>
{(listPage - 1) * LIST_PAGE_SIZE + 1}
{Math.min(listPage * LIST_PAGE_SIZE, filteredEmails.length)} sur{" "}
{filteredEmails.length}
{totalPages > 1 ? ` · p. ${listPage}/${totalPages}` : null}
</span>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-9 w-9",
isViewMode && openMailIndex > 0
? "text-gray-600"
: !isViewMode && listPage > 1
? "text-gray-600"
: "text-gray-400"
)}
disabled={isViewMode ? openMailIndex <= 0 : listPage <= 1}
onClick={isViewMode ? goToPrev : goListPrevPage}
aria-label={isViewMode ? "Plus récent" : "Page précédente"}
>
<ChevronLeft className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{isViewMode ? "Plus récent" : "Page précédente"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-9 w-9",
isViewMode && openMailIndex < filteredEmails.length - 1
? "text-gray-600"
: !isViewMode && listPage < totalPages
? "text-gray-600"
: "text-gray-400"
)}
disabled={
isViewMode
? openMailIndex >= filteredEmails.length - 1
: listPage >= totalPages
}
onClick={isViewMode ? goToNext : goListNextPage}
aria-label={isViewMode ? "Plus ancien" : "Page suivante"}
>
<ChevronRight className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{isViewMode ? "Plus ancien" : "Page suivante"}
</TooltipContent>
</Tooltip>
</div>
</div>
{selectedFolder === "inbox" && (
<div className="relative z-10 w-full shrink-0 bg-white after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:z-0 after:h-px after:bg-[#dadce0]">
{!isViewMode && (
<div
className="grid w-full max-w-[1260px] min-w-0"
style={{
gridTemplateColumns: `repeat(${categoryTabs.length}, minmax(0, 1fr))`,
}}
>
{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 (
<button
key={tab.id}
type="button"
onClick={() => handleCategoryInboxTabClick(tab.id)}
className={cn(
"relative z-[1] flex min-w-0 w-full cursor-pointer overflow-hidden transition-colors",
"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 && "shadow-[inset_0_-3px_0_0_#0b57d0]",
!isActive && "hover:bg-[#f1f3f4]"
)}
>
<div className="flex h-10 w-full items-center justify-center sm:hidden">
<div className="relative inline-flex shrink-0">
<tab.icon
strokeWidth={CATEGORY_TAB_ICON_STROKE}
className={cn(
"h-4 w-4 shrink-0",
isActive ? "text-[#0b57d0]" : "text-[#5f6368]"
)}
/>
{showMeta && unseen > 0 && tab.badgeTone ? (
<span
className={categoryBadgeDotClass(tab.badgeTone)}
aria-hidden
/>
) : null}
</div>
</div>
<div className="hidden min-w-0 flex-1 items-center gap-2 mx-2 sm:mx-3 sm:flex">
<tab.icon
strokeWidth={CATEGORY_TAB_ICON_STROKE}
className={cn(
"h-4 w-4 shrink-0 self-center",
isActive ? "text-[#0b57d0]" : "text-[#5f6368]"
)}
/>
<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",
isActive ? "text-[#0b57d0]" : "text-[#3c4043]"
)}
>
{tab.label}
</span>
{showMeta && unseen > 0 && tab.badgeTone ? (
<span className={categoryBadgeClass(tab.badgeTone)}>
{unseen}
<span className="hidden md:inline">
{" "}
{unseen === 1 ? "nouveau" : "nouveaux"}
</span>
</span>
) : null}
</div>
{isExpandedTabMeta ? (
<span className="block min-h-4 min-w-0 truncate text-[11px] leading-snug text-[#5f6368]">
{senderLine}
</span>
) : null}
</div>
</div>
</button>
)
})}
</div>
)}
</div>
)}
<div
ref={listViewportRef}
className={cn(mainScrollClass, "relative overscroll-y-none", isXs && "pb-16")}
>
{isXs && !isViewMode ? (
<div
className="pointer-events-none absolute inset-x-0 top-0 z-10 flex items-center justify-center pt-2"
style={{ height: PULL_HOLD_HEIGHT }}
aria-hidden
>
<RefreshCw
ref={pullIconRef}
className={cn(
"h-5 w-5 text-[#5f6368]",
isRefreshing && REFRESH_SPIN_CLASS
)}
style={{ opacity: 0 }}
/>
</div>
) : null}
<div
ref={pullContentRef}
className={cn(
isXs && !isViewMode && "min-h-full [transform:translateZ(0)]",
!isXs && !isViewMode && "flex min-h-full flex-col"
)}
>
{isViewMode && openEmail ? (
/* ── EMAIL VIEW ── */
<EmailView
email={openEmail}
onToggleStar={toggleStar}
isStarred={starredEmails.includes(openEmail.id) || openEmail.starred}
onNavigateToLabel={handleNavigateToLabel}
onNotSpam={openEmail.spam === true ? singleNotSpam : undefined}
labelBgByText={listRowLabelBgByTextLower}
emailLabelToSidebarFolderId={sidebarNav.emailLabelToSidebarFolderId}
getNavItemPrefs={sidebarNav.getNavItemPrefs}
showLabelChip={(lab) => {
if (LABEL_PICKER_EXCLUDE.has(lab)) return true
const fid = sidebarNav.emailLabelToSidebarFolderId[lab]
if (!fid) return true
return sidebarNav.getNavItemPrefs(fid).messages !== "hide"
}}
/>
) : (
<TooltipProvider delayDuration={400}>
<>
{selectedFolder === "scheduled" && (
<div className="flex shrink-0 items-start gap-3 border-b border-[#eceff1] bg-[#f8f9fa] px-4 py-3">
<Clock
className="h-5 w-5 shrink-0 text-[#5f6368]"
strokeWidth={1.5}
aria-hidden
/>
<p className="text-sm leading-snug text-[#3c4043]">
Les messages de la liste «&nbsp;Envois programmés&nbsp;» seront envoyés à l&apos;heure prévue pour chacun d&apos;eux.
</p>
</div>
)}
{filteredEmails.length === 0 ? (
selectedFolder === "scheduled" ? (
<div className="flex min-h-[220px] flex-col items-center justify-center px-4 py-12 text-center">
<p className="text-sm text-[#5f6368]">Aucun message planifié.</p>
</div>
) : (
<Empty className="min-h-[240px] flex-1 border-0 bg-white py-10 shadow-none">
<EmptyHeader className="max-w-md">
<EmptyMedia
variant="icon"
className="mb-1 border-0 bg-[#f1f3f4] text-[#5f6368] [&_svg]:size-6"
>
<Mail className="size-6" strokeWidth={1.5} aria-hidden />
</EmptyMedia>
<EmptyTitle className="text-[15px] font-medium text-[#3c4043]">
Aucun message
</EmptyTitle>
<EmptyDescription className="text-[13px] text-[#5f6368]">
{selectedFolder === "inbox" ? (
<>
Aucun message dans l&apos;onglet{" "}
<span className="font-medium text-[#3c4043]">
{inboxCategoryTabLabel}
</span>{" "}
de la boîte de réception.
</>
) : (
<>
Aucun message dans{" "}
<span className="font-medium text-[#3c4043]">
{getMailNavFolderLabel(selectedFolder, sidebarNav.folderIdToLabel)}
</span>
.
</>
)}
</EmptyDescription>
</EmptyHeader>
</Empty>
)
) : (
<div className="divide-y divide-[#eceff1]">
{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 (
<ContextMenu
key={email.id}
modal={false}
onOpenChange={(open) => {
if (open) {
rowContextMenuOpenedAtRef.current = Date.now()
setSelectedEmails((prev) => {
const next = contextMenuTargetIdsForRow(
email.id,
prev,
selectedFolder,
allEmails
)
contextMenuTargetIdsRef.current = [...next]
return next
})
} else {
setLabelPickerQuery("")
}
}}
>
<ContextMenuTrigger asChild>
<div
data-email-row-id={email.id}
draggable={!isXs}
onDragStart={isXs ? undefined : (e) => 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 */}
<div
className={cn(
"flex w-full min-w-0 md:hidden",
isXs && mobileSelectionMode ? "items-center gap-2" : "flex-col gap-0.5"
)}
>
{isXs && mobileSelectionMode && (
<div
className="flex shrink-0 self-center"
onClick={(e) => e.stopPropagation()}
onClickCapture={(e) => handleRowCheckboxClickCapture(email.id, e)}
>
<Checkbox
className={listRowCheckboxClass(true)}
checked={isSelected}
onCheckedChange={() => {
toggleSelect(email.id)
lastSelectionAnchorIdRef.current = email.id
}}
/>
</div>
)}
<div
className={cn(
"flex min-w-0 flex-1 flex-col gap-0.5",
isXs && mobileSelectionMode && "pointer-events-none"
)}
>
<div className="flex w-full min-w-0 items-center gap-2">
{!isXs && (
<div
className="flex shrink-0 items-center"
onClick={(e) => e.stopPropagation()}
onClickCapture={(e) => handleRowCheckboxClickCapture(email.id, e)}
>
<Checkbox
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"
checked={isSelected}
onCheckedChange={() => {
toggleSelect(email.id)
lastSelectionAnchorIdRef.current = email.id
}}
/>
</div>
)}
<div className="flex min-w-0 flex-1 items-center justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-1">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
toggleImportant(email.id)
}}
className={cn(
"flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-full",
isSpam
? "text-[#d93025] hover:bg-[#d93025]/10 hover:text-[#b3261e]"
: "text-[#c2c2c2] hover:bg-black/4 hover:text-[#5f6368]"
)}
aria-label={
isSpam
? "Marqué comme spam"
: isImportant
? "Retirer important"
: "Marquer important"
}
>
<Icon
icon={importantSignalIcon(isSpam, isImportant)}
className={cn(
"size-4 shrink-0",
isSpam && "text-[#d93025]",
!isSpam &&
(isImportant ? "text-[#f4cc70]" : "text-[#c2c2c2]")
)}
aria-hidden
/>
</button>
{isScheduled && (
<span
className="flex h-7 w-6 shrink-0 items-center justify-center text-[#5f6368]"
aria-hidden
>
<Send className="size-3.5" strokeWidth={2} />
</span>
)}
{isScheduled ? (
<span
className={cn(
"min-w-0 truncate text-sm",
!isRead ? "font-semibold text-gray-900" : "font-normal text-gray-700"
)}
>
À : {email.scheduledToName ?? email.sender}
</span>
) : (
<ContactHoverCard displayName={email.sender} email={senderHoverEmail}>
<span
className={cn(
"min-w-0 truncate text-sm",
!isRead ? "font-semibold text-gray-900" : "font-normal text-gray-700"
)}
>
{showDraftBadge && (
<span className="font-medium text-[#d93025]">Brouillon </span>
)}
{email.sender}
</span>
</ContactHoverCard>
)}
{email.participantCount != null && email.participantCount > 1 && (
<span className="shrink-0 text-sm font-normal text-gray-500">
{email.participantCount}
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{(parsedInvitation || hasInvitation) && (
<Icon
icon={
parsedInvitation
? VIDEO_CONFERENCE_LOGOS[
parsedInvitation.conferenceProvider
]
: "mdi:calendar"
}
className="size-4 shrink-0 text-[#5f6368]"
aria-label={
parsedInvitation
? "Invitation visioconférence"
: "Invitation calendrier"
}
/>
)}
{attachmentList.length > 0 && (
<Paperclip
className="size-4 shrink-0 text-[#5f6368]"
strokeWidth={1.75}
aria-label="Pièces jointes"
/>
)}
<span
className={cn(
"shrink-0 text-sm font-semibold tabular-nums tracking-tight",
!isRead ? "text-gray-900" : "text-gray-700"
)}
>
{isScheduled
? formatScheduledDateTimeDisplay(email.scheduledSendAt)
: email.date}
</span>
</div>
</div>
</div>
<div className={cn("flex min-w-0 flex-wrap items-center gap-1", !isXs && "pl-6")}>
{email.tag && (
<span className="shrink-0 rounded bg-gray-200 px-1.5 py-0.5 text-xs text-gray-600 opacity-[0.92]">
{email.tag}
</span>
)}
<MailLabelPillStrip
variant="list"
labels={email.labels}
labelBgByText={listRowLabelBgByTextLower}
emailLabelToSidebarFolderId={sidebarNav.emailLabelToSidebarFolderId}
getNavItemPrefs={sidebarNav.getNavItemPrefs}
onLabelNavigate={handleNavigateToLabel}
currentFolderId={selectedFolder}
folderTree={sidebarNav.folderTree}
/>
<span
className={cn(
"min-w-0 flex-1 text-sm leading-snug line-clamp-1",
!isRead ? "font-semibold text-gray-900" : "font-semibold text-[#202124]"
)}
>
{email.subject}
</span>
</div>
<div className={cn("flex min-w-0 items-start gap-1.5", !isXs && "pl-6")}>
<p className="min-w-0 flex-1 text-sm leading-snug text-[#5f6368] line-clamp-1">
{email.preview}
</p>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
toggleStar(email.id)
}}
className="mt-0.5 flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-full text-[#c2c2c2] hover:bg-black/4 hover:text-[#5f6368]"
aria-label={isStarred ? "Retirer des favoris" : "Marquer comme favori"}
>
<Star
strokeWidth={isStarred ? 0 : 1.25}
className={cn(
"size-4",
isStarred
? "fill-[#f4cc70] stroke-none text-[#f4cc70]"
: "fill-transparent stroke-[#c2c2c2]"
)}
/>
</button>
</div>
</div>
</div>
{/* Desktop >= md */}
<div className="hidden w-full items-start gap-2 md:flex">
<div className="flex shrink-0 items-center gap-2">
<div
className="flex shrink-0"
onClick={(e) => e.stopPropagation()}
onClickCapture={(e) => handleRowCheckboxClickCapture(email.id, e)}
>
<Checkbox
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"
checked={isSelected}
onCheckedChange={() => {
toggleSelect(email.id)
lastSelectionAnchorIdRef.current = email.id
}}
/>
</div>
<div className="flex items-center gap-0">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
toggleStar(email.id)
}}
className="flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-full text-[#c2c2c2] hover:bg-black/4 hover:text-[#5f6368]"
aria-label={isStarred ? "Retirer des favoris" : "Marquer comme favori"}
>
<Star
strokeWidth={isStarred ? 0 : 1.25}
className={cn(
"size-4",
isStarred
? "fill-[#f4cc70] stroke-none text-[#f4cc70]"
: "fill-transparent stroke-[#c2c2c2]"
)}
/>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
toggleImportant(email.id)
}}
className={cn(
"flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-full",
isSpam
? "text-[#d93025] hover:bg-[#d93025]/10 hover:text-[#b3261e]"
: "text-[#c2c2c2] hover:bg-black/4 hover:text-[#5f6368]"
)}
aria-label={
isSpam
? "Marqué comme spam"
: isImportant
? "Retirer important"
: "Marquer important"
}
>
<Icon
icon={importantSignalIcon(isSpam, isImportant)}
className={cn(
"size-4 shrink-0",
isSpam && "text-[#d93025]",
!isSpam &&
(isImportant ? "text-[#f4cc70]" : "text-[#c2c2c2]")
)}
aria-hidden
/>
</button>
{isScheduled && (
<span
className="flex h-7 w-6 shrink-0 items-center justify-center text-[#5f6368]"
aria-hidden
>
<Send className="size-3.5" strokeWidth={2} />
</span>
)}
</div>
</div>
<div
className={cn(
"w-48 shrink-0 truncate pl-2",
attachmentList.length === 0 ? "pt-px" : "pt-0"
)}
>
{isScheduled ? (
<span
className={cn(
"text-sm",
!isRead ? "font-semibold text-gray-900" : "text-gray-700"
)}
>
À : {email.scheduledToName ?? email.sender}
</span>
) : (
<ContactHoverCard displayName={email.sender} email={senderHoverEmail}>
<span className={cn(
"text-sm",
!isRead ? "font-semibold text-gray-900" : "text-gray-700"
)}>
{showDraftBadge && (
<span className="font-medium text-[#d93025]">Brouillon </span>
)}
{email.sender}
</span>
</ContactHoverCard>
)}
{email.participantCount && email.participantCount > 1 && (
<span className="text-sm text-gray-500 ml-1">{email.participantCount}</span>
)}
</div>
<div className="flex min-w-0 flex-1 flex-col justify-start gap-0.5 px-2 pb-0.5">
<div
className={cn(
"flex min-w-0 items-center gap-1",
attachmentList.length === 0 ? "pt-1" : "pt-0.5"
)}
>
{email.tag && (
<span className="shrink-0 rounded bg-gray-200 px-1.5 py-0.5 text-xs text-gray-600 opacity-[0.92]">
{email.tag}
</span>
)}
<MailLabelPillStrip
variant="list"
labels={email.labels}
labelBgByText={listRowLabelBgByTextLower}
emailLabelToSidebarFolderId={sidebarNav.emailLabelToSidebarFolderId}
getNavItemPrefs={sidebarNav.getNavItemPrefs}
onLabelNavigate={handleNavigateToLabel}
currentFolderId={selectedFolder}
folderTree={sidebarNav.folderTree}
/>
<span
className={cn(
"min-w-0 shrink truncate text-sm",
!isRead ? "font-semibold text-gray-900" : "font-normal text-[#3c4043]"
)}
>
{email.subject}
</span>
<span className="min-w-0 flex-1 truncate text-sm text-gray-500">{email.preview}</span>
</div>
{attachmentList.length > 0 && (
<EmailListAttachmentRow emailId={email.id} attachments={attachmentList} />
)}
</div>
<div
className={cn(
"flex shrink-0 flex-col items-end gap-1 self-start whitespace-nowrap pr-2 text-right",
isScheduled ? "md:min-w-[200px] lg:min-w-[280px]" : "",
attachmentList.length === 0 ? "pt-1" : "pt-0.5"
)}
>
{isScheduled ? (
<div className="relative flex w-full min-w-0 shrink-0 items-center justify-end">
<span
className={cn(
"block text-sm font-semibold tabular-nums text-[#c65308]",
"transition-opacity duration-150",
isRescheduleOpenThisRow
? "opacity-0"
: "opacity-100 group-hover:opacity-0"
)}
>
{formatScheduledDateTimeDisplay(email.scheduledSendAt)}
</span>
<div
className={cn(
"pointer-events-none absolute right-0 top-1/2 z-[1] flex w-max -translate-y-1/2 flex-nowrap items-center gap-0.5 rounded-md py-0.5 pl-1 opacity-0 transition-opacity duration-150",
isSelected
? "bg-[#e8f0fe]"
: isRead
? "bg-[#f5f5f5]"
: "bg-white",
isRescheduleOpenThisRow
? "pointer-events-auto opacity-100"
: "group-hover:pointer-events-auto group-hover:opacity-100"
)}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label="Archiver"
onClick={(e) => {
e.stopPropagation()
void requestArchiveScheduled(email.id)
}}
>
<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-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label="Supprimer"
onClick={(e) => {
e.stopPropagation()
void requestDeleteScheduled(email.id)
}}
>
<Trash2 className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Supprimer
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label={isRead ? "Marquer comme non lu" : "Marquer comme lu"}
onClick={(e) => {
e.stopPropagation()
const next = !isRead
setReadOverrides((prev) => ({ ...prev, [email.id]: next }))
void requestToggleReadScheduled(email.id, next)
}}
>
{isRead ? (
<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">
{isRead ? "Marquer comme non lu" : "Marquer comme lu"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label="Mettre en attente"
onClick={(e) => {
e.stopPropagation()
void requestSnoozeScheduled(email.id)
}}
>
<Clock className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Mettre en attente
</TooltipContent>
</Tooltip>
<Popover
open={
rescheduleTarget?.id === email.id &&
rescheduleTarget.panelOpen
}
onOpenChange={(open) => {
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)
}
}}
>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label="Reprogrammer"
onClick={(e) => e.stopPropagation()}
>
<CalendarClock
className="h-[18px] w-[18px]"
strokeWidth={1.5}
/>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Reprogrammer
</TooltipContent>
</Tooltip>
<PopoverContent
className="w-[min(100vw-2rem,280px)] p-3"
align="end"
side="bottom"
sideOffset={6}
collisionPadding={12}
onClick={(e) => e.stopPropagation()}
>
<p className="mb-2 text-xs font-medium text-[#3c4043]">
Nouvelle date d&apos;envoi
</p>
<input
type="datetime-local"
className="mb-3 w-full rounded border border-[#dadce0] px-2 py-1.5 text-sm text-[#3c4043]"
value={
rescheduleTarget?.id === email.id
? rescheduleTarget.value
: ""
}
onChange={(e) =>
setRescheduleTarget((prev) =>
prev?.id === email.id
? {
...prev,
value: e.target.value,
panelOpen: true,
}
: prev
)
}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => {
setRescheduleTarget((prev) =>
prev?.id === email.id
? { ...prev, panelOpen: false }
: prev
)
scheduleReschedulePopoverDismiss(email.id)
}}
>
Annuler
</Button>
<Button
type="button"
size="sm"
className="h-8 text-xs"
onClick={() => {
if (rescheduleTarget?.id !== email.id) return
const iso = parseDatetimeLocalToIso(
rescheduleTarget.value
)
if (!iso) return
void requestRescheduleScheduled(email.id, iso)
setRescheduleTarget((prev) =>
prev?.id === email.id
? { ...prev, panelOpen: false }
: prev
)
scheduleReschedulePopoverDismiss(email.id)
}}
>
Valider
</Button>
</div>
</PopoverContent>
</Popover>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label="Modifier le mail"
onClick={(e) => {
e.stopPropagation()
void handleEditScheduledMail(email.id)
}}
>
<Pencil
className="h-[18px] w-[18px]"
strokeWidth={1.5}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Modifier le mail
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label="Envoyer maintenant"
onClick={(e) => {
e.stopPropagation()
void requestSendScheduledNow(email.id)
}}
>
<Send
className="h-[18px] w-[18px]"
strokeWidth={1.5}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Envoyer maintenant
</TooltipContent>
</Tooltip>
</div>
</div>
) : (
<div className="flex items-center gap-1.5">
{(parsedInvitation || hasInvitation) && (
<Icon
icon={
parsedInvitation
? VIDEO_CONFERENCE_LOGOS[
parsedInvitation.conferenceProvider
]
: "mdi:calendar"
}
className="size-[18px] shrink-0 text-[#5f6368]"
aria-label={
parsedInvitation
? "Invitation visioconférence"
: "Invitation calendrier"
}
/>
)}
<span
className={cn(
"whitespace-nowrap text-sm tabular-nums",
!isRead ? "font-semibold text-gray-900" : "text-gray-600"
)}
>
{email.date}
</span>
</div>
)}
</div>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent
onCloseAutoFocus={(e) => 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 ? (
<>
<ContextMenuItem
onSelect={() => {
const ids = [...contextMenuTargetIdsRef.current]
void Promise.all(
ids.map((id) => requestArchiveScheduled(id))
)
}}
>
<Archive
strokeWidth={1.5}
className="size-[18px] text-[#5f6368]"
/>
Archiver
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
const ids = [...contextMenuTargetIdsRef.current]
void Promise.all(
ids.map((id) => requestDeleteScheduled(id))
)
}}
>
<Trash2
strokeWidth={1.5}
className="size-[18px] text-[#5f6368]"
/>
Supprimer
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
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 ? (
<Mail
strokeWidth={1.5}
className="size-[18px] text-[#5f6368]"
/>
) : (
<MailOpen
strokeWidth={1.5}
className="size-[18px] text-[#5f6368]"
/>
)}
{scheduledCtxAnyUnread
? "Marquer comme lu"
: "Marquer comme non lu"}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
const ids = [...contextMenuTargetIdsRef.current]
void Promise.all(
ids.map((id) => requestSnoozeScheduled(id))
)
}}
>
<Clock
strokeWidth={1.5}
className="size-[18px] text-[#5f6368]"
/>
Mettre en attente
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSub
onOpenChange={(subOpen) => {
if (!subOpen) return
const ids = contextMenuTargetIdsRef.current
const first = allEmails.find((e) => e.id === ids[0])
setCmScheduledRescheduleValue(
scheduledIsoToDatetimeLocalValue(
first?.scheduledSendAt
)
)
}}
>
<ContextMenuSubTrigger className="[&>svg:last-child]:text-[#5f6368]">
<CalendarClock
strokeWidth={1.5}
className="size-[18px] text-[#5f6368]"
/>
Reprogrammer
</ContextMenuSubTrigger>
<ContextMenuSubContent
className={cn(
"min-w-[288px] rounded-lg border border-[#dadce0] bg-white px-4 py-3.5 text-[#3c4043] shadow-lg"
)}
>
<div
className="flex flex-col gap-3.5"
onPointerDown={(e) => e.stopPropagation()}
>
<p className="text-xs font-medium leading-snug text-[#3c4043]">
Nouvelle date d&apos;envoi
{contextTargetIds.length > 1
? ` (${contextTargetIds.length} messages)`
: null}
</p>
<input
type="datetime-local"
className="w-full rounded border border-[#dadce0] px-2.5 py-1.5 text-sm text-[#3c4043]"
value={cmScheduledRescheduleValue}
onChange={(e) =>
setCmScheduledRescheduleValue(e.target.value)
}
onPointerDown={(e) => e.stopPropagation()}
/>
<button
type="button"
className="rounded bg-[#0b57d0] px-3 py-1.5 text-xs font-medium text-white hover:bg-[#0842a0]"
onPointerDown={(e) => {
e.stopPropagation()
e.preventDefault()
}}
onClick={(e) => {
e.stopPropagation()
const iso = parseDatetimeLocalToIso(
cmScheduledRescheduleValue
)
if (!iso) return
const ids = [
...contextMenuTargetIdsRef.current,
]
void Promise.all(
ids.map((id) =>
requestRescheduleScheduled(id, iso)
)
)
}}
>
Valider
</button>
</div>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuItem
disabled={contextTargetIds.length > 1}
onSelect={() => {
if (contextTargetIds.length !== 1) return
void handleEditScheduledMail(contextTargetIds[0]!)
}}
>
<Pencil
strokeWidth={1.5}
className="size-[18px] text-[#5f6368]"
/>
Modifier le mail
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
const ids = [...contextMenuTargetIdsRef.current]
void Promise.all(
ids.map((id) => requestSendScheduledNow(id))
)
}}
>
<Send
strokeWidth={1.5}
className="size-[18px] text-[#5f6368]"
/>
Envoyer maintenant
</ContextMenuItem>
</>
) : (
<>
<ContextMenuItem>
<Reply strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
Répondre
</ContextMenuItem>
<ContextMenuItem>
<ReplyAll strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
Répondre à tous
</ContextMenuItem>
<ContextMenuItem>
<Forward strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
Transférer
</ContextMenuItem>
<ContextMenuItem>
<Paperclip strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
Transférer en tant que pièce jointe
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem>
<Archive strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
Archiver
</ContextMenuItem>
<ContextMenuItem>
<Trash2 strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
Supprimer
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
const newRead = !isRead
const ids = contextMenuTargetIdsRef.current
setReadOverrides((prev) => {
const next = { ...prev }
for (const id of ids) {
next[id] = newRead
}
return next
})
}}
>
{!isRead ? (
<Mail strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
) : (
<MailOpen strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
)}
{isRead ? "Marquer comme non lu" : "Marquer comme lu"}
</ContextMenuItem>
<ContextMenuItem>
<Clock strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
Mettre en attente
</ContextMenuItem>
<ContextMenuItem>
<ListTodo strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
Ajouter à Tasks
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger className="[&>svg:last-child]:text-[#5f6368]">
<FolderInput strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
Déplacer vers
</ContextMenuSubTrigger>
<ContextMenuSubContent
className={cn(
"max-h-80 min-w-[200px] overflow-y-auto 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]"
)}
>
<MoveToContextMenuItems
targets={moveTargets}
onMoveTo={(targetId) => {
moveEmailsToTarget(contextTargetIds, targetId)
if (targetId !== "inbox") {
setSelectedEmails((prev) => prev.filter((id) => !contextTargetIds.includes(id)))
}
}}
/>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSub>
<ContextMenuSubTrigger className="[&>svg:last-child]:text-[#5f6368]">
<Tag strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
Ajouter le libellé
</ContextMenuSubTrigger>
<ContextMenuSubContent
className={cn(
"z-[100] flex max-h-72 min-w-[260px] flex-col overflow-hidden rounded-lg border border-[#dadce0] bg-white p-0 py-0 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]"
)}
>
<EmailLabelPickerBlock
query={labelPickerQuery}
onQueryChange={setLabelPickerQuery}
catalogLabels={catalogLabels}
Item={ContextMenuItem}
getLabelPresence={(lab) =>
getCatalogLabelPresence(contextTargetIds, lab)
}
onToggleCatalogLabel={(lab) =>
toggleLabelOnEmails(contextTargetIds, lab)
}
onCreateLabel={(lab) => {
addLabelToEmails(contextTargetIds, lab)
setLabelPickerQuery("")
}}
/>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuItem>
<VolumeX strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
Ignorer la conversation
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem title={`Recherche : ${senderForSearch}`}>
<Search strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
<span className="min-w-0 truncate">
Rech. e-mails de {senderForSearch}
</span>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem>
<SquareArrowOutUpRight strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
Ouvrir dans une nouvelle fenêtre
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
)
})}
</div>
)}
</>
</TooltipProvider>
)}
{!isXs && !isViewMode ? (
<div className="sticky bottom-0 left-0 z-20 mt-auto flex w-fit max-w-full shrink-0 pt-2">
<MailFolderStackIndicator
currentKey={mailNavVisitKey(selectedFolder, inboxTab)}
folderTree={sidebarNav.folderTree}
folderIdToLabel={sidebarNav.folderIdToLabel}
/>
</div>
) : null}
</div>
</div>
</div>
)
}