4614 lines
180 KiB
TypeScript
4614 lines
180 KiB
TypeScript
"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,
|
||
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,
|
||
getThreadMessageCount,
|
||
type Email,
|
||
type EmailAttachment,
|
||
} from "@/lib/email-data"
|
||
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 {
|
||
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,
|
||
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"
|
||
|
||
addCollection(mdiIcons)
|
||
|
||
const LIST_PAGE_SIZE = 50
|
||
const PULL_HOLD_HEIGHT = 48
|
||
const PULL_REFRESH_THRESHOLD = 56
|
||
const PULL_REFRESH_MAX = 112
|
||
const PULL_SNAP_BACK_TRANSITION =
|
||
"transform 0.24s cubic-bezier(0.32, 0.72, 0, 1)"
|
||
const REFRESH_SPIN_CLASS = "animate-[spin_0.55s_linear_infinite]"
|
||
const PULL_ICON_FADE_MS = 120
|
||
/** Tirage (px) avant que le spinner ne devienne visible. */
|
||
const PULL_SPINNER_REVEAL_OFFSET = 26
|
||
|
||
function computePullOffset(delta: number): number {
|
||
if (delta <= 0) return 0
|
||
const damped = delta * 0.48
|
||
const capped = Math.min(PULL_REFRESH_MAX, damped)
|
||
const ratio = capped / PULL_REFRESH_MAX
|
||
return capped * (1 - ratio * 0.12)
|
||
}
|
||
|
||
function computeSpinnerRevealProgress(y: number): number {
|
||
if (y <= PULL_SPINNER_REVEAL_OFFSET) return 0
|
||
const range = Math.max(1, PULL_REFRESH_THRESHOLD - PULL_SPINNER_REVEAL_OFFSET)
|
||
return Math.min(1, ((y - PULL_SPINNER_REVEAL_OFFSET) / range) * 1.35)
|
||
}
|
||
|
||
/** Libellés système qu’on ne propose pas dans « Ajouter le libellé ». */
|
||
const LABEL_PICKER_EXCLUDE = new Set(["inbox", "sent", "drafts", "spam", "starred"])
|
||
|
||
function collectTreeLabels(nodes: FolderTreeNode[]): string[] {
|
||
const out: string[] = []
|
||
for (const n of nodes) {
|
||
out.push(n.label)
|
||
if (n.children?.length) out.push(...collectTreeLabels(n.children))
|
||
}
|
||
return out
|
||
}
|
||
|
||
function formatScheduledDateTimeDisplay(iso: string | undefined): string {
|
||
if (!iso) return "—"
|
||
return formatMailDetailDate(iso)
|
||
}
|
||
|
||
function scheduledIsoToDatetimeLocalValue(iso: string | undefined): string {
|
||
if (!iso) return ""
|
||
const d = new Date(iso)
|
||
if (Number.isNaN(d.getTime())) return ""
|
||
const p = (n: number) => String(n).padStart(2, "0")
|
||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`
|
||
}
|
||
|
||
function parseDatetimeLocalToIso(value: string): string | null {
|
||
const d = new Date(value)
|
||
if (Number.isNaN(d.getTime())) return null
|
||
return d.toISOString()
|
||
}
|
||
|
||
/** Cibles du clic droit : sélection courante ou ligne seule ; en Planifié, seulement les ids réellement planifiés. */
|
||
function contextMenuTargetIdsForRow(
|
||
emailId: string,
|
||
selectedEmails: string[],
|
||
selectedFolder: string,
|
||
pool: Email[]
|
||
): string[] {
|
||
const raw = selectedEmails.includes(emailId) ? selectedEmails : [emailId]
|
||
if (selectedFolder !== "scheduled") return raw
|
||
const onlyScheduled = raw.filter((id) =>
|
||
pool.some((e) => e.id === id && e.labels?.includes("scheduled"))
|
||
)
|
||
return onlyScheduled.length > 0 ? onlyScheduled : [emailId]
|
||
}
|
||
|
||
function applyNavRenameToEdits(
|
||
pool: Email[],
|
||
prev: LabelEditState,
|
||
from: string,
|
||
to: string
|
||
): LabelEditState {
|
||
const lcFrom = from.toLowerCase()
|
||
const toTrim = to.trim()
|
||
if (!toTrim) return prev
|
||
const nextAdd = { ...prev.additions }
|
||
const nextRem = { ...prev.removals }
|
||
for (const e of pool) {
|
||
const id = e.id
|
||
const eff = effectiveLabels(e, prev.additions, prev.removals)
|
||
if (!eff.some((l) => l.toLowerCase() === lcFrom)) continue
|
||
const wanted = eff.map((l) => (l.toLowerCase() === lcFrom ? toTrim : l))
|
||
delete nextAdd[id]
|
||
delete nextRem[id]
|
||
const base = e.labels ?? []
|
||
const removals = base.filter(
|
||
(b) => !wanted.some((w) => w.toLowerCase() === b.toLowerCase())
|
||
)
|
||
const additions = wanted.filter(
|
||
(w) => !base.some((b) => b.toLowerCase() === w.toLowerCase())
|
||
)
|
||
if (removals.length) nextRem[id] = removals
|
||
if (additions.length) nextAdd[id] = additions
|
||
}
|
||
return { additions: nextAdd, removals: nextRem }
|
||
}
|
||
|
||
function applyNavRemoveLabelToEdits(
|
||
pool: Email[],
|
||
prev: LabelEditState,
|
||
label: string
|
||
): LabelEditState {
|
||
const lc = label.toLowerCase()
|
||
const nextAdd = { ...prev.additions }
|
||
const nextRem = { ...prev.removals }
|
||
for (const e of pool) {
|
||
const id = e.id
|
||
const eff = effectiveLabels(e, prev.additions, prev.removals)
|
||
if (!eff.some((l) => l.toLowerCase() === lc)) continue
|
||
const wanted = eff.filter((l) => l.toLowerCase() !== lc)
|
||
delete nextAdd[id]
|
||
delete nextRem[id]
|
||
const base = e.labels ?? []
|
||
const removals = base.filter(
|
||
(b) => !wanted.some((w) => w.toLowerCase() === b.toLowerCase())
|
||
)
|
||
const additions = wanted.filter(
|
||
(w) => !base.some((b) => b.toLowerCase() === w.toLowerCase())
|
||
)
|
||
if (removals.length) nextRem[id] = removals
|
||
if (additions.length) nextAdd[id] = additions
|
||
}
|
||
return { additions: nextAdd, removals: nextRem }
|
||
}
|
||
|
||
|
||
function escapeHtml(text: string): string {
|
||
return text
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
}
|
||
|
||
function importantSignalIcon(isSpam: boolean, isImportant: boolean): string {
|
||
if (isSpam) return "mdi:flag-outline"
|
||
if (isImportant) return "mdi:label-variant"
|
||
return "mdi:label-variant-outline"
|
||
}
|
||
|
||
type InboxTabBarItem = {
|
||
id: string
|
||
label: string
|
||
icon: string
|
||
badgeColor: string
|
||
}
|
||
|
||
function buildInboxTabBarItems(labelRows: readonly LabelRowItem[]): InboxTabBarItem[] {
|
||
return [
|
||
...buildInboxCategoryTabIcons(labelRows),
|
||
{
|
||
id: INBOX_ALL_TAB,
|
||
label: "Tous les messages",
|
||
icon: "mdi:inbox",
|
||
badgeColor: "bg-[#0b57d0]",
|
||
},
|
||
]
|
||
}
|
||
|
||
function inboxTabBadgeCountClass(badgeColor: string) {
|
||
return cn(
|
||
"shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium leading-none",
|
||
badgeColor,
|
||
labelPillTextClassForTailwindBgUtility(badgeColor)
|
||
)
|
||
}
|
||
|
||
function inboxTabBadgeDotClass(badgeColor: string) {
|
||
return cn(
|
||
"absolute -right-0.5 -top-0.5 size-2 rounded-full ring-2 ring-white",
|
||
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 l’URL. */
|
||
inboxTab: string
|
||
/** Page de liste (1-based), depuis l’URL. */
|
||
listPage: number
|
||
openMailId: string | null
|
||
/** md+ split pane: list left, reading pane right (tablet landscape or user setting). */
|
||
splitView?: boolean
|
||
onToggleSidebar?: () => void
|
||
onMailRouteNavigate: (patch: Partial<MailRouteState>) => void
|
||
onSelectFolder?: (folder: string) => void
|
||
onFolderUnreadCountsChange?: (counts: Record<string, number>) => void
|
||
}
|
||
|
||
function listRowCheckboxClass(circular: boolean) {
|
||
return cn(
|
||
"size-4 min-h-4 min-w-4 shrink-0 border-[1.5px] border-[#c2c2c2] bg-transparent shadow-none dark:bg-transparent focus-visible:ring-[#c2c2c2]/30 data-[state=checked]:border-[#1a73e8] data-[state=checked]:bg-[#1a73e8] data-[state=checked]:text-white",
|
||
circular ? "rounded-full" : "rounded-[2.5px]"
|
||
)
|
||
}
|
||
|
||
function listRowQuickHoverTrayToneClass(isSelected: boolean, isRead: boolean) {
|
||
return isSelected
|
||
? "bg-[#e8f0fe]"
|
||
: isRead
|
||
? "bg-[#f5f5f5]"
|
||
: "bg-white"
|
||
}
|
||
|
||
export function EmailList({
|
||
selectedFolder,
|
||
inboxTab,
|
||
listPage,
|
||
openMailId,
|
||
splitView = false,
|
||
onToggleSidebar,
|
||
onMailRouteNavigate,
|
||
onSelectFolder,
|
||
onFolderUnreadCountsChange,
|
||
}: 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 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 d’actions reste visible (évite saut d’ancrage). */
|
||
panelOpen: boolean
|
||
} | null>(null)
|
||
const rescheduleDismissTimeoutsRef = useRef<
|
||
Map<string, ReturnType<typeof setTimeout>>
|
||
>(new Map())
|
||
|
||
const scheduleReschedulePopoverDismiss = useCallback((rowId: string) => {
|
||
const existing = rescheduleDismissTimeoutsRef.current.get(rowId)
|
||
if (existing) clearTimeout(existing)
|
||
const t = setTimeout(() => {
|
||
rescheduleDismissTimeoutsRef.current.delete(rowId)
|
||
setRescheduleTarget((p) => (p?.id === rowId ? null : p))
|
||
}, 280)
|
||
rescheduleDismissTimeoutsRef.current.set(rowId, t)
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
const m = rescheduleDismissTimeoutsRef.current
|
||
return () => {
|
||
for (const t of m.values()) clearTimeout(t)
|
||
m.clear()
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
ensureVcLogosCollection()
|
||
}, [])
|
||
|
||
const [cmScheduledRescheduleValue, setCmScheduledRescheduleValue] =
|
||
useState("")
|
||
|
||
const handleEditScheduledMail = useCallback(
|
||
async (id: string) => {
|
||
const payload = await requestGetScheduledEditPayload(id)
|
||
if (!payload) return
|
||
openComposeWithInitial({
|
||
to: payload.to,
|
||
subject: payload.subject,
|
||
bodyHtml: payload.bodyHtml,
|
||
editingScheduledId: id,
|
||
scheduledSendAtIso: payload.sendAtIso,
|
||
focusToOnMount: false,
|
||
focusBodyOnMount: true,
|
||
})
|
||
},
|
||
[requestGetScheduledEditPayload, openComposeWithInitial]
|
||
)
|
||
|
||
useEffect(() => {
|
||
if (!openMailId) {
|
||
closeAllInlineComposes()
|
||
} else {
|
||
pruneInlineComposesToOpenThread(openMailId)
|
||
}
|
||
}, [
|
||
openMailId,
|
||
closeAllInlineComposes,
|
||
pruneInlineComposesToOpenThread,
|
||
])
|
||
|
||
const { beginDrag, registerOnDrop } = useEmailDrag()
|
||
const starredEmails = useMailStore((s) => s.starredIds)
|
||
const importantEmails = useMailStore((s) => s.importantIds)
|
||
const [selectedEmails, setSelectedEmails] = useState<string[]>([])
|
||
const readOverrides = useMailStore((s) => s.readOverrides)
|
||
const labelEdits = useMailStore((s) => s.labelEdits)
|
||
const mailActions = useRef(useMailStore.getState()).current
|
||
const setReadOverrides = useCallback(
|
||
(updater: (prev: Record<string, boolean>) => Record<string, boolean>) => {
|
||
const current = useMailStore.getState().readOverrides
|
||
const next = updater(current)
|
||
if (next !== current) mailActions.setReadOverrides(next)
|
||
},
|
||
[mailActions]
|
||
)
|
||
const setLabelEdits = useCallback(
|
||
(updater: (prev: LabelEditState) => LabelEditState) => {
|
||
mailActions.setLabelEdits(updater)
|
||
},
|
||
[mailActions]
|
||
)
|
||
|
||
useEffect(() => {
|
||
registerNavEmailSync({
|
||
renameLabel: (from, to) => {
|
||
setLabelEdits((prev) => applyNavRenameToEdits(allEmails, prev, from, to))
|
||
},
|
||
removeLabel: (label) => {
|
||
setLabelEdits((prev) => applyNavRemoveLabelToEdits(allEmails, prev, label))
|
||
},
|
||
})
|
||
return () => registerNavEmailSync(null)
|
||
}, [allEmails])
|
||
const [labelPickerQuery, setLabelPickerQuery] = useState("")
|
||
const hiddenEmailIds = useMailStore((s) => s.hiddenEmailIds)
|
||
const 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 inboxCategoryTabLabel = useMemo(
|
||
() =>
|
||
inboxTabDisplayLabel(
|
||
inboxTab,
|
||
sidebarNav.labelRows,
|
||
sidebarNav.folderIdToLabel
|
||
),
|
||
[inboxTab, sidebarNav.labelRows, sidebarNav.folderIdToLabel]
|
||
)
|
||
|
||
const mobileUnreadCount = useMemo(
|
||
() => filteredEmails.filter((e) => !(readOverrides[e.id] ?? e.read)).length,
|
||
[filteredEmails, readOverrides]
|
||
)
|
||
|
||
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(filteredEmails.length / LIST_PAGE_SIZE)),
|
||
[filteredEmails.length]
|
||
)
|
||
|
||
const pagedEmails = useMemo(() => {
|
||
const start = (listPage - 1) * LIST_PAGE_SIZE
|
||
return filteredEmails.slice(start, start + LIST_PAGE_SIZE)
|
||
}, [filteredEmails, listPage])
|
||
|
||
const listEmails = useMemo(() => {
|
||
if (isXs && !isViewMode) {
|
||
return filteredEmails.slice(0, mobileVisibleCount)
|
||
}
|
||
return pagedEmails
|
||
}, [isXs, isViewMode, filteredEmails, mobileVisibleCount, pagedEmails])
|
||
|
||
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 >= filteredEmails.length) return
|
||
const nearBottom =
|
||
root.scrollTop + root.clientHeight >= root.scrollHeight - 120
|
||
if (nearBottom) {
|
||
setMobileVisibleCount((prev) =>
|
||
Math.min(prev + LIST_PAGE_SIZE, filteredEmails.length)
|
||
)
|
||
}
|
||
}
|
||
|
||
root.addEventListener("scroll", onScroll, { passive: true })
|
||
return () => root.removeEventListener("scroll", onScroll)
|
||
}, [isXs, isViewMode, mobileVisibleCount, filteredEmails.length])
|
||
|
||
useEffect(() => {
|
||
const root = listViewportRef.current
|
||
if (!root || !isXs || isViewMode) return
|
||
|
||
const onTouchStart = (e: TouchEvent) => {
|
||
if (root.scrollTop > 0 || isRefreshing) return
|
||
pullActiveRef.current = true
|
||
pullTouchStartYRef.current = e.touches[0]?.clientY ?? 0
|
||
}
|
||
|
||
const onTouchMove = (e: TouchEvent) => {
|
||
if (!pullActiveRef.current || isRefreshing) return
|
||
const y = e.touches[0]?.clientY ?? 0
|
||
const delta = y - pullTouchStartYRef.current
|
||
if (root.scrollTop > 0) {
|
||
pullActiveRef.current = false
|
||
resetPullVisual(true)
|
||
return
|
||
}
|
||
if (delta <= 0) {
|
||
resetPullVisual(true)
|
||
return
|
||
}
|
||
e.preventDefault()
|
||
const next = computePullOffset(delta)
|
||
pullYRef.current = next
|
||
schedulePullVisual(next)
|
||
}
|
||
|
||
const endPull = () => {
|
||
if (!pullActiveRef.current) return
|
||
pullActiveRef.current = false
|
||
void releasePull()
|
||
}
|
||
|
||
root.addEventListener("touchstart", onTouchStart, { passive: true })
|
||
root.addEventListener("touchmove", onTouchMove, { passive: false })
|
||
root.addEventListener("touchend", endPull)
|
||
root.addEventListener("touchcancel", endPull)
|
||
return () => {
|
||
if (pullRafRef.current != null) {
|
||
cancelAnimationFrame(pullRafRef.current)
|
||
pullRafRef.current = null
|
||
}
|
||
root.removeEventListener("touchstart", onTouchStart)
|
||
root.removeEventListener("touchmove", onTouchMove)
|
||
root.removeEventListener("touchend", endPull)
|
||
root.removeEventListener("touchcancel", endPull)
|
||
}
|
||
}, [isXs, isViewMode, isRefreshing, releasePull, resetPullVisual, schedulePullVisual])
|
||
|
||
const moveTargets = useMoveTargets({
|
||
folderTree: sidebarNav.folderTree,
|
||
recentMoveTargets,
|
||
currentFolderId: selectedFolder,
|
||
})
|
||
|
||
const collectAllFolderLabels = useCallback((): Set<string> => {
|
||
const s = new Set<string>()
|
||
const walk = (nodes: FolderTreeNode[]) => {
|
||
for (const n of nodes) {
|
||
s.add(n.label.toLowerCase())
|
||
if (n.children?.length) walk(n.children)
|
||
}
|
||
}
|
||
walk(sidebarNav.folderTree)
|
||
return s
|
||
}, [sidebarNav.folderTree])
|
||
|
||
const moveEmailsToTarget = useCallback(
|
||
(emailIds: string[], targetId: string) => {
|
||
if (emailIds.length === 0) return
|
||
const folderLabel = sidebarNav.folderIdToLabel[targetId]
|
||
const isSystemTarget = ["inbox", "sent", "drafts", "spam", "trash"].includes(targetId)
|
||
const allFolderLabels = collectAllFolderLabels()
|
||
|
||
setLabelEdits((prev) => {
|
||
const nextAdd = { ...prev.additions }
|
||
const nextRem = { ...prev.removals }
|
||
|
||
for (const id of emailIds) {
|
||
const email = allEmails.find((e) => e.id === id)
|
||
const currentLabels = effectiveLabels(email, nextAdd, nextRem)
|
||
|
||
if (isSystemTarget) {
|
||
if (targetId === "inbox") {
|
||
for (const lab of currentLabels) {
|
||
if (allFolderLabels.has(lab.toLowerCase())) {
|
||
const cur = nextRem[id] ?? []
|
||
if (!cur.some((l) => l.toLowerCase() === lab.toLowerCase())) {
|
||
nextRem[id] = [...cur, lab]
|
||
}
|
||
if (nextAdd[id]?.length) {
|
||
nextAdd[id] = nextAdd[id].filter((l) => l.toLowerCase() !== lab.toLowerCase())
|
||
if (nextAdd[id].length === 0) delete nextAdd[id]
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else if (folderLabel) {
|
||
for (const lab of currentLabels) {
|
||
if (allFolderLabels.has(lab.toLowerCase()) && lab.toLowerCase() !== folderLabel.toLowerCase()) {
|
||
const cur = nextRem[id] ?? []
|
||
if (!cur.some((l) => l.toLowerCase() === lab.toLowerCase())) {
|
||
nextRem[id] = [...cur, lab]
|
||
}
|
||
if (nextAdd[id]?.length) {
|
||
nextAdd[id] = nextAdd[id].filter((l) => l.toLowerCase() !== lab.toLowerCase())
|
||
if (nextAdd[id].length === 0) delete nextAdd[id]
|
||
}
|
||
}
|
||
}
|
||
if (!currentLabels.some((l) => l.toLowerCase() === folderLabel.toLowerCase())) {
|
||
nextAdd[id] = [...(nextAdd[id] ?? []), folderLabel]
|
||
}
|
||
if (nextRem[id]?.length) {
|
||
nextRem[id] = nextRem[id].filter((l) => l.toLowerCase() !== folderLabel.toLowerCase())
|
||
if (nextRem[id].length === 0) delete nextRem[id]
|
||
}
|
||
const inboxIdx = currentLabels.findIndex((l) => l.toLowerCase() === "inbox")
|
||
if (inboxIdx >= 0 || !email?.labels?.length || email.labels.includes("inbox")) {
|
||
const cur = nextRem[id] ?? []
|
||
if (!cur.some((l) => l.toLowerCase() === "inbox")) {
|
||
nextRem[id] = [...cur, "inbox"]
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return { additions: nextAdd, removals: nextRem }
|
||
})
|
||
|
||
if (!isSystemTarget || targetId === "inbox") {
|
||
mailActions.pushRecentMoveTarget(targetId)
|
||
}
|
||
|
||
if (isSystemTarget && targetId !== "inbox") {
|
||
mailActions.hideEmails(emailIds)
|
||
mailActions.pushRecentMoveTarget(targetId)
|
||
}
|
||
},
|
||
[allEmails, sidebarNav.folderIdToLabel, collectAllFolderLabels, setLabelEdits, mailActions]
|
||
)
|
||
|
||
const catalogLabels = useMemo(() => {
|
||
const s = new Set<string>()
|
||
for (const l of collectTreeLabels(sidebarNav.folderTree)) s.add(l)
|
||
for (const row of sidebarNav.labelRows) s.add(row.label)
|
||
for (const e of allEmails) {
|
||
const eff = mergeEmailNotSpam(
|
||
mergeEmailLabelEdits(e, labelEdits),
|
||
notSpamEmailIds
|
||
)
|
||
for (const lab of eff.labels ?? []) {
|
||
if (!LABEL_PICKER_EXCLUDE.has(lab)) s.add(lab)
|
||
}
|
||
}
|
||
return [...s].sort((a, b) => a.localeCompare(b, "fr"))
|
||
}, [sidebarNav.folderTree, sidebarNav.labelRows, allEmails, labelEdits, notSpamEmailIds])
|
||
|
||
const resolveLabelVisual = useCallback(
|
||
(label: string) =>
|
||
resolveLabelPickerVisual(label, {
|
||
folderTree: sidebarNav.folderTree,
|
||
labelRows: sidebarNav.labelRows,
|
||
emailLabelToSidebarFolderId: sidebarNav.emailLabelToSidebarFolderId,
|
||
}),
|
||
[
|
||
sidebarNav.folderTree,
|
||
sidebarNav.labelRows,
|
||
sidebarNav.emailLabelToSidebarFolderId,
|
||
]
|
||
)
|
||
|
||
const resolveLabelCasing = useCallback(
|
||
(raw: string) => {
|
||
const t = raw.trim()
|
||
if (!t) return ""
|
||
const hit = catalogLabels.find((c) => c.toLowerCase() === t.toLowerCase())
|
||
return hit ?? t
|
||
},
|
||
[catalogLabels]
|
||
)
|
||
|
||
const addLabelToEmails = useCallback(
|
||
(ids: string[], label: string) => {
|
||
const resolved = resolveLabelCasing(label)
|
||
if (!resolved || ids.length === 0) return
|
||
sidebarNav.ensureLabelRowForLabelText(resolved)
|
||
setLabelEdits((prev) => {
|
||
const nextAdd = { ...prev.additions }
|
||
const nextRem = { ...prev.removals }
|
||
for (const id of ids) {
|
||
if (nextRem[id]?.length) {
|
||
nextRem[id] = nextRem[id].filter(
|
||
(x) => x.toLowerCase() !== resolved.toLowerCase()
|
||
)
|
||
if (nextRem[id].length === 0) delete nextRem[id]
|
||
}
|
||
const base = allEmails.find((e) => e.id === id)
|
||
const merged = effectiveLabels(base, nextAdd, nextRem)
|
||
if (merged.some((x) => x.toLowerCase() === resolved.toLowerCase())) {
|
||
continue
|
||
}
|
||
nextAdd[id] = [...(nextAdd[id] ?? []), resolved]
|
||
}
|
||
return { additions: nextAdd, removals: nextRem }
|
||
})
|
||
},
|
||
[resolveLabelCasing, allEmails, sidebarNav]
|
||
)
|
||
|
||
const getCatalogLabelPresence = useCallback(
|
||
(ids: string[], catalogLabel: string): CatalogLabelPresence => {
|
||
const resolved = resolveLabelCasing(catalogLabel)
|
||
if (!resolved || ids.length === 0) return "none"
|
||
const lc = resolved.toLowerCase()
|
||
let n = 0
|
||
for (const id of ids) {
|
||
const e = allEmails.find((x) => x.id === id)
|
||
const eff = effectiveLabels(e, labelEdits.additions, labelEdits.removals)
|
||
if (eff.some((l) => l.toLowerCase() === lc)) n++
|
||
}
|
||
if (n === 0) return "none"
|
||
if (n === ids.length) return "all"
|
||
return "some"
|
||
},
|
||
[allEmails, labelEdits, resolveLabelCasing]
|
||
)
|
||
|
||
const toggleLabelOnEmails = useCallback(
|
||
(ids: string[], label: string) => {
|
||
const resolved = resolveLabelCasing(label)
|
||
if (!resolved || ids.length === 0) return
|
||
|
||
setLabelEdits((prev) => {
|
||
const presence = (id: string) => {
|
||
const e = allEmails.find((x) => x.id === id)
|
||
if (!e) return false
|
||
return effectiveLabels(e, prev.additions, prev.removals).some(
|
||
(l) => l.toLowerCase() === resolved.toLowerCase()
|
||
)
|
||
}
|
||
const allHave = ids.every((id) => presence(id))
|
||
const nextAdd = { ...prev.additions }
|
||
const nextRem = { ...prev.removals }
|
||
|
||
if (allHave) {
|
||
for (const id of ids) {
|
||
if (nextAdd[id]?.length) {
|
||
const filtered = nextAdd[id].filter(
|
||
(l) => l.toLowerCase() !== resolved.toLowerCase()
|
||
)
|
||
if (filtered.length) nextAdd[id] = filtered
|
||
else delete nextAdd[id]
|
||
}
|
||
const e = allEmails.find((x) => x.id === id)
|
||
if (!e) continue
|
||
const still = effectiveLabels(e, nextAdd, nextRem).some(
|
||
(l) => l.toLowerCase() === resolved.toLowerCase()
|
||
)
|
||
if (still) {
|
||
const cur = nextRem[id] ?? []
|
||
if (!cur.some((l) => l.toLowerCase() === resolved.toLowerCase())) {
|
||
nextRem[id] = [...cur, resolved]
|
||
}
|
||
} else if (nextRem[id]?.length) {
|
||
const fr = nextRem[id].filter(
|
||
(l) => l.toLowerCase() !== resolved.toLowerCase()
|
||
)
|
||
if (fr.length) nextRem[id] = fr
|
||
else delete nextRem[id]
|
||
}
|
||
}
|
||
} else {
|
||
const anyMissing = ids.some((id) => !presence(id))
|
||
if (anyMissing) {
|
||
queueMicrotask(() => sidebarNav.ensureLabelRowForLabelText(resolved))
|
||
}
|
||
for (const id of ids) {
|
||
const e = allEmails.find((x) => x.id === id)
|
||
if (!e) continue
|
||
const had = effectiveLabels(e, prev.additions, prev.removals).some(
|
||
(l) => l.toLowerCase() === resolved.toLowerCase()
|
||
)
|
||
if (nextRem[id]?.length) {
|
||
const fr = nextRem[id].filter(
|
||
(l) => l.toLowerCase() !== resolved.toLowerCase()
|
||
)
|
||
if (fr.length) nextRem[id] = fr
|
||
else delete nextRem[id]
|
||
}
|
||
if (!had) {
|
||
if (!nextAdd[id]) nextAdd[id] = []
|
||
if (!nextAdd[id].some((l) => l.toLowerCase() === resolved.toLowerCase())) {
|
||
nextAdd[id] = [...nextAdd[id], resolved]
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return { additions: nextAdd, removals: nextRem }
|
||
})
|
||
},
|
||
[allEmails, resolveLabelCasing, sidebarNav]
|
||
)
|
||
|
||
const folderUnreadCounts = useMemo(
|
||
() =>
|
||
computeFolderUnreadCounts(
|
||
allEmails,
|
||
folderFilterCtx,
|
||
hiddenEmailIds,
|
||
readOverrides,
|
||
navMaps,
|
||
labelEdits,
|
||
notSpamEmailIds
|
||
),
|
||
[
|
||
folderFilterCtx,
|
||
hiddenEmailIds,
|
||
readOverrides,
|
||
allEmails,
|
||
navMaps,
|
||
labelEdits,
|
||
notSpamEmailIds,
|
||
]
|
||
)
|
||
|
||
const pageIds = useMemo(() => listEmails.map((e) => e.id), [listEmails])
|
||
const selectedOnPageCount = useMemo(
|
||
() => pageIds.filter((id) => selectedEmails.includes(id)).length,
|
||
[pageIds, selectedEmails]
|
||
)
|
||
const allPageSelected = pageIds.length > 0 && selectedOnPageCount === pageIds.length
|
||
const somePageSelected = selectedOnPageCount > 0 && !allPageSelected
|
||
const selectAllChecked: boolean | "indeterminate" = allPageSelected
|
||
? true
|
||
: somePageSelected
|
||
? "indeterminate"
|
||
: false
|
||
|
||
const toggleStar = (id: string) => {
|
||
mailActions.toggleStar(id)
|
||
}
|
||
|
||
const toggleImportant = (id: string) => {
|
||
mailActions.toggleImportant(id)
|
||
}
|
||
|
||
const toggleSelect = (id: string) => {
|
||
setSelectedEmails(prev =>
|
||
prev.includes(id) ? prev.filter(e => e !== id) : [...prev, id]
|
||
)
|
||
}
|
||
|
||
const selectRangeInclusive = (fromId: string, toId: string) => {
|
||
const ids = pageIds
|
||
const i0 = ids.indexOf(fromId)
|
||
const i1 = ids.indexOf(toId)
|
||
if (i0 === -1 || i1 === -1) return
|
||
const lo = Math.min(i0, i1)
|
||
const hi = Math.max(i0, i1)
|
||
const range = ids.slice(lo, hi + 1)
|
||
setSelectedEmails((prev) => [...new Set([...prev, ...range])])
|
||
}
|
||
|
||
const handleSelectAllChange = (checked: boolean | "indeterminate") => {
|
||
if (checked === true) {
|
||
setSelectedEmails((prev) => [...new Set([...prev, ...pageIds])])
|
||
} else {
|
||
setSelectedEmails((prev) => prev.filter((id) => !pageIds.includes(id)))
|
||
}
|
||
}
|
||
|
||
const mergePageSelection = (subsetOfPageIds: string[]) => {
|
||
setSelectedEmails((prev) => {
|
||
const outsidePage = prev.filter((id) => !pageIds.includes(id))
|
||
return [...new Set([...outsidePage, ...subsetOfPageIds])]
|
||
})
|
||
}
|
||
|
||
const effectiveRead = (email: Email) =>
|
||
readOverrides[email.id] !== undefined ? readOverrides[email.id]! : email.read
|
||
|
||
const seenSerialized = useMemo(
|
||
() => [...seenEmailIds].sort().join(","),
|
||
[seenEmailIds]
|
||
)
|
||
|
||
/** Onglets catégories : « nouveaux » + ligne d’expéditeurs = non vus (pas encore aperçus dans la liste), pas non lus. */
|
||
const { unseenInTabById, tabUnseenSenderLineById } = useMemo(() => {
|
||
const seen = new Set(
|
||
seenSerialized.length > 0 ? seenSerialized.split(",") : []
|
||
)
|
||
const hidden = new Set(hiddenEmailIds)
|
||
const visible = allEmails
|
||
.filter((email) => !hidden.has(email.id))
|
||
.map((e) =>
|
||
mergeEmailNotSpam(mergeEmailLabelEdits(e, labelEdits), notSpamEmailIds)
|
||
)
|
||
const inboxPool = visible.filter((e) =>
|
||
emailMatchesFolder(e, "inbox", folderFilterCtx, navMaps)
|
||
)
|
||
const counts: Record<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 filteredEmails) next[e.id] = true
|
||
return next
|
||
})
|
||
}, [filteredEmails])
|
||
|
||
const bulkMoveTo = useCallback(
|
||
(targetId: string) => {
|
||
if (bulkTargetIds.length === 0) return
|
||
moveEmailsToTarget(bulkTargetIds, targetId)
|
||
if (targetId !== "inbox") {
|
||
setSelectedEmails((prev) => prev.filter((id) => !bulkTargetIds.includes(id)))
|
||
}
|
||
},
|
||
[bulkTargetIds, moveEmailsToTarget]
|
||
)
|
||
|
||
// --- View mode helpers ---
|
||
const openEmail = useMemo(() => {
|
||
if (!openMailId) return null
|
||
const raw = allEmails.find((e) => e.id === openMailId) ?? null
|
||
if (!raw) return null
|
||
if (raw.labels?.includes("scheduled")) return null
|
||
return mergeEmailNotSpam(mergeEmailLabelEdits(raw, labelEdits), notSpamEmailIds)
|
||
}, [openMailId, labelEdits, allEmails, notSpamEmailIds])
|
||
const openMailIndex = useMemo(
|
||
() => (openMailId ? filteredEmails.findIndex((e) => e.id === openMailId) : -1),
|
||
[openMailId, filteredEmails]
|
||
)
|
||
|
||
useEffect(() => {
|
||
if (!openMailId) return
|
||
markEmailSeen(openMailId)
|
||
setReadOverrides((prev) =>
|
||
prev[openMailId] !== undefined ? prev : { ...prev, [openMailId]: true }
|
||
)
|
||
}, [openMailId, markEmailSeen])
|
||
|
||
const navigateToMail = useCallback(
|
||
(id: string | null) => {
|
||
if (id && splitView) {
|
||
const idx = filteredEmails.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, filteredEmails, 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 = filteredEmails.findIndex((e) => e.id === currentId)
|
||
if (idx < 0) return filteredEmails[0]?.id ?? null
|
||
if (idx < filteredEmails.length - 1) return filteredEmails[idx + 1]!.id
|
||
if (idx > 0) return filteredEmails[idx - 1]!.id
|
||
return null
|
||
},
|
||
[filteredEmails]
|
||
)
|
||
|
||
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 = filteredEmails[openMailIndex - 1]!.id
|
||
markEmailSeen(id)
|
||
setReadOverrides((prev) => ({ ...prev, [id]: true }))
|
||
navigateToMail(id)
|
||
}
|
||
}, [openMailIndex, filteredEmails, navigateToMail, markEmailSeen])
|
||
|
||
const goToNext = useCallback(() => {
|
||
if (openMailIndex >= 0 && openMailIndex < filteredEmails.length - 1) {
|
||
const id = filteredEmails[openMailIndex + 1]!.id
|
||
markEmailSeen(id)
|
||
setReadOverrides((prev) => ({ ...prev, [id]: true }))
|
||
navigateToMail(id)
|
||
}
|
||
}, [openMailIndex, filteredEmails, navigateToMail, markEmailSeen])
|
||
|
||
const handleOpenEmail = useCallback(
|
||
(id: string) => {
|
||
const em = allEmails.find((e) => e.id === id)
|
||
if (em?.labels?.includes("scheduled")) return
|
||
markEmailSeen(id)
|
||
setReadOverrides((prev) => ({ ...prev, [id]: true }))
|
||
navigateToMail(id)
|
||
},
|
||
[navigateToMail, markEmailSeen, allEmails]
|
||
)
|
||
|
||
const openDraftInCompose = useCallback(
|
||
(email: Email) => {
|
||
markEmailSeen(email.id)
|
||
setReadOverrides((prev) => ({ ...prev, [email.id]: true }))
|
||
const to: Contact[] = email.senderEmail
|
||
? [{ name: email.sender.trim(), email: email.senderEmail }]
|
||
: []
|
||
const body =
|
||
email.body ??
|
||
(email.preview
|
||
? `<p style="color:#5f6368">${escapeHtml(email.preview)}</p>`
|
||
: "<p></p>")
|
||
openComposeWithInitial({
|
||
to,
|
||
subject: email.subject,
|
||
bodyHtml: body,
|
||
focusToOnMount: false,
|
||
focusBodyOnMount: true,
|
||
})
|
||
},
|
||
[markEmailSeen, openComposeWithInitial]
|
||
)
|
||
|
||
const handleRowActivate = useCallback(
|
||
(email: Email) => {
|
||
if (email.labels?.includes("scheduled")) return
|
||
if (email.labels?.includes("drafts")) {
|
||
openDraftInCompose(email)
|
||
return
|
||
}
|
||
handleOpenEmail(email.id)
|
||
},
|
||
[handleOpenEmail, openDraftInCompose]
|
||
)
|
||
|
||
const viewModeIsRead = useMemo(() => {
|
||
if (!openEmail) return true
|
||
return readOverrides[openEmail.id] !== undefined
|
||
? readOverrides[openEmail.id]!
|
||
: openEmail.read
|
||
}, [openEmail, readOverrides])
|
||
|
||
const 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]
|
||
)
|
||
|
||
useEffect(() => {
|
||
if (!splitView) return
|
||
const firstId = filteredEmails[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 (!filteredEmails.some((e) => e.id === openMailId)) {
|
||
navigateToMail(firstId)
|
||
}
|
||
}, [
|
||
splitView,
|
||
selectedFolder,
|
||
inboxTab,
|
||
listPage,
|
||
filteredEmails,
|
||
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 =
|
||
"min-w-[220px] rounded-lg border border-[#dadce0] bg-white p-0 py-1 text-[#3c4043] shadow-lg [&_[data-slot=dropdown-menu-item]]:gap-3 [&_[data-slot=dropdown-menu-item]]:rounded-none [&_[data-slot=dropdown-menu-item]]:px-3 [&_[data-slot=dropdown-menu-item]]:py-2 [&_[data-slot=dropdown-menu-item]]:text-sm [&_[data-slot=dropdown-menu-item]:focus]:bg-[#f1f3f4] [&_[data-slot=dropdown-menu-sub-trigger]]:gap-3 [&_[data-slot=dropdown-menu-sub-trigger]]:rounded-none [&_[data-slot=dropdown-menu-sub-trigger]]:px-3 [&_[data-slot=dropdown-menu-sub-trigger]]:py-2 [&_[data-slot=dropdown-menu-sub-trigger]]:text-sm [&_[data-slot=dropdown-menu-sub-trigger]:focus]:bg-[#f1f3f4] [&_[data-slot=dropdown-menu-sub-content]]:min-w-[200px] [&_[data-slot=dropdown-menu-sub-content]]:rounded-lg [&_[data-slot=dropdown-menu-sub-content]]:border [&_[data-slot=dropdown-menu-sub-content]]:border-[#dadce0] [&_[data-slot=dropdown-menu-sub-content]]:bg-white [&_[data-slot=dropdown-menu-sub-content]]:p-0 [&_[data-slot=dropdown-menu-sub-content]]:py-1 [&_[data-slot=dropdown-menu-sub-content]]:shadow-lg [&_[data-slot=dropdown-menu-separator]]:mx-0 [&_[data-slot=dropdown-menu-separator]]:my-1 [&_[data-slot=dropdown-menu-separator]]:bg-[#eceff1]"
|
||
|
||
const listToolbarMode = splitView || !isViewMode
|
||
/** xs + split : icône (+ point si non lus) ; libellé uniquement sur l’onglet actif. */
|
||
const compactInboxTabs = isXs || splitView
|
||
const activeInboxTabId = useMemo(
|
||
() => normalizeInboxTabSegment(inboxTab),
|
||
[inboxTab]
|
||
)
|
||
|
||
const openMailToolbar = (showBack: boolean) => (
|
||
<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"
|
||
)}
|
||
>
|
||
{filteredEmails.length === 0 ? (
|
||
<span>Aucun résultat</span>
|
||
) : mode === "view" ? (
|
||
<span className="hidden sm:inline">
|
||
{openMailIndex >= 0 ? openMailIndex + 1 : "–"} sur {filteredEmails.length}
|
||
</span>
|
||
) : (
|
||
<span>
|
||
{(listPage - 1) * LIST_PAGE_SIZE + 1}–
|
||
{Math.min(listPage * LIST_PAGE_SIZE, filteredEmails.length)} sur{" "}
|
||
{filteredEmails.length}
|
||
{totalPages > 1 ? ` · p. ${listPage}/${totalPages}` : null}
|
||
</span>
|
||
)}
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className={cn(
|
||
"h-9 w-9",
|
||
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 < filteredEmails.length - 1
|
||
? "text-gray-600"
|
||
: mode === "list" && listPage < totalPages
|
||
? "text-gray-600"
|
||
: "text-gray-400"
|
||
)}
|
||
disabled={
|
||
mode === "view"
|
||
? openMailIndex >= filteredEmails.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 rounded-b-2xl border-0 bg-white shadow-none outline-none " +
|
||
"[scrollbar-color:#9aa0a6_#ffffff] [scrollbar-width:auto] " +
|
||
"[&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar]:border-0 [&::-webkit-scrollbar]:bg-white " +
|
||
"[&::-webkit-scrollbar-track]:border-0 [&::-webkit-scrollbar-track]:bg-white [&::-webkit-scrollbar-track]:shadow-none " +
|
||
"[&::-webkit-scrollbar-thumb]:rounded-none [&::-webkit-scrollbar-thumb]:border-0 [&::-webkit-scrollbar-thumb]:shadow-none " +
|
||
"[&::-webkit-scrollbar-thumb]:bg-[#9aa0a6] hover:[&::-webkit-scrollbar-thumb]:bg-[#5f6368] " +
|
||
"[&::-webkit-scrollbar-corner]:border-0 [&::-webkit-scrollbar-corner]:bg-white"
|
||
|
||
return (
|
||
<div className="flex h-full min-h-0 flex-1 flex-col">
|
||
{/* Mobile xs top bar */}
|
||
{!isViewMode && (
|
||
<div className="relative z-20 flex shrink-0 items-center gap-2 border-b border-gray-200 bg-white px-4 py-2.5 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">
|
||
{filteredEmails.length} message{filteredEmails.length !== 1 ? "s" : ""}
|
||
{mobileUnreadCount > 0 && ` · ${mobileUnreadCount} non lu${mobileUnreadCount !== 1 ? "s" : ""}`}
|
||
</p>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size={mobileSelectionMode ? "icon" : "sm"}
|
||
className={cn(
|
||
"shrink-0 text-[#444746]",
|
||
mobileSelectionMode
|
||
? "size-9 rounded-full border border-gray-200 bg-white/80 shadow-md backdrop-blur"
|
||
: "h-9 min-h-9 gap-1.5 rounded-full border border-gray-200 bg-white/80 px-3 text-xs font-medium shadow-md backdrop-blur"
|
||
)}
|
||
onClick={() => {
|
||
setMobileSelectionMode((p) => !p)
|
||
if (mobileSelectionMode) setSelectedEmails([])
|
||
}}
|
||
aria-label={mobileSelectionMode ? "Annuler la sélection" : "Sélection"}
|
||
>
|
||
{mobileSelectionMode ? (
|
||
<X className="size-[18px]" strokeWidth={1.5} />
|
||
) : (
|
||
<>
|
||
<CheckSquare className="size-4" strokeWidth={1.5} />
|
||
<span>Sélection</span>
|
||
</>
|
||
)}
|
||
</Button>
|
||
<DropdownMenu
|
||
open={mobileXsMoreMenuOpen}
|
||
onOpenChange={setMobileXsMoreMenuOpen}
|
||
>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="size-9 shrink-0 rounded-full border border-gray-200 bg-white/80 text-[#444746] shadow-md backdrop-blur"
|
||
aria-label="Plus d'actions"
|
||
>
|
||
<MoreVertical className="size-[18px]" strokeWidth={1.5} />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent
|
||
align="end"
|
||
sideOffset={4}
|
||
className={cn(dropdownSurfaceClass, "min-w-[260px]")}
|
||
>
|
||
{showBulkToolbar ? (
|
||
<>
|
||
<DropdownMenuItem onSelect={bulkArchive}>
|
||
<Archive className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
|
||
Archiver
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem onSelect={bulkDelete}>
|
||
<Trash2 className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
|
||
Supprimer
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem onSelect={bulkSpam}>
|
||
<ShieldAlert className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
|
||
Signaler comme spam
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem
|
||
onSelect={hasUnreadInSelection ? bulkMarkRead : bulkMarkUnread}
|
||
>
|
||
{hasUnreadInSelection ? (
|
||
<>
|
||
<MailOpen className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
|
||
Marquer comme lu
|
||
</>
|
||
) : (
|
||
<>
|
||
<Mail className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
|
||
Marquer comme non lu
|
||
</>
|
||
)}
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem
|
||
onSelect={(e) => {
|
||
e.preventDefault()
|
||
openMobileXsMoveSheet()
|
||
}}
|
||
>
|
||
<FolderInput className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
|
||
<span className="flex-1">Déplacer vers</span>
|
||
<ChevronRight className="ml-auto size-4 text-[#5f6368]" strokeWidth={1.5} />
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem
|
||
onSelect={(e) => {
|
||
e.preventDefault()
|
||
openMobileXsLabelSheet()
|
||
}}
|
||
>
|
||
<Tag className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
|
||
<span className="flex-1">Ajouter le libellé</span>
|
||
<ChevronRight className="ml-auto size-4 text-[#5f6368]" strokeWidth={1.5} />
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem>
|
||
<VolumeX className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
|
||
Ignorer la conversation
|
||
</DropdownMenuItem>
|
||
</>
|
||
) : (
|
||
<>
|
||
<DropdownMenuItem onSelect={markAllInViewAsRead}>
|
||
<MailOpen className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
|
||
Tout marquer comme lu
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<div
|
||
className="px-3 py-2 text-sm leading-snug text-[#5f6368] select-none"
|
||
role="note"
|
||
>
|
||
Sélectionnez des messages pour plus d'actions
|
||
</div>
|
||
</>
|
||
)}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
)}
|
||
{!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-gray-200 bg-white 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-gray-200 bg-white py-1.5 pl-2 pr-4",
|
||
splitView ? "rounded-none" : "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-white shadow-none dark:bg-white focus-visible:ring-[#c2c2c2]/30 data-[state=checked]:border-[#1a73e8] data-[state=checked]:bg-[#1a73e8] data-[state=checked]:text-white data-[state=indeterminate]:border-[#1a73e8] data-[state=indeterminate]:bg-[#1a73e8] data-[state=indeterminate]:text-white"
|
||
/>
|
||
</div>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className={cn(
|
||
"h-9 w-7 shrink-0 rounded-none p-0 text-[#5f6368]",
|
||
bulkSelectMenuOpen
|
||
? "border-l border-[#dadce0] hover:bg-[#e8eaed]"
|
||
: "hover:bg-[#f1f3f4]"
|
||
)}
|
||
aria-label="Options de sélection"
|
||
>
|
||
<ChevronDown className="h-4 w-4" />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
</div>
|
||
<DropdownMenuContent
|
||
align="start"
|
||
sideOffset={4}
|
||
className={cn(dropdownSurfaceClass, "min-w-[180px]")}
|
||
>
|
||
<DropdownMenuItem onSelect={selectMenuAll}>Tous</DropdownMenuItem>
|
||
<DropdownMenuItem onSelect={selectMenuNone}>Aucun</DropdownMenuItem>
|
||
<DropdownMenuItem onSelect={selectMenuRead}>Lus</DropdownMenuItem>
|
||
<DropdownMenuItem onSelect={selectMenuUnread}>Non lus</DropdownMenuItem>
|
||
<DropdownMenuItem onSelect={selectMenuStarred}>Suivis</DropdownMenuItem>
|
||
<DropdownMenuItem onSelect={selectMenuUnstarred}>
|
||
Non suivis
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
|
||
{showBulkToolbar ? (
|
||
<TooltipProvider delayDuration={400}>
|
||
<div className="flex min-w-0 items-center gap-0.5 pl-1">
|
||
<div className="flex shrink-0 items-center gap-0.5">
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-9 w-9 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
|
||
aria-label="Archiver"
|
||
onClick={bulkArchive}
|
||
>
|
||
<Archive className="h-[18px] w-[18px]" strokeWidth={1.5} />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom" className="text-xs">
|
||
Archiver
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-9 w-9 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
|
||
aria-label="Signaler comme spam"
|
||
onClick={bulkSpam}
|
||
>
|
||
<ShieldAlert className="h-[18px] w-[18px]" strokeWidth={1.5} />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom" className="text-xs">
|
||
Signaler comme spam
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-9 w-9 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
|
||
aria-label="Supprimer"
|
||
onClick={bulkDelete}
|
||
>
|
||
<Trash2 className="h-[18px] w-[18px]" strokeWidth={1.5} />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom" className="text-xs">
|
||
Supprimer
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
|
||
<span
|
||
className="mx-1 h-6 w-px shrink-0 bg-[#dadce0]"
|
||
aria-hidden
|
||
/>
|
||
|
||
<div className="flex min-w-0 shrink-0 items-center gap-0.5">
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-9 w-9 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
|
||
aria-label={
|
||
hasUnreadInSelection
|
||
? "Marquer comme lu"
|
||
: "Marquer comme non lu"
|
||
}
|
||
onClick={() =>
|
||
hasUnreadInSelection ? bulkMarkRead() : bulkMarkUnread()
|
||
}
|
||
>
|
||
{hasUnreadInSelection ? (
|
||
<MailOpen className="h-[18px] w-[18px]" strokeWidth={1.5} />
|
||
) : (
|
||
<Mail className="h-[18px] w-[18px]" strokeWidth={1.5} />
|
||
)}
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom" className="text-xs">
|
||
{hasUnreadInSelection
|
||
? "Marquer comme lu"
|
||
: "Marquer comme non lu"}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-9 w-9 shrink-0 gap-1 px-0 text-[#444746] hover:bg-[#f1f3f4] lg:h-9 lg:w-auto lg:px-2"
|
||
aria-label="Déplacer vers"
|
||
>
|
||
<FolderInput className="h-[18px] w-[18px] shrink-0" strokeWidth={1.5} />
|
||
<span className="hidden max-w-32 truncate lg:inline">
|
||
Déplacer vers
|
||
</span>
|
||
<ChevronDown className="hidden h-3.5 w-3.5 shrink-0 opacity-70 lg:block" />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent
|
||
align="start"
|
||
className={cn(dropdownSurfaceClass, "max-h-80 overflow-y-auto")}
|
||
>
|
||
<MoveToDropdownItems targets={moveTargets} onMoveTo={bulkMoveTo} />
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
|
||
<span
|
||
className="mx-1 h-6 w-px shrink-0 bg-[#dadce0]"
|
||
aria-hidden
|
||
/>
|
||
|
||
<DropdownMenu
|
||
onOpenChange={(open) => {
|
||
if (!open) setLabelPickerQuery("")
|
||
}}
|
||
>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-9 w-9 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
|
||
aria-label="Plus d'actions"
|
||
>
|
||
<MoreVertical className="h-[18px] w-[18px]" strokeWidth={1.5} />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent
|
||
align="start"
|
||
className={cn(
|
||
dropdownSurfaceClass,
|
||
/* Sous-menus Radix restent dans ce nœud : overflow-auto les clippe */
|
||
"overflow-visible"
|
||
)}
|
||
>
|
||
<DropdownMenuItem>
|
||
<Clock className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
|
||
Mettre en attente
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem>
|
||
<ListTodo className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
|
||
Ajouter à Tasks
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuSub>
|
||
<DropdownMenuSubTrigger className="[&>svg:last-child]:text-[#5f6368]">
|
||
<Tag className="size-[18px] text-[#5f6368]" strokeWidth={1.5} />
|
||
Ajouter le libellé
|
||
</DropdownMenuSubTrigger>
|
||
<DropdownMenuSubContent
|
||
className={cn(
|
||
dropdownSurfaceClass,
|
||
"z-[100] flex max-h-72 min-w-[260px] flex-col overflow-hidden p-0 py-0"
|
||
)}
|
||
>
|
||
<EmailLabelPickerBlock
|
||
query={labelPickerQuery}
|
||
onQueryChange={setLabelPickerQuery}
|
||
catalogLabels={catalogLabels}
|
||
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'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-white after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:z-0 after:h-px after:bg-[#dadce0]">
|
||
{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 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)}
|
||
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 && "shadow-[inset_0_-3px_0_0_#0b57d0]",
|
||
!isActive && "hover:bg-[#f1f3f4]"
|
||
)}
|
||
>
|
||
<>
|
||
<div className="flex h-10 w-full items-center justify-center sm:hidden">
|
||
<div className="relative inline-flex shrink-0">
|
||
<Icon
|
||
icon={tab.icon}
|
||
className={cn(
|
||
CATEGORY_TAB_ICON_CLASS,
|
||
isActive ? "text-[#0b57d0]" : "text-[#5f6368]"
|
||
)}
|
||
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",
|
||
isActive ? "text-[#0b57d0]" : "text-[#5f6368]"
|
||
)}
|
||
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",
|
||
isActive ? "text-[#0b57d0]" : "text-[#3c4043]"
|
||
)}
|
||
>
|
||
{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="block min-h-4 min-w-0 truncate text-[11px] leading-snug text-[#5f6368]">
|
||
{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 && "sm:pb-12"
|
||
)}
|
||
>
|
||
{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 && "flex min-h-0 flex-1 flex-col",
|
||
listToolbarMode && "max-sm:[transform:translateZ(0)]"
|
||
)}
|
||
>
|
||
{!splitView && isViewMode && openEmail ? (
|
||
/* ── EMAIL VIEW ── */
|
||
<EmailView
|
||
email={openEmail}
|
||
onToggleStar={toggleStar}
|
||
isStarred={starredEmails.includes(openEmail.id) || openEmail.starred}
|
||
onNavigateToLabel={handleNavigateToLabel}
|
||
onNotSpam={openEmail.spam === true ? singleNotSpam : undefined}
|
||
labelBgByText={listRowLabelBgByTextLower}
|
||
emailLabelToSidebarFolderId={sidebarNav.emailLabelToSidebarFolderId}
|
||
getNavItemPrefs={sidebarNav.getNavItemPrefs}
|
||
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 « Envois programmés » seront envoyés à l'heure prévue pour chacun d'eux.
|
||
</p>
|
||
</div>
|
||
)}
|
||
{filteredEmails.length === 0 ? (
|
||
selectedFolder === "scheduled" ? (
|
||
<div className="flex min-h-[220px] flex-col items-center justify-center px-4 py-12 text-center">
|
||
<p className="text-sm text-[#5f6368]">Aucun message planifié.</p>
|
||
</div>
|
||
) : (
|
||
<Empty className="min-h-[240px] flex-1 border-0 bg-white py-10 shadow-none">
|
||
<EmptyHeader className="max-w-md">
|
||
<EmptyMedia
|
||
variant="icon"
|
||
className="mb-1 border-0 bg-[#f1f3f4] text-[#5f6368] [&_svg]:size-6"
|
||
>
|
||
<Mail className="size-6" strokeWidth={1.5} aria-hidden />
|
||
</EmptyMedia>
|
||
<EmptyTitle className="text-[15px] font-medium text-[#3c4043]">
|
||
Aucun message
|
||
</EmptyTitle>
|
||
<EmptyDescription className="text-[13px] text-[#5f6368]">
|
||
{selectedFolder === "inbox" ? (
|
||
<>
|
||
Aucun message dans l'onglet{" "}
|
||
<span className="font-medium text-[#3c4043]">
|
||
{inboxCategoryTabLabel}
|
||
</span>{" "}
|
||
de la boîte de réception.
|
||
</>
|
||
) : (
|
||
<>
|
||
Aucun message dans{" "}
|
||
<span className="font-medium text-[#3c4043]">
|
||
{getMailNavFolderLabel(selectedFolder, sidebarNav.folderIdToLabel)}
|
||
</span>
|
||
.
|
||
</>
|
||
)}
|
||
</EmptyDescription>
|
||
</EmptyHeader>
|
||
</Empty>
|
||
)
|
||
) : (
|
||
<div className="divide-y divide-[#eceff1]">
|
||
{listEmails.map((email) => {
|
||
const isStarred = starredEmails.includes(email.id) || email.starred
|
||
const isImportant = importantEmails.includes(email.id) || email.important
|
||
const isSpam = email.spam === true
|
||
const isDraft = email.labels?.includes("drafts") === true
|
||
const hasThreadReplyDraft =
|
||
savedThreadReplyDrafts[email.id] !== undefined
|
||
const showDraftBadge = isDraft || hasThreadReplyDraft
|
||
const isRead =
|
||
readOverrides[email.id] !== undefined ? readOverrides[email.id]! : email.read
|
||
const senderHoverEmail = resolveSenderEmail(email.sender, email.senderEmail)
|
||
const threadMessageCount = getThreadMessageCount(email)
|
||
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 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:items-start md:gap-2 md:px-2 md:py-1.5",
|
||
isSplitActiveRow
|
||
? "z-[1] bg-[#e8f0fe] shadow-[inset_3px_0_0_0_#0b57d0]"
|
||
: isSelected
|
||
? "bg-[#e8f0fe]"
|
||
: isRead
|
||
? "bg-[#f5f5f5]"
|
||
: "bg-white",
|
||
!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",
|
||
attachmentList.length === 0 ? "pt-px" : "pt-0"
|
||
)}
|
||
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",
|
||
attachmentList.length === 0 ? "pt-1" : "pt-0.5"
|
||
)}
|
||
>
|
||
{email.tag && (
|
||
<span className="shrink-0 rounded bg-gray-200 px-1.5 py-0.5 text-xs text-gray-600 opacity-[0.92]">
|
||
{email.tag}
|
||
</span>
|
||
)}
|
||
<MailLabelPillStrip
|
||
variant="list"
|
||
labels={email.labels}
|
||
labelBgByText={listRowLabelBgByTextLower}
|
||
emailLabelToSidebarFolderId={sidebarNav.emailLabelToSidebarFolderId}
|
||
getNavItemPrefs={sidebarNav.getNavItemPrefs}
|
||
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>
|
||
{attachmentList.length > 0 && (
|
||
<EmailListAttachmentRow emailId={email.id} attachments={attachmentList} />
|
||
)}
|
||
</div>
|
||
|
||
<div
|
||
className={cn(
|
||
"flex shrink-0 flex-col items-end gap-1 self-start pr-2 text-right md:max-w-[150px] md:min-w-0",
|
||
attachmentList.length === 0 ? "pt-1" : "pt-0.5"
|
||
)}
|
||
>
|
||
{isScheduled ? (
|
||
<div className="relative flex w-full min-w-0 shrink-0 items-center justify-end">
|
||
<span
|
||
className={cn(
|
||
"block 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'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}
|
||
<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(
|
||
"min-w-[280px] overflow-visible rounded-lg border border-[#dadce0] bg-white p-0 py-1 text-[#3c4043] shadow-lg",
|
||
"[&_[data-slot=context-menu-item]]:gap-3 [&_[data-slot=context-menu-item]]:rounded-none [&_[data-slot=context-menu-item]]:px-3 [&_[data-slot=context-menu-item]]:py-2 [&_[data-slot=context-menu-item]]:text-sm",
|
||
"[&_[data-slot=context-menu-item]:focus]:bg-[#f1f3f4] [&_[data-slot=context-menu-item]:focus]:text-[#3c4043]",
|
||
"[&_[data-slot=context-menu-sub-trigger]]:gap-3 [&_[data-slot=context-menu-sub-trigger]]:rounded-none [&_[data-slot=context-menu-sub-trigger]]:px-3 [&_[data-slot=context-menu-sub-trigger]]:py-2 [&_[data-slot=context-menu-sub-trigger]]:text-sm",
|
||
"[&_[data-slot=context-menu-sub-trigger]:focus]:bg-[#f1f3f4] [&_[data-slot=context-menu-sub-trigger]:focus]:text-[#3c4043]",
|
||
"[&_[data-slot=context-menu-separator]]:mx-0 [&_[data-slot=context-menu-separator]]:my-1 [&_[data-slot=context-menu-separator]]:h-px [&_[data-slot=context-menu-separator]]:bg-[#eceff1]",
|
||
"[&_[data-slot=context-menu-sub-content]]:min-w-[200px] [&_[data-slot=context-menu-sub-content]]:rounded-lg [&_[data-slot=context-menu-sub-content]]:border [&_[data-slot=context-menu-sub-content]]:border-[#dadce0] [&_[data-slot=context-menu-sub-content]]:bg-white [&_[data-slot=context-menu-sub-content]]:shadow-lg"
|
||
)}
|
||
>
|
||
{allContextTargetsScheduled ? (
|
||
<>
|
||
<ContextMenuItem
|
||
onSelect={() => {
|
||
const ids = [...contextMenuTargetIdsRef.current]
|
||
void Promise.all(
|
||
ids.map((id) => requestArchiveScheduled(id))
|
||
)
|
||
}}
|
||
>
|
||
<Archive
|
||
strokeWidth={1.5}
|
||
className="size-[18px] text-[#5f6368]"
|
||
/>
|
||
Archiver
|
||
</ContextMenuItem>
|
||
<ContextMenuItem
|
||
onSelect={() => {
|
||
const ids = [...contextMenuTargetIdsRef.current]
|
||
void Promise.all(
|
||
ids.map((id) => requestDeleteScheduled(id))
|
||
)
|
||
}}
|
||
>
|
||
<Trash2
|
||
strokeWidth={1.5}
|
||
className="size-[18px] text-[#5f6368]"
|
||
/>
|
||
Supprimer
|
||
</ContextMenuItem>
|
||
<ContextMenuItem
|
||
onSelect={() => {
|
||
const ids = [...contextMenuTargetIdsRef.current]
|
||
const markRead = scheduledCtxAnyUnread
|
||
setReadOverrides((prev) => {
|
||
const next = { ...prev }
|
||
for (const id of ids) next[id] = markRead
|
||
return next
|
||
})
|
||
void Promise.all(
|
||
ids.map((id) =>
|
||
requestToggleReadScheduled(id, markRead)
|
||
)
|
||
)
|
||
}}
|
||
>
|
||
{scheduledCtxAnyUnread ? (
|
||
<Mail
|
||
strokeWidth={1.5}
|
||
className="size-[18px] text-[#5f6368]"
|
||
/>
|
||
) : (
|
||
<MailOpen
|
||
strokeWidth={1.5}
|
||
className="size-[18px] text-[#5f6368]"
|
||
/>
|
||
)}
|
||
{scheduledCtxAnyUnread
|
||
? "Marquer comme lu"
|
||
: "Marquer comme non lu"}
|
||
</ContextMenuItem>
|
||
<ContextMenuItem
|
||
onSelect={() => {
|
||
const ids = [...contextMenuTargetIdsRef.current]
|
||
void Promise.all(
|
||
ids.map((id) => requestSnoozeScheduled(id))
|
||
)
|
||
}}
|
||
>
|
||
<Clock
|
||
strokeWidth={1.5}
|
||
className="size-[18px] text-[#5f6368]"
|
||
/>
|
||
Mettre en attente
|
||
</ContextMenuItem>
|
||
<ContextMenuSeparator />
|
||
<ContextMenuSub
|
||
onOpenChange={(subOpen) => {
|
||
if (!subOpen) return
|
||
const ids = contextMenuTargetIdsRef.current
|
||
const first = allEmails.find((e) => e.id === ids[0])
|
||
setCmScheduledRescheduleValue(
|
||
scheduledIsoToDatetimeLocalValue(
|
||
first?.scheduledSendAt
|
||
)
|
||
)
|
||
}}
|
||
>
|
||
<ContextMenuSubTrigger className="[&>svg:last-child]:text-[#5f6368]">
|
||
<CalendarClock
|
||
strokeWidth={1.5}
|
||
className="size-[18px] text-[#5f6368]"
|
||
/>
|
||
Reprogrammer
|
||
</ContextMenuSubTrigger>
|
||
<ContextMenuSubContent
|
||
className={cn(
|
||
"min-w-[288px] rounded-lg border border-[#dadce0] bg-white px-4 py-3.5 text-[#3c4043] shadow-lg"
|
||
)}
|
||
>
|
||
<div
|
||
className="flex flex-col gap-3.5"
|
||
onPointerDown={(e) => e.stopPropagation()}
|
||
>
|
||
<p className="text-xs font-medium leading-snug text-[#3c4043]">
|
||
Nouvelle date d'envoi
|
||
{contextTargetIds.length > 1
|
||
? ` (${contextTargetIds.length} messages)`
|
||
: null}
|
||
</p>
|
||
<input
|
||
type="datetime-local"
|
||
className="w-full rounded border border-[#dadce0] px-2.5 py-1.5 text-sm text-[#3c4043]"
|
||
value={cmScheduledRescheduleValue}
|
||
onChange={(e) =>
|
||
setCmScheduledRescheduleValue(e.target.value)
|
||
}
|
||
onPointerDown={(e) => e.stopPropagation()}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="rounded bg-[#0b57d0] px-3 py-1.5 text-xs font-medium text-white hover:bg-[#0842a0]"
|
||
onPointerDown={(e) => {
|
||
e.stopPropagation()
|
||
e.preventDefault()
|
||
}}
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
const iso = parseDatetimeLocalToIso(
|
||
cmScheduledRescheduleValue
|
||
)
|
||
if (!iso) return
|
||
const ids = [
|
||
...contextMenuTargetIdsRef.current,
|
||
]
|
||
void Promise.all(
|
||
ids.map((id) =>
|
||
requestRescheduleScheduled(id, iso)
|
||
)
|
||
)
|
||
}}
|
||
>
|
||
Valider
|
||
</button>
|
||
</div>
|
||
</ContextMenuSubContent>
|
||
</ContextMenuSub>
|
||
<ContextMenuItem
|
||
disabled={contextTargetIds.length > 1}
|
||
onSelect={() => {
|
||
if (contextTargetIds.length !== 1) return
|
||
void handleEditScheduledMail(contextTargetIds[0]!)
|
||
}}
|
||
>
|
||
<Pencil
|
||
strokeWidth={1.5}
|
||
className="size-[18px] text-[#5f6368]"
|
||
/>
|
||
Modifier le mail
|
||
</ContextMenuItem>
|
||
<ContextMenuItem
|
||
onSelect={() => {
|
||
const ids = [...contextMenuTargetIdsRef.current]
|
||
void Promise.all(
|
||
ids.map((id) => requestSendScheduledNow(id))
|
||
)
|
||
}}
|
||
>
|
||
<Send
|
||
strokeWidth={1.5}
|
||
className="size-[18px] text-[#5f6368]"
|
||
/>
|
||
Envoyer maintenant
|
||
</ContextMenuItem>
|
||
</>
|
||
) : (
|
||
<>
|
||
<ContextMenuItem>
|
||
<Reply strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
|
||
Répondre
|
||
</ContextMenuItem>
|
||
<ContextMenuItem>
|
||
<ReplyAll strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
|
||
Répondre à tous
|
||
</ContextMenuItem>
|
||
<ContextMenuItem>
|
||
<Forward strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
|
||
Transférer
|
||
</ContextMenuItem>
|
||
<ContextMenuItem>
|
||
<Paperclip strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
|
||
Transférer en tant que pièce jointe
|
||
</ContextMenuItem>
|
||
|
||
<ContextMenuSeparator />
|
||
|
||
<ContextMenuItem>
|
||
<Archive strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
|
||
Archiver
|
||
</ContextMenuItem>
|
||
<ContextMenuItem>
|
||
<Trash2 strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
|
||
Supprimer
|
||
</ContextMenuItem>
|
||
<ContextMenuItem
|
||
onSelect={() => {
|
||
const newRead = !isRead
|
||
const ids = contextMenuTargetIdsRef.current
|
||
setReadOverrides((prev) => {
|
||
const next = { ...prev }
|
||
for (const id of ids) {
|
||
next[id] = newRead
|
||
}
|
||
return next
|
||
})
|
||
}}
|
||
>
|
||
{!isRead ? (
|
||
<Mail strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
|
||
) : (
|
||
<MailOpen strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
|
||
)}
|
||
{isRead ? "Marquer comme non lu" : "Marquer comme lu"}
|
||
</ContextMenuItem>
|
||
<ContextMenuItem>
|
||
<Clock strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
|
||
Mettre en attente
|
||
</ContextMenuItem>
|
||
<ContextMenuItem>
|
||
<ListTodo strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
|
||
Ajouter à Tasks
|
||
</ContextMenuItem>
|
||
|
||
<ContextMenuSeparator />
|
||
|
||
<ContextMenuSub>
|
||
<ContextMenuSubTrigger className="[&>svg:last-child]:text-[#5f6368]">
|
||
<FolderInput strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
|
||
Déplacer vers
|
||
</ContextMenuSubTrigger>
|
||
<ContextMenuSubContent
|
||
className={cn(
|
||
"max-h-80 min-w-[200px] overflow-y-auto rounded-lg border border-[#dadce0] bg-white p-0 py-1 text-[#3c4043] shadow-lg",
|
||
"[&_[data-slot=context-menu-item]]:gap-3 [&_[data-slot=context-menu-item]]:rounded-none [&_[data-slot=context-menu-item]]:px-3 [&_[data-slot=context-menu-item]]:py-2 [&_[data-slot=context-menu-item]]:text-sm",
|
||
"[&_[data-slot=context-menu-item]:focus]:bg-[#f1f3f4]"
|
||
)}
|
||
>
|
||
<MoveToContextMenuItems
|
||
targets={moveTargets}
|
||
onMoveTo={(targetId) => {
|
||
moveEmailsToTarget(contextTargetIds, targetId)
|
||
if (targetId !== "inbox") {
|
||
setSelectedEmails((prev) => prev.filter((id) => !contextTargetIds.includes(id)))
|
||
}
|
||
}}
|
||
/>
|
||
</ContextMenuSubContent>
|
||
</ContextMenuSub>
|
||
|
||
<ContextMenuSub>
|
||
<ContextMenuSubTrigger className="[&>svg:last-child]:text-[#5f6368]">
|
||
<Tag strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
|
||
Ajouter le libellé
|
||
</ContextMenuSubTrigger>
|
||
<ContextMenuSubContent
|
||
className={cn(
|
||
"z-[100] flex max-h-72 min-w-[260px] flex-col overflow-hidden rounded-lg border border-[#dadce0] bg-white p-0 py-0 text-[#3c4043] shadow-lg",
|
||
"[&_[data-slot=context-menu-item]]:gap-3 [&_[data-slot=context-menu-item]]:rounded-none [&_[data-slot=context-menu-item]]:px-3 [&_[data-slot=context-menu-item]]:py-2 [&_[data-slot=context-menu-item]]:text-sm",
|
||
"[&_[data-slot=context-menu-item]:focus]:bg-[#f1f3f4]"
|
||
)}
|
||
>
|
||
<EmailLabelPickerBlock
|
||
query={labelPickerQuery}
|
||
onQueryChange={setLabelPickerQuery}
|
||
catalogLabels={catalogLabels}
|
||
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 bottom-0 left-0 z-20 hidden max-w-full sm:block">
|
||
<div className="pointer-events-auto w-fit max-w-full">
|
||
<MailFolderStackIndicator
|
||
currentKey={mailNavVisitKey(selectedFolder, inboxTab)}
|
||
folderTree={sidebarNav.folderTree}
|
||
folderIdToLabel={sidebarNav.folderIdToLabel}
|
||
labelRows={sidebarNav.labelRows}
|
||
onNavigate={handleBreadcrumbNavigate}
|
||
/>
|
||
</div>
|
||
</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-gray-200 bg-white 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-white">
|
||
{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}
|
||
onToggleStar={toggleStar}
|
||
isStarred={starredEmails.includes(openEmail.id) || openEmail.starred}
|
||
onNavigateToLabel={handleNavigateToLabel}
|
||
onNotSpam={openEmail.spam === true ? singleNotSpam : undefined}
|
||
labelBgByText={listRowLabelBgByTextLower}
|
||
emailLabelToSidebarFolderId={sidebarNav.emailLabelToSidebarFolderId}
|
||
getNavItemPrefs={sidebarNav.getNavItemPrefs}
|
||
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-white py-10 shadow-none">
|
||
<EmptyHeader className="max-w-md">
|
||
<EmptyMedia
|
||
variant="icon"
|
||
className="mb-1 border-0 bg-[#f1f3f4] text-[#5f6368] [&_svg]:size-6"
|
||
>
|
||
<Mail className="size-6" strokeWidth={1.5} aria-hidden />
|
||
</EmptyMedia>
|
||
<EmptyTitle className="text-[15px] font-medium text-[#3c4043]">
|
||
Aucun message 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>
|
||
)
|
||
}
|