1623 lines
57 KiB
TypeScript
1623 lines
57 KiB
TypeScript
"use client"
|
|
|
|
import { memo, type Dispatch, type MouseEvent, type RefObject, type SetStateAction } from "react"
|
|
import { Icon } from "@iconify/react"
|
|
import {
|
|
Archive,
|
|
CalendarClock,
|
|
Clock,
|
|
FolderInput,
|
|
Forward,
|
|
Inbox as InboxIcon,
|
|
ListTodo,
|
|
Mail,
|
|
MailOpen,
|
|
Paperclip,
|
|
Pencil,
|
|
Reply,
|
|
ReplyAll,
|
|
Search,
|
|
Send,
|
|
ShieldAlert,
|
|
SquareArrowOutUpRight,
|
|
Star,
|
|
Tag,
|
|
Trash2,
|
|
VolumeX,
|
|
} from "lucide-react"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Checkbox } from "@/components/ui/checkbox"
|
|
import {
|
|
ContextMenu,
|
|
ContextMenuContent,
|
|
ContextMenuItem,
|
|
ContextMenuSeparator,
|
|
ContextMenuSub,
|
|
ContextMenuSubContent,
|
|
ContextMenuSubTrigger,
|
|
ContextMenuTrigger,
|
|
} from "@/components/ui/context-menu"
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip"
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
|
import { ContactHoverCard } from "@/components/gmail/contact-hover-card"
|
|
import { EmailLabelPickerBlock } from "@/components/gmail/email-label-picker-block"
|
|
import type { CatalogLabelPresence } from "@/components/gmail/email-label-picker-block"
|
|
import { MailInboxCategoryTabIcons } from "@/components/gmail/mail-inbox-category-tab-icons"
|
|
import {
|
|
MailLabelPillStrip,
|
|
} from "@/components/gmail/mail-label-pills"
|
|
import { MailListSwipeRow } from "@/components/gmail/mail-list-swipe-row"
|
|
import { MailDateText } from "@/components/gmail/mail-date-text"
|
|
import { EmailListAttachmentRow } from "@/components/gmail/email-list/attachments/email-list-attachment-row"
|
|
import {
|
|
MoveToContextMenuItems,
|
|
} from "@/components/gmail/email-list/move-to-menu-items"
|
|
import type { MoveTarget, MailMoveTargets } from "@/components/gmail/move-to-menu-items"
|
|
import { cn } from "@/lib/utils"
|
|
import type { Email } from "@/lib/email-data"
|
|
import {
|
|
getThreadMessageCount,
|
|
isListRowRead,
|
|
} from "@/lib/mail-thread"
|
|
import { threadStoreId } from "@/lib/mail-settings/list-row-id"
|
|
import { resolveSenderEmail } from "@/lib/sender-display"
|
|
import { VIDEO_CONFERENCE_LOGOS } from "@/lib/calendar-invitation"
|
|
import {
|
|
MAIL_MENU_SURFACE_CLASS,
|
|
MAIL_MENU_SURFACE_WIDE_CLASS,
|
|
} from "@/lib/mail-chrome-classes"
|
|
import { readXsMatches } from "@/hooks/use-xs"
|
|
import type { LabelRowItem, FolderTreeNode } from "@/lib/sidebar-nav-data"
|
|
import type { LabelEditState } from "@/lib/stores/mail-store"
|
|
import {
|
|
contextMenuTargetIdsForRow,
|
|
formatScheduledDateTimeDisplay,
|
|
importantSignalIcon,
|
|
listRowCheckboxClass,
|
|
listRowQuickHoverTrayToneClass,
|
|
parseDatetimeLocalToIso,
|
|
scheduledIsoToDatetimeLocalValue,
|
|
} from "@/components/gmail/email-list/email-list-helpers"
|
|
import type { ListMailIndex } from "@/components/gmail/email-list/list-mail-index"
|
|
|
|
type ListRowExtras = {
|
|
invitationById: Map<string, ReturnType<typeof import("@/lib/resolve-email-calendar-invitation").resolveParsedCalendarInvitation>>
|
|
attachmentsById: Map<string, import("@/lib/email-data").EmailAttachment[]>
|
|
categoryTabsById: Map<string, import("@/lib/inbox-category-tabs").InboxCategoryTabIcon[]>
|
|
}
|
|
|
|
export type EmailListRowProps = {
|
|
email: Email
|
|
allEmails: Email[]
|
|
emailById: Map<string, Email>
|
|
listMailIndex: ListMailIndex
|
|
listRowExtras: ListRowExtras
|
|
starredEmails: string[]
|
|
importantEmails: string[]
|
|
readOverrides: Record<string, boolean>
|
|
conversationMode: boolean
|
|
savedThreadReplyDrafts: Record<string, unknown>
|
|
selectedEmails: string[]
|
|
selectedFolder: string
|
|
splitView: boolean
|
|
openMailId: string | null
|
|
isXs: boolean
|
|
isMd: boolean
|
|
density: string
|
|
mobileSelectionMode: boolean
|
|
touchListSwipeEnabled: boolean
|
|
openSwipeRowId: string | null
|
|
setOpenSwipeRowId: (id: string | null) => void
|
|
listRowLabelBgByTextLower: Map<string, string>
|
|
sidebarNav: {
|
|
emailLabelToSidebarFolderId: Record<string, string>
|
|
getNavItemPrefs: (id: string) => { messages: string }
|
|
labelRows: LabelRowItem[]
|
|
folderTree: FolderTreeNode[]
|
|
}
|
|
rescheduleTarget: {
|
|
id: string
|
|
value: string
|
|
panelOpen: boolean
|
|
} | null
|
|
setRescheduleTarget: Dispatch<SetStateAction<{
|
|
id: string
|
|
value: string
|
|
panelOpen: boolean
|
|
} | null>>
|
|
rescheduleDismissTimeoutsRef: RefObject<Map<string, ReturnType<typeof setTimeout>>>
|
|
scheduleReschedulePopoverDismiss: (rowId: string) => void
|
|
rowContextMenuOpenedAtRef: RefObject<number>
|
|
contextMenuTargetIdsRef: RefObject<string[]>
|
|
lastSelectionAnchorIdRef: RefObject<string | null>
|
|
setSelectedEmails: Dispatch<SetStateAction<string[]>>
|
|
setLabelPickerQuery: (q: string) => void
|
|
labelPickerQuery: string
|
|
catalogLabels: string[]
|
|
resolveLabelVisual: (label: string) => ReturnType<typeof import("@/lib/label-picker-visual").resolveLabelPickerVisual>
|
|
getCatalogLabelPresence: (ids: string[], catalogLabel: string) => CatalogLabelPresence
|
|
toggleLabelOnEmails: (ids: string[], label: string) => void
|
|
addLabelToEmails: (ids: string[], label: string) => void
|
|
moveTargets: MailMoveTargets
|
|
moveEmailsToTarget: (emailIds: string[], targetId: string) => void
|
|
cmScheduledRescheduleValue: string
|
|
setCmScheduledRescheduleValue: (v: string) => void
|
|
mailActions: {
|
|
hideEmail: (id: string) => void
|
|
markNotSpam: (id: string) => void
|
|
unhideEmail: (id: string) => void
|
|
}
|
|
setReadOverrides: (updater: (prev: Record<string, boolean>) => Record<string, boolean>) => void
|
|
onSelectFolder?: (folder: string) => void
|
|
toggleSelect: (id: string) => void
|
|
handleRowCheckboxClickCapture: (id: string, e: MouseEvent) => void
|
|
handleRowActivate: (email: Email) => void
|
|
startRowDrag: (rowId: string, e: import("react").DragEvent<HTMLDivElement>) => void
|
|
archiveListRow: (email: Email) => void
|
|
deleteListRow: (email: Email) => void
|
|
toggleStar: (id: string) => void
|
|
toggleImportant: (id: string) => void
|
|
openSwipeRowLabelSheet: (emailId: string) => void
|
|
handleNavigateToLabel: (label: string) => void
|
|
handleCategoryInboxTabClick: (tabId: string) => void
|
|
closeViewIfShowingEmail: (emailId: string) => void
|
|
restoreSnoozedRowToMailbox: (email: Email) => void
|
|
handleEditScheduledMail: (id: string) => void
|
|
requestArchiveScheduled: (id: string) => void | Promise<void>
|
|
requestDeleteScheduled: (id: string) => void | Promise<void>
|
|
requestToggleReadScheduled: (id: string, read: boolean) => void | Promise<void>
|
|
requestSnoozeScheduled: (id: string) => void | Promise<void>
|
|
requestRescheduleScheduled: (id: string, iso: string) => void | Promise<void>
|
|
requestSendScheduledNow: (id: string) => void | Promise<void>
|
|
requestSnoozeMailboxEmail: (email: Email) => void | Promise<void>
|
|
}
|
|
|
|
function EmailListRowInner(props: EmailListRowProps) {
|
|
const {
|
|
email,
|
|
allEmails,
|
|
emailById,
|
|
listMailIndex,
|
|
listRowExtras,
|
|
starredEmails,
|
|
importantEmails,
|
|
readOverrides,
|
|
conversationMode,
|
|
savedThreadReplyDrafts,
|
|
selectedEmails,
|
|
selectedFolder,
|
|
splitView,
|
|
openMailId,
|
|
isXs,
|
|
isMd,
|
|
density,
|
|
mobileSelectionMode,
|
|
touchListSwipeEnabled,
|
|
openSwipeRowId,
|
|
setOpenSwipeRowId,
|
|
listRowLabelBgByTextLower,
|
|
sidebarNav,
|
|
rescheduleTarget,
|
|
setRescheduleTarget,
|
|
rescheduleDismissTimeoutsRef,
|
|
scheduleReschedulePopoverDismiss,
|
|
rowContextMenuOpenedAtRef,
|
|
contextMenuTargetIdsRef,
|
|
lastSelectionAnchorIdRef,
|
|
setSelectedEmails,
|
|
setLabelPickerQuery,
|
|
labelPickerQuery,
|
|
catalogLabels,
|
|
resolveLabelVisual,
|
|
getCatalogLabelPresence,
|
|
toggleLabelOnEmails,
|
|
addLabelToEmails,
|
|
moveTargets,
|
|
moveEmailsToTarget,
|
|
cmScheduledRescheduleValue,
|
|
setCmScheduledRescheduleValue,
|
|
mailActions,
|
|
setReadOverrides,
|
|
onSelectFolder,
|
|
toggleSelect,
|
|
handleRowCheckboxClickCapture,
|
|
handleRowActivate,
|
|
startRowDrag,
|
|
archiveListRow,
|
|
deleteListRow,
|
|
toggleStar,
|
|
toggleImportant,
|
|
openSwipeRowLabelSheet,
|
|
handleNavigateToLabel,
|
|
handleCategoryInboxTabClick,
|
|
closeViewIfShowingEmail,
|
|
restoreSnoozedRowToMailbox,
|
|
handleEditScheduledMail,
|
|
requestArchiveScheduled,
|
|
requestDeleteScheduled,
|
|
requestToggleReadScheduled,
|
|
requestSnoozeScheduled,
|
|
requestRescheduleScheduled,
|
|
requestSendScheduledNow,
|
|
requestSnoozeMailboxEmail,
|
|
} = props
|
|
|
|
|
|
const rowThreadId = threadStoreId(email)
|
|
const isStarred =
|
|
starredEmails.includes(rowThreadId) || email.starred
|
|
const isImportant =
|
|
importantEmails.includes(rowThreadId) || email.important
|
|
const isSpam = email.spam === true
|
|
const isDraft = email.labels?.includes("drafts") === true
|
|
const hasThreadReplyDraft =
|
|
savedThreadReplyDrafts[rowThreadId] !== undefined
|
|
const showDraftBadge = isDraft || hasThreadReplyDraft
|
|
const isRead = isListRowRead(
|
|
email,
|
|
readOverrides,
|
|
emailById,
|
|
conversationMode
|
|
)
|
|
const senderHoverEmail = resolveSenderEmail(email.sender, email.senderEmail)
|
|
const threadMessageCount = conversationMode
|
|
? getThreadMessageCount(email)
|
|
: 0
|
|
const senderForSearch = email.sender.replace(/\s+/g, " ").trim()
|
|
const isSelected = selectedEmails.includes(email.id)
|
|
const isSplitActiveRow =
|
|
splitView && openMailId === email.id
|
|
const hasInvitation = email.hasInvitation === true
|
|
const parsedInvitation =
|
|
listRowExtras.invitationById.get(email.id) ?? null
|
|
const attachmentList =
|
|
listRowExtras.attachmentsById.get(email.id) ?? []
|
|
const showAttachmentPills =
|
|
attachmentList.length > 0 && (!isMd || density === "default")
|
|
const showListPaperclip =
|
|
attachmentList.length > 0 && isMd && density !== "default"
|
|
const isCompactListRow = isMd && density === "compact"
|
|
const listRowPadTop = !showAttachmentPills
|
|
? isCompactListRow
|
|
? "pt-0"
|
|
: "pt-1"
|
|
: isCompactListRow
|
|
? "pt-0"
|
|
: "pt-0.5"
|
|
const isScheduled = email.labels?.includes("scheduled") === true
|
|
const contextTargetIds = contextMenuTargetIdsForRow(
|
|
email.id,
|
|
selectedEmails,
|
|
selectedFolder,
|
|
allEmails
|
|
)
|
|
const allContextTargetsScheduled =
|
|
contextTargetIds.length > 0 &&
|
|
contextTargetIds.every((id) =>
|
|
listMailIndex.scheduledIds.has(id)
|
|
)
|
|
const scheduledCtxAnyUnread =
|
|
allContextTargetsScheduled &&
|
|
contextTargetIds.some((id) => {
|
|
const em = listMailIndex.emailById.get(id)
|
|
if (!em) return false
|
|
return !(readOverrides[id] ?? em.read)
|
|
})
|
|
const isRescheduleOpenThisRow =
|
|
rescheduleTarget?.id === email.id
|
|
const spamRowHoverNoArchive = selectedFolder === "spam"
|
|
const snoozedFolderRow = selectedFolder === "snoozed"
|
|
|
|
return (
|
|
<ContextMenu
|
|
key={email.id}
|
|
modal={false}
|
|
onOpenChange={(open) => {
|
|
if (open) {
|
|
rowContextMenuOpenedAtRef.current = Date.now()
|
|
setSelectedEmails((prev) => {
|
|
const next = contextMenuTargetIdsForRow(
|
|
email.id,
|
|
prev,
|
|
selectedFolder,
|
|
allEmails
|
|
)
|
|
contextMenuTargetIdsRef.current = [...next]
|
|
return next
|
|
})
|
|
} else {
|
|
setLabelPickerQuery("")
|
|
}
|
|
}}
|
|
>
|
|
<ContextMenuTrigger asChild>
|
|
<MailListSwipeRow
|
|
enabled={touchListSwipeEnabled}
|
|
emailId={email.id}
|
|
isOpen={openSwipeRowId === email.id}
|
|
onOpenChange={(open) => {
|
|
if (open) setOpenSwipeRowId(email.id)
|
|
else if (openSwipeRowId === email.id) setOpenSwipeRowId(null)
|
|
}}
|
|
onArchive={() => archiveListRow(email)}
|
|
onDelete={() => deleteListRow(email)}
|
|
onStar={() => toggleStar(email.id)}
|
|
onLabel={() => openSwipeRowLabelSheet(email.id)}
|
|
>
|
|
<div
|
|
data-email-row-id={email.id}
|
|
data-split-active={isSplitActiveRow ? "" : undefined}
|
|
aria-current={isSplitActiveRow ? "true" : undefined}
|
|
draggable={!isXs}
|
|
onDragStart={isXs ? undefined : (e) => startRowDrag(email.id, e)}
|
|
onClick={() => {
|
|
if (readXsMatches() && mobileSelectionMode) {
|
|
toggleSelect(email.id)
|
|
lastSelectionAnchorIdRef.current = email.id
|
|
return
|
|
}
|
|
handleRowActivate(email)
|
|
}}
|
|
className={cn(
|
|
"group relative z-0 w-full cursor-pointer pl-3 pr-2 py-2 transition-[background-color,box-shadow] duration-[50ms] ease-out",
|
|
!splitView &&
|
|
"md:flex md:gap-2 md:px-2 md:py-1.5",
|
|
!splitView &&
|
|
(isCompactListRow && !showAttachmentPills
|
|
? "md:items-center"
|
|
: "md:items-start"),
|
|
isCompactListRow && "md:!py-1 md:text-[13px]",
|
|
isSplitActiveRow
|
|
? "z-[1] bg-mail-row-active-split shadow-[inset_3px_0_0_0_#669df6]"
|
|
: isSelected
|
|
? "bg-mail-row-selected"
|
|
: isRead
|
|
? "bg-mail-row-read"
|
|
: "bg-mail-row-unread",
|
|
!isSplitActiveRow &&
|
|
"hover:z-1 hover:shadow-[inset_1px_0_0_#d2d5da,inset_-1px_0_0_#d2d5da,0_4px_10px_-3px_rgba(60,64,67,.16),0_2px_5px_0_rgba(60,64,67,.09)]"
|
|
)}
|
|
>
|
|
{/* Compact < md */}
|
|
<div
|
|
className={cn(
|
|
"flex w-full min-w-0 flex-col gap-0.5",
|
|
!splitView && "md:hidden",
|
|
mobileSelectionMode &&
|
|
"max-sm:flex-row max-sm:items-center max-sm:gap-2"
|
|
)}
|
|
>
|
|
{mobileSelectionMode && (
|
|
<div
|
|
className="flex shrink-0 self-center sm:hidden"
|
|
onClick={(e) => e.stopPropagation()}
|
|
onClickCapture={(e) => handleRowCheckboxClickCapture(email.id, e)}
|
|
>
|
|
<Checkbox
|
|
className={listRowCheckboxClass(true)}
|
|
checked={isSelected}
|
|
onCheckedChange={() => {
|
|
toggleSelect(email.id)
|
|
lastSelectionAnchorIdRef.current = email.id
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div
|
|
className={cn(
|
|
"flex min-w-0 flex-1 flex-col gap-0.5",
|
|
mobileSelectionMode && "max-sm:pointer-events-none"
|
|
)}
|
|
data-selectable-text
|
|
>
|
|
<div className="flex w-full min-w-0 items-center gap-2">
|
|
<div
|
|
className="hidden shrink-0 items-center sm:flex"
|
|
onClick={(e) => e.stopPropagation()}
|
|
onClickCapture={(e) => handleRowCheckboxClickCapture(email.id, e)}
|
|
>
|
|
<Checkbox
|
|
className={listRowCheckboxClass(false)}
|
|
checked={isSelected}
|
|
onCheckedChange={() => {
|
|
toggleSelect(email.id)
|
|
lastSelectionAnchorIdRef.current = email.id
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="flex min-w-0 flex-1 items-center justify-between gap-2">
|
|
<div className="flex min-w-0 flex-1 items-center gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
toggleImportant(email.id)
|
|
}}
|
|
className={cn(
|
|
"flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-full",
|
|
isSpam
|
|
? "text-[#d93025] hover:bg-[#d93025]/10 hover:text-[#b3261e]"
|
|
: "text-[#c2c2c2] hover:bg-black/4 hover:text-[#5f6368]"
|
|
)}
|
|
aria-label={
|
|
isSpam
|
|
? "Marqué comme spam"
|
|
: isImportant
|
|
? "Retirer important"
|
|
: "Marquer important"
|
|
}
|
|
>
|
|
<Icon
|
|
icon={importantSignalIcon(isSpam, isImportant)}
|
|
className={cn(
|
|
"size-4 shrink-0",
|
|
isSpam && "text-[#d93025]",
|
|
!isSpam &&
|
|
(isImportant ? "text-[#f4cc70]" : "text-[#c2c2c2]")
|
|
)}
|
|
aria-hidden
|
|
/>
|
|
</button>
|
|
{isScheduled && (
|
|
<span
|
|
className="flex h-7 w-6 shrink-0 items-center justify-center text-[#5f6368]"
|
|
aria-hidden
|
|
>
|
|
<Send className="size-3.5" strokeWidth={2} />
|
|
</span>
|
|
)}
|
|
{isScheduled ? (
|
|
<span
|
|
className={cn(
|
|
"min-w-0 truncate text-sm",
|
|
!isRead ? "font-semibold text-gray-900" : "font-normal text-gray-700"
|
|
)}
|
|
>
|
|
À : {email.scheduledToName ?? email.sender}
|
|
</span>
|
|
) : (
|
|
<ContactHoverCard displayName={email.sender} email={senderHoverEmail}>
|
|
<span
|
|
className={cn(
|
|
"min-w-0 truncate text-sm",
|
|
!isRead ? "font-semibold text-gray-900" : "font-normal text-gray-700"
|
|
)}
|
|
>
|
|
{showDraftBadge && (
|
|
<span className="font-medium text-[#d93025]">Brouillon </span>
|
|
)}
|
|
{email.sender}
|
|
</span>
|
|
</ContactHoverCard>
|
|
)}
|
|
{threadMessageCount > 1 && (
|
|
<span className="shrink-0 text-sm font-normal text-gray-500">
|
|
{threadMessageCount}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
{(parsedInvitation || hasInvitation) && (
|
|
<Icon
|
|
icon={
|
|
parsedInvitation
|
|
? VIDEO_CONFERENCE_LOGOS[
|
|
parsedInvitation.conferenceProvider
|
|
]
|
|
: "mdi:calendar"
|
|
}
|
|
className="size-4 shrink-0 text-[#5f6368]"
|
|
aria-label={
|
|
parsedInvitation
|
|
? "Invitation visioconférence"
|
|
: "Invitation calendrier"
|
|
}
|
|
/>
|
|
)}
|
|
{attachmentList.length > 0 && (
|
|
<Paperclip
|
|
className="size-4 shrink-0 text-[#5f6368]"
|
|
strokeWidth={1.75}
|
|
aria-label="Pièces jointes"
|
|
/>
|
|
)}
|
|
{listRowExtras.categoryTabsById.get(email.id) ? (
|
|
<MailInboxCategoryTabIcons
|
|
tabs={listRowExtras.categoryTabsById.get(email.id)!}
|
|
onTabClick={handleCategoryInboxTabClick}
|
|
/>
|
|
) : null}
|
|
<span
|
|
className={cn(
|
|
"shrink-0 text-sm font-semibold tabular-nums tracking-tight",
|
|
!isRead ? "text-gray-900" : "text-gray-700"
|
|
)}
|
|
>
|
|
{isScheduled ? (
|
|
formatScheduledDateTimeDisplay(email.scheduledSendAt)
|
|
) : (
|
|
<MailDateText iso={email.date} variant="list" />
|
|
)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={cn("flex min-w-0 flex-wrap items-center gap-1 sm:pl-6")}>
|
|
{email.tag && (
|
|
<span className="shrink-0 rounded bg-gray-200 px-1.5 py-0.5 text-xs text-gray-600 opacity-[0.92]">
|
|
{email.tag}
|
|
</span>
|
|
)}
|
|
<MailLabelPillStrip
|
|
variant="list"
|
|
labels={email.labels}
|
|
labelBgByText={listRowLabelBgByTextLower}
|
|
emailLabelToSidebarFolderId={sidebarNav.emailLabelToSidebarFolderId}
|
|
getNavItemPrefs={sidebarNav.getNavItemPrefs}
|
|
labelRows={sidebarNav.labelRows}
|
|
onLabelNavigate={handleNavigateToLabel}
|
|
currentFolderId={selectedFolder}
|
|
folderTree={sidebarNav.folderTree}
|
|
/>
|
|
<span
|
|
className={cn(
|
|
"min-w-0 flex-1 text-sm leading-snug line-clamp-1",
|
|
!isRead ? "font-semibold text-gray-900" : "font-semibold text-[#202124]"
|
|
)}
|
|
>
|
|
{email.subject}
|
|
</span>
|
|
</div>
|
|
|
|
<div className={cn("flex min-w-0 items-start gap-1.5 sm:pl-6")}>
|
|
<p className="min-w-0 flex-1 text-sm leading-snug text-[#5f6368] line-clamp-1">
|
|
{email.preview}
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
toggleStar(email.id)
|
|
}}
|
|
className="mt-0.5 flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-full text-[#c2c2c2] hover:bg-black/4 hover:text-[#5f6368]"
|
|
aria-label={isStarred ? "Retirer des favoris" : "Marquer comme favori"}
|
|
>
|
|
<Star
|
|
strokeWidth={isStarred ? 0 : 1.25}
|
|
className={cn(
|
|
"size-4",
|
|
isStarred
|
|
? "fill-[#f4cc70] stroke-none text-[#f4cc70]"
|
|
: "fill-transparent stroke-[#c2c2c2]"
|
|
)}
|
|
/>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Desktop >= md */}
|
|
<div
|
|
className={cn(
|
|
"hidden w-full items-start gap-2",
|
|
!splitView && "md:flex"
|
|
)}
|
|
>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<div
|
|
className="flex shrink-0"
|
|
onClick={(e) => e.stopPropagation()}
|
|
onClickCapture={(e) => handleRowCheckboxClickCapture(email.id, e)}
|
|
>
|
|
<Checkbox
|
|
className="size-4 min-h-4 min-w-4 shrink-0 rounded-[2.5px] border-[1.5px] border-[#c2c2c2] bg-transparent shadow-none dark:bg-transparent focus-visible:ring-[#c2c2c2]/30 data-[state=checked]:border-[#1a73e8] data-[state=checked]:bg-[#1a73e8] data-[state=checked]:text-white"
|
|
checked={isSelected}
|
|
onCheckedChange={() => {
|
|
toggleSelect(email.id)
|
|
lastSelectionAnchorIdRef.current = email.id
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-0">
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
toggleStar(email.id)
|
|
}}
|
|
className="flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-full text-[#c2c2c2] hover:bg-black/4 hover:text-[#5f6368]"
|
|
aria-label={isStarred ? "Retirer des favoris" : "Marquer comme favori"}
|
|
>
|
|
<Star
|
|
strokeWidth={isStarred ? 0 : 1.25}
|
|
className={cn(
|
|
"size-4",
|
|
isStarred
|
|
? "fill-[#f4cc70] stroke-none text-[#f4cc70]"
|
|
: "fill-transparent stroke-[#c2c2c2]"
|
|
)}
|
|
/>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
toggleImportant(email.id)
|
|
}}
|
|
className={cn(
|
|
"flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-full",
|
|
isSpam
|
|
? "text-[#d93025] hover:bg-[#d93025]/10 hover:text-[#b3261e]"
|
|
: "text-[#c2c2c2] hover:bg-black/4 hover:text-[#5f6368]"
|
|
)}
|
|
aria-label={
|
|
isSpam
|
|
? "Marqué comme spam"
|
|
: isImportant
|
|
? "Retirer important"
|
|
: "Marquer important"
|
|
}
|
|
>
|
|
<Icon
|
|
icon={importantSignalIcon(isSpam, isImportant)}
|
|
className={cn(
|
|
"size-4 shrink-0",
|
|
isSpam && "text-[#d93025]",
|
|
!isSpam &&
|
|
(isImportant ? "text-[#f4cc70]" : "text-[#c2c2c2]")
|
|
)}
|
|
aria-hidden
|
|
/>
|
|
</button>
|
|
{isScheduled && (
|
|
<span
|
|
className="flex h-7 w-6 shrink-0 items-center justify-center text-[#5f6368]"
|
|
aria-hidden
|
|
>
|
|
<Send className="size-3.5" strokeWidth={2} />
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className={cn(
|
|
"w-44 shrink-0 truncate pl-2 lg:w-40",
|
|
listRowPadTop,
|
|
isCompactListRow &&
|
|
"flex min-h-7 items-center leading-tight"
|
|
)}
|
|
data-selectable-text
|
|
>
|
|
{isScheduled ? (
|
|
<span
|
|
className={cn(
|
|
"text-sm",
|
|
!isRead ? "font-semibold text-gray-900" : "text-gray-700"
|
|
)}
|
|
>
|
|
À : {email.scheduledToName ?? email.sender}
|
|
</span>
|
|
) : (
|
|
<ContactHoverCard displayName={email.sender} email={senderHoverEmail}>
|
|
<span className={cn(
|
|
"text-sm",
|
|
!isRead ? "font-semibold text-gray-900" : "text-gray-700"
|
|
)}>
|
|
{showDraftBadge && (
|
|
<span className="font-medium text-[#d93025]">Brouillon </span>
|
|
)}
|
|
{email.sender}
|
|
</span>
|
|
</ContactHoverCard>
|
|
)}
|
|
{threadMessageCount > 1 && (
|
|
<span className="text-sm text-gray-500 ml-1">
|
|
{threadMessageCount}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div
|
|
className="flex min-w-0 flex-1 flex-col justify-start gap-0.5 px-2 pb-0.5"
|
|
data-selectable-text
|
|
>
|
|
<div
|
|
className={cn(
|
|
"flex min-w-0 items-center gap-1",
|
|
listRowPadTop,
|
|
isCompactListRow && "leading-tight"
|
|
)}
|
|
>
|
|
{email.tag && (
|
|
<span className="shrink-0 rounded bg-gray-200 px-1.5 py-0.5 text-xs text-gray-600 opacity-[0.92]">
|
|
{email.tag}
|
|
</span>
|
|
)}
|
|
<MailLabelPillStrip
|
|
variant="list"
|
|
labels={email.labels}
|
|
labelBgByText={listRowLabelBgByTextLower}
|
|
emailLabelToSidebarFolderId={sidebarNav.emailLabelToSidebarFolderId}
|
|
getNavItemPrefs={sidebarNav.getNavItemPrefs}
|
|
labelRows={sidebarNav.labelRows}
|
|
onLabelNavigate={handleNavigateToLabel}
|
|
currentFolderId={selectedFolder}
|
|
folderTree={sidebarNav.folderTree}
|
|
/>
|
|
<span
|
|
className={cn(
|
|
"min-w-0 shrink truncate text-sm",
|
|
!isRead ? "font-semibold text-gray-900" : "font-normal text-[#3c4043]"
|
|
)}
|
|
>
|
|
{email.subject}
|
|
</span>
|
|
<span className="min-w-0 flex-1 truncate text-sm text-gray-500">{email.preview}</span>
|
|
</div>
|
|
{showAttachmentPills && (
|
|
<EmailListAttachmentRow emailId={email.id} attachments={attachmentList} />
|
|
)}
|
|
</div>
|
|
|
|
<div
|
|
className={cn(
|
|
"flex shrink-0 flex-col items-end gap-1 pr-2 text-right md:max-w-[150px] md:min-w-0",
|
|
listRowPadTop,
|
|
isCompactListRow && !showAttachmentPills
|
|
? "self-center"
|
|
: "self-start"
|
|
)}
|
|
>
|
|
{isScheduled ? (
|
|
<div className="relative flex w-full min-w-0 shrink-0 items-center justify-end">
|
|
<span
|
|
className={cn(
|
|
"block max-w-full truncate text-sm font-semibold tabular-nums text-[#c65308]",
|
|
"transition-opacity duration-[50ms] ease-out",
|
|
isRescheduleOpenThisRow
|
|
? "opacity-0"
|
|
: "opacity-100 group-hover:opacity-0"
|
|
)}
|
|
>
|
|
{formatScheduledDateTimeDisplay(email.scheduledSendAt)}
|
|
</span>
|
|
<div
|
|
className={cn(
|
|
"pointer-events-none absolute right-0 top-1/2 z-[1] flex w-max -translate-y-1/2 flex-nowrap items-center gap-0.5 rounded-md py-0.5 pl-1 opacity-0 transition-opacity duration-[50ms] ease-out",
|
|
listRowQuickHoverTrayToneClass(isSelected, isRead),
|
|
isRescheduleOpenThisRow
|
|
? "pointer-events-auto opacity-100"
|
|
: "group-hover:pointer-events-auto group-hover:opacity-100"
|
|
)}
|
|
>
|
|
{!spamRowHoverNoArchive && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
|
|
aria-label="Archiver"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
void requestArchiveScheduled(email.id)
|
|
}}
|
|
>
|
|
<Archive className="h-[18px] w-[18px]" strokeWidth={1.5} />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="text-xs">
|
|
Archiver
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
|
|
aria-label="Supprimer"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
void requestDeleteScheduled(email.id)
|
|
}}
|
|
>
|
|
<Trash2 className="h-[18px] w-[18px]" strokeWidth={1.5} />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="text-xs">
|
|
Supprimer
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
|
|
aria-label={isRead ? "Marquer comme non lu" : "Marquer comme lu"}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
const next = !isRead
|
|
setReadOverrides((prev) => ({ ...prev, [email.id]: next }))
|
|
void requestToggleReadScheduled(email.id, next)
|
|
}}
|
|
>
|
|
{isRead ? (
|
|
<Mail className="h-[18px] w-[18px]" strokeWidth={1.5} />
|
|
) : (
|
|
<MailOpen className="h-[18px] w-[18px]" strokeWidth={1.5} />
|
|
)}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="text-xs">
|
|
{isRead ? "Marquer comme non lu" : "Marquer comme lu"}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
|
|
aria-label="Mettre en attente"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
void requestSnoozeScheduled(email.id)
|
|
}}
|
|
>
|
|
<Clock className="h-[18px] w-[18px]" strokeWidth={1.5} />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="text-xs">
|
|
Mettre en attente
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<Popover
|
|
open={
|
|
rescheduleTarget?.id === email.id &&
|
|
rescheduleTarget.panelOpen
|
|
}
|
|
onOpenChange={(open) => {
|
|
if (open) {
|
|
const pending =
|
|
rescheduleDismissTimeoutsRef.current.get(
|
|
email.id
|
|
)
|
|
if (pending) {
|
|
clearTimeout(pending)
|
|
rescheduleDismissTimeoutsRef.current.delete(
|
|
email.id
|
|
)
|
|
}
|
|
setRescheduleTarget({
|
|
id: email.id,
|
|
value: scheduledIsoToDatetimeLocalValue(
|
|
email.scheduledSendAt
|
|
),
|
|
panelOpen: true,
|
|
})
|
|
} else {
|
|
setRescheduleTarget((prev) =>
|
|
prev?.id === email.id
|
|
? { ...prev, panelOpen: false }
|
|
: prev
|
|
)
|
|
scheduleReschedulePopoverDismiss(email.id)
|
|
}
|
|
}}
|
|
>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
|
|
aria-label="Reprogrammer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<CalendarClock
|
|
className="h-[18px] w-[18px]"
|
|
strokeWidth={1.5}
|
|
/>
|
|
</Button>
|
|
</PopoverTrigger>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="text-xs">
|
|
Reprogrammer
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<PopoverContent
|
|
className="w-[min(100vw-2rem,280px)] p-3"
|
|
align="end"
|
|
side="bottom"
|
|
sideOffset={6}
|
|
collisionPadding={12}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<p className="mb-2 text-xs font-medium text-[#3c4043]">
|
|
Nouvelle date d'envoi
|
|
</p>
|
|
<input
|
|
type="datetime-local"
|
|
className="mb-3 w-full rounded border border-[#dadce0] px-2 py-1.5 text-sm text-[#3c4043]"
|
|
value={
|
|
rescheduleTarget?.id === email.id
|
|
? rescheduleTarget.value
|
|
: ""
|
|
}
|
|
onChange={(e) =>
|
|
setRescheduleTarget((prev) =>
|
|
prev?.id === email.id
|
|
? {
|
|
...prev,
|
|
value: e.target.value,
|
|
panelOpen: true,
|
|
}
|
|
: prev
|
|
)
|
|
}
|
|
/>
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 text-xs"
|
|
onClick={() => {
|
|
setRescheduleTarget((prev) =>
|
|
prev?.id === email.id
|
|
? { ...prev, panelOpen: false }
|
|
: prev
|
|
)
|
|
scheduleReschedulePopoverDismiss(email.id)
|
|
}}
|
|
>
|
|
Annuler
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
className="h-8 text-xs"
|
|
onClick={() => {
|
|
if (rescheduleTarget?.id !== email.id) return
|
|
const iso = parseDatetimeLocalToIso(
|
|
rescheduleTarget.value
|
|
)
|
|
if (!iso) return
|
|
void requestRescheduleScheduled(email.id, iso)
|
|
setRescheduleTarget((prev) =>
|
|
prev?.id === email.id
|
|
? { ...prev, panelOpen: false }
|
|
: prev
|
|
)
|
|
scheduleReschedulePopoverDismiss(email.id)
|
|
}}
|
|
>
|
|
Valider
|
|
</Button>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
|
|
aria-label="Modifier le mail"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
void handleEditScheduledMail(email.id)
|
|
}}
|
|
>
|
|
<Pencil
|
|
className="h-[18px] w-[18px]"
|
|
strokeWidth={1.5}
|
|
/>
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="text-xs">
|
|
Modifier le mail
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
|
|
aria-label="Envoyer maintenant"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
void requestSendScheduledNow(email.id)
|
|
}}
|
|
>
|
|
<Send
|
|
className="h-[18px] w-[18px]"
|
|
strokeWidth={1.5}
|
|
/>
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="text-xs">
|
|
Envoyer maintenant
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="relative flex w-full min-w-0 shrink-0 items-center justify-end">
|
|
<div
|
|
className={cn(
|
|
"flex min-w-0 max-w-full items-center justify-end gap-1.5 overflow-hidden",
|
|
"transition-opacity duration-[50ms] ease-out",
|
|
"group-hover:opacity-0"
|
|
)}
|
|
>
|
|
{(parsedInvitation || hasInvitation) && (
|
|
<Icon
|
|
icon={
|
|
parsedInvitation
|
|
? VIDEO_CONFERENCE_LOGOS[
|
|
parsedInvitation.conferenceProvider
|
|
]
|
|
: "mdi:calendar"
|
|
}
|
|
className="size-[18px] shrink-0 text-[#5f6368]"
|
|
aria-label={
|
|
parsedInvitation
|
|
? "Invitation visioconférence"
|
|
: "Invitation calendrier"
|
|
}
|
|
/>
|
|
)}
|
|
{listRowExtras.categoryTabsById.get(email.id) ? (
|
|
<MailInboxCategoryTabIcons
|
|
tabs={listRowExtras.categoryTabsById.get(email.id)!}
|
|
onTabClick={handleCategoryInboxTabClick}
|
|
iconClassName="size-[18px] shrink-0"
|
|
/>
|
|
) : null}
|
|
{showListPaperclip && (
|
|
<Paperclip
|
|
className="size-[18px] shrink-0 text-[#5f6368]"
|
|
strokeWidth={1.75}
|
|
aria-label="Pièces jointes"
|
|
/>
|
|
)}
|
|
<span
|
|
className={cn(
|
|
"min-w-0 truncate text-sm tabular-nums",
|
|
!isRead ? "font-semibold text-gray-900" : "text-gray-600"
|
|
)}
|
|
>
|
|
<MailDateText iso={email.date} variant="list" />
|
|
</span>
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
"pointer-events-none absolute right-0 top-1/2 z-[1] flex w-max -translate-y-1/2 flex-nowrap items-center gap-0.5 rounded-md py-0.5 pl-1 opacity-0 transition-opacity duration-[50ms] ease-out",
|
|
listRowQuickHoverTrayToneClass(isSelected, isRead),
|
|
"group-hover:pointer-events-auto group-hover:opacity-100"
|
|
)}
|
|
>
|
|
{!spamRowHoverNoArchive && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
|
|
aria-label="Archiver"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
mailActions.hideEmail(email.id)
|
|
closeViewIfShowingEmail(email.id)
|
|
}}
|
|
>
|
|
<Archive className="h-[18px] w-[18px]" strokeWidth={1.5} />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="text-xs">
|
|
Archiver
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
|
|
aria-label="Supprimer"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
mailActions.hideEmail(email.id)
|
|
closeViewIfShowingEmail(email.id)
|
|
}}
|
|
>
|
|
<Trash2 className="h-[18px] w-[18px]" strokeWidth={1.5} />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="text-xs">
|
|
Supprimer
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
|
|
aria-label={isRead ? "Marquer comme non lu" : "Marquer comme lu"}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
const next = !isRead
|
|
setReadOverrides((prev) => ({ ...prev, [email.id]: next }))
|
|
}}
|
|
>
|
|
{isRead ? (
|
|
<Mail className="h-[18px] w-[18px]" strokeWidth={1.5} />
|
|
) : (
|
|
<MailOpen className="h-[18px] w-[18px]" strokeWidth={1.5} />
|
|
)}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="text-xs">
|
|
{isRead ? "Marquer comme non lu" : "Marquer comme lu"}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
{spamRowHoverNoArchive && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
|
|
aria-label="Déplacer vers la boîte de réception"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
mailActions.markNotSpam(email.id)
|
|
onSelectFolder?.("inbox")
|
|
closeViewIfShowingEmail(email.id)
|
|
}}
|
|
>
|
|
<InboxIcon
|
|
className="h-[18px] w-[18px]"
|
|
strokeWidth={1.5}
|
|
/>
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="text-xs">
|
|
Boîte de réception
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
{!spamRowHoverNoArchive &&
|
|
(snoozedFolderRow ? (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
|
|
aria-label={
|
|
email.id.startsWith("snz-")
|
|
? "Déplacer vers la boîte de réception"
|
|
: "Remettre dans les mails planifiés"
|
|
}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
restoreSnoozedRowToMailbox(email)
|
|
}}
|
|
>
|
|
<InboxIcon
|
|
className="h-[18px] w-[18px]"
|
|
strokeWidth={1.5}
|
|
/>
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="text-xs">
|
|
{email.id.startsWith("snz-")
|
|
? "Boîte de réception"
|
|
: "Planifiés"}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
) : (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
|
|
aria-label="Mettre en attente"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
void requestSnoozeMailboxEmail(email)
|
|
if (email.labels?.includes("snoozed")) return
|
|
mailActions.hideEmail(email.id)
|
|
closeViewIfShowingEmail(email.id)
|
|
}}
|
|
>
|
|
<Clock className="h-[18px] w-[18px]" strokeWidth={1.5} />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="text-xs">
|
|
Mettre en attente
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</MailListSwipeRow>
|
|
</ContextMenuTrigger>
|
|
|
|
<ContextMenuContent
|
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
onPointerDownOutside={(event) => {
|
|
const native = event.detail.originalEvent
|
|
if (
|
|
native.pointerType === "mouse" &&
|
|
native.button === 2 &&
|
|
Date.now() - rowContextMenuOpenedAtRef.current < 450
|
|
) {
|
|
event.preventDefault()
|
|
}
|
|
}}
|
|
className={cn(
|
|
cn(MAIL_MENU_SURFACE_WIDE_CLASS, "overflow-visible"),
|
|
"[&_[data-slot=context-menu-item]]:gap-3 [&_[data-slot=context-menu-item]]:rounded-none [&_[data-slot=context-menu-item]]:px-3 [&_[data-slot=context-menu-item]]:py-2 [&_[data-slot=context-menu-item]]:text-sm",
|
|
"[&_[data-slot=context-menu-item]:focus]:bg-[#f1f3f4] [&_[data-slot=context-menu-item]:focus]:text-[#3c4043]",
|
|
"[&_[data-slot=context-menu-sub-trigger]]:gap-3 [&_[data-slot=context-menu-sub-trigger]]:rounded-none [&_[data-slot=context-menu-sub-trigger]]:px-3 [&_[data-slot=context-menu-sub-trigger]]:py-2 [&_[data-slot=context-menu-sub-trigger]]:text-sm",
|
|
"[&_[data-slot=context-menu-sub-trigger]:focus]:bg-[#f1f3f4] [&_[data-slot=context-menu-sub-trigger]:focus]:text-[#3c4043]",
|
|
"[&_[data-slot=context-menu-separator]]:mx-0 [&_[data-slot=context-menu-separator]]:my-1 [&_[data-slot=context-menu-separator]]:h-px [&_[data-slot=context-menu-separator]]:bg-[#eceff1]",
|
|
"[&_[data-slot=context-menu-sub-content]]:min-w-[200px] [&_[data-slot=context-menu-sub-content]]:rounded-lg [&_[data-slot=context-menu-sub-content]]:border [&_[data-slot=context-menu-sub-content]]:border-border [&_[data-slot=context-menu-sub-content]]:bg-popover [&_[data-slot=context-menu-sub-content]]:shadow-lg"
|
|
)}
|
|
>
|
|
{allContextTargetsScheduled ? (
|
|
<>
|
|
<ContextMenuItem
|
|
onSelect={() => {
|
|
const ids = [...contextMenuTargetIdsRef.current]
|
|
void Promise.all(
|
|
ids.map((id) => requestArchiveScheduled(id))
|
|
)
|
|
}}
|
|
>
|
|
<Archive
|
|
strokeWidth={1.5}
|
|
className="size-[18px] text-[#5f6368]"
|
|
/>
|
|
Archiver
|
|
</ContextMenuItem>
|
|
<ContextMenuItem
|
|
onSelect={() => {
|
|
const ids = [...contextMenuTargetIdsRef.current]
|
|
void Promise.all(
|
|
ids.map((id) => requestDeleteScheduled(id))
|
|
)
|
|
}}
|
|
>
|
|
<Trash2
|
|
strokeWidth={1.5}
|
|
className="size-[18px] text-[#5f6368]"
|
|
/>
|
|
Supprimer
|
|
</ContextMenuItem>
|
|
<ContextMenuItem
|
|
onSelect={() => {
|
|
const ids = [...contextMenuTargetIdsRef.current]
|
|
const markRead = scheduledCtxAnyUnread
|
|
setReadOverrides((prev) => {
|
|
const next = { ...prev }
|
|
for (const id of ids) next[id] = markRead
|
|
return next
|
|
})
|
|
void Promise.all(
|
|
ids.map((id) =>
|
|
requestToggleReadScheduled(id, markRead)
|
|
)
|
|
)
|
|
}}
|
|
>
|
|
{scheduledCtxAnyUnread ? (
|
|
<Mail
|
|
strokeWidth={1.5}
|
|
className="size-[18px] text-[#5f6368]"
|
|
/>
|
|
) : (
|
|
<MailOpen
|
|
strokeWidth={1.5}
|
|
className="size-[18px] text-[#5f6368]"
|
|
/>
|
|
)}
|
|
{scheduledCtxAnyUnread
|
|
? "Marquer comme lu"
|
|
: "Marquer comme non lu"}
|
|
</ContextMenuItem>
|
|
<ContextMenuItem
|
|
onSelect={() => {
|
|
const ids = [...contextMenuTargetIdsRef.current]
|
|
void Promise.all(
|
|
ids.map((id) => requestSnoozeScheduled(id))
|
|
)
|
|
}}
|
|
>
|
|
<Clock
|
|
strokeWidth={1.5}
|
|
className="size-[18px] text-[#5f6368]"
|
|
/>
|
|
Mettre en attente
|
|
</ContextMenuItem>
|
|
<ContextMenuSeparator />
|
|
<ContextMenuSub
|
|
onOpenChange={(subOpen) => {
|
|
if (!subOpen) return
|
|
const ids = contextMenuTargetIdsRef.current
|
|
const first = allEmails.find((e) => e.id === ids[0])
|
|
setCmScheduledRescheduleValue(
|
|
scheduledIsoToDatetimeLocalValue(
|
|
first?.scheduledSendAt
|
|
)
|
|
)
|
|
}}
|
|
>
|
|
<ContextMenuSubTrigger className="[&>svg:last-child]:text-[#5f6368]">
|
|
<CalendarClock
|
|
strokeWidth={1.5}
|
|
className="size-[18px] text-[#5f6368]"
|
|
/>
|
|
Reprogrammer
|
|
</ContextMenuSubTrigger>
|
|
<ContextMenuSubContent
|
|
className={cn(
|
|
"min-w-[288px] rounded-lg border border-border bg-popover px-4 py-3.5 text-[#3c4043] shadow-lg"
|
|
)}
|
|
>
|
|
<div
|
|
className="flex flex-col gap-3.5"
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
>
|
|
<p className="text-xs font-medium leading-snug text-[#3c4043]">
|
|
Nouvelle date d'envoi
|
|
{contextTargetIds.length > 1
|
|
? ` (${contextTargetIds.length} messages)`
|
|
: null}
|
|
</p>
|
|
<input
|
|
type="datetime-local"
|
|
className="w-full rounded border border-[#dadce0] px-2.5 py-1.5 text-sm text-[#3c4043]"
|
|
value={cmScheduledRescheduleValue}
|
|
onChange={(e) =>
|
|
setCmScheduledRescheduleValue(e.target.value)
|
|
}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="rounded bg-[#0b57d0] px-3 py-1.5 text-xs font-medium text-white hover:bg-[#0842a0]"
|
|
onPointerDown={(e) => {
|
|
e.stopPropagation()
|
|
e.preventDefault()
|
|
}}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
const iso = parseDatetimeLocalToIso(
|
|
cmScheduledRescheduleValue
|
|
)
|
|
if (!iso) return
|
|
const ids = [
|
|
...contextMenuTargetIdsRef.current,
|
|
]
|
|
void Promise.all(
|
|
ids.map((id) =>
|
|
requestRescheduleScheduled(id, iso)
|
|
)
|
|
)
|
|
}}
|
|
>
|
|
Valider
|
|
</button>
|
|
</div>
|
|
</ContextMenuSubContent>
|
|
</ContextMenuSub>
|
|
<ContextMenuItem
|
|
disabled={contextTargetIds.length > 1}
|
|
onSelect={() => {
|
|
if (contextTargetIds.length !== 1) return
|
|
void handleEditScheduledMail(contextTargetIds[0]!)
|
|
}}
|
|
>
|
|
<Pencil
|
|
strokeWidth={1.5}
|
|
className="size-[18px] text-[#5f6368]"
|
|
/>
|
|
Modifier le mail
|
|
</ContextMenuItem>
|
|
<ContextMenuItem
|
|
onSelect={() => {
|
|
const ids = [...contextMenuTargetIdsRef.current]
|
|
void Promise.all(
|
|
ids.map((id) => requestSendScheduledNow(id))
|
|
)
|
|
}}
|
|
>
|
|
<Send
|
|
strokeWidth={1.5}
|
|
className="size-[18px] text-[#5f6368]"
|
|
/>
|
|
Envoyer maintenant
|
|
</ContextMenuItem>
|
|
</>
|
|
) : (
|
|
<>
|
|
<ContextMenuItem>
|
|
<Reply strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
|
|
Répondre
|
|
</ContextMenuItem>
|
|
<ContextMenuItem>
|
|
<ReplyAll strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
|
|
Répondre à tous
|
|
</ContextMenuItem>
|
|
<ContextMenuItem>
|
|
<Forward strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
|
|
Transférer
|
|
</ContextMenuItem>
|
|
<ContextMenuItem>
|
|
<Paperclip strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
|
|
Transférer en tant que pièce jointe
|
|
</ContextMenuItem>
|
|
|
|
<ContextMenuSeparator />
|
|
|
|
<ContextMenuItem>
|
|
<Archive strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
|
|
Archiver
|
|
</ContextMenuItem>
|
|
<ContextMenuItem>
|
|
<Trash2 strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
|
|
Supprimer
|
|
</ContextMenuItem>
|
|
<ContextMenuItem
|
|
onSelect={() => {
|
|
const newRead = !isRead
|
|
const ids = contextMenuTargetIdsRef.current
|
|
setReadOverrides((prev) => {
|
|
const next = { ...prev }
|
|
for (const id of ids) {
|
|
next[id] = newRead
|
|
}
|
|
return next
|
|
})
|
|
}}
|
|
>
|
|
{!isRead ? (
|
|
<Mail strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
|
|
) : (
|
|
<MailOpen strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
|
|
)}
|
|
{isRead ? "Marquer comme non lu" : "Marquer comme lu"}
|
|
</ContextMenuItem>
|
|
<ContextMenuItem>
|
|
<Clock strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
|
|
Mettre en attente
|
|
</ContextMenuItem>
|
|
<ContextMenuItem>
|
|
<ListTodo strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
|
|
Ajouter à Tasks
|
|
</ContextMenuItem>
|
|
|
|
<ContextMenuSeparator />
|
|
|
|
<ContextMenuSub>
|
|
<ContextMenuSubTrigger className="[&>svg:last-child]:text-[#5f6368]">
|
|
<FolderInput strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
|
|
Déplacer vers
|
|
</ContextMenuSubTrigger>
|
|
<ContextMenuSubContent
|
|
className={cn(
|
|
cn(MAIL_MENU_SURFACE_CLASS, "max-h-80 overflow-y-auto"),
|
|
"[&_[data-slot=context-menu-item]]:gap-3 [&_[data-slot=context-menu-item]]:rounded-none [&_[data-slot=context-menu-item]]:px-3 [&_[data-slot=context-menu-item]]:py-2 [&_[data-slot=context-menu-item]]:text-sm",
|
|
"[&_[data-slot=context-menu-item]:focus]:bg-[#f1f3f4]"
|
|
)}
|
|
>
|
|
<MoveToContextMenuItems
|
|
targets={moveTargets}
|
|
onMoveTo={(targetId) => {
|
|
moveEmailsToTarget(contextTargetIds, targetId)
|
|
if (targetId !== "inbox") {
|
|
setSelectedEmails((prev) => prev.filter((id) => !contextTargetIds.includes(id)))
|
|
}
|
|
}}
|
|
/>
|
|
</ContextMenuSubContent>
|
|
</ContextMenuSub>
|
|
|
|
<ContextMenuSub>
|
|
<ContextMenuSubTrigger className="[&>svg:last-child]:text-[#5f6368]">
|
|
<Tag strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
|
|
Ajouter le libellé
|
|
</ContextMenuSubTrigger>
|
|
<ContextMenuSubContent
|
|
className={cn(
|
|
"z-[100] flex max-h-72 min-w-[260px] flex-col overflow-hidden rounded-lg border border-border bg-popover p-0 py-0 text-[#3c4043] shadow-lg",
|
|
"[&_[data-slot=context-menu-item]]:gap-3 [&_[data-slot=context-menu-item]]:rounded-none [&_[data-slot=context-menu-item]]:px-3 [&_[data-slot=context-menu-item]]:py-2 [&_[data-slot=context-menu-item]]:text-sm",
|
|
"[&_[data-slot=context-menu-item]:focus]:bg-[#f1f3f4]"
|
|
)}
|
|
>
|
|
<EmailLabelPickerBlock
|
|
query={labelPickerQuery}
|
|
onQueryChange={setLabelPickerQuery}
|
|
catalogLabels={catalogLabels}
|
|
resolveLabelVisual={resolveLabelVisual}
|
|
Item={ContextMenuItem}
|
|
getLabelPresence={(lab) =>
|
|
getCatalogLabelPresence(contextTargetIds, lab)
|
|
}
|
|
onToggleCatalogLabel={(lab) =>
|
|
toggleLabelOnEmails(contextTargetIds, lab)
|
|
}
|
|
onCreateLabel={(lab) => {
|
|
addLabelToEmails(contextTargetIds, lab)
|
|
setLabelPickerQuery("")
|
|
}}
|
|
/>
|
|
</ContextMenuSubContent>
|
|
</ContextMenuSub>
|
|
|
|
<ContextMenuItem>
|
|
<VolumeX strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
|
|
Ignorer la conversation
|
|
</ContextMenuItem>
|
|
|
|
<ContextMenuSeparator />
|
|
|
|
<ContextMenuItem title={`Recherche : ${senderForSearch}`}>
|
|
<Search strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
|
|
<span className="min-w-0 truncate">
|
|
Rech. e-mails de {senderForSearch}
|
|
</span>
|
|
</ContextMenuItem>
|
|
|
|
<ContextMenuSeparator />
|
|
|
|
<ContextMenuItem>
|
|
<SquareArrowOutUpRight strokeWidth={1.5} className="size-[18px] text-[#5f6368]" />
|
|
Ouvrir dans une nouvelle fenêtre
|
|
</ContextMenuItem>
|
|
</>
|
|
)}
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
)
|
|
}
|
|
|
|
export const EmailListRow = memo(EmailListRowInner)
|