"use client" import { startTransition, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentType, type DragEvent, type MouseEvent, type ReactNode, } from "react" import { Icon, addCollection } from "@iconify/react" import { icons as mdiIcons } from "@iconify-json/mdi" import { attachmentsForEmailList } from "@/lib/attachment-display" import { resolveParsedCalendarInvitation } from "@/lib/resolve-email-calendar-invitation" import { VIDEO_CONFERENCE_LOGOS } from "@/lib/calendar-invitation" import { ensureVcLogosCollection } from "@/lib/register-vc-logos" import { useEmailDrag } from "@/lib/drag-context" import { Star, ChevronLeft, ChevronRight, ChevronUp, MoreVertical, RefreshCw, ChevronDown, Tag, Reply, ReplyAll, Forward, Paperclip, Archive, Trash2, Mail, MailOpen, Menu, Clock, ListTodo, FolderInput, VolumeX, Search, SquareArrowOutUpRight, File, Image as ImageIcon, ShieldAlert, ArrowLeft, Plus, Send, Pencil, CalendarClock, CalendarX2, X, CheckSquare, Inbox as InboxIcon, User as UserIcon, } from "lucide-react" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" import { Input } from "@/components/ui/input" import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, } from "@/components/ui/context-menu" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from "@/components/ui/empty" import { CompactInboxCategoryTabs } from "@/components/gmail/compact-inbox-category-tabs" import { MailInboxCategoryTabIcons } from "@/components/gmail/mail-inbox-category-tab-icons" import { cn } from "@/lib/utils" import { labelPillTextClassForTailwindBgUtility } from "@/lib/label-pill-contrast" import { buildLabelTextToNavColorClass, MailLabelPillStrip, mailLabelShouldShowInListStrip, } from "@/components/gmail/mail-label-pills" import { emails, type Email, type EmailAttachment, } from "@/lib/email-data" import { getThreadMessageCount, isListRowRead, isThreadHeadMessage, readStateTargets, } from "@/lib/mail-thread" import { useScheduledMail } from "@/lib/scheduled-mail-context" import { useMailStore } from "@/lib/stores/mail-store" import { useScheduledStore } from "@/lib/stores/scheduled-store" import { usePersistHydrated } from "@/hooks/use-persist-hydrated" import { useIsMd } from "@/hooks/use-md-breakpoint" import { threadStoreId } from "@/lib/mail-settings/list-row-id" import { resolveOpenEmailView } from "@/lib/mail-settings/resolve-open-email" import { sortEmailsForInbox } from "@/lib/mail-settings/sort-emails" import { useMailSettingsStore } from "@/lib/stores/mail-settings-store" import { useActiveAccount } from "@/lib/stores/account-store" import { useMailSearchStore } from "@/lib/stores/mail-search-store" import { MAIL_INBOX_CATEGORY_TAB_CONTENT_DARK_CLASS, MAIL_MENU_SURFACE_CLASS, MAIL_MENU_SURFACE_WIDE_CLASS, MAIL_TOOLBAR_ICON_BTN, } from "@/lib/mail-chrome-classes" import { emailMatchesFolder, emailMatchesInboxPrimaryTab, type MailNavFolderMaps, } from "@/lib/mail-folder-filter" import { cleanSenderName, resolveSenderEmail } from "@/lib/sender-display" import { getMailNavFolderLabel, inboxTabDisplayLabel, type FolderTreeNode, type LabelRowItem, } from "@/lib/sidebar-nav-data" import { mailNavVisitKey, parseMailNavVisitKey, } from "@/lib/mail-folder-display" import { buildInboxCategoryTabIcons, inboxTabActiveAccentColor, resolveEmailInboxCategoryTabs, } from "@/lib/inbox-category-tabs" import { DEFAULT_INBOX_TAB, INBOX_ALL_TAB, SEARCH_FOLDER_ID, inboxTabShowsInactiveMeta, normalizeInboxTabSegment, } from "@/lib/mail-url" import { useSearchParams, useRouter } from "next/navigation" import { parseSearchParams, buildSearchUrl, DATE_RANGE_OPTIONS, type SearchParams, } from "@/lib/mail-search/search-params" import { filterEmailsBySearchParams } from "@/lib/mail-search/search-engine" import { MailFolderStackIndicator } from "@/components/gmail/mail-folder-stack-indicator" import { useSidebarNav, registerNavEmailSync } from "@/lib/sidebar-nav-context" import { ContactHoverCard } from "./contact-hover-card" import { EmailLabelPickerBlock } from "@/components/gmail/email-label-picker-block" import type { CatalogLabelPresence } from "@/components/gmail/email-label-picker-block" import { resolveLabelPickerVisual } from "@/lib/label-picker-visual" import { MobileXsBulkSheets } from "@/components/gmail/mobile-xs-bulk-sheets" import { MailListSwipeRow } from "@/components/gmail/mail-list-swipe-row" import { useMoveTargets, type MoveTarget, } from "@/components/gmail/move-to-menu-items" import { EmailView } from "./email-view" import { MailSearchBar } from "@/components/gmail/mail-search-bar" import { MailDateText } from "@/components/gmail/mail-date-text" import { formatMailDetailDate } from "@/lib/mail-date" import { buildListMailIndex } from "./email-list-row" import { useComposeActions, useComposeDrafts, type Contact, } from "@/lib/compose-context" import { computeFolderUnreadCounts } from "@/lib/mail-nav-metrics" import { effectiveLabels, mergeEmailLabelEdits, mergeEmailNotSpam, } from "@/lib/label-edits" import type { LabelEditState } from "@/lib/stores/mail-store" import type { MailRouteState } from "@/lib/mail-url" import { readXsMatches, useIsXs } from "@/hooks/use-xs" import { useTouchNav } from "@/hooks/use-touch-nav" import type { MailXsViewChrome } from "@/lib/mail-xs-view-chrome" import { buildThreadComposePreset, withTouchFullscreenComposePreset, } from "@/lib/thread-compose-preset" addCollection(mdiIcons) const LIST_PAGE_SIZE = 50 const PULL_HOLD_HEIGHT = 48 const PULL_REFRESH_THRESHOLD = 56 const PULL_REFRESH_MAX = 112 const PULL_SNAP_BACK_TRANSITION = "transform 0.24s cubic-bezier(0.32, 0.72, 0, 1)" const REFRESH_SPIN_CLASS = "animate-[spin_0.55s_linear_infinite]" const PULL_ICON_FADE_MS = 120 /** Tirage (px) avant que le spinner ne devienne visible. */ const PULL_SPINNER_REVEAL_OFFSET = 26 function computePullOffset(delta: number): number { if (delta <= 0) return 0 const damped = delta * 0.48 const capped = Math.min(PULL_REFRESH_MAX, damped) const ratio = capped / PULL_REFRESH_MAX return capped * (1 - ratio * 0.12) } function computeSpinnerRevealProgress(y: number): number { if (y <= PULL_SPINNER_REVEAL_OFFSET) return 0 const range = Math.max(1, PULL_REFRESH_THRESHOLD - PULL_SPINNER_REVEAL_OFFSET) return Math.min(1, ((y - PULL_SPINNER_REVEAL_OFFSET) / range) * 1.35) } /** Libellés système qu’on ne propose pas dans « Ajouter le libellé ». */ const LABEL_PICKER_EXCLUDE = new Set(["inbox", "sent", "drafts", "spam", "starred"]) function collectTreeLabels(nodes: FolderTreeNode[]): string[] { const out: string[] = [] for (const n of nodes) { out.push(n.label) if (n.children?.length) out.push(...collectTreeLabels(n.children)) } return out } function formatScheduledDateTimeDisplay(iso: string | undefined): string { if (!iso) return "—" return formatMailDetailDate(iso) } function scheduledIsoToDatetimeLocalValue(iso: string | undefined): string { if (!iso) return "" const d = new Date(iso) if (Number.isNaN(d.getTime())) return "" const p = (n: number) => String(n).padStart(2, "0") return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}` } function parseDatetimeLocalToIso(value: string): string | null { const d = new Date(value) if (Number.isNaN(d.getTime())) return null return d.toISOString() } /** Cibles du clic droit : sélection courante ou ligne seule ; en Planifié, seulement les ids réellement planifiés. */ function contextMenuTargetIdsForRow( emailId: string, selectedEmails: string[], selectedFolder: string, pool: Email[] ): string[] { const raw = selectedEmails.includes(emailId) ? selectedEmails : [emailId] if (selectedFolder !== "scheduled") return raw const onlyScheduled = raw.filter((id) => pool.some((e) => e.id === id && e.labels?.includes("scheduled")) ) return onlyScheduled.length > 0 ? onlyScheduled : [emailId] } function applyNavRenameToEdits( pool: Email[], prev: LabelEditState, from: string, to: string ): LabelEditState { const lcFrom = from.toLowerCase() const toTrim = to.trim() if (!toTrim) return prev const nextAdd = { ...prev.additions } const nextRem = { ...prev.removals } for (const e of pool) { const id = e.id const eff = effectiveLabels(e, prev.additions, prev.removals) if (!eff.some((l) => l.toLowerCase() === lcFrom)) continue const wanted = eff.map((l) => (l.toLowerCase() === lcFrom ? toTrim : l)) delete nextAdd[id] delete nextRem[id] const base = e.labels ?? [] const removals = base.filter( (b) => !wanted.some((w) => w.toLowerCase() === b.toLowerCase()) ) const additions = wanted.filter( (w) => !base.some((b) => b.toLowerCase() === w.toLowerCase()) ) if (removals.length) nextRem[id] = removals if (additions.length) nextAdd[id] = additions } return { additions: nextAdd, removals: nextRem } } function applyNavRemoveLabelToEdits( pool: Email[], prev: LabelEditState, label: string ): LabelEditState { const lc = label.toLowerCase() const nextAdd = { ...prev.additions } const nextRem = { ...prev.removals } for (const e of pool) { const id = e.id const eff = effectiveLabels(e, prev.additions, prev.removals) if (!eff.some((l) => l.toLowerCase() === lc)) continue const wanted = eff.filter((l) => l.toLowerCase() !== lc) delete nextAdd[id] delete nextRem[id] const base = e.labels ?? [] const removals = base.filter( (b) => !wanted.some((w) => w.toLowerCase() === b.toLowerCase()) ) const additions = wanted.filter( (w) => !base.some((b) => b.toLowerCase() === w.toLowerCase()) ) if (removals.length) nextRem[id] = removals if (additions.length) nextAdd[id] = additions } return { additions: nextAdd, removals: nextRem } } function escapeHtml(text: string): string { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) } function importantSignalIcon(isSpam: boolean, isImportant: boolean): string { if (isSpam) return "mdi:flag-outline" if (isImportant) return "mdi:label-variant" return "mdi:label-variant-outline" } type InboxTabBarItem = { id: string label: string icon: string badgeColor: string } function buildInboxTabBarItems(labelRows: readonly LabelRowItem[]): InboxTabBarItem[] { return [ ...buildInboxCategoryTabIcons(labelRows), { id: INBOX_ALL_TAB, label: "Tous les messages", icon: "mdi:inbox", badgeColor: "bg-[#0b57d0]", }, ] } function inboxTabBadgeCountClass(badgeColor: string) { return cn( "shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium leading-none", badgeColor, labelPillTextClassForTailwindBgUtility(badgeColor) ) } function inboxTabBadgeDotClass(badgeColor: string) { return cn( "absolute -right-0.5 -top-0.5 size-2 rounded-full ring-2 ring-mail-surface", badgeColor ) } const CATEGORY_TAB_ICON_CLASS = "h-4 w-4 shrink-0" function ListAttachmentChip({ att }: { att: EmailAttachment }) { return ( {att.kind === "pdf" ? ( ) : att.kind === "image" ? ( ) : ( )} {att.name} ) } function EmailListAttachmentRow({ emailId, attachments, }: { emailId: string attachments: EmailAttachment[] }) { const containerRef = useRef(null) const measureRef = useRef(null) const [collapsed, setCollapsed] = useState(false) const attachSig = attachments.map((a) => `${a.name}\u0001${a.kind ?? ""}`).join("\u0002") const updateCollapsed = useCallback(() => { const container = containerRef.current const measure = measureRef.current if (!container || !measure || attachments.length <= 1) { setCollapsed(false) return } const available = container.clientWidth const needed = measure.scrollWidth setCollapsed(needed > available + 1) }, [attachSig, attachments.length]) useLayoutEffect(() => { updateCollapsed() }, [updateCollapsed]) useEffect(() => { const el = containerRef.current if (!el || typeof ResizeObserver === "undefined") return const ro = new ResizeObserver(() => updateCollapsed()) ro.observe(el) return () => ro.disconnect() }, [updateCollapsed]) const othersLabel = attachments.length - 1 === 1 ? "1 autre" : `${attachments.length - 1} autres` const othersTitle = attachments .slice(1) .map((a) => a.name) .join(", ") return (
{attachments.length > 1 && (
{attachments.map((att, idx) => ( ))}
)}
{collapsed && attachments.length > 1 ? ( <> {othersLabel} ) : ( attachments.map((att, idx) => ( )) )}
) } function MoveToDropdownItems({ targets, onMoveTo, }: { targets: { recents: MoveTarget[]; system: MoveTarget[]; folders: MoveTarget[] } onMoveTo: (targetId: string) => void }) { return ( <> {targets.recents.length > 0 && ( <>
Récents
{targets.recents.map((t) => ( onMoveTo(t.id)}> {t.icon} {t.label} ))} )} {targets.system.map((t) => ( onMoveTo(t.id)}> {t.icon} {t.label} ))} {targets.folders.length > 0 && ( <>
Dossiers
{targets.folders.map((t) => ( onMoveTo(t.id)} style={{ paddingLeft: `${12 + t.depth * 16}px` }} > {t.icon} {t.label} ))} )} ) } function MoveToContextMenuItems({ targets, onMoveTo, }: { targets: { recents: MoveTarget[]; system: MoveTarget[]; folders: MoveTarget[] } onMoveTo: (targetId: string) => void }) { return ( <> {targets.recents.length > 0 && ( <>
Récents
{targets.recents.map((t) => ( onMoveTo(t.id)}> {t.icon} {t.label} ))} )} {targets.system.map((t) => ( onMoveTo(t.id)}> {t.icon} {t.label} ))} {targets.folders.length > 0 && ( <>
Dossiers
{targets.folders.map((t) => ( onMoveTo(t.id)} style={{ paddingLeft: `${12 + t.depth * 16}px` }} > {t.icon} {t.label} ))} )} ) } interface EmailListProps { selectedFolder: string /** Onglet catégories (boîte de réception), depuis l’URL. */ inboxTab: string /** Page de liste (1-based), depuis l’URL. */ listPage: number openMailId: string | null /** md+ split pane: list left, reading pane right (tablet landscape or user setting). */ splitView?: boolean onToggleSidebar?: () => void onMailRouteNavigate: (patch: Partial) => void onSelectFolder?: (folder: string) => void onFolderUnreadCountsChange?: (counts: Record) => void /** Barre basse xs en lecture d’un message. */ onXsViewChromeChange?: (chrome: MailXsViewChrome | null) => void } function listRowCheckboxClass(circular: boolean) { return cn( "size-4 min-h-4 min-w-4 shrink-0 border-[1.5px] border-[#c2c2c2] bg-transparent shadow-none dark:bg-transparent focus-visible:ring-[#c2c2c2]/30 data-[state=checked]:border-[#1a73e8] data-[state=checked]:bg-[#1a73e8] data-[state=checked]:text-white", circular ? "rounded-full" : "rounded-[2.5px]" ) } function listRowQuickHoverTrayToneClass(isSelected: boolean, isRead: boolean) { return isSelected ? "bg-mail-row-selected" : isRead ? "bg-mail-row-read" : "bg-mail-row-unread" } export function EmailList({ selectedFolder, inboxTab, listPage, openMailId, splitView = false, onToggleSidebar, onMailRouteNavigate, onSelectFolder, onFolderUnreadCountsChange, onXsViewChromeChange, }: EmailListProps) { const isViewMode = openMailId !== null && !splitView const showSplitReadingPane = splitView && openMailId !== null const isSearchMode = selectedFolder === SEARCH_FOLDER_ID const searchRouter = useRouter() const searchAccount = useActiveAccount() const setAdvancedOpen = useMailSearchStore((s) => s.setAdvancedOpen) const urlSearchParams = useSearchParams() const searchParams = useMemo( () => (isSearchMode ? parseSearchParams(urlSearchParams) : null), [isSearchMode, urlSearchParams] ) const setSearchFilter = useCallback( (patch: Partial) => { if (!searchParams) return searchRouter.push(buildSearchUrl({ ...searchParams, ...patch })) }, [searchParams, searchRouter] ) const toggleSearchFilter = useCallback( (key: keyof SearchParams, value: string) => { if (!searchParams) return const next = { ...searchParams } if (key === "has") { const arr = [...next.has] if (arr.includes(value)) next.has = arr.filter((v) => v !== value) else next.has = [...arr, value] } else if (key === "excludeChats") { next.excludeChats = !next.excludeChats } else { const cur = (next as Record)[key] ;(next as Record)[key] = cur === value ? "" : value } searchRouter.push(buildSearchUrl(next)) }, [searchParams, searchRouter] ) const { savedThreadReplyDrafts } = useComposeDrafts() const { openCompose, openComposeWithInitial, closeAllInlineComposes, pruneInlineComposesToOpenThread, } = useComposeActions() const { scheduledEmails, snoozedEmails, sentPlaceholderEmails, requestDeleteScheduled, requestArchiveScheduled, requestSnoozeScheduled, requestToggleReadScheduled, requestRescheduleScheduled, requestGetScheduledEditPayload, requestSendScheduledNow, requestSnoozeMailboxEmail, requestRestoreSnoozedToInbox, } = useScheduledMail() const scheduledPersistHydrated = usePersistHydrated(useScheduledStore) const allEmails = useMemo( () => scheduledPersistHydrated ? [...emails, ...scheduledEmails, ...snoozedEmails, ...sentPlaceholderEmails] : emails, [scheduledPersistHydrated, scheduledEmails, snoozedEmails, sentPlaceholderEmails] ) const emailById = useMemo( () => new Map(allEmails.map((e) => [e.id, e])), [allEmails] ) const sidebarNav = useSidebarNav() const navMaps = useMemo( () => ({ folderIdToLabel: sidebarNav.folderIdToLabel, folderTree: sidebarNav.folderTree, labelRows: sidebarNav.labelRows, }), [sidebarNav.folderIdToLabel, sidebarNav.folderTree, sidebarNav.labelRows] ) const inboxCategoryTabIconsCatalog = useMemo( () => buildInboxCategoryTabIcons(sidebarNav.labelRows), [sidebarNav.labelRows] ) const inboxTabBarItems = useMemo( () => buildInboxTabBarItems(sidebarNav.labelRows), [sidebarNav.labelRows] ) const listRowLabelBgByTextLower = useMemo( () => buildLabelTextToNavColorClass(sidebarNav.folderTree, sidebarNav.labelRows), [sidebarNav.folderTree, sidebarNav.labelRows] ) const [rescheduleTarget, setRescheduleTarget] = useState<{ id: string value: string /** Faux pendant la fermeture du Popover : la barre d’actions reste visible (évite saut d’ancrage). */ panelOpen: boolean } | null>(null) const rescheduleDismissTimeoutsRef = useRef< Map> >(new Map()) const scheduleReschedulePopoverDismiss = useCallback((rowId: string) => { const existing = rescheduleDismissTimeoutsRef.current.get(rowId) if (existing) clearTimeout(existing) const t = setTimeout(() => { rescheduleDismissTimeoutsRef.current.delete(rowId) setRescheduleTarget((p) => (p?.id === rowId ? null : p)) }, 280) rescheduleDismissTimeoutsRef.current.set(rowId, t) }, []) useEffect(() => { const m = rescheduleDismissTimeoutsRef.current return () => { for (const t of m.values()) clearTimeout(t) m.clear() } }, []) useEffect(() => { ensureVcLogosCollection() }, []) const [cmScheduledRescheduleValue, setCmScheduledRescheduleValue] = useState("") const handleEditScheduledMail = useCallback( async (id: string) => { const payload = await requestGetScheduledEditPayload(id) if (!payload) return openComposeWithInitial({ to: payload.to, subject: payload.subject, bodyHtml: payload.bodyHtml, editingScheduledId: id, scheduledSendAtIso: payload.sendAtIso, focusToOnMount: false, focusBodyOnMount: true, }) }, [requestGetScheduledEditPayload, openComposeWithInitial] ) useEffect(() => { if (!openMailId) { closeAllInlineComposes() } else { const msg = emailById.get(openMailId) pruneInlineComposesToOpenThread(msg ? threadStoreId(msg) : openMailId) } }, [ openMailId, emailById, closeAllInlineComposes, pruneInlineComposesToOpenThread, ]) const { beginDrag, registerOnDrop } = useEmailDrag() const starredEmails = useMailStore((s) => s.starredIds) const importantEmails = useMailStore((s) => s.importantIds) const [selectedEmails, setSelectedEmails] = useState([]) const readOverrides = useMailStore((s) => s.readOverrides) const conversationMode = useMailSettingsStore((s) => s.conversationMode) const inboxSort = useMailSettingsStore((s) => s.inboxSort) const density = useMailSettingsStore((s) => s.density) const isMd = useIsMd() const labelEdits = useMailStore((s) => s.labelEdits) const mailActions = useRef(useMailStore.getState()).current const setReadOverrides = useCallback( (updater: (prev: Record) => Record) => { const current = useMailStore.getState().readOverrides const next = updater(current) if (next !== current) mailActions.setReadOverrides(next) }, [mailActions] ) const setLabelEdits = useCallback( (updater: (prev: LabelEditState) => LabelEditState) => { mailActions.setLabelEdits(updater) }, [mailActions] ) useEffect(() => { registerNavEmailSync({ renameLabel: (from, to) => { setLabelEdits((prev) => applyNavRenameToEdits(allEmails, prev, from, to)) }, removeLabel: (label) => { setLabelEdits((prev) => applyNavRemoveLabelToEdits(allEmails, prev, label)) }, }) return () => registerNavEmailSync(null) }, [allEmails]) const [labelPickerQuery, setLabelPickerQuery] = useState("") const hiddenEmailIds = useMailStore((s) => s.hiddenEmailIds) const notSpamEmailIds = useMailStore((s) => s.notSpamEmailIds) const recentMoveTargets = useMailStore((s) => s.recentMoveTargets) const rowContextMenuOpenedAtRef = useRef(0) const contextMenuTargetIdsRef = useRef([]) const lastSelectionAnchorIdRef = useRef(null) const [bulkSelectMenuOpen, setBulkSelectMenuOpen] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false) const [mobileVisibleCount, setMobileVisibleCount] = useState(LIST_PAGE_SIZE) const [mobileSelectionMode, setMobileSelectionMode] = useState(false) const [mobileXsMoreMenuOpen, setMobileXsMoreMenuOpen] = useState(false) const [mobileXsMoveSheetOpen, setMobileXsMoveSheetOpen] = useState(false) const [mobileXsLabelSheetOpen, setMobileXsLabelSheetOpen] = useState(false) const [swipeLabelEmailId, setSwipeLabelEmailId] = useState(null) const [openSwipeRowId, setOpenSwipeRowId] = useState(null) const isXs = useIsXs() const touchNav = useTouchNav() const openMobileXsMoveSheet = useCallback(() => { setMobileXsMoreMenuOpen(false) window.setTimeout(() => setMobileXsMoveSheetOpen(true), 0) }, []) const handleMobileXsMoveSheetOpenChange = useCallback((open: boolean) => { setMobileXsMoveSheetOpen(open) if (!open) { setMobileSelectionMode(false) setSelectedEmails([]) } }, []) const openMobileXsLabelSheet = useCallback(() => { setMobileXsMoreMenuOpen(false) setSwipeLabelEmailId(null) window.setTimeout(() => setMobileXsLabelSheetOpen(true), 0) }, []) const handleLabelSheetOpenChange = useCallback((open: boolean) => { setMobileXsLabelSheetOpen(open) if (!open) setSwipeLabelEmailId(null) }, []) const touchListSwipeEnabled = touchNav && !mobileSelectionMode && !isViewMode useEffect(() => { if (!openSwipeRowId) return const handler = (e: globalThis.TouchEvent) => { const target = e.target as HTMLElement | null if (!target) return const swipeRow = target.closest(`[data-swipe-row-id="${openSwipeRowId}"]`) if (!swipeRow) setOpenSwipeRowId(null) } document.addEventListener("touchstart", handler, { passive: true }) return () => document.removeEventListener("touchstart", handler) }, [openSwipeRowId]) const listViewportRef = useRef(null) const pullContentRef = useRef(null) const pullIconRef = useRef(null) const pullTouchStartYRef = useRef(0) const pullActiveRef = useRef(false) const pullYRef = useRef(0) const pullRafRef = useRef(null) const pendingPullYRef = useRef(0) const seenEmailIdsRaw = useMailStore((s) => s.seenEmailIds) const seenEmailIds = useMemo(() => new Set(seenEmailIdsRaw), [seenEmailIdsRaw]) const markEmailSeen = useCallback((id: string) => { mailActions.markSeen(id) }, [mailActions]) const folderFilterCtx = useMemo( () => ({ starredEmailIds: starredEmails, importantEmailIds: importantEmails, }), [starredEmails, importantEmails] ) const handleRefreshMessages = useCallback(async () => { if (isRefreshing) return setIsRefreshing(true) try { await new Promise((resolve) => setTimeout(resolve, 900)) } finally { setIsRefreshing(false) } }, [isRefreshing]) const applyPullVisual = useCallback((y: number, animate: boolean) => { const content = pullContentRef.current const icon = pullIconRef.current const transition = animate ? PULL_SNAP_BACK_TRANSITION : "none" if (content) { content.style.transition = transition content.style.transform = `translate3d(0, ${y}px, 0)` } if (icon) { if (y === 0) { icon.style.transition = animate ? `opacity ${PULL_ICON_FADE_MS}ms ease-out, transform ${PULL_ICON_FADE_MS}ms ease-out` : "none" icon.style.opacity = "0" icon.style.transform = "rotate(0deg)" icon.style.removeProperty("animation") } else { const progress = computeSpinnerRevealProgress(y) icon.style.transition = animate ? `opacity ${PULL_ICON_FADE_MS}ms ease-out, transform ${PULL_ICON_FADE_MS}ms ease-out` : "none" icon.style.opacity = String(progress) icon.style.transform = `rotate(${Math.min(320, progress * 320)}deg)` } } }, []) const schedulePullVisual = useCallback( (y: number) => { pendingPullYRef.current = y if (pullRafRef.current != null) return pullRafRef.current = requestAnimationFrame(() => { pullRafRef.current = null applyPullVisual(pendingPullYRef.current, false) }) }, [applyPullVisual] ) const resetPullVisual = useCallback( (animate: boolean) => { if (pullRafRef.current != null) { cancelAnimationFrame(pullRafRef.current) pullRafRef.current = null } pullYRef.current = 0 pendingPullYRef.current = 0 applyPullVisual(0, animate) }, [applyPullVisual] ) const armPullRefreshSpinner = useCallback(() => { const icon = pullIconRef.current if (!icon) return icon.style.transition = "none" icon.style.opacity = "1" icon.style.removeProperty("transform") icon.style.animation = "spin 0.55s linear infinite" }, []) const releasePull = useCallback(async () => { if (pullRafRef.current != null) { cancelAnimationFrame(pullRafRef.current) pullRafRef.current = null } const offset = pullYRef.current if (offset >= PULL_REFRESH_THRESHOLD) { pullYRef.current = PULL_HOLD_HEIGHT applyPullVisual(PULL_HOLD_HEIGHT, false) armPullRefreshSpinner() void handleRefreshMessages() return } pullYRef.current = 0 applyPullVisual(0, true) }, [applyPullVisual, armPullRefreshSpinner, handleRefreshMessages]) useEffect(() => { if (isViewMode || !isXs || isRefreshing) return pullYRef.current = 0 applyPullVisual(0, true) }, [isRefreshing, isViewMode, isXs, applyPullVisual]) const filteredEmails = useMemo(() => { const hiddenSet = new Set(hiddenEmailIds) const subtreeIdsCache = new Map() let visible = allEmails.filter((email) => !hiddenSet.has(email.id)) const hasLabelEdits = labelEdits && (Object.keys(labelEdits.additions).length > 0 || Object.keys(labelEdits.removals).length > 0) if (hasLabelEdits || notSpamEmailIds.length > 0) { visible = visible.map((e) => mergeEmailNotSpam(mergeEmailLabelEdits(e, labelEdits), notSpamEmailIds) ) } if (isSearchMode && searchParams) { return filterEmailsBySearchParams(visible, searchParams, { starredIds: starredEmails, importantIds: importantEmails, }) } let rows = visible.filter((email) => emailMatchesFolder( email, selectedFolder, folderFilterCtx, navMaps, subtreeIdsCache ) ) if (selectedFolder === "inbox") { const tab = normalizeInboxTabSegment(inboxTab) if (tab === "primary") { rows = rows.filter((email) => emailMatchesInboxPrimaryTab( email, folderFilterCtx, navMaps, subtreeIdsCache ) ) } else if (tab !== INBOX_ALL_TAB) { rows = rows.filter( (email) => emailMatchesFolder( email, "inbox", folderFilterCtx, navMaps, subtreeIdsCache ) && emailMatchesFolder( email, tab, folderFilterCtx, navMaps, subtreeIdsCache ) ) } } return rows }, [ selectedFolder, inboxTab, hiddenEmailIds, folderFilterCtx, labelEdits, notSpamEmailIds, allEmails, navMaps, isSearchMode, searchParams, starredEmails, importantEmails, ]) const displayListEmails = useMemo(() => { let rows = filteredEmails if (conversationMode) { rows = rows.filter(isThreadHeadMessage) } return sortEmailsForInbox( rows, inboxSort, { readOverrides, starredIds: starredEmails, importantIds: importantEmails, }, { conversationMode, byId: emailById } ) }, [ filteredEmails, conversationMode, inboxSort, readOverrides, starredEmails, importantEmails, emailById, ]) const inboxCategoryTabLabel = useMemo( () => inboxTabDisplayLabel( inboxTab, sidebarNav.labelRows, sidebarNav.folderIdToLabel ), [inboxTab, sidebarNav.labelRows, sidebarNav.folderIdToLabel] ) const mobileUnreadCount = useMemo( () => displayListEmails.filter( (e) => !isListRowRead(e, readOverrides, emailById, conversationMode) ).length, [displayListEmails, readOverrides, emailById, conversationMode] ) const mobileFolderLabel = useMemo(() => { if (isSearchMode) return "Résultats de recherche" const inboxTabNorm = normalizeInboxTabSegment(inboxTab) return selectedFolder === "inbox" && inboxTabNorm !== "primary" ? inboxCategoryTabLabel : getMailNavFolderLabel(selectedFolder, sidebarNav.folderIdToLabel) }, [ selectedFolder, inboxTab, inboxCategoryTabLabel, sidebarNav.folderIdToLabel, isSearchMode, ]) useEffect(() => { setMobileSelectionMode(false) setSelectedEmails([]) }, [selectedFolder, inboxTab]) const totalPages = useMemo( () => Math.max(1, Math.ceil(displayListEmails.length / LIST_PAGE_SIZE)), [displayListEmails.length] ) const pagedEmails = useMemo(() => { const start = (listPage - 1) * LIST_PAGE_SIZE return displayListEmails.slice(start, start + LIST_PAGE_SIZE) }, [displayListEmails, listPage]) const listEmails = useMemo(() => { if (isXs && !isViewMode) { return displayListEmails.slice(0, mobileVisibleCount) } return pagedEmails }, [isXs, isViewMode, displayListEmails, mobileVisibleCount, pagedEmails]) const listMailIndex = useMemo(() => buildListMailIndex(allEmails), [allEmails]) const listRowExtras = useMemo(() => { const invitationById = new Map< string, ReturnType >() const attachmentsById = new Map() const categoryTabsById = new Map< string, ReturnType >() const subtreeIdsCache = new Map() const showCategoryTabIcons = selectedFolder === "inbox" && normalizeInboxTabSegment(inboxTab) === INBOX_ALL_TAB for (const e of listEmails) { invitationById.set(e.id, resolveParsedCalendarInvitation(e)) attachmentsById.set(e.id, attachmentsForEmailList(e)) if (showCategoryTabIcons) { const tabs = resolveEmailInboxCategoryTabs( e, folderFilterCtx, navMaps, inboxCategoryTabIconsCatalog, subtreeIdsCache ) if (tabs.length > 0) categoryTabsById.set(e.id, tabs) } } return { invitationById, attachmentsById, categoryTabsById } }, [ listEmails, selectedFolder, inboxTab, folderFilterCtx, navMaps, inboxCategoryTabIconsCatalog, ]) useEffect(() => { if (isXs) return if (listPage > totalPages) { onMailRouteNavigate({ page: totalPages }) } }, [isXs, listPage, totalPages, onMailRouteNavigate]) useEffect(() => { if (isXs && !isViewMode) return listViewportRef.current?.scrollTo(0, 0) }, [listPage, selectedFolder, inboxTab, isXs, isViewMode]) useEffect(() => { if (!isXs) return setMobileVisibleCount(LIST_PAGE_SIZE) listViewportRef.current?.scrollTo(0, 0) }, [selectedFolder, inboxTab, isXs]) useEffect(() => { const root = listViewportRef.current if (!root || !isXs || isViewMode) return const onScroll = () => { if (mobileVisibleCount >= displayListEmails.length) return const nearBottom = root.scrollTop + root.clientHeight >= root.scrollHeight - 120 if (nearBottom) { setMobileVisibleCount((prev) => Math.min(prev + LIST_PAGE_SIZE, displayListEmails.length) ) } } root.addEventListener("scroll", onScroll, { passive: true }) return () => root.removeEventListener("scroll", onScroll) }, [isXs, isViewMode, mobileVisibleCount, displayListEmails.length]) useEffect(() => { const root = listViewportRef.current if (!root || !isXs || isViewMode) return const onTouchStart = (e: TouchEvent) => { if (root.scrollTop > 0 || isRefreshing) return pullActiveRef.current = true pullTouchStartYRef.current = e.touches[0]?.clientY ?? 0 } const onTouchMove = (e: TouchEvent) => { if (!pullActiveRef.current || isRefreshing) return const y = e.touches[0]?.clientY ?? 0 const delta = y - pullTouchStartYRef.current if (root.scrollTop > 0) { pullActiveRef.current = false resetPullVisual(true) return } if (delta <= 0) { resetPullVisual(true) return } e.preventDefault() const next = computePullOffset(delta) pullYRef.current = next schedulePullVisual(next) } const endPull = () => { if (!pullActiveRef.current) return pullActiveRef.current = false void releasePull() } root.addEventListener("touchstart", onTouchStart, { passive: true }) root.addEventListener("touchmove", onTouchMove, { passive: false }) root.addEventListener("touchend", endPull) root.addEventListener("touchcancel", endPull) return () => { if (pullRafRef.current != null) { cancelAnimationFrame(pullRafRef.current) pullRafRef.current = null } root.removeEventListener("touchstart", onTouchStart) root.removeEventListener("touchmove", onTouchMove) root.removeEventListener("touchend", endPull) root.removeEventListener("touchcancel", endPull) } }, [isXs, isViewMode, isRefreshing, releasePull, resetPullVisual, schedulePullVisual]) const moveTargets = useMoveTargets({ folderTree: sidebarNav.folderTree, recentMoveTargets, currentFolderId: selectedFolder, }) const collectAllFolderLabels = useCallback((): Set => { const s = new Set() const walk = (nodes: FolderTreeNode[]) => { for (const n of nodes) { s.add(n.label.toLowerCase()) if (n.children?.length) walk(n.children) } } walk(sidebarNav.folderTree) return s }, [sidebarNav.folderTree]) const moveEmailsToTarget = useCallback( (emailIds: string[], targetId: string) => { if (emailIds.length === 0) return const folderLabel = sidebarNav.folderIdToLabel[targetId] const isSystemTarget = ["inbox", "sent", "drafts", "spam", "trash"].includes(targetId) const allFolderLabels = collectAllFolderLabels() setLabelEdits((prev) => { const nextAdd = { ...prev.additions } const nextRem = { ...prev.removals } for (const id of emailIds) { const email = allEmails.find((e) => e.id === id) const currentLabels = effectiveLabels(email, nextAdd, nextRem) if (isSystemTarget) { if (targetId === "inbox") { for (const lab of currentLabels) { if (allFolderLabels.has(lab.toLowerCase())) { const cur = nextRem[id] ?? [] if (!cur.some((l) => l.toLowerCase() === lab.toLowerCase())) { nextRem[id] = [...cur, lab] } if (nextAdd[id]?.length) { nextAdd[id] = nextAdd[id].filter((l) => l.toLowerCase() !== lab.toLowerCase()) if (nextAdd[id].length === 0) delete nextAdd[id] } } } } } else if (folderLabel) { for (const lab of currentLabels) { if (allFolderLabels.has(lab.toLowerCase()) && lab.toLowerCase() !== folderLabel.toLowerCase()) { const cur = nextRem[id] ?? [] if (!cur.some((l) => l.toLowerCase() === lab.toLowerCase())) { nextRem[id] = [...cur, lab] } if (nextAdd[id]?.length) { nextAdd[id] = nextAdd[id].filter((l) => l.toLowerCase() !== lab.toLowerCase()) if (nextAdd[id].length === 0) delete nextAdd[id] } } } if (!currentLabels.some((l) => l.toLowerCase() === folderLabel.toLowerCase())) { nextAdd[id] = [...(nextAdd[id] ?? []), folderLabel] } if (nextRem[id]?.length) { nextRem[id] = nextRem[id].filter((l) => l.toLowerCase() !== folderLabel.toLowerCase()) if (nextRem[id].length === 0) delete nextRem[id] } const inboxIdx = currentLabels.findIndex((l) => l.toLowerCase() === "inbox") if (inboxIdx >= 0 || !email?.labels?.length || email.labels.includes("inbox")) { const cur = nextRem[id] ?? [] if (!cur.some((l) => l.toLowerCase() === "inbox")) { nextRem[id] = [...cur, "inbox"] } } } } return { additions: nextAdd, removals: nextRem } }) if (!isSystemTarget || targetId === "inbox") { mailActions.pushRecentMoveTarget(targetId) } if (isSystemTarget && targetId !== "inbox") { mailActions.hideEmails(emailIds) mailActions.pushRecentMoveTarget(targetId) } }, [allEmails, sidebarNav.folderIdToLabel, collectAllFolderLabels, setLabelEdits, mailActions] ) const catalogLabels = useMemo(() => { const s = new Set() for (const l of collectTreeLabels(sidebarNav.folderTree)) s.add(l) for (const row of sidebarNav.labelRows) s.add(row.label) for (const e of allEmails) { const eff = mergeEmailNotSpam( mergeEmailLabelEdits(e, labelEdits), notSpamEmailIds ) for (const lab of eff.labels ?? []) { if (!LABEL_PICKER_EXCLUDE.has(lab)) s.add(lab) } } return [...s].sort((a, b) => a.localeCompare(b, "fr")) }, [sidebarNav.folderTree, sidebarNav.labelRows, allEmails, labelEdits, notSpamEmailIds]) const resolveLabelVisual = useCallback( (label: string) => resolveLabelPickerVisual(label, { folderTree: sidebarNav.folderTree, labelRows: sidebarNav.labelRows, emailLabelToSidebarFolderId: sidebarNav.emailLabelToSidebarFolderId, }), [ sidebarNav.folderTree, sidebarNav.labelRows, sidebarNav.emailLabelToSidebarFolderId, ] ) const resolveLabelCasing = useCallback( (raw: string) => { const t = raw.trim() if (!t) return "" const hit = catalogLabels.find((c) => c.toLowerCase() === t.toLowerCase()) return hit ?? t }, [catalogLabels] ) const addLabelToEmails = useCallback( (ids: string[], label: string) => { const resolved = resolveLabelCasing(label) if (!resolved || ids.length === 0) return sidebarNav.ensureLabelRowForLabelText(resolved) setLabelEdits((prev) => { const nextAdd = { ...prev.additions } const nextRem = { ...prev.removals } for (const id of ids) { if (nextRem[id]?.length) { nextRem[id] = nextRem[id].filter( (x) => x.toLowerCase() !== resolved.toLowerCase() ) if (nextRem[id].length === 0) delete nextRem[id] } const base = allEmails.find((e) => e.id === id) const merged = effectiveLabels(base, nextAdd, nextRem) if (merged.some((x) => x.toLowerCase() === resolved.toLowerCase())) { continue } nextAdd[id] = [...(nextAdd[id] ?? []), resolved] } return { additions: nextAdd, removals: nextRem } }) }, [resolveLabelCasing, allEmails, sidebarNav] ) const getCatalogLabelPresence = useCallback( (ids: string[], catalogLabel: string): CatalogLabelPresence => { const resolved = resolveLabelCasing(catalogLabel) if (!resolved || ids.length === 0) return "none" const lc = resolved.toLowerCase() let n = 0 for (const id of ids) { const e = allEmails.find((x) => x.id === id) const eff = effectiveLabels(e, labelEdits.additions, labelEdits.removals) if (eff.some((l) => l.toLowerCase() === lc)) n++ } if (n === 0) return "none" if (n === ids.length) return "all" return "some" }, [allEmails, labelEdits, resolveLabelCasing] ) const toggleLabelOnEmails = useCallback( (ids: string[], label: string) => { const resolved = resolveLabelCasing(label) if (!resolved || ids.length === 0) return setLabelEdits((prev) => { const presence = (id: string) => { const e = allEmails.find((x) => x.id === id) if (!e) return false return effectiveLabels(e, prev.additions, prev.removals).some( (l) => l.toLowerCase() === resolved.toLowerCase() ) } const allHave = ids.every((id) => presence(id)) const nextAdd = { ...prev.additions } const nextRem = { ...prev.removals } if (allHave) { for (const id of ids) { if (nextAdd[id]?.length) { const filtered = nextAdd[id].filter( (l) => l.toLowerCase() !== resolved.toLowerCase() ) if (filtered.length) nextAdd[id] = filtered else delete nextAdd[id] } const e = allEmails.find((x) => x.id === id) if (!e) continue const still = effectiveLabels(e, nextAdd, nextRem).some( (l) => l.toLowerCase() === resolved.toLowerCase() ) if (still) { const cur = nextRem[id] ?? [] if (!cur.some((l) => l.toLowerCase() === resolved.toLowerCase())) { nextRem[id] = [...cur, resolved] } } else if (nextRem[id]?.length) { const fr = nextRem[id].filter( (l) => l.toLowerCase() !== resolved.toLowerCase() ) if (fr.length) nextRem[id] = fr else delete nextRem[id] } } } else { const anyMissing = ids.some((id) => !presence(id)) if (anyMissing) { queueMicrotask(() => sidebarNav.ensureLabelRowForLabelText(resolved)) } for (const id of ids) { const e = allEmails.find((x) => x.id === id) if (!e) continue const had = effectiveLabels(e, prev.additions, prev.removals).some( (l) => l.toLowerCase() === resolved.toLowerCase() ) if (nextRem[id]?.length) { const fr = nextRem[id].filter( (l) => l.toLowerCase() !== resolved.toLowerCase() ) if (fr.length) nextRem[id] = fr else delete nextRem[id] } if (!had) { if (!nextAdd[id]) nextAdd[id] = [] if (!nextAdd[id].some((l) => l.toLowerCase() === resolved.toLowerCase())) { nextAdd[id] = [...nextAdd[id], resolved] } } } } return { additions: nextAdd, removals: nextRem } }) }, [allEmails, resolveLabelCasing, sidebarNav] ) const folderUnreadCounts = useMemo( () => computeFolderUnreadCounts( allEmails, folderFilterCtx, hiddenEmailIds, readOverrides, navMaps, labelEdits, notSpamEmailIds ), [ folderFilterCtx, hiddenEmailIds, readOverrides, allEmails, navMaps, labelEdits, notSpamEmailIds, ] ) const pageIds = useMemo(() => listEmails.map((e) => e.id), [listEmails]) const selectedOnPageCount = useMemo( () => pageIds.filter((id) => selectedEmails.includes(id)).length, [pageIds, selectedEmails] ) const allPageSelected = pageIds.length > 0 && selectedOnPageCount === pageIds.length const somePageSelected = selectedOnPageCount > 0 && !allPageSelected const selectAllChecked: boolean | "indeterminate" = allPageSelected ? true : somePageSelected ? "indeterminate" : false const toggleStar = (id: string) => { mailActions.toggleStar(id) } const toggleImportant = (id: string) => { mailActions.toggleImportant(id) } const toggleSelect = (id: string) => { setSelectedEmails(prev => prev.includes(id) ? prev.filter(e => e !== id) : [...prev, id] ) } const selectRangeInclusive = (fromId: string, toId: string) => { const ids = pageIds const i0 = ids.indexOf(fromId) const i1 = ids.indexOf(toId) if (i0 === -1 || i1 === -1) return const lo = Math.min(i0, i1) const hi = Math.max(i0, i1) const range = ids.slice(lo, hi + 1) setSelectedEmails((prev) => [...new Set([...prev, ...range])]) } const handleSelectAllChange = (checked: boolean | "indeterminate") => { if (checked === true) { setSelectedEmails((prev) => [...new Set([...prev, ...pageIds])]) } else { setSelectedEmails((prev) => prev.filter((id) => !pageIds.includes(id))) } } const mergePageSelection = (subsetOfPageIds: string[]) => { setSelectedEmails((prev) => { const outsidePage = prev.filter((id) => !pageIds.includes(id)) return [...new Set([...outsidePage, ...subsetOfPageIds])] }) } const effectiveRead = (email: Email) => readOverrides[email.id] !== undefined ? readOverrides[email.id]! : email.read const seenSerialized = useMemo( () => [...seenEmailIds].sort().join(","), [seenEmailIds] ) /** Onglets catégories : « nouveaux » + ligne d’expéditeurs = non vus (pas encore aperçus dans la liste), pas non lus. */ const { unseenInTabById, tabUnseenSenderLineById } = useMemo(() => { const seen = new Set( seenSerialized.length > 0 ? seenSerialized.split(",") : [] ) const hidden = new Set(hiddenEmailIds) const visible = allEmails .filter((email) => !hidden.has(email.id)) .map((e) => mergeEmailNotSpam(mergeEmailLabelEdits(e, labelEdits), notSpamEmailIds) ) const inboxPool = visible.filter((e) => emailMatchesFolder(e, "inbox", folderFilterCtx, navMaps) ) const counts: Record = {} const preview: Record = {} const tabCache = new Map() for (const tab of inboxTabBarItems) { const rows = inboxPool.filter((e) => { if (tab.id === "primary") { return ( emailMatchesInboxPrimaryTab(e, folderFilterCtx, navMaps, tabCache) && !seen.has(e.id) ) } if (tab.id === INBOX_ALL_TAB) { return !seen.has(e.id) } return ( emailMatchesFolder(e, "inbox", folderFilterCtx, navMaps, tabCache) && emailMatchesFolder(e, tab.id, folderFilterCtx, navMaps, tabCache) && !seen.has(e.id) ) }) counts[tab.id] = rows.length if (inboxTabShowsInactiveMeta(tab.id)) { const chain: string[] = [] const used = new Set() for (const e of rows) { const n = cleanSenderName(e.sender).trim() if (!n || used.has(n)) continue used.add(n) chain.push(n) if (chain.length >= 6) break } preview[tab.id] = chain.join(", ") } } return { unseenInTabById: counts, tabUnseenSenderLineById: preview } }, [folderFilterCtx, hiddenEmailIds, labelEdits, seenSerialized, allEmails, navMaps, notSpamEmailIds, inboxTabBarItems]) const effectiveStarred = (email: Email) => starredEmails.includes(email.id) || email.starred const selectMenuAll = () => mergePageSelection(pageIds) const selectMenuNone = () => setSelectedEmails((prev) => prev.filter((id) => !pageIds.includes(id))) const selectMenuRead = () => mergePageSelection( listEmails.filter((e) => effectiveRead(e)).map((e) => e.id) ) const selectMenuUnread = () => mergePageSelection( listEmails.filter((e) => !effectiveRead(e)).map((e) => e.id) ) const selectMenuStarred = () => mergePageSelection( listEmails.filter((e) => effectiveStarred(e)).map((e) => e.id) ) const selectMenuUnstarred = () => mergePageSelection( listEmails.filter((e) => !effectiveStarred(e)).map((e) => e.id) ) const handleRowCheckboxClickCapture = (id: string, e: MouseEvent) => { if (e.shiftKey && lastSelectionAnchorIdRef.current != null) { e.preventDefault() e.stopPropagation() selectRangeInclusive(lastSelectionAnchorIdRef.current, id) lastSelectionAnchorIdRef.current = id } } const bulkTargetIds = useMemo( () => pageIds.filter((id) => selectedEmails.includes(id)), [pageIds, selectedEmails] ) const hasUnreadInSelection = useMemo(() => { for (const id of bulkTargetIds) { const email = allEmails.find((e) => e.id === id) if (!email) continue const isRead = readOverrides[id] !== undefined ? readOverrides[id]! : email.read if (!isRead) return true } return false }, [bulkTargetIds, readOverrides, allEmails]) const showBulkToolbar = bulkTargetIds.length > 0 const labelSheetTargetIds = useMemo( () => (swipeLabelEmailId ? [swipeLabelEmailId] : bulkTargetIds), [swipeLabelEmailId, bulkTargetIds] ) const clearBulkSelection = (ids: string[]) => { setSelectedEmails((prev) => prev.filter((id) => !ids.includes(id))) } const bulkHideFromList = (ids: string[]) => { if (ids.length === 0) return mailActions.hideEmails(ids) clearBulkSelection(ids) } const bulkArchive = () => bulkHideFromList(bulkTargetIds) const bulkDelete = () => bulkHideFromList(bulkTargetIds) const bulkSpam = () => bulkHideFromList(bulkTargetIds) const handleEmailsDroppedOnTarget = useCallback( (targetId: string, _targetLabel: string, ids: string[]) => { if (ids.length === 0) return moveEmailsToTarget(ids, targetId) setSelectedEmails((prev) => prev.filter((id) => !ids.includes(id))) }, [moveEmailsToTarget] ) useEffect(() => { return registerOnDrop(handleEmailsDroppedOnTarget) }, [registerOnDrop, handleEmailsDroppedOnTarget]) const startRowDrag = useCallback( (rowId: string, e: DragEvent) => { if (isXs) return const inSelection = selectedEmails.includes(rowId) const ids = inSelection && bulkTargetIds.length > 0 ? bulkTargetIds : [rowId] if (e.dataTransfer) { e.dataTransfer.effectAllowed = "move" try { e.dataTransfer.setData("text/plain", ids.join(",")) } catch { /* some browsers throw if called outside dragstart context */ } const ghost = document.createElement("div") ghost.style.position = "fixed" ghost.style.top = "-1000px" ghost.style.left = "-1000px" ghost.style.width = "1px" ghost.style.height = "1px" ghost.style.opacity = "0" document.body.appendChild(ghost) e.dataTransfer.setDragImage(ghost, 0, 0) window.setTimeout(() => { if (ghost.parentNode) ghost.parentNode.removeChild(ghost) }, 0) } beginDrag(ids, selectedFolder, e.clientX, e.clientY) }, [beginDrag, isXs, selectedEmails, bulkTargetIds, selectedFolder] ) const bulkMarkRead = () => { if (bulkTargetIds.length === 0) return setReadOverrides((prev) => { const next = { ...prev } for (const id of bulkTargetIds) next[id] = true return next }) } const bulkMarkUnread = () => { if (bulkTargetIds.length === 0) return setReadOverrides((prev) => { const next = { ...prev } for (const id of bulkTargetIds) next[id] = false return next }) } const markAllInViewAsRead = useCallback(() => { setReadOverrides((prev) => { const next = { ...prev } for (const e of displayListEmails) { for (const id of readStateTargets(e, conversationMode)) { next[id] = true } } return next }) }, [displayListEmails, conversationMode]) const bulkMoveTo = useCallback( (targetId: string) => { if (bulkTargetIds.length === 0) return moveEmailsToTarget(bulkTargetIds, targetId) if (targetId !== "inbox") { setSelectedEmails((prev) => prev.filter((id) => !bulkTargetIds.includes(id))) } }, [bulkTargetIds, moveEmailsToTarget] ) // --- View mode helpers --- const openEmailView = useMemo(() => { if (!openMailId) return null const resolved = resolveOpenEmailView( openMailId, allEmails, conversationMode ) if (!resolved) return null if (resolved.email.labels?.includes("scheduled")) return null const email = mergeEmailNotSpam( mergeEmailLabelEdits(resolved.email, labelEdits), notSpamEmailIds ) const threadRoot = mergeEmailNotSpam( mergeEmailLabelEdits(resolved.threadRoot, labelEdits), notSpamEmailIds ) return { email, threadRoot, isSingleMessageView: resolved.isSingleMessageView, } }, [openMailId, labelEdits, allEmails, notSpamEmailIds, conversationMode]) const openEmail = openEmailView?.email ?? null const openEmailThreadRoot = openEmailView?.threadRoot ?? null const isSingleMessageView = openEmailView?.isSingleMessageView ?? false const openMailIndex = useMemo( () => openMailId ? displayListEmails.findIndex((e) => e.id === openMailId) : -1, [openMailId, displayListEmails] ) useEffect(() => { if (!openMailId) return const message = emailById.get(openMailId) if (!message) return const targets = readStateTargets(message, conversationMode) for (const id of targets) { markEmailSeen(id) } setReadOverrides((prev) => { let changed = false const next = { ...prev } for (const id of targets) { if (next[id] === undefined) { next[id] = true changed = true } } return changed ? next : prev }) }, [openMailId, markEmailSeen, emailById, conversationMode]) const navigateToMail = useCallback( (id: string | null) => { if (id && splitView) { const idx = displayListEmails.findIndex((e) => e.id === id) if (idx >= 0) { const page = Math.floor(idx / LIST_PAGE_SIZE) + 1 onMailRouteNavigate({ mailId: id, page }) return } } onMailRouteNavigate({ mailId: id }) }, [splitView, displayListEmails, onMailRouteNavigate] ) useEffect(() => { if (!openMailId) return const raw = allEmails.find((e) => e.id === openMailId) if (raw?.labels?.includes("scheduled")) { navigateToMail(null) } }, [openMailId, allEmails, navigateToMail]) const pickAdjacentMailId = useCallback( (currentId: string) => { const idx = displayListEmails.findIndex((e) => e.id === currentId) if (idx < 0) return displayListEmails[0]?.id ?? null if (idx < displayListEmails.length - 1) return displayListEmails[idx + 1]!.id if (idx > 0) return displayListEmails[idx - 1]!.id return null }, [displayListEmails] ) const leaveReadingPane = useCallback(() => { if (!splitView) { navigateToMail(null) return } if (!openMailId) return navigateToMail(pickAdjacentMailId(openMailId)) }, [splitView, openMailId, navigateToMail, pickAdjacentMailId]) const goBack = useCallback(() => { if (splitView) leaveReadingPane() else navigateToMail(null) }, [splitView, leaveReadingPane, navigateToMail]) const closeViewIfShowingEmail = useCallback( (emailId: string) => { if (openMailId === emailId) goBack() }, [openMailId, goBack] ) const archiveListRow = useCallback( (email: Email) => { if (email.labels?.includes("scheduled")) { void requestArchiveScheduled(email.id) } else { mailActions.hideEmail(email.id) closeViewIfShowingEmail(email.id) } }, [closeViewIfShowingEmail, mailActions, requestArchiveScheduled] ) const deleteListRow = useCallback( (email: Email) => { if (email.labels?.includes("scheduled")) { void requestDeleteScheduled(email.id) } else { mailActions.hideEmail(email.id) closeViewIfShowingEmail(email.id) } }, [closeViewIfShowingEmail, mailActions, requestDeleteScheduled] ) const openSwipeRowLabelSheet = useCallback((emailId: string) => { setSwipeLabelEmailId(emailId) setMobileXsLabelSheetOpen(true) }, []) const restoreSnoozedRowToMailbox = useCallback( (emailRow: Email) => { void requestRestoreSnoozedToInbox(emailRow) if (emailRow.id.startsWith("snz-")) { const baseId = emailRow.id.slice(4) if (baseId.length > 0) mailActions.unhideEmail(baseId) onSelectFolder?.("inbox") } else { onSelectFolder?.("scheduled") } closeViewIfShowingEmail(emailRow.id) }, [ requestRestoreSnoozedToInbox, mailActions, closeViewIfShowingEmail, onSelectFolder, ] ) const handleCategoryInboxTabClick = useCallback( (tabId: string) => { startTransition(() => { onMailRouteNavigate({ inboxTab: tabId, page: 1, mailId: null, }) }) }, [onMailRouteNavigate] ) const handleBreadcrumbNavigate = useCallback( (visitKey: string) => { if (visitKey === mailNavVisitKey(selectedFolder, inboxTab)) return const { folderId, inboxTab: tab } = parseMailNavVisitKey(visitKey) startTransition(() => { if (folderId === "inbox" && tab && tab !== DEFAULT_INBOX_TAB) { onMailRouteNavigate({ folderId: "inbox", inboxTab: tab, page: 1, mailId: null, }) return } if (onSelectFolder) { onSelectFolder(folderId) return } onMailRouteNavigate({ folderId, inboxTab: DEFAULT_INBOX_TAB, page: 1, mailId: null, }) }) }, [ selectedFolder, inboxTab, onMailRouteNavigate, onSelectFolder, ] ) const goListPrevPage = useCallback(() => { if (listPage <= 1) return onMailRouteNavigate({ page: listPage - 1 }) }, [listPage, onMailRouteNavigate]) const goListNextPage = useCallback(() => { if (listPage >= totalPages) return onMailRouteNavigate({ page: listPage + 1 }) }, [listPage, totalPages, onMailRouteNavigate]) const goToPrev = useCallback(() => { if (openMailIndex > 0) { const id = displayListEmails[openMailIndex - 1]!.id markEmailSeen(id) setReadOverrides((prev) => ({ ...prev, [id]: true })) navigateToMail(id) } }, [openMailIndex, displayListEmails, navigateToMail, markEmailSeen]) const goToNext = useCallback(() => { if (openMailIndex >= 0 && openMailIndex < displayListEmails.length - 1) { const id = displayListEmails[openMailIndex + 1]!.id markEmailSeen(id) setReadOverrides((prev) => ({ ...prev, [id]: true })) navigateToMail(id) } }, [openMailIndex, displayListEmails, navigateToMail, markEmailSeen]) const handleOpenEmail = useCallback( (id: string) => { const em = allEmails.find((e) => e.id === id) if (em?.labels?.includes("scheduled")) return markEmailSeen(id) setReadOverrides((prev) => ({ ...prev, [id]: true })) navigateToMail(id) }, [navigateToMail, markEmailSeen, allEmails] ) const openDraftInCompose = useCallback( (email: Email) => { markEmailSeen(email.id) setReadOverrides((prev) => ({ ...prev, [email.id]: true })) const to: Contact[] = email.senderEmail ? [{ name: email.sender.trim(), email: email.senderEmail }] : [] const body = email.body ?? (email.preview ? `

${escapeHtml(email.preview)}

` : "

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

{mobileFolderLabel}

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

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

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

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

Aucun message planifié.

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

{email.preview}

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

Nouvelle date d'envoi

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

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

setCmScheduledRescheduleValue(e.target.value) } onPointerDown={(e) => e.stopPropagation()} />
1} onSelect={() => { if (contextTargetIds.length !== 1) return void handleEditScheduledMail(contextTargetIds[0]!) }} > Modifier le mail { const ids = [...contextMenuTargetIdsRef.current] void Promise.all( ids.map((id) => requestSendScheduledNow(id)) ) }} > Envoyer maintenant ) : ( <> Répondre Répondre à tous Transférer Transférer en tant que pièce jointe Archiver Supprimer { const newRead = !isRead const ids = contextMenuTargetIdsRef.current setReadOverrides((prev) => { const next = { ...prev } for (const id of ids) { next[id] = newRead } return next }) }} > {!isRead ? ( ) : ( )} {isRead ? "Marquer comme non lu" : "Marquer comme lu"} Mettre en attente Ajouter à Tasks Déplacer vers { moveEmailsToTarget(contextTargetIds, targetId) if (targetId !== "inbox") { setSelectedEmails((prev) => prev.filter((id) => !contextTargetIds.includes(id))) } }} /> Ajouter le libellé getCatalogLabelPresence(contextTargetIds, lab) } onToggleCatalogLabel={(lab) => toggleLabelOnEmails(contextTargetIds, lab) } onCreateLabel={(lab) => { addLabelToEmails(contextTargetIds, lab) setLabelPickerQuery("") }} /> Ignorer la conversation Rech. e-mails de {senderForSearch} Ouvrir dans une nouvelle fenêtre )}
) })}
)}
)}
{listToolbarMode ? (
) : null}
{splitView ? ( ) : null}
{splitView ? (
{openEmail ? ( <>
{openMailToolbar(false)}
{mailPaginationControls("view")}
{ if (LABEL_PICKER_EXCLUDE.has(lab)) return true return mailLabelShouldShowInListStrip( lab, sidebarNav.emailLabelToSidebarFolderId, sidebarNav.getNavItemPrefs, sidebarNav.labelRows ) }} />
) : ( Aucun message sélectionné Choisissez un message dans la liste ou ouvrez une boîte contenant des messages. )}
) : null}
) }