Refactor email components to enhance user experience with improved category tab icons and swipe functionality. Added new MailInboxCategoryTabIcons and MailListSwipeRow components, updated inbox tab handling, and refined styles for better visual consistency.

This commit is contained in:
R3D347HR4Y 2026-05-18 12:22:16 +02:00
parent 4207b5eb55
commit a48823cf1e
17 changed files with 731 additions and 73 deletions

View File

@ -2,6 +2,7 @@
import { memo, useEffect, useLayoutEffect, useRef, useState } from "react"
import { Icon } from "@iconify/react"
import { inboxTabShowsInactiveMeta } from "@/lib/mail-url"
import { cn } from "@/lib/utils"
export type CompactInboxCategoryTab = {
@ -99,9 +100,9 @@ export const CompactInboxCategoryTabs = memo(function CompactInboxCategoryTabs({
/>
{tabs.map((tab) => {
const isActive = displayedTabId === tab.id
const isPrimaryTab = tab.id === "primary"
const unseen = unseenInTabById[tab.id] ?? 0
const showMeta = !isPrimaryTab && !isActive && unseen > 0
const showMeta =
inboxTabShowsInactiveMeta(tab.id) && !isActive && unseen > 0
return (
<button

View File

@ -307,7 +307,7 @@ function RecipientField({
>
<span
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-medium text-white",
"flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-bold text-white",
getColor(c.email)
)}
>
@ -367,7 +367,7 @@ function RecipientField({
>
<span
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-medium text-white",
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white",
getColor(s.email)
)}
>

View File

@ -150,7 +150,7 @@ export function ContactHoverCard({
<div className="p-4 pb-3">
<div className="relative flex items-start gap-3">
<div
className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full text-lg font-medium text-white"
className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full text-lg font-bold text-white"
style={{ backgroundColor: color }}
>
{senderInitial(name)}

View File

@ -93,6 +93,7 @@ import {
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 {
@ -119,7 +120,6 @@ import { cleanSenderName, resolveSenderEmail } from "@/lib/sender-display"
import {
getMailNavFolderLabel,
inboxTabDisplayLabel,
tabbedInboxLabelRows,
type FolderTreeNode,
type LabelRowItem,
} from "@/lib/sidebar-nav-data"
@ -127,7 +127,16 @@ import {
mailNavVisitKey,
parseMailNavVisitKey,
} from "@/lib/mail-folder-display"
import { DEFAULT_INBOX_TAB, normalizeInboxTabSegment } from "@/lib/mail-url"
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"
@ -135,6 +144,7 @@ import { EmailLabelPickerBlock } from "@/components/gmail/email-label-picker-blo
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,
@ -158,6 +168,7 @@ import {
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)
@ -315,18 +326,13 @@ type InboxTabBarItem = {
function buildInboxTabBarItems(labelRows: readonly LabelRowItem[]): InboxTabBarItem[] {
return [
...buildInboxCategoryTabIcons(labelRows),
{
id: "primary",
label: "Principale",
id: INBOX_ALL_TAB,
label: "Tous les messages",
icon: "mdi:inbox",
badgeColor: "bg-[#0b57d0]",
},
...tabbedInboxLabelRows(labelRows).map((r) => ({
id: r.id,
label: r.label,
icon: r.icon ?? "mdi:label-outline",
badgeColor: r.color,
})),
]
}
@ -637,6 +643,11 @@ export function EmailList({
[sidebarNav.folderIdToLabel, sidebarNav.folderTree, sidebarNav.labelRows]
)
const inboxCategoryTabIconsCatalog = useMemo(
() => buildInboxCategoryTabIcons(sidebarNav.labelRows),
[sidebarNav.labelRows]
)
const inboxTabBarItems = useMemo(
() => buildInboxTabBarItems(sidebarNav.labelRows),
[sidebarNav.labelRows]
@ -758,7 +769,10 @@ export function EmailList({
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)
@ -775,8 +789,29 @@ export function EmailList({
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)
@ -927,7 +962,7 @@ export function EmailList({
subtreeIdsCache
)
)
} else {
} else if (tab !== INBOX_ALL_TAB) {
rows = rows.filter(
(email) =>
emailMatchesFolder(
@ -1016,12 +1051,38 @@ export function EmailList({
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 }
}, [listEmails])
}
return { invitationById, attachmentsById, categoryTabsById }
}, [
listEmails,
selectedFolder,
inboxTab,
folderFilterCtx,
navMaps,
inboxCategoryTabIconsCatalog,
])
useEffect(() => {
if (isXs) return
@ -1473,6 +1534,9 @@ export function EmailList({
!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) &&
@ -1480,6 +1544,7 @@ export function EmailList({
)
})
counts[tab.id] = rows.length
if (inboxTabShowsInactiveMeta(tab.id)) {
const chain: string[] = []
const used = new Set<string>()
for (const e of rows) {
@ -1491,6 +1556,7 @@ export function EmailList({
}
preview[tab.id] = chain.join(", ")
}
}
return { unseenInTabById: counts, tabUnseenSenderLineById: preview }
}, [folderFilterCtx, hiddenEmailIds, labelEdits, seenSerialized, allEmails, navMaps, notSpamEmailIds, inboxTabBarItems])
@ -1542,6 +1608,11 @@ export function EmailList({
}, [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)))
}
@ -1713,6 +1784,35 @@ export function EmailList({
[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)
@ -2477,25 +2577,27 @@ export function EmailList({
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{!isViewMode && touchNav && (
<MobileXsBulkSheets
moveSheetOpen={mobileXsMoveSheetOpen}
moveSheetOpen={isXs && mobileXsMoveSheetOpen}
onMoveSheetOpenChange={handleMobileXsMoveSheetOpenChange}
labelSheetOpen={mobileXsLabelSheetOpen}
onLabelSheetOpenChange={setMobileXsLabelSheetOpen}
onLabelSheetOpenChange={handleLabelSheetOpenChange}
labelPickerQuery={labelPickerQuery}
onLabelPickerQueryChange={setLabelPickerQuery}
catalogLabels={catalogLabels}
resolveLabelVisual={resolveLabelVisual}
moveTargets={moveTargets}
onMoveTo={bulkMoveTo}
getLabelPresence={(lab) => getCatalogLabelPresence(bulkTargetIds, lab)}
onToggleCatalogLabel={(lab) => toggleLabelOnEmails(bulkTargetIds, lab)}
getLabelPresence={(lab) => getCatalogLabelPresence(labelSheetTargetIds, lab)}
onToggleCatalogLabel={(lab) => toggleLabelOnEmails(labelSheetTargetIds, lab)}
onCreateLabel={(lab) => {
addLabelToEmails(bulkTargetIds, lab)
addLabelToEmails(labelSheetTargetIds, lab)
setLabelPickerQuery("")
}}
/>
</div>
)}
<div className={cn("flex min-h-0 flex-1 flex-col", splitView && "min-h-0 flex-row overflow-hidden")}>
@ -2879,11 +2981,10 @@ export function EmailList({
>
{inboxTabBarItems.map((tab) => {
const isActive = activeInboxTabId === tab.id
const isPrimaryTab = tab.id === "primary"
const unseen = unseenInTabById[tab.id] ?? 0
const senderLine = tabUnseenSenderLineById[tab.id] ?? ""
const showMeta =
!isPrimaryTab && !isActive && unseen > 0
inboxTabShowsInactiveMeta(tab.id) && !isActive && unseen > 0
const showSenderLine = showMeta && Boolean(senderLine)
const isExpandedTabMeta = showSenderLine
return (
@ -3152,6 +3253,19 @@ export function EmailList({
}}
>
<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}
@ -3324,6 +3438,12 @@ export function EmailList({
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",
@ -3875,6 +3995,13 @@ export function EmailList({
}
/>
)}
{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",
@ -4049,6 +4176,7 @@ export function EmailList({
</div>
</div>
</div>
</MailListSwipeRow>
</ContextMenuTrigger>
<ContextMenuContent

View File

@ -448,7 +448,7 @@ function CollapsedMessage({
className="group flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-[#f6f9fe]"
>
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-medium text-white"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"
style={{ backgroundColor: color }}
>
{senderInitial(name)}
@ -522,7 +522,7 @@ function ExpandedMessage({
</div>
) : (
<div
className="flex h-10 w-10 shrink-0 self-start items-center justify-center rounded-full text-sm font-medium text-white"
className="flex h-10 w-10 shrink-0 self-start items-center justify-center rounded-full text-sm font-bold text-white"
style={{ backgroundColor: avatarColor(name) }}
>
{senderInitial(name)}
@ -1032,7 +1032,7 @@ export function EmailView({
<div ref={threadComposeAnchorRef} className="mt-6 px-4 pb-6 pl-[68px]">
<div className="flex items-start gap-3">
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-medium text-white"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"
style={{ backgroundColor: avatarColor(selfName) }}
aria-hidden
>

View File

@ -141,7 +141,7 @@ export function Header({
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
target.parentElement!.innerHTML = `<div class="h-10 w-10 rounded-full bg-blue-500 flex items-center justify-center text-white font-medium">${app.name[0]}</div>`
target.parentElement!.innerHTML = `<div class="h-10 w-10 rounded-full bg-blue-500 flex items-center justify-center text-white font-bold">${app.name[0]}</div>`
}}
/>
</div>
@ -154,7 +154,7 @@ export function Header({
</div>
<Button variant="ghost" size="icon-lg" className="size-11 rounded-full overflow-hidden ml-2 p-0">
<div className="h-10 w-10 rounded-full bg-purple-500 flex items-center justify-center text-white text-base font-medium">
<div className="h-10 w-10 rounded-full bg-purple-500 flex items-center justify-center text-white text-base font-bold">
E
</div>
</Button>

View File

@ -71,8 +71,14 @@ export function MailFolderStackIndicator({
onNavigate,
}: MailFolderStackIndicatorProps) {
const items = useMemo(
() => breadcrumbItemsForVisitKey(currentKey, folderTree, folderIdToLabel),
[currentKey, folderTree, folderIdToLabel]
() =>
breadcrumbItemsForVisitKey(
currentKey,
folderTree,
folderIdToLabel,
labelRows
),
[currentKey, folderTree, folderIdToLabel, labelRows]
)
const ariaLabel = items.map((i) => i.label).join(" · ")

View File

@ -0,0 +1,72 @@
"use client"
import { memo } from "react"
import { Icon } from "@iconify/react"
import type { InboxCategoryTabIcon } from "@/lib/inbox-category-tabs"
import { navFolderIconColorFromBgClass } from "@/lib/label-pill-contrast"
import { cn } from "@/lib/utils"
const DEFAULT_ICON_CLASS = "size-4 shrink-0"
type MailInboxCategoryTabIconsProps = {
tabs: readonly InboxCategoryTabIcon[]
onTabClick?: (tabId: string) => void
className?: string
iconClassName?: string
}
export const MailInboxCategoryTabIcons = memo(function MailInboxCategoryTabIcons({
tabs,
onTabClick,
className,
iconClassName = DEFAULT_ICON_CLASS,
}: MailInboxCategoryTabIconsProps) {
if (tabs.length === 0) return null
const ariaLabel = tabs.map((t) => t.label).join(", ")
return (
<span
className={cn("inline-flex shrink-0 items-center gap-0.5", className)}
aria-label={`Catégories : ${ariaLabel}`}
>
{tabs.map((tab) => {
const color = navFolderIconColorFromBgClass(tab.badgeColor)
const icon = (
<Icon
icon={tab.icon}
className={iconClassName}
style={{ color }}
aria-hidden
/>
)
if (!onTabClick) {
return (
<span
key={tab.id}
title={tab.label}
className="inline-flex items-center justify-center p-0.5"
>
{icon}
</span>
)
}
return (
<button
key={tab.id}
type="button"
title={tab.label}
aria-label={tab.label}
onClick={(e) => {
e.stopPropagation()
onTabClick(tab.id)
}}
className="inline-flex cursor-pointer items-center justify-center rounded-full p-0.5 hover:bg-black/6"
>
{icon}
</button>
)
})}
</span>
)
})

View File

@ -0,0 +1,352 @@
"use client"
import { memo, useCallback, useRef, type ReactNode, type TouchEvent } from "react"
import { Archive, Star, Tag, Trash2 } from "lucide-react"
import { cn } from "@/lib/utils"
const SNAP_MS = 200
const SNAP_EASE = "cubic-bezier(0.25, 0.46, 0.45, 0.94)"
const LEFT_PANEL_PX = 240
const RIGHT_PANEL_PX = 100
const OPEN_THRESHOLD_RATIO = 0.15
const OPEN_VELOCITY = 0.28
type Props = {
enabled: boolean
emailId: string
isOpen: boolean
onOpenChange: (open: boolean) => void
onArchive: () => void
onDelete: () => void
onStar: () => void
onLabel: () => void
className?: string
children: ReactNode
}
function MailListSwipeRowInner({
enabled,
emailId,
isOpen,
onOpenChange,
onArchive,
onDelete,
onStar,
onLabel,
className,
children,
}: Props) {
const rowRef = useRef<HTMLDivElement>(null)
const fgRef = useRef<HTMLDivElement>(null)
const leftPanelRef = useRef<HTMLDivElement>(null)
const rightPanelRef = useRef<HTMLDivElement>(null)
const offsetRef = useRef(0)
const directionRef = useRef<"none" | "left" | "right">("none")
const touchRef = useRef<{
startX: number
startY: number
startOffset: number
active: boolean
axis: "none" | "x" | "y"
startTime: number
prevX: number
prevT: number
} | null>(null)
const suppressClickUntilRef = useRef(0)
const applyOffset = useCallback((px: number) => {
offsetRef.current = px
const fg = fgRef.current
if (!fg) return
fg.style.transform = `translate3d(${px}px,0,0)`
const lp = leftPanelRef.current
const rp = rightPanelRef.current
if (lp) lp.style.visibility = px > 0 ? "visible" : "hidden"
if (rp) rp.style.visibility = px < 0 ? "visible" : "hidden"
}, [])
const animateTo = useCallback(
(px: number) => {
const fg = fgRef.current
if (!fg) return
fg.style.transition = `transform ${SNAP_MS}ms ${SNAP_EASE}`
applyOffset(px)
window.setTimeout(() => {
if (fg) fg.style.transition = "none"
}, SNAP_MS + 10)
},
[applyOffset]
)
const close = useCallback(() => {
animateTo(0)
directionRef.current = "none"
if (isOpen) onOpenChange(false)
}, [animateTo, isOpen, onOpenChange])
const onTouchStart = useCallback(
(e: TouchEvent<HTMLDivElement>) => {
if (!enabled) return
const t = e.touches[0]
if (!t) return
const fg = fgRef.current
if (fg) fg.style.transition = "none"
touchRef.current = {
startX: t.clientX,
startY: t.clientY,
startOffset: offsetRef.current,
active: true,
axis: "none",
startTime: performance.now(),
prevX: t.clientX,
prevT: performance.now(),
}
},
[enabled]
)
const onTouchMove = useCallback(
(e: TouchEvent<HTMLDivElement>) => {
const tr = touchRef.current
if (!tr?.active) return
const t = e.touches[0]
if (!t) return
const dx = t.clientX - tr.startX
const dy = t.clientY - tr.startY
if (tr.axis === "none") {
if (Math.abs(dx) < 6 && Math.abs(dy) < 6) return
if (Math.abs(dy) > Math.abs(dx)) {
tr.axis = "y"
tr.active = false
return
}
tr.axis = "x"
}
if (tr.axis !== "x") return
e.preventDefault()
let raw = tr.startOffset + dx
// Clamp to panel width (no overswipe)
if (raw > LEFT_PANEL_PX) raw = LEFT_PANEL_PX
if (raw < -RIGHT_PANEL_PX) raw = -RIGHT_PANEL_PX
tr.prevX = t.clientX
tr.prevT = performance.now()
applyOffset(raw)
},
[applyOffset]
)
const onTouchEnd = useCallback(() => {
const tr = touchRef.current
touchRef.current = null
if (!tr?.active || tr.axis !== "x") return
suppressClickUntilRef.current = performance.now() + 350
const offset = offsetRef.current
const elapsed = Math.max(1, performance.now() - tr.startTime)
const instantVelocity = (tr.prevX - tr.startX) / elapsed
// If panel was open and user swiped opposite direction → just close
if (directionRef.current === "right" && offset <= 0) {
animateTo(0)
directionRef.current = "none"
if (isOpen) onOpenChange(false)
return
}
if (directionRef.current === "left" && offset >= 0) {
animateTo(0)
directionRef.current = "none"
if (isOpen) onOpenChange(false)
return
}
// Open/close based on threshold + velocity
// Closing requires less energy than opening (lower threshold & velocity)
const closing = isOpen && (
(directionRef.current === "right" && offset < LEFT_PANEL_PX) ||
(directionRef.current === "left" && -offset < RIGHT_PANEL_PX)
)
const closeThreshold = 0.08
const closeVelocity = 0.12
if (offset > 0) {
if (closing) {
const shouldStayOpen =
offset >= LEFT_PANEL_PX * (1 - closeThreshold) &&
instantVelocity >= -closeVelocity
if (shouldStayOpen) {
animateTo(LEFT_PANEL_PX)
} else {
animateTo(0)
directionRef.current = "none"
onOpenChange(false)
}
} else {
const shouldOpen =
offset >= LEFT_PANEL_PX * OPEN_THRESHOLD_RATIO ||
(instantVelocity > OPEN_VELOCITY && offset > 20)
if (shouldOpen) {
animateTo(LEFT_PANEL_PX)
directionRef.current = "right"
if (!isOpen) onOpenChange(true)
} else {
animateTo(0)
directionRef.current = "none"
if (isOpen) onOpenChange(false)
}
}
} else if (offset < 0) {
if (closing) {
const shouldStayOpen =
-offset >= RIGHT_PANEL_PX * (1 - closeThreshold) &&
instantVelocity <= closeVelocity
if (shouldStayOpen) {
animateTo(-RIGHT_PANEL_PX)
} else {
animateTo(0)
directionRef.current = "none"
onOpenChange(false)
}
} else {
const shouldOpen =
-offset >= RIGHT_PANEL_PX * OPEN_THRESHOLD_RATIO ||
(instantVelocity < -OPEN_VELOCITY && offset < -20)
if (shouldOpen) {
animateTo(-RIGHT_PANEL_PX)
directionRef.current = "left"
if (!isOpen) onOpenChange(true)
} else {
animateTo(0)
directionRef.current = "none"
if (isOpen) onOpenChange(false)
}
}
} else {
directionRef.current = "none"
if (isOpen) onOpenChange(false)
}
}, [
animateTo,
isOpen,
onOpenChange,
])
const onTouchCancel = useCallback(() => {
const tr = touchRef.current
touchRef.current = null
if (!tr?.active || tr.axis !== "x") return
animateTo(0)
directionRef.current = "none"
if (isOpen) onOpenChange(false)
}, [animateTo, isOpen, onOpenChange])
const handleClickCapture = useCallback(
(e: React.MouseEvent) => {
if (!enabled) return
if (performance.now() < suppressClickUntilRef.current) {
e.preventDefault()
e.stopPropagation()
return
}
if (offsetRef.current !== 0) {
e.preventDefault()
e.stopPropagation()
close()
}
},
[close, enabled]
)
// Close when isOpen flips to false externally (another row opened)
if (!isOpen && offsetRef.current !== 0 && !touchRef.current?.active) {
const fg = fgRef.current
if (fg) {
fg.style.transition = `transform ${SNAP_MS}ms ${SNAP_EASE}`
applyOffset(0)
directionRef.current = "none"
window.setTimeout(() => {
if (fg) fg.style.transition = "none"
}, SNAP_MS + 10)
}
}
if (!enabled) {
return <div className={className}>{children}</div>
}
return (
<div ref={rowRef} className={cn("relative overflow-hidden", className)} data-swipe-row-id={emailId}>
{/* Left actions (swipe right to reveal) */}
<div className="invisible absolute inset-y-0 left-0 flex" ref={leftPanelRef}>
<button
type="button"
aria-label="Mettre en suivi"
className="flex h-full w-20 shrink-0 flex-col items-center justify-center gap-1 bg-[#f4b400] px-1 text-[11px] font-medium text-white"
onClick={(e) => { e.stopPropagation(); onStar(); close() }}
>
<Star className="size-5 fill-white text-white" strokeWidth={0} />
<span className="max-w-full truncate text-center">Suivi</span>
</button>
<button
type="button"
aria-label="Ajouter un libellé"
className="flex h-full w-20 shrink-0 flex-col items-center justify-center gap-1 bg-[#34a853] px-1 text-[11px] font-medium text-white"
onClick={(e) => { e.stopPropagation(); onLabel(); close() }}
>
<Tag className="size-5 text-white" strokeWidth={1.75} />
<span className="max-w-full truncate text-center">Libellé</span>
</button>
<button
type="button"
aria-label="Archiver"
className="flex h-full w-20 shrink-0 flex-col items-center justify-center gap-1 bg-[#1a73e8] px-1 text-[11px] font-medium text-white"
onClick={(e) => { e.stopPropagation(); onArchive(); close() }}
>
<Archive className="size-5 text-white" strokeWidth={1.75} />
<span className="max-w-full truncate text-center">Archiver</span>
</button>
</div>
{/* Right actions (swipe left to reveal) */}
<div className="invisible absolute inset-y-0 right-0 flex" ref={rightPanelRef}>
<button
type="button"
aria-label="Supprimer"
className="flex h-full w-[100px] shrink-0 flex-col items-center justify-center gap-1 bg-[#d93025] px-2 text-[11px] font-medium text-white"
onClick={(e) => { e.stopPropagation(); onDelete(); close() }}
>
<Trash2 className="size-5 text-white" strokeWidth={1.75} />
<span className="max-w-full truncate text-center">Supprimer</span>
</button>
</div>
{/* Foreground (row content) */}
<div
ref={fgRef}
className="relative z-1 bg-inherit will-change-transform"
style={{ transform: "translate3d(0,0,0)" }}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onTouchCancel={onTouchCancel}
onClickCapture={handleClickCapture}
>
{children}
</div>
</div>
)
}
export const MailListSwipeRow = memo(MailListSwipeRowInner)

View File

@ -1005,7 +1005,9 @@ export function Sidebar({
/** pl-6 + demi-largeur icône nav (h-5) → axe à 34px ; picto split (size-9) centré sur cet axe. */
const splitViewLogoIconClass = "size-9 shrink-0"
const splitViewLogoHeaderClass = "min-h-10 pl-4 pr-3.5 pb-2"
/** Aligné sur la barre split (pt-1 shell + py-2 + recherche h-12) et le bouton menu size-9. */
const splitViewLogoHeaderClass =
"box-border min-h-[80px] pt-3 pl-4 pr-3.5 pb-4"
/** Same row geometry collapsed / expanded / hover so icons never jump (h-8, pl-6 icon column). */
const NavItem = ({
@ -2687,7 +2689,11 @@ export function Sidebar({
className="sticky top-0 z-30 flex h-8 w-full min-w-0 shrink-0 items-center gap-2 bg-app-canvas pl-6 pr-3"
title={!isExpanded ? "Libellés" : undefined}
>
<Tag className="h-5 w-5 shrink-0 text-gray-600" aria-hidden />
<Icon
icon="mdi:label-outline"
className="h-5 w-5 shrink-0 text-gray-600"
aria-hidden
/>
{isExpanded && (
<span className="min-w-0 flex-1 truncate text-left text-sm font-medium text-gray-700">
Libellés

View File

@ -22,4 +22,4 @@ export function folderTreeNavIconNameClosed(hasChildren: boolean): string {
return folderTreeNavIconName(hasChildren, false)
}
export const FOLDER_SECTION_ICON = "fluent:folder-mail-20-filled"
export const FOLDER_SECTION_ICON = "fluent:folder-mail-20-regular"

View File

@ -0,0 +1,59 @@
import type { Email } from "@/lib/email-data"
import {
emailMatchesFolder,
type MailFolderFilterCtx,
type MailNavFolderMaps,
} from "@/lib/mail-folder-filter"
import { DEFAULT_INBOX_TAB, INBOX_ALL_TAB } from "@/lib/mail-url"
import {
tabbedInboxLabelRows,
type LabelRowItem,
} from "@/lib/sidebar-nav-data"
export type InboxCategoryTabIcon = {
id: string
label: string
icon: string
badgeColor: string
}
/** Onglets catégorie boîte (Principale + libellés tabbed), hors « Tous les messages ». */
export function buildInboxCategoryTabIcons(
labelRows: readonly LabelRowItem[]
): InboxCategoryTabIcon[] {
return [
{
id: DEFAULT_INBOX_TAB,
label: "Principale",
icon: "mdi:account",
badgeColor: "bg-[#0b57d0]",
},
...tabbedInboxLabelRows(labelRows).map((r) => ({
id: r.id,
label: r.label,
icon: r.icon ?? "mdi:label-outline",
badgeColor: r.color,
})),
]
}
/** Onglets catégorie (hors Principale) auxquels le message appartient — pour la liste « Tous les messages ». */
export function resolveEmailInboxCategoryTabs(
email: Email,
ctx: MailFolderFilterCtx,
maps: MailNavFolderMaps,
tabs: readonly InboxCategoryTabIcon[],
subtreeIdsCache?: Map<string, string[] | null>
): InboxCategoryTabIcon[] {
const matched: InboxCategoryTabIcon[] = []
for (const tab of tabs) {
if (tab.id === INBOX_ALL_TAB || tab.id === DEFAULT_INBOX_TAB) continue
if (
emailMatchesFolder(email, "inbox", ctx, maps, subtreeIdsCache) &&
emailMatchesFolder(email, tab.id, ctx, maps, subtreeIdsCache)
) {
matched.push(tab)
}
}
return matched
}

View File

@ -1,10 +1,16 @@
import type { FolderTreeNode } from "@/lib/sidebar-nav-maps"
import { findFolderPath } from "@/lib/sidebar-nav-folder-ids"
import { getMailNavFolderLabel } from "@/lib/sidebar-nav-data"
import {
defaultNavLabelRowsSnapshot,
getMailNavFolderLabel,
inboxTabDisplayLabel,
type LabelRowItem,
} from "@/lib/sidebar-nav-data"
/** @deprecated Utiliser `getMailNavFolderLabel(inboxTab, folderIdToLabel)` ou `inboxTabDisplayLabel`. */
export const INBOX_CATEGORY_TAB_LABELS: Record<string, string> = {
primary: "Principale",
all: "Tous les messages",
}
/** Clé stable pour historique navigation (dossier + onglet boîte de réception). */
@ -88,14 +94,19 @@ export function getMailNavFolderBreadcrumbSegments(
export function breadcrumbItemsForVisitKey(
key: string,
folderTree: FolderTreeNode[],
folderIdToLabel?: Record<string, string>
folderIdToLabel?: Record<string, string>,
labelRows: readonly LabelRowItem[] = defaultNavLabelRowsSnapshot
): MailNavBreadcrumbItem[] {
const { folderId, inboxTab } = parseMailNavVisitKey(key)
const inboxCategory =
folderId === "inbox" && inboxTab && inboxTab !== "primary"
? {
tabId: inboxTab,
label: getMailNavFolderLabel(inboxTab, folderIdToLabel),
label: inboxTabDisplayLabel(
inboxTab,
labelRows,
folderIdToLabel ?? {}
),
}
: null
return getMailNavFolderBreadcrumbItems(
@ -109,19 +120,27 @@ export function breadcrumbItemsForVisitKey(
export function breadcrumbSegmentsForVisitKey(
key: string,
folderTree: FolderTreeNode[],
folderIdToLabel?: Record<string, string>
folderIdToLabel?: Record<string, string>,
labelRows?: readonly LabelRowItem[]
): string[] {
return breadcrumbItemsForVisitKey(key, folderTree, folderIdToLabel).map(
(i) => i.label
)
return breadcrumbItemsForVisitKey(
key,
folderTree,
folderIdToLabel,
labelRows
).map((i) => i.label)
}
export function breadcrumbForVisitKey(
key: string,
folderTree: FolderTreeNode[],
folderIdToLabel?: Record<string, string>
folderIdToLabel?: Record<string, string>,
labelRows?: readonly LabelRowItem[]
): string {
return breadcrumbSegmentsForVisitKey(key, folderTree, folderIdToLabel).join(
" · "
)
return breadcrumbSegmentsForVisitKey(
key,
folderTree,
folderIdToLabel,
labelRows
).join(" · ")
}

View File

@ -9,6 +9,7 @@ import {
ClockArrowUp,
ShieldAlert,
Trash2,
User,
} from "lucide-react"
import {
folderTreeNavIconNameClosed,
@ -47,7 +48,8 @@ export function resolveMailNavIcon(
if (folderId === "inbox") {
const tab = normalizeInboxTabSegment(inboxTab ?? "primary")
if (tab === "primary") return { kind: "lucide", Icon: Inbox }
if (tab === "primary") return { kind: "lucide", Icon: User }
if (tab === "all") return { kind: "lucide", Icon: Inbox }
const row = labelRowById(labelRows, tab)
if (row?.icon) return { kind: "iconify", icon: row.icon }
return { kind: "lucide", Icon: Inbox }

View File

@ -7,6 +7,14 @@ import {
export const DEFAULT_MAIL_FOLDER = "inbox"
export const DEFAULT_INBOX_TAB = "primary"
/** Onglet boîte : tous les messages inbox sans filtre libellé catégorie. */
export const INBOX_ALL_TAB = "all"
/** Onglets sans pastille « nouveaux » ni ligne expéditeurs quand inactifs. */
export function inboxTabShowsInactiveMeta(tabId: string): boolean {
const tab = normalizeInboxTabSegment(tabId)
return tab !== DEFAULT_INBOX_TAB && tab !== INBOX_ALL_TAB
}
/** Segments dURL historiques → ids libellés actuels. */
const LEGACY_INBOX_TAB_SEGMENT: Record<string, string> = {
@ -22,7 +30,7 @@ export function normalizeInboxTabSegment(tab: string): string {
}
function defaultInboxTabIdSet(): Set<string> {
const s = new Set<string>([DEFAULT_INBOX_TAB])
const s = new Set<string>([DEFAULT_INBOX_TAB, INBOX_ALL_TAB])
for (const r of tabbedInboxLabelRows(cloneDefaultLabelRows())) {
s.add(r.id)
}

View File

@ -60,7 +60,7 @@ export const SYSTEM_NAV_LABEL_DEFAULTS: readonly LabelRowItem[] = [
id: "promotions",
label: "Promotions",
color: "bg-[#1e8e3e]",
icon: "mdi:tag-outline",
icon: "mdi:tag",
tabbed: true,
favorite: true,
excludeFromPrincipal: true,
@ -230,6 +230,7 @@ export function inboxTabDisplayLabel(
folderIdToLabel: Record<string, string>
): string {
if (inboxTab === "primary") return "Principale"
if (inboxTab === "all") return "Tous les messages"
const row = labelRowById(labelRows, inboxTab)
if (row && row.enabled !== false) return row.label
return getMailNavFolderLabel(inboxTab, folderIdToLabel)
@ -265,7 +266,11 @@ export function reconcileLabelRowsFromPersisted(persisted: LabelRowItem[] | unde
const merged = defaults.map((d) => {
const p = persistByResolvedId.get(d.id)
if (!p) return { ...d }
return normalizeLabelRow({ ...d, ...p, id: d.id })
const row = normalizeLabelRow({ ...d, ...p, id: d.id })
if (SYSTEM_NAV_LABEL_ID_SET.has(d.id)) {
return { ...row, icon: d.icon, color: d.color }
}
return row
})
const mergedIds = new Set(merged.map((r) => r.id))

File diff suppressed because one or more lines are too long