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

View File

@ -307,7 +307,7 @@ function RecipientField({
> >
<span <span
className={cn( 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) getColor(c.email)
)} )}
> >
@ -367,7 +367,7 @@ function RecipientField({
> >
<span <span
className={cn( 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) getColor(s.email)
)} )}
> >

View File

@ -150,7 +150,7 @@ export function ContactHoverCard({
<div className="p-4 pb-3"> <div className="p-4 pb-3">
<div className="relative flex items-start gap-3"> <div className="relative flex items-start gap-3">
<div <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 }} style={{ backgroundColor: color }}
> >
{senderInitial(name)} {senderInitial(name)}

View File

@ -93,6 +93,7 @@ import {
EmptyTitle, EmptyTitle,
} from "@/components/ui/empty" } from "@/components/ui/empty"
import { CompactInboxCategoryTabs } from "@/components/gmail/compact-inbox-category-tabs" 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 { cn } from "@/lib/utils"
import { labelPillTextClassForTailwindBgUtility } from "@/lib/label-pill-contrast" import { labelPillTextClassForTailwindBgUtility } from "@/lib/label-pill-contrast"
import { import {
@ -119,7 +120,6 @@ import { cleanSenderName, resolveSenderEmail } from "@/lib/sender-display"
import { import {
getMailNavFolderLabel, getMailNavFolderLabel,
inboxTabDisplayLabel, inboxTabDisplayLabel,
tabbedInboxLabelRows,
type FolderTreeNode, type FolderTreeNode,
type LabelRowItem, type LabelRowItem,
} from "@/lib/sidebar-nav-data" } from "@/lib/sidebar-nav-data"
@ -127,7 +127,16 @@ import {
mailNavVisitKey, mailNavVisitKey,
parseMailNavVisitKey, parseMailNavVisitKey,
} from "@/lib/mail-folder-display" } 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 { MailFolderStackIndicator } from "@/components/gmail/mail-folder-stack-indicator"
import { useSidebarNav, registerNavEmailSync } from "@/lib/sidebar-nav-context" import { useSidebarNav, registerNavEmailSync } from "@/lib/sidebar-nav-context"
import { ContactHoverCard } from "./contact-hover-card" 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 type { CatalogLabelPresence } from "@/components/gmail/email-label-picker-block"
import { resolveLabelPickerVisual } from "@/lib/label-picker-visual" import { resolveLabelPickerVisual } from "@/lib/label-picker-visual"
import { MobileXsBulkSheets } from "@/components/gmail/mobile-xs-bulk-sheets" import { MobileXsBulkSheets } from "@/components/gmail/mobile-xs-bulk-sheets"
import { MailListSwipeRow } from "@/components/gmail/mail-list-swipe-row"
import { import {
useMoveTargets, useMoveTargets,
type MoveTarget, type MoveTarget,
@ -158,6 +168,7 @@ import {
import type { LabelEditState } from "@/lib/stores/mail-store" import type { LabelEditState } from "@/lib/stores/mail-store"
import type { MailRouteState } from "@/lib/mail-url" import type { MailRouteState } from "@/lib/mail-url"
import { readXsMatches, useIsXs } from "@/hooks/use-xs" import { readXsMatches, useIsXs } from "@/hooks/use-xs"
import { useTouchNav } from "@/hooks/use-touch-nav"
addCollection(mdiIcons) addCollection(mdiIcons)
@ -315,18 +326,13 @@ type InboxTabBarItem = {
function buildInboxTabBarItems(labelRows: readonly LabelRowItem[]): InboxTabBarItem[] { function buildInboxTabBarItems(labelRows: readonly LabelRowItem[]): InboxTabBarItem[] {
return [ return [
...buildInboxCategoryTabIcons(labelRows),
{ {
id: "primary", id: INBOX_ALL_TAB,
label: "Principale", label: "Tous les messages",
icon: "mdi:inbox", icon: "mdi:inbox",
badgeColor: "bg-[#0b57d0]", 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] [sidebarNav.folderIdToLabel, sidebarNav.folderTree, sidebarNav.labelRows]
) )
const inboxCategoryTabIconsCatalog = useMemo(
() => buildInboxCategoryTabIcons(sidebarNav.labelRows),
[sidebarNav.labelRows]
)
const inboxTabBarItems = useMemo( const inboxTabBarItems = useMemo(
() => buildInboxTabBarItems(sidebarNav.labelRows), () => buildInboxTabBarItems(sidebarNav.labelRows),
[sidebarNav.labelRows] [sidebarNav.labelRows]
@ -758,7 +769,10 @@ export function EmailList({
const [mobileXsMoreMenuOpen, setMobileXsMoreMenuOpen] = useState(false) const [mobileXsMoreMenuOpen, setMobileXsMoreMenuOpen] = useState(false)
const [mobileXsMoveSheetOpen, setMobileXsMoveSheetOpen] = useState(false) const [mobileXsMoveSheetOpen, setMobileXsMoveSheetOpen] = useState(false)
const [mobileXsLabelSheetOpen, setMobileXsLabelSheetOpen] = 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 isXs = useIsXs()
const touchNav = useTouchNav()
const openMobileXsMoveSheet = useCallback(() => { const openMobileXsMoveSheet = useCallback(() => {
setMobileXsMoreMenuOpen(false) setMobileXsMoreMenuOpen(false)
@ -775,8 +789,29 @@ export function EmailList({
const openMobileXsLabelSheet = useCallback(() => { const openMobileXsLabelSheet = useCallback(() => {
setMobileXsMoreMenuOpen(false) setMobileXsMoreMenuOpen(false)
setSwipeLabelEmailId(null)
window.setTimeout(() => setMobileXsLabelSheetOpen(true), 0) 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 listViewportRef = useRef<HTMLDivElement>(null)
const pullContentRef = useRef<HTMLDivElement>(null) const pullContentRef = useRef<HTMLDivElement>(null)
const pullIconRef = useRef<SVGSVGElement>(null) const pullIconRef = useRef<SVGSVGElement>(null)
@ -927,7 +962,7 @@ export function EmailList({
subtreeIdsCache subtreeIdsCache
) )
) )
} else { } else if (tab !== INBOX_ALL_TAB) {
rows = rows.filter( rows = rows.filter(
(email) => (email) =>
emailMatchesFolder( emailMatchesFolder(
@ -1016,12 +1051,38 @@ export function EmailList({
ReturnType<typeof resolveParsedCalendarInvitation> ReturnType<typeof resolveParsedCalendarInvitation>
>() >()
const attachmentsById = new Map<string, EmailAttachment[]>() 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) { for (const e of listEmails) {
invitationById.set(e.id, resolveParsedCalendarInvitation(e)) invitationById.set(e.id, resolveParsedCalendarInvitation(e))
attachmentsById.set(e.id, attachmentsForEmailList(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 } return { invitationById, attachmentsById, categoryTabsById }
}, [listEmails]) }, [
listEmails,
selectedFolder,
inboxTab,
folderFilterCtx,
navMaps,
inboxCategoryTabIconsCatalog,
])
useEffect(() => { useEffect(() => {
if (isXs) return if (isXs) return
@ -1473,6 +1534,9 @@ export function EmailList({
!seen.has(e.id) !seen.has(e.id)
) )
} }
if (tab.id === INBOX_ALL_TAB) {
return !seen.has(e.id)
}
return ( return (
emailMatchesFolder(e, "inbox", folderFilterCtx, navMaps, tabCache) && emailMatchesFolder(e, "inbox", folderFilterCtx, navMaps, tabCache) &&
emailMatchesFolder(e, tab.id, folderFilterCtx, navMaps, tabCache) && emailMatchesFolder(e, tab.id, folderFilterCtx, navMaps, tabCache) &&
@ -1480,16 +1544,18 @@ export function EmailList({
) )
}) })
counts[tab.id] = rows.length counts[tab.id] = rows.length
const chain: string[] = [] if (inboxTabShowsInactiveMeta(tab.id)) {
const used = new Set<string>() const chain: string[] = []
for (const e of rows) { const used = new Set<string>()
const n = cleanSenderName(e.sender).trim() for (const e of rows) {
if (!n || used.has(n)) continue const n = cleanSenderName(e.sender).trim()
used.add(n) if (!n || used.has(n)) continue
chain.push(n) used.add(n)
if (chain.length >= 6) break chain.push(n)
if (chain.length >= 6) break
}
preview[tab.id] = chain.join(", ")
} }
preview[tab.id] = chain.join(", ")
} }
return { unseenInTabById: counts, tabUnseenSenderLineById: preview } return { unseenInTabById: counts, tabUnseenSenderLineById: preview }
}, [folderFilterCtx, hiddenEmailIds, labelEdits, seenSerialized, allEmails, navMaps, notSpamEmailIds, inboxTabBarItems]) }, [folderFilterCtx, hiddenEmailIds, labelEdits, seenSerialized, allEmails, navMaps, notSpamEmailIds, inboxTabBarItems])
@ -1542,6 +1608,11 @@ export function EmailList({
}, [bulkTargetIds, readOverrides, allEmails]) }, [bulkTargetIds, readOverrides, allEmails])
const showBulkToolbar = bulkTargetIds.length > 0 const showBulkToolbar = bulkTargetIds.length > 0
const labelSheetTargetIds = useMemo(
() => (swipeLabelEmailId ? [swipeLabelEmailId] : bulkTargetIds),
[swipeLabelEmailId, bulkTargetIds]
)
const clearBulkSelection = (ids: string[]) => { const clearBulkSelection = (ids: string[]) => {
setSelectedEmails((prev) => prev.filter((id) => !ids.includes(id))) setSelectedEmails((prev) => prev.filter((id) => !ids.includes(id)))
} }
@ -1713,6 +1784,35 @@ export function EmailList({
[openMailId, 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( const restoreSnoozedRowToMailbox = useCallback(
(emailRow: Email) => { (emailRow: Email) => {
void requestRestoreSnoozedToInbox(emailRow) void requestRestoreSnoozedToInbox(emailRow)
@ -2477,26 +2577,28 @@ export function EmailList({
)} )}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<MobileXsBulkSheets
moveSheetOpen={mobileXsMoveSheetOpen}
onMoveSheetOpenChange={handleMobileXsMoveSheetOpenChange}
labelSheetOpen={mobileXsLabelSheetOpen}
onLabelSheetOpenChange={setMobileXsLabelSheetOpen}
labelPickerQuery={labelPickerQuery}
onLabelPickerQueryChange={setLabelPickerQuery}
catalogLabels={catalogLabels}
resolveLabelVisual={resolveLabelVisual}
moveTargets={moveTargets}
onMoveTo={bulkMoveTo}
getLabelPresence={(lab) => getCatalogLabelPresence(bulkTargetIds, lab)}
onToggleCatalogLabel={(lab) => toggleLabelOnEmails(bulkTargetIds, lab)}
onCreateLabel={(lab) => {
addLabelToEmails(bulkTargetIds, lab)
setLabelPickerQuery("")
}}
/>
</div> </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 flex-1 flex-col", splitView && "min-h-0 flex-row overflow-hidden")}>
<div <div
@ -2879,11 +2981,10 @@ export function EmailList({
> >
{inboxTabBarItems.map((tab) => { {inboxTabBarItems.map((tab) => {
const isActive = activeInboxTabId === tab.id const isActive = activeInboxTabId === tab.id
const isPrimaryTab = tab.id === "primary"
const unseen = unseenInTabById[tab.id] ?? 0 const unseen = unseenInTabById[tab.id] ?? 0
const senderLine = tabUnseenSenderLineById[tab.id] ?? "" const senderLine = tabUnseenSenderLineById[tab.id] ?? ""
const showMeta = const showMeta =
!isPrimaryTab && !isActive && unseen > 0 inboxTabShowsInactiveMeta(tab.id) && !isActive && unseen > 0
const showSenderLine = showMeta && Boolean(senderLine) const showSenderLine = showMeta && Boolean(senderLine)
const isExpandedTabMeta = showSenderLine const isExpandedTabMeta = showSenderLine
return ( return (
@ -3152,6 +3253,19 @@ export function EmailList({
}} }}
> >
<ContextMenuTrigger asChild> <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 <div
data-email-row-id={email.id} data-email-row-id={email.id}
data-split-active={isSplitActiveRow ? "" : undefined} data-split-active={isSplitActiveRow ? "" : undefined}
@ -3324,6 +3438,12 @@ export function EmailList({
aria-label="Pièces jointes" aria-label="Pièces jointes"
/> />
)} )}
{listRowExtras.categoryTabsById.get(email.id) ? (
<MailInboxCategoryTabIcons
tabs={listRowExtras.categoryTabsById.get(email.id)!}
onTabClick={handleCategoryInboxTabClick}
/>
) : null}
<span <span
className={cn( className={cn(
"shrink-0 text-sm font-semibold tabular-nums tracking-tight", "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 <span
className={cn( className={cn(
"min-w-0 truncate text-sm tabular-nums", "min-w-0 truncate text-sm tabular-nums",
@ -4049,6 +4176,7 @@ export function EmailList({
</div> </div>
</div> </div>
</div> </div>
</MailListSwipeRow>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent <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]" className="group flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-[#f6f9fe]"
> >
<div <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 }} style={{ backgroundColor: color }}
> >
{senderInitial(name)} {senderInitial(name)}
@ -522,7 +522,7 @@ function ExpandedMessage({
</div> </div>
) : ( ) : (
<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) }} style={{ backgroundColor: avatarColor(name) }}
> >
{senderInitial(name)} {senderInitial(name)}
@ -1032,7 +1032,7 @@ export function EmailView({
<div ref={threadComposeAnchorRef} className="mt-6 px-4 pb-6 pl-[68px]"> <div ref={threadComposeAnchorRef} className="mt-6 px-4 pb-6 pl-[68px]">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div <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) }} style={{ backgroundColor: avatarColor(selfName) }}
aria-hidden aria-hidden
> >

View File

@ -141,7 +141,7 @@ export function Header({
onError={(e) => { onError={(e) => {
const target = e.target as HTMLImageElement const target = e.target as HTMLImageElement
target.style.display = 'none' 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> </div>
@ -154,7 +154,7 @@ export function Header({
</div> </div>
<Button variant="ghost" size="icon-lg" className="size-11 rounded-full overflow-hidden ml-2 p-0"> <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 E
</div> </div>
</Button> </Button>

View File

@ -71,8 +71,14 @@ export function MailFolderStackIndicator({
onNavigate, onNavigate,
}: MailFolderStackIndicatorProps) { }: MailFolderStackIndicatorProps) {
const items = useMemo( 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(" · ") 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. */ /** 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 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). */ /** Same row geometry collapsed / expanded / hover so icons never jump (h-8, pl-6 icon column). */
const NavItem = ({ 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" 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} 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 && ( {isExpanded && (
<span className="min-w-0 flex-1 truncate text-left text-sm font-medium text-gray-700"> <span className="min-w-0 flex-1 truncate text-left text-sm font-medium text-gray-700">
Libellés Libellés

View File

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

View File

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

View File

@ -7,6 +7,14 @@ import {
export const DEFAULT_MAIL_FOLDER = "inbox" export const DEFAULT_MAIL_FOLDER = "inbox"
export const DEFAULT_INBOX_TAB = "primary" 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. */ /** Segments dURL historiques → ids libellés actuels. */
const LEGACY_INBOX_TAB_SEGMENT: Record<string, string> = { const LEGACY_INBOX_TAB_SEGMENT: Record<string, string> = {
@ -22,7 +30,7 @@ export function normalizeInboxTabSegment(tab: string): string {
} }
function defaultInboxTabIdSet(): Set<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())) { for (const r of tabbedInboxLabelRows(cloneDefaultLabelRows())) {
s.add(r.id) s.add(r.id)
} }

View File

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

File diff suppressed because one or more lines are too long