ultisuite-client/components/gmail/email-list.tsx
R3D347HR4Y 9266aa34cd huhu
2026-05-19 22:20:43 +02:00

4858 lines
187 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 {
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,
X,
CheckSquare,
Inbox as InboxIcon,
} 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 {
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,
inboxTabShowsInactiveMeta,
normalizeInboxTabSegment,
} from "@/lib/mail-url"
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 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 "—"
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, "&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 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 (
<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
/** md+ split pane: list left, reading pane right (tablet landscape or user setting). */
splitView?: boolean
onToggleSidebar?: () => void
onMailRouteNavigate: (patch: Partial<MailRouteState>) => void
onSelectFolder?: (folder: string) => void
onFolderUnreadCountsChange?: (counts: Record<string, number>) => void
/** Barre basse xs en lecture dun 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 { 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<MailNavFolderMaps>(
() => ({
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 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 {
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<string[]>([])
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<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 notSpamEmailIds = useMailStore((s) => s.notSpamEmailIds)
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 [swipeLabelEmailId, setSwipeLabelEmailId] = useState<string | null>(null)
const [openSwipeRowId, setOpenSwipeRowId] = useState<string | null>(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<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 hiddenSet = new Set(hiddenEmailIds)
const subtreeIdsCache = new Map<string, string[] | null>()
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)
)
}
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,
])
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(() => {
const inboxTabNorm = normalizeInboxTabSegment(inboxTab)
return selectedFolder === "inbox" && inboxTabNorm !== "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(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<typeof resolveParsedCalendarInvitation>
>()
const attachmentsById = new Map<string, EmailAttachment[]>()
const categoryTabsById = new Map<
string,
ReturnType<typeof resolveEmailInboxCategoryTabs>
>()
const subtreeIdsCache = new Map<string, string[] | null>()
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<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 = 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 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) =>
mergeEmailNotSpam(mergeEmailLabelEdits(e, labelEdits), notSpamEmailIds)
)
const inboxPool = visible.filter((e) =>
emailMatchesFolder(e, "inbox", folderFilterCtx, navMaps)
)
const counts: Record<string, number> = {}
const preview: Record<string, string> = {}
const tabCache = new Map<string, string[] | null>()
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<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, 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<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 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
? `<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 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<HTMLElement>(
`[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<HTMLElement>("[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 longlet actif. */
const compactInboxTabs = isXs || splitView
const activeInboxTabId = useMemo(
() => normalizeInboxTabSegment(inboxTab),
[inboxTab]
)
const openMailToolbar = (showBack: boolean) => (
<TooltipProvider delayDuration={400}>
{showBack ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label="Retour à la boîte de réception"
onClick={goBack}
>
<ArrowLeft className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Retour à la boîte de réception
</TooltipContent>
</Tooltip>
) : null}
<div className="flex min-w-0 flex-wrap items-center gap-0.5 pl-1">
{openEmail?.spam === true ? (
<>
<div className="flex min-w-0 shrink-0 flex-wrap items-center gap-0.5">
<Button
type="button"
variant="ghost"
size="sm"
className="h-9 shrink-0 px-2.5 text-sm font-medium text-[#444746] hover:bg-[#f1f3f4]"
onClick={singleDelete}
>
Supprimer définitivement
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-9 shrink-0 px-2.5 text-sm font-medium text-[#444746] hover:bg-[#f1f3f4]"
onClick={singleNotSpam}
>
Non-spam
</Button>
</div>
<span className="mx-1 h-6 w-px shrink-0 bg-[#dadce0]" aria-hidden />
<div className="flex shrink-0 items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label="Archiver"
onClick={singleArchive}
>
<Archive className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Archiver
</TooltipContent>
</Tooltip>
</div>
<span className="mx-1 h-6 w-px shrink-0 bg-[#dadce0]" aria-hidden />
<div className="flex min-w-0 shrink-0 items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label={
viewModeIsRead ? "Marquer comme non lu" : "Marquer comme lu"
}
onClick={singleToggleRead}
>
{viewModeIsRead ? (
<Mail className="h-[18px] w-[18px]" strokeWidth={1.5} />
) : (
<MailOpen className="h-[18px] w-[18px]" strokeWidth={1.5} />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{viewModeIsRead ? "Marquer comme non lu" : "Marquer comme lu"}
</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-9 w-9 shrink-0 gap-1 px-0 text-[#444746] hover:bg-[#f1f3f4] lg:h-9 lg:w-auto lg:px-2"
aria-label="Déplacer vers"
>
<FolderInput
className="h-[18px] w-[18px] shrink-0"
strokeWidth={1.5}
/>
<span className="hidden max-w-32 truncate lg:inline">
Déplacer vers
</span>
<ChevronDown className="hidden h-3.5 w-3.5 shrink-0 opacity-70 lg:block" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={cn(dropdownSurfaceClass, "max-h-80 overflow-y-auto")}
>
<MoveToDropdownItems targets={moveTargets} onMoveTo={singleMoveTo} />
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
) : (
<>
<div className="flex shrink-0 items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label="Archiver"
onClick={singleArchive}
>
<Archive className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Archiver
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label="Signaler comme spam"
onClick={singleSpam}
>
<ShieldAlert className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Signaler comme spam
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label="Supprimer"
onClick={singleDelete}
>
<Trash2 className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Supprimer
</TooltipContent>
</Tooltip>
</div>
<span className="mx-1 h-6 w-px shrink-0 bg-[#dadce0]" aria-hidden />
<div className="flex min-w-0 shrink-0 items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label={
viewModeIsRead ? "Marquer comme non lu" : "Marquer comme lu"
}
onClick={singleToggleRead}
>
{viewModeIsRead ? (
<Mail className="h-[18px] w-[18px]" strokeWidth={1.5} />
) : (
<MailOpen className="h-[18px] w-[18px]" strokeWidth={1.5} />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{viewModeIsRead ? "Marquer comme non lu" : "Marquer comme lu"}
</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-9 w-9 shrink-0 gap-1 px-0 text-[#444746] hover:bg-[#f1f3f4] lg:h-9 lg:w-auto lg:px-2"
aria-label="Déplacer vers"
>
<FolderInput
className="h-[18px] w-[18px] shrink-0"
strokeWidth={1.5}
/>
<span className="hidden max-w-32 truncate lg:inline">
Déplacer vers
</span>
<ChevronDown className="hidden h-3.5 w-3.5 shrink-0 opacity-70 lg:block" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={cn(dropdownSurfaceClass, "max-h-80 overflow-y-auto")}
>
<MoveToDropdownItems targets={moveTargets} onMoveTo={singleMoveTo} />
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
)}
</div>
</TooltipProvider>
)
const mailPaginationControls = (mode: "list" | "view") => (
<div
className={cn(
"flex shrink-0 items-center gap-2 whitespace-nowrap text-sm text-gray-600",
mode === "list" && "max-sm:hidden sm:flex"
)}
>
{displayListEmails.length === 0 ? (
<span>Aucun résultat</span>
) : mode === "view" ? (
<span className="hidden sm:inline">
{openMailIndex >= 0 ? openMailIndex + 1 : ""} sur {displayListEmails.length}
</span>
) : (
<span>
{(listPage - 1) * LIST_PAGE_SIZE + 1}
{Math.min(listPage * LIST_PAGE_SIZE, displayListEmails.length)} sur{" "}
{displayListEmails.length}
{totalPages > 1 ? ` · p. ${listPage}/${totalPages}` : null}
</span>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-9 w-9",
mode === "view" && openMailIndex > 0
? "text-gray-600"
: mode === "list" && listPage > 1
? "text-gray-600"
: "text-gray-400"
)}
disabled={mode === "view" ? openMailIndex <= 0 : listPage <= 1}
onClick={mode === "view" ? goToPrev : goListPrevPage}
aria-label={mode === "view" ? "Plus récent" : "Page précédente"}
>
<ChevronLeft className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{mode === "view" ? "Plus récent" : "Page précédente"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-9 w-9",
mode === "view" && openMailIndex < displayListEmails.length - 1
? "text-gray-600"
: mode === "list" && listPage < totalPages
? "text-gray-600"
: "text-gray-400"
)}
disabled={
mode === "view"
? openMailIndex >= displayListEmails.length - 1
: listPage >= totalPages
}
onClick={mode === "view" ? goToNext : goListNextPage}
aria-label={mode === "view" ? "Plus ancien" : "Page suivante"}
>
<ChevronRight className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{mode === "view" ? "Plus ancien" : "Page suivante"}
</TooltipContent>
</Tooltip>
</div>
)
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 (
<div className="flex h-full min-h-0 flex-1 flex-col">
{/* Mobile xs top bar */}
{!isViewMode && (
<div className="relative z-20 flex shrink-0 items-center gap-2 border-b border-border bg-mail-surface dark:bg-zinc-800 px-4 py-2.5 sm:hidden">
<div className="min-w-0 flex-1">
<h1 className="truncate text-base font-semibold text-[#1f1f1f] leading-tight">
{mobileFolderLabel}
</h1>
<p className="text-xs text-[#5f6368] leading-snug">
{displayListEmails.length} message{displayListEmails.length !== 1 ? "s" : ""}
{mobileUnreadCount > 0 && ` · ${mobileUnreadCount} non lu${mobileUnreadCount !== 1 ? "s" : ""}`}
</p>
</div>
<Button
type="button"
variant="ghost"
size={mobileSelectionMode ? "icon" : "sm"}
className={cn(
"shrink-0 text-[#444746]",
mobileSelectionMode
? "size-9 rounded-full border border-gray-200 bg-white/80 shadow-md backdrop-blur hover:bg-white"
: "h-9 min-h-9 gap-1.5 rounded-full border border-gray-200 bg-white/80 px-3 text-xs font-medium shadow-md backdrop-blur hover:bg-white"
)}
onClick={() => {
setMobileSelectionMode((p) => !p)
if (mobileSelectionMode) setSelectedEmails([])
}}
aria-label={mobileSelectionMode ? "Annuler la sélection" : "Sélection"}
>
{mobileSelectionMode ? (
<X className="size-[18px]" strokeWidth={1.5} />
) : (
<>
<CheckSquare className="size-4" strokeWidth={1.5} />
<span>Sélection</span>
</>
)}
</Button>
<DropdownMenu
open={mobileXsMoreMenuOpen}
onOpenChange={setMobileXsMoreMenuOpen}
>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-9 shrink-0 rounded-full border border-gray-200 bg-white/80 text-[#444746] shadow-md backdrop-blur hover:bg-white"
aria-label="Plus d'actions"
>
<MoreVertical className="size-[18px]" strokeWidth={1.5} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={4}
className={cn(dropdownSurfaceClass, "min-w-[260px]")}
>
{showBulkToolbar ? (
<>
<DropdownMenuItem onSelect={bulkArchive}>
<Archive className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
Archiver
</DropdownMenuItem>
<DropdownMenuItem onSelect={bulkDelete}>
<Trash2 className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
Supprimer
</DropdownMenuItem>
<DropdownMenuItem onSelect={bulkSpam}>
<ShieldAlert className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
Signaler comme spam
</DropdownMenuItem>
<DropdownMenuItem
onSelect={hasUnreadInSelection ? bulkMarkRead : bulkMarkUnread}
>
{hasUnreadInSelection ? (
<>
<MailOpen className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
Marquer comme lu
</>
) : (
<>
<Mail className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
Marquer comme non lu
</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
openMobileXsMoveSheet()
}}
>
<FolderInput className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
<span className="flex-1">Déplacer vers</span>
<ChevronRight className="ml-auto size-4 text-[#5f6368]" strokeWidth={1.5} />
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
openMobileXsLabelSheet()
}}
>
<Tag className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
<span className="flex-1">Ajouter le libellé</span>
<ChevronRight className="ml-auto size-4 text-[#5f6368]" strokeWidth={1.5} />
</DropdownMenuItem>
<DropdownMenuItem>
<VolumeX className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
Ignorer la conversation
</DropdownMenuItem>
</>
) : (
<>
<DropdownMenuItem onSelect={markAllInViewAsRead}>
<MailOpen className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
Tout marquer comme lu
</DropdownMenuItem>
<DropdownMenuSeparator />
<div
className="px-3 py-2 text-sm leading-snug text-[#5f6368] select-none"
role="note"
>
Sélectionnez des messages pour plus d&apos;actions
</div>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{/* View-mode xs nav buttons are rendered inside the scroll area below */}
{!isViewMode && touchNav && (
<MobileXsBulkSheets
moveSheetOpen={isXs && mobileXsMoveSheetOpen}
onMoveSheetOpenChange={handleMobileXsMoveSheetOpenChange}
labelSheetOpen={mobileXsLabelSheetOpen}
onLabelSheetOpenChange={handleLabelSheetOpenChange}
labelPickerQuery={labelPickerQuery}
onLabelPickerQueryChange={setLabelPickerQuery}
catalogLabels={catalogLabels}
resolveLabelVisual={resolveLabelVisual}
moveTargets={moveTargets}
onMoveTo={bulkMoveTo}
getLabelPresence={(lab) => getCatalogLabelPresence(labelSheetTargetIds, lab)}
onToggleCatalogLabel={(lab) => toggleLabelOnEmails(labelSheetTargetIds, lab)}
onCreateLabel={(lab) => {
addLabelToEmails(labelSheetTargetIds, lab)
setLabelPickerQuery("")
}}
/>
)}
<div className={cn("flex min-h-0 flex-1 flex-col", splitView && "min-h-0 flex-row overflow-hidden")}>
<div
className={cn(
"flex min-h-0 min-w-0 flex-col",
splitView
? "relative w-[min(42%,480px)] min-w-[280px] max-w-[480px] shrink-0 border-r border-gray-200"
: "min-h-0 flex-1"
)}
>
{splitView ? (
<div className="flex max-sm:hidden shrink-0 items-center gap-2 border-b border-border bg-mail-surface px-2 py-2">
{onToggleSidebar ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-9 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label="Ouvrir le menu"
onClick={onToggleSidebar}
>
<Menu className="size-5" strokeWidth={1.5} />
</Button>
) : null}
<MailSearchBar compact className="min-w-0 flex-1" />
</div>
) : null}
{/* Toolbar — relative: scroll lives in sibling below */}
<div
className={cn(
"relative z-20 flex shrink-0 min-h-12 gap-2 border-b border-border bg-mail-surface py-1.5 pl-2 pr-4",
splitView ? "rounded-none" : "sm:rounded-t-2xl",
isViewMode ? "items-start" : "items-center",
(isViewMode ? !listToolbarMode : true) && "max-sm:hidden"
)}
>
{!splitView && isViewMode ? (
openMailToolbar(true)
) : (
/* ── LIST MODE TOOLBAR (original) ── */
<>
<DropdownMenu
open={bulkSelectMenuOpen}
onOpenChange={setBulkSelectMenuOpen}
>
<div
className={cn(
"flex items-center overflow-hidden rounded-md border pr-0 transition-[background-color,box-shadow,border-color]",
bulkSelectMenuOpen
? "border-[#dadce0] bg-[#f1f3f4] shadow-sm"
: "border-transparent"
)}
>
<div className="flex h-9 shrink-0 items-center pl-1 pr-0.5 md:pl-0">
<Checkbox
checked={selectAllChecked}
onCheckedChange={handleSelectAllChange}
className="size-4 min-h-4 min-w-4 shrink-0 rounded-[2.5px] border-[1.5px] border-[#c2c2c2] bg-transparent shadow-none dark:bg-transparent focus-visible:ring-[#c2c2c2]/30 data-[state=checked]:border-[#1a73e8] data-[state=checked]:bg-[#1a73e8] data-[state=checked]:text-white data-[state=indeterminate]:border-[#1a73e8] data-[state=indeterminate]:bg-[#1a73e8] data-[state=indeterminate]:text-white"
/>
</div>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"h-9 w-7 shrink-0 rounded-none p-0 text-[#5f6368]",
bulkSelectMenuOpen
? "border-l border-[#dadce0] hover:bg-[#e8eaed]"
: "hover:bg-[#f1f3f4]"
)}
aria-label="Options de sélection"
>
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent
align="start"
sideOffset={4}
className={cn(dropdownSurfaceClass, "min-w-[180px]")}
>
<DropdownMenuItem onSelect={selectMenuAll}>Tous</DropdownMenuItem>
<DropdownMenuItem onSelect={selectMenuNone}>Aucun</DropdownMenuItem>
<DropdownMenuItem onSelect={selectMenuRead}>Lus</DropdownMenuItem>
<DropdownMenuItem onSelect={selectMenuUnread}>Non lus</DropdownMenuItem>
<DropdownMenuItem onSelect={selectMenuStarred}>Suivis</DropdownMenuItem>
<DropdownMenuItem onSelect={selectMenuUnstarred}>
Non suivis
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{showBulkToolbar ? (
<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}
resolveLabelVisual={resolveLabelVisual}
Item={DropdownMenuItem}
getLabelPresence={(lab) =>
getCatalogLabelPresence(bulkTargetIds, lab)
}
onToggleCatalogLabel={(lab) =>
toggleLabelOnEmails(bulkTargetIds, lab)
}
onCreateLabel={(lab) => {
addLabelToEmails(bulkTargetIds, lab)
setLabelPickerQuery("")
}}
/>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem>
<VolumeX className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
Ignorer la conversation
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<SquareArrowOutUpRight
className="size-[18px] text-[#5f6368]"
strokeWidth={1.5}
/>
Ouvrir dans une nouvelle fenêtre
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</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" />
{listToolbarMode ? mailPaginationControls("list") : null}
{!splitView && !listToolbarMode ? mailPaginationControls("view") : null}
</div>
{selectedFolder === "inbox" && (
<div className="relative z-10 w-full shrink-0 bg-mail-surface dark:bg-zinc-800 sm:dark:bg-mail-surface after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:z-0 after:h-px after:bg-border">
{listToolbarMode &&
(compactInboxTabs ? (
<CompactInboxCategoryTabs
tabs={inboxTabBarItems}
activeTabId={activeInboxTabId}
unseenInTabById={unseenInTabById}
onTabClick={handleCategoryInboxTabClick}
/>
) : (
<div
className="grid w-full min-w-0 max-w-[1260px]"
style={{
gridTemplateColumns: `repeat(${inboxTabBarItems.length}, minmax(0, 1fr))`,
}}
>
{inboxTabBarItems.map((tab) => {
const isActive = activeInboxTabId === tab.id
const accentColor = isActive
? inboxTabActiveAccentColor(tab.id, tab.badgeColor)
: undefined
const unseen = unseenInTabById[tab.id] ?? 0
const senderLine = tabUnseenSenderLineById[tab.id] ?? ""
const showMeta =
inboxTabShowsInactiveMeta(tab.id) && !isActive && unseen > 0
const showSenderLine = showMeta && Boolean(senderLine)
const isExpandedTabMeta = showSenderLine
return (
<button
key={tab.id}
type="button"
aria-label={tab.label}
aria-current={isActive ? "true" : undefined}
onClick={() => handleCategoryInboxTabClick(tab.id)}
style={
accentColor
? { boxShadow: `inset 0 -3px 0 0 ${accentColor}` }
: undefined
}
className={cn(
"relative z-[1] flex cursor-pointer transition-colors",
"min-w-0 w-full overflow-hidden max-sm:min-h-10 max-sm:items-center max-sm:justify-center",
"sm:min-h-14 sm:items-center sm:py-2 sm:text-left",
!isActive && "hover:bg-[#f1f3f4]"
)}
>
<>
<div className="flex h-10 w-full items-center justify-center sm:hidden">
<div className="relative inline-flex shrink-0">
<Icon
icon={tab.icon}
className={cn(
CATEGORY_TAB_ICON_CLASS,
MAIL_INBOX_CATEGORY_TAB_CONTENT_DARK_CLASS,
!isActive && "text-[#5f6368]"
)}
style={accentColor ? { color: accentColor } : undefined}
aria-hidden
/>
{showMeta && unseen > 0 ? (
<span
className={inboxTabBadgeDotClass(tab.badgeColor)}
aria-hidden
/>
) : null}
</div>
</div>
<div className="hidden min-w-0 flex-1 items-center gap-2 mx-2 sm:mx-3 sm:flex">
<Icon
icon={tab.icon}
className={cn(
CATEGORY_TAB_ICON_CLASS,
"self-center",
MAIL_INBOX_CATEGORY_TAB_CONTENT_DARK_CLASS,
!isActive && "text-[#5f6368]"
)}
style={accentColor ? { color: accentColor } : undefined}
aria-hidden
/>
<div className="flex min-w-0 w-0 flex-1 flex-col gap-px">
<div
className={cn(
"flex min-w-0 items-center gap-1.5",
isExpandedTabMeta && "min-h-5"
)}
>
<span
className={cn(
"min-w-0 flex-1 truncate text-[13px] font-semibold leading-tight",
MAIL_INBOX_CATEGORY_TAB_CONTENT_DARK_CLASS,
!isActive && "text-[#3c4043]"
)}
style={accentColor ? { color: accentColor } : undefined}
>
{tab.label}
</span>
{showMeta && unseen > 0 ? (
<span className={inboxTabBadgeCountClass(tab.badgeColor)}>
{unseen}
<span className="hidden md:inline">
{" "}
{unseen === 1 ? "nouveau" : "nouveaux"}
</span>
</span>
) : null}
</div>
{isExpandedTabMeta ? (
<span
className={cn(
"block min-h-4 min-w-0 truncate text-[11px] leading-snug text-[#5f6368]",
MAIL_INBOX_CATEGORY_TAB_CONTENT_DARK_CLASS
)}
>
{senderLine}
</span>
) : null}
</div>
</div>
</>
</button>
)
})}
</div>
))}
</div>
)}
<div className={cn("relative flex min-h-0 flex-1 flex-col")}>
<div
ref={listViewportRef}
className={cn(
!splitView && isViewMode && openEmail
? "relative flex min-h-0 flex-1 flex-col overflow-hidden"
: mainScrollClass,
"relative min-h-0 flex-1 overscroll-y-none max-sm:pb-16"
)}
>
{listToolbarMode && (
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 flex items-center justify-center pt-2 sm:hidden"
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>
)}
<div
ref={pullContentRef}
className={cn(
!splitView && isViewMode && openEmail && "relative flex min-h-0 flex-1 flex-col",
listToolbarMode && "max-sm:[transform:translateZ(0)]"
)}
>
{!splitView && isViewMode && openEmail ? (
/* ── EMAIL VIEW ── */
<>
<div className="pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center justify-between gap-2 px-3 py-2 sm:hidden">
<Button
type="button"
variant="ghost"
size="icon"
className="pointer-events-auto size-9 shrink-0 rounded-full border border-gray-200 bg-white/80 text-[#444746] shadow-md backdrop-blur hover:bg-white"
aria-label="Retour à la boîte de réception"
onClick={goBack}
>
<ChevronLeft className="size-5" strokeWidth={1.5} />
</Button>
<div className="pointer-events-auto flex shrink-0 overflow-hidden rounded-full border border-gray-200 bg-white/80 shadow-md backdrop-blur">
<Button
type="button"
variant="ghost"
size="icon"
className="size-9 rounded-none text-[#444746] hover:bg-[#f1f3f4] disabled:opacity-40"
disabled={openMailIndex <= 0}
onClick={goToPrev}
aria-label="Message plus récent"
>
<ChevronUp className="size-5" strokeWidth={1.5} />
</Button>
<span className="w-px shrink-0 self-stretch bg-border" aria-hidden />
<Button
type="button"
variant="ghost"
size="icon"
className="size-9 rounded-none text-[#444746] hover:bg-[#f1f3f4] disabled:opacity-40"
disabled={openMailIndex >= displayListEmails.length - 1}
onClick={goToNext}
aria-label="Message plus ancien"
>
<ChevronDown className="size-5" strokeWidth={1.5} />
</Button>
</div>
</div>
<EmailView
email={openEmail}
threadRoot={openEmailThreadRoot}
isSingleMessageView={isSingleMessageView}
onToggleStar={toggleStar}
isStarred={
starredEmails.includes(threadStoreId(openEmail)) ||
openEmail.starred
}
onNavigateToLabel={handleNavigateToLabel}
onNotSpam={openEmail.spam === true ? singleNotSpam : undefined}
labelBgByText={listRowLabelBgByTextLower}
emailLabelToSidebarFolderId={sidebarNav.emailLabelToSidebarFolderId}
getNavItemPrefs={sidebarNav.getNavItemPrefs}
folderTree={sidebarNav.folderTree}
labelRows={sidebarNav.labelRows}
currentFolderId={selectedFolder}
showLabelChip={(lab) => {
if (LABEL_PICKER_EXCLUDE.has(lab)) return true
return mailLabelShouldShowInListStrip(
lab,
sidebarNav.emailLabelToSidebarFolderId,
sidebarNav.getNavItemPrefs,
sidebarNav.labelRows
)
}}
/>
</>
) : (
<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>
)}
{displayListEmails.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-mail-surface 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={cn(
"divide-y divide-[#eceff1]",
listToolbarMode && "sm:pb-14"
)}
>
{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 (
<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>
<MailListSwipeRow
enabled={touchListSwipeEnabled}
emailId={email.id}
isOpen={openSwipeRowId === email.id}
onOpenChange={(open) => {
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)}
>
<div
data-email-row-id={email.id}
data-split-active={isSplitActiveRow ? "" : undefined}
aria-current={isSplitActiveRow ? "true" : undefined}
draggable={!isXs}
onDragStart={isXs ? undefined : (e) => 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 */}
<div
className={cn(
"flex w-full min-w-0 flex-col gap-0.5",
!splitView && "md:hidden",
mobileSelectionMode &&
"max-sm:flex-row max-sm:items-center max-sm:gap-2"
)}
>
{mobileSelectionMode && (
<div
className="flex shrink-0 self-center sm:hidden"
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",
mobileSelectionMode && "max-sm:pointer-events-none"
)}
data-selectable-text
>
<div className="flex w-full min-w-0 items-center gap-2">
<div
className="hidden shrink-0 items-center sm:flex"
onClick={(e) => e.stopPropagation()}
onClickCapture={(e) => handleRowCheckboxClickCapture(email.id, e)}
>
<Checkbox
className={listRowCheckboxClass(false)}
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>
)}
{threadMessageCount > 1 && (
<span className="shrink-0 text-sm font-normal text-gray-500">
{threadMessageCount}
</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"
/>
)}
{listRowExtras.categoryTabsById.get(email.id) ? (
<MailInboxCategoryTabIcons
tabs={listRowExtras.categoryTabsById.get(email.id)!}
onTabClick={handleCategoryInboxTabClick}
/>
) : null}
<span
className={cn(
"shrink-0 text-sm font-semibold tabular-nums tracking-tight",
!isRead ? "text-gray-900" : "text-gray-700"
)}
>
{isScheduled ? (
formatScheduledDateTimeDisplay(email.scheduledSendAt)
) : (
<MailDateText iso={email.date} variant="list" />
)}
</span>
</div>
</div>
</div>
<div className={cn("flex min-w-0 flex-wrap items-center gap-1 sm: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}
labelRows={sidebarNav.labelRows}
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 sm: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={cn(
"hidden w-full items-start gap-2",
!splitView && "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-44 shrink-0 truncate pl-2 lg:w-40",
listRowPadTop,
isCompactListRow &&
"flex min-h-7 items-center leading-tight"
)}
data-selectable-text
>
{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>
)}
{threadMessageCount > 1 && (
<span className="text-sm text-gray-500 ml-1">
{threadMessageCount}
</span>
)}
</div>
<div
className="flex min-w-0 flex-1 flex-col justify-start gap-0.5 px-2 pb-0.5"
data-selectable-text
>
<div
className={cn(
"flex min-w-0 items-center gap-1",
listRowPadTop,
isCompactListRow && "leading-tight"
)}
>
{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}
labelRows={sidebarNav.labelRows}
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>
{showAttachmentPills && (
<EmailListAttachmentRow emailId={email.id} attachments={attachmentList} />
)}
</div>
<div
className={cn(
"flex shrink-0 flex-col items-end gap-1 pr-2 text-right md:max-w-[150px] md:min-w-0",
listRowPadTop,
isCompactListRow && !showAttachmentPills
? "self-center"
: "self-start"
)}
>
{isScheduled ? (
<div className="relative flex w-full min-w-0 shrink-0 items-center justify-end">
<span
className={cn(
"block max-w-full truncate text-sm font-semibold tabular-nums text-[#c65308]",
"transition-opacity duration-[50ms] ease-out",
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-[50ms] ease-out",
listRowQuickHoverTrayToneClass(isSelected, isRead),
isRescheduleOpenThisRow
? "pointer-events-auto opacity-100"
: "group-hover:pointer-events-auto group-hover:opacity-100"
)}
>
{!spamRowHoverNoArchive && (
<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="relative flex w-full min-w-0 shrink-0 items-center justify-end">
<div
className={cn(
"flex min-w-0 max-w-full items-center justify-end gap-1.5 overflow-hidden",
"transition-opacity duration-[50ms] ease-out",
"group-hover:opacity-0"
)}
>
{(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"
}
/>
)}
{listRowExtras.categoryTabsById.get(email.id) ? (
<MailInboxCategoryTabIcons
tabs={listRowExtras.categoryTabsById.get(email.id)!}
onTabClick={handleCategoryInboxTabClick}
iconClassName="size-[18px] shrink-0"
/>
) : null}
{showListPaperclip && (
<Paperclip
className="size-[18px] shrink-0 text-[#5f6368]"
strokeWidth={1.75}
aria-label="Pièces jointes"
/>
)}
<span
className={cn(
"min-w-0 truncate text-sm tabular-nums",
!isRead ? "font-semibold text-gray-900" : "text-gray-600"
)}
>
<MailDateText iso={email.date} variant="list" />
</span>
</div>
<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-[50ms] ease-out",
listRowQuickHoverTrayToneClass(isSelected, isRead),
"group-hover:pointer-events-auto group-hover:opacity-100"
)}
>
{!spamRowHoverNoArchive && (
<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()
mailActions.hideEmail(email.id)
closeViewIfShowingEmail(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()
mailActions.hideEmail(email.id)
closeViewIfShowingEmail(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 }))
}}
>
{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>
{spamRowHoverNoArchive && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label="Déplacer vers la boîte de réception"
onClick={(e) => {
e.stopPropagation()
mailActions.markNotSpam(email.id)
onSelectFolder?.("inbox")
closeViewIfShowingEmail(email.id)
}}
>
<InboxIcon
className="h-[18px] w-[18px]"
strokeWidth={1.5}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Boîte de réception
</TooltipContent>
</Tooltip>
)}
{!spamRowHoverNoArchive &&
(snoozedFolderRow ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label={
email.id.startsWith("snz-")
? "Déplacer vers la boîte de réception"
: "Remettre dans les mails planifiés"
}
onClick={(e) => {
e.stopPropagation()
restoreSnoozedRowToMailbox(email)
}}
>
<InboxIcon
className="h-[18px] w-[18px]"
strokeWidth={1.5}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{email.id.startsWith("snz-")
? "Boîte de réception"
: "Planifiés"}
</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 requestSnoozeMailboxEmail(email)
if (email.labels?.includes("snoozed")) return
mailActions.hideEmail(email.id)
closeViewIfShowingEmail(email.id)
}}
>
<Clock className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Mettre en attente
</TooltipContent>
</Tooltip>
))}
</div>
</div>
)}
</div>
</div>
</div>
</MailListSwipeRow>
</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(
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 ? (
<>
<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-border bg-popover 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(
cn(MAIL_MENU_SURFACE_CLASS, "max-h-80 overflow-y-auto"),
"[&_[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-border bg-popover 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}
resolveLabelVisual={resolveLabelVisual}
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>
)}
</div>
</div>
{listToolbarMode ? (
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 hidden sm:flex sm:justify-start">
<MailFolderStackIndicator
currentKey={mailNavVisitKey(selectedFolder, inboxTab)}
folderTree={sidebarNav.folderTree}
folderIdToLabel={sidebarNav.folderIdToLabel}
labelRows={sidebarNav.labelRows}
onNavigate={handleBreadcrumbNavigate}
className="pointer-events-auto"
/>
</div>
) : null}
</div>
{splitView ? (
<button
type="button"
onClick={openCompose}
className="absolute bottom-4 right-4 z-30 flex size-14 cursor-pointer items-center justify-center rounded-2xl border border-border bg-mail-surface text-[#444746] shadow-[0_1px_3px_rgba(60,64,67,.3),0_4px_8px_rgba(60,64,67,.15)] transition-[box-shadow,background-color] hover:bg-[#f6f8fc] hover:shadow-[0_1px_3px_rgba(60,64,67,.35),0_6px_12px_rgba(60,64,67,.2)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40"
aria-label="Nouveau message"
>
<Pencil className="size-6" strokeWidth={1.5} />
</button>
) : null}
</div>
{splitView ? (
<section className="flex min-h-0 min-w-0 flex-1 flex-col bg-mail-surface">
{openEmail ? (
<>
<div className="relative z-20 flex shrink-0 min-h-12 items-start gap-2 border-b border-gray-200 py-1.5 pl-2 pr-4">
{openMailToolbar(false)}
<div className="flex-1" />
{mailPaginationControls("view")}
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-none">
<EmailView
email={openEmail}
threadRoot={openEmailThreadRoot}
isSingleMessageView={isSingleMessageView}
onToggleStar={toggleStar}
isStarred={
starredEmails.includes(threadStoreId(openEmail)) ||
openEmail.starred
}
onNavigateToLabel={handleNavigateToLabel}
onNotSpam={openEmail.spam === true ? singleNotSpam : undefined}
labelBgByText={listRowLabelBgByTextLower}
emailLabelToSidebarFolderId={sidebarNav.emailLabelToSidebarFolderId}
getNavItemPrefs={sidebarNav.getNavItemPrefs}
folderTree={sidebarNav.folderTree}
labelRows={sidebarNav.labelRows}
currentFolderId={selectedFolder}
showLabelChip={(lab) => {
if (LABEL_PICKER_EXCLUDE.has(lab)) return true
return mailLabelShouldShowInListStrip(
lab,
sidebarNav.emailLabelToSidebarFolderId,
sidebarNav.getNavItemPrefs,
sidebarNav.labelRows
)
}}
/>
</div>
</>
) : (
<Empty className="min-h-[240px] flex-1 border-0 bg-mail-surface 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 sélectionné
</EmptyTitle>
<EmptyDescription className="text-[13px] text-[#5f6368]">
Choisissez un message dans la liste ou ouvrez une boîte contenant des messages.
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</section>
) : null}
</div>
</div>
)
}