Major improvements on mobile

This commit is contained in:
R3D347HR4Y 2026-05-15 23:22:24 +02:00
parent 53d5c76d76
commit 22e7b8e1d2
13 changed files with 689 additions and 255 deletions

View File

@ -4,12 +4,13 @@ import {
Suspense,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useState,
type CSSProperties,
} from "react"
import dynamic from "next/dynamic"
import { useIsXs } from "@/hooks/use-xs"
import { readXsMatches, useIsXs } from "@/hooks/use-xs"
import { MobileBottomBar } from "@/components/gmail/mobile-bottom-bar"
import { Toaster } from "sonner"
import { useRouter, usePathname } from "next/navigation"
import { Sidebar } from "@/components/gmail/sidebar"
@ -24,14 +25,6 @@ import { ComposeModalManager } from "@/components/gmail/compose-modal"
import { SidebarNavProvider } from "@/lib/sidebar-nav-context"
import { mailNavVisitKey } from "@/lib/mail-folder-display"
import { useMailStore } from "@/lib/stores/mail-store"
const MobileBottomBar = dynamic(
() =>
import("@/components/gmail/mobile-bottom-bar").then(
(m) => m.MobileBottomBar
),
{ ssr: false }
)
import {
parseMailSegments,
buildMailPath,
@ -54,7 +47,12 @@ function MailAppInner() {
const isXs = useIsXs()
const pushRecentFolderVisit = useMailStore((s) => s.pushRecentFolderVisit)
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
/** Start closed so narrow viewports match SSR/CSS before JS runs; desktop opens in layout. */
const [sidebarCollapsed, setSidebarCollapsed] = useState(true)
useLayoutEffect(() => {
if (!readXsMatches()) setSidebarCollapsed(false)
}, [])
useEffect(() => {
if (isXs) setSidebarCollapsed(true)
@ -91,9 +89,9 @@ function MailAppInner() {
page: 1,
mailId: null,
})
if (isXs) setSidebarCollapsed(true)
if (readXsMatches()) setSidebarCollapsed(true)
},
[navigateRoute, isXs]
[navigateRoute]
)
return (
@ -109,36 +107,33 @@ function MailAppInner() {
}
>
<div className="flex h-screen flex-col bg-app-canvas">
{!isXs && (
<div className="hidden sm:block">
<Header
isXs={false}
sidebarCollapsed={sidebarCollapsed}
onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
/>
)}
</div>
<div className="relative flex min-h-0 flex-1 gap-0 overflow-hidden bg-app-canvas pl-0 pr-0 pb-1 pt-1 sm:gap-1 sm:pl-1">
{isXs && !sidebarCollapsed && (
{!sidebarCollapsed && (
<button
type="button"
aria-label="Fermer le menu"
className="absolute inset-0 z-30 bg-black/20"
className="absolute inset-0 z-30 bg-black/20 sm:hidden"
onClick={() => setSidebarCollapsed(true)}
/>
)}
<div
className={
isXs
? "w-0 shrink-0"
: sidebarCollapsed
? "w-[68px] shrink-0"
: "w-60 shrink-0"
sidebarCollapsed
? "w-0 shrink-0 sm:w-[68px]"
: "w-0 shrink-0 sm:w-60"
}
/>
<Sidebar
selectedFolder={route.folderId}
onSelectFolder={handleSelectFolder}
collapsed={sidebarCollapsed}
isXs={isXs}
folderUnreadCounts={folderUnreadCounts}
/>
<main className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-none bg-white shadow-sm sm:rounded-2xl">
@ -156,12 +151,10 @@ function MailAppInner() {
</main>
<RightPanel />
</div>
{isXs && (
<MobileBottomBar
sidebarOpen={!sidebarCollapsed}
onToggleSidebar={() => setSidebarCollapsed((c) => !c)}
/>
)}
<MobileBottomBar
sidebarOpen={!sidebarCollapsed}
onToggleSidebar={() => setSidebarCollapsed((c) => !c)}
/>
</div>
</SidebarNavProvider>
)
@ -179,7 +172,7 @@ export function MailAppShell({
<Suspense
fallback={
<div className="flex h-screen flex-col bg-app-canvas">
<div className="h-14 shrink-0 border-b border-gray-200 bg-white" />
<div className="hidden h-14 shrink-0 border-b border-gray-200 bg-white sm:block" />
<div className="min-h-0 flex-1 bg-app-canvas" />
</div>
}

View File

@ -10,6 +10,8 @@ import {
lazy,
Suspense,
} from "react"
import { useIsXs } from "@/hooks/use-xs"
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
import { useEditor, EditorContent } from "@tiptap/react"
import { Editor, Node as TipTapNode, mergeAttributes, type Extensions } from "@tiptap/core"
import StarterKit from "@tiptap/starter-kit"
@ -141,6 +143,9 @@ function insertSignatureHtml(html: string, sigId: string | null) {
return clean + `<div id="ultimail-signature"><p>--</p>${sig.html}</div>`
}
/** Menus/popovers Radix default z-50 ; compose sheet content uses z-61+. */
const COMPOSE_PORTAL_Z = "z-[100]"
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
function RecipientField({
@ -408,7 +413,10 @@ function AlignmentDropdown({
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[160px]">
<DropdownMenuContent
align="start"
className={cn("min-w-[160px]", COMPOSE_PORTAL_Z)}
>
<DropdownMenuItem
onSelect={() => editor.chain().focus().setTextAlign("left").run()}
className={cn(editor.isActive({ textAlign: "left" }) && "bg-[#e8eaed]")}
@ -483,7 +491,10 @@ function FontDropdown({
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-[280px] min-w-[180px] overflow-y-auto">
<DropdownMenuContent
align="start"
className={cn("max-h-[280px] min-w-[180px] overflow-y-auto", COMPOSE_PORTAL_Z)}
>
{FONT_FAMILIES.map((f) => (
<DropdownMenuItem
key={f.value}
@ -516,7 +527,10 @@ function FontSizeDropdown({
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[140px]">
<DropdownMenuContent
align="start"
className={cn("min-w-[140px]", COMPOSE_PORTAL_Z)}
>
{FONT_SIZES.map((s) => (
<DropdownMenuItem
key={s.label}
@ -557,7 +571,11 @@ function ColorDropdown({
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[268px] p-2" onCloseAutoFocus={(e) => e.preventDefault()}>
<DropdownMenuContent
align="start"
className={cn("w-[268px] p-2", COMPOSE_PORTAL_Z)}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="mb-2 flex gap-1 border-b border-[#eef0f2] pb-2">
<button
type="button"
@ -772,7 +790,7 @@ function EmojiButton({
<PopoverContent
align="start"
side="top"
className="w-auto border-0 p-0 shadow-xl"
className={cn("w-auto border-0 p-0 shadow-xl", COMPOSE_PORTAL_Z)}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<EmojiPicker onSelect={handleSelect} />
@ -892,7 +910,7 @@ function LinkButton({
<PopoverContent
align="start"
side="top"
className="w-[340px] p-3"
className={cn("w-[340px] p-3", COMPOSE_PORTAL_Z)}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="flex flex-col gap-2.5">
@ -995,7 +1013,7 @@ function SignatureButton({
if (!editor) return null
return (
<DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type="button"
@ -1005,7 +1023,11 @@ function SignatureButton({
<PenTool className="h-[18px] w-[18px]" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="top" className="min-w-[220px]">
<DropdownMenuContent
align="start"
side="top"
className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}
>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
@ -1076,7 +1098,7 @@ function ComposeRecipientFields({
{showFromField && (
<div className="flex min-w-0 items-center gap-2 overflow-hidden px-3 py-1.5">
<span className="shrink-0 text-sm text-[#5f6368]">De</span>
<DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type="button"
@ -1089,7 +1111,7 @@ function ComposeRecipientFields({
<ChevronDown className="h-3 w-3 shrink-0 text-[#5f6368]" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[300px]">
<DropdownMenuContent align="start" className={cn("min-w-[300px]", COMPOSE_PORTAL_Z)}>
{DEFAULT_IDENTITIES.map((id) => (
<DropdownMenuItem
key={id.email}
@ -1194,10 +1216,15 @@ function ComposeRecipientFields({
export function ComposeWindow({
compose,
threadSourceEmail = null,
isXsSheet = false,
bindXsSheetClose,
}: {
compose: ComposeState
/** Fil courant : nécessaire pour le menu Répondre / Transférer en inline */
threadSourceEmail?: Email | null
/** Plein écran dans une bottom sheet (xs) — pas de file ni réduction */
isXsSheet?: boolean
bindXsSheetClose?: (fn: (() => void) | null) => void
}) {
const {
closeCompose,
@ -1262,7 +1289,11 @@ export function ComposeWindow({
attributes: {
class: cn(
"prose prose-sm max-w-none px-3 py-2 text-sm text-[#202124] outline-none focus:outline-none",
isInline ? "min-h-[200px]" : "min-h-[150px]"
isInline
? "min-h-[200px]"
: isXsSheet
? "min-h-[min(36vh,280px)]"
: "min-h-[150px]"
),
},
},
@ -1307,6 +1338,17 @@ export function ComposeWindow({
}
}
const handleCloseRef = useRef(handleClose)
handleCloseRef.current = handleClose
useLayoutEffect(() => {
if (!isXsSheet || !bindXsSheetClose) return
bindXsSheetClose(() => {
handleCloseRef.current()
})
return () => bindXsSheetClose(null)
}, [isXsSheet, bindXsSheetClose, compose.id])
const htmlToPreviewText = useCallback((html: string) => {
return html
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, " ")
@ -1500,7 +1542,7 @@ export function ComposeWindow({
}
}, [])
const showFromField = recipientsFocused
const showFromField = recipientsFocused || isXsSheet
useLayoutEffect(() => {
if (!isInline || !compose.focusToOnMount) return
@ -1509,10 +1551,14 @@ export function ComposeWindow({
useEffect(() => {
if (!recipientsFocused) return
const handleClickOutside = (e: MouseEvent) => {
const handleClickOutside = (e: Event) => {
const target = e.target as Node
const root = isInline ? inlineRecipientShellRef.current : fieldsRef.current
if (root && !root.contains(e.target as Node)) {
const portal = (e.target as HTMLElement)?.closest?.("[data-radix-popper-content-wrapper]")
if (root && !root.contains(target)) {
const el = e.target as HTMLElement | null
const portal = el?.closest?.(
"[data-radix-popper-content-wrapper], [data-radix-dropdown-menu-content], [data-slot='dropdown-menu-content'], [data-slot='popover-content']"
)
if (portal) return
setRecipientsFocused(false)
if (compose.showCc && compose.cc.length === 0) {
@ -1523,8 +1569,8 @@ export function ComposeWindow({
}
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
document.addEventListener("pointerdown", handleClickOutside)
return () => document.removeEventListener("pointerdown", handleClickOutside)
}, [
recipientsFocused,
isInline,
@ -1645,12 +1691,14 @@ export function ComposeWindow({
"relative flex flex-col overflow-hidden bg-white",
isInline
? "min-h-[360px] w-full rounded-xl border border-[#dadce0] shadow-none transition-shadow focus-within:shadow-[0_1px_4px_rgba(60,64,67,0.12)]"
: cn(
"rounded-t-lg shadow-[0_-2px_8px_rgba(0,0,0,0.08),_-4px_0_12px_rgba(0,0,0,0.12),_4px_0_12px_rgba(0,0,0,0.12)]",
compose.maximized
? "fixed inset-12 z-60 rounded-lg"
: "h-[480px] w-[500px]"
)
: isXsSheet
? "h-full min-h-0 w-full max-w-none flex-1 rounded-none shadow-none"
: cn(
"rounded-t-lg shadow-[0_-2px_8px_rgba(0,0,0,0.08),_-4px_0_12px_rgba(0,0,0,0.12),_4px_0_12px_rgba(0,0,0,0.12)]",
compose.maximized
? "fixed inset-12 z-60 rounded-lg"
: "h-[480px] w-[500px]"
)
)}
onDrop={handleDrop}
onDragOver={handleDragOver}
@ -1702,7 +1750,7 @@ export function ComposeWindow({
: undefined
}
>
<DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type="button"
@ -1715,7 +1763,7 @@ export function ComposeWindow({
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="min-w-[260px]"
className={cn("min-w-[260px]", COMPOSE_PORTAL_Z)}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuItem
@ -1809,69 +1857,88 @@ export function ComposeWindow({
<ComposeRecipientFields {...recipientFieldsProps} />
</div>
</div>
) : (
<>
{/* Title bar */}
<div
className="flex h-10 shrink-0 cursor-pointer items-center rounded-t-lg bg-[#f2f6fc] px-3"
onClick={() => toggleMinimize(compose.id)}
>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-[#3c4043]">
{titleText}
</span>
<div className="flex items-center gap-0.5">
) : isXsSheet ? (
<div
className={cn(
"flex h-11 shrink-0 items-center border-b border-[#dadce0] bg-[#f2f6fc] px-3",
"pt-[max(_0.25rem,env(safe-area-inset-top))]"
)}
>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-[#3c4043]">
{titleText}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
toggleMinimize(compose.id)
}}
className="flex h-6 w-6 items-center justify-center rounded-full text-[#5f6368] hover:text-[#202124] hover:bg-black/5"
title="Réduire"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
className="shrink-0"
aria-hidden
>
<line x1="5" y1="17" x2="19" y2="17" />
</svg>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
toggleMaximize(compose.id)
}}
className="flex h-6 w-6 items-center justify-center rounded-full text-[#5f6368] hover:text-[#202124] hover:bg-black/5"
title={compose.maximized ? "Réduire la fenêtre" : "Plein écran"}
>
{compose.maximized ? (
<Minimize2 className="h-3.5 w-3.5" />
) : (
<Maximize2 className="h-3.5 w-3.5" />
)}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleClose()
}}
className="flex h-6 w-6 items-center justify-center rounded-full text-[#5f6368] hover:text-[#202124] hover:bg-black/5"
onClick={handleClose}
className="flex h-8 w-8 items-center justify-center rounded-full text-[#5f6368] hover:text-[#202124] hover:bg-black/5"
title="Fermer"
>
<X className="h-3.5 w-3.5" />
<X className="h-4 w-4" />
</button>
</div>
</div>
) : (
<>
{/* Title bar */}
<div
className="flex h-10 shrink-0 cursor-pointer items-center rounded-t-lg bg-[#f2f6fc] px-3"
onClick={() => toggleMinimize(compose.id)}
>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-[#3c4043]">
{titleText}
</span>
<div className="flex items-center gap-0.5">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
toggleMinimize(compose.id)
}}
className="flex h-6 w-6 items-center justify-center rounded-full text-[#5f6368] hover:text-[#202124] hover:bg-black/5"
title="Réduire"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
className="shrink-0"
aria-hidden
>
<line x1="5" y1="17" x2="19" y2="17" />
</svg>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
toggleMaximize(compose.id)
}}
className="flex h-6 w-6 items-center justify-center rounded-full text-[#5f6368] hover:text-[#202124] hover:bg-black/5"
title={compose.maximized ? "Réduire la fenêtre" : "Plein écran"}
>
{compose.maximized ? (
<Minimize2 className="h-3.5 w-3.5" />
) : (
<Maximize2 className="h-3.5 w-3.5" />
)}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleClose()
}}
className="flex h-6 w-6 items-center justify-center rounded-full text-[#5f6368] hover:text-[#202124] hover:bg-black/5"
title="Fermer"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
</>
)}
@ -1941,7 +2008,7 @@ export function ComposeWindow({
>
Enregistrer
</button>
<DropdownMenu open={sendMenuOpen} onOpenChange={setSendMenuOpen}>
<DropdownMenu modal={false} open={sendMenuOpen} onOpenChange={setSendMenuOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
@ -1950,7 +2017,7 @@ export function ComposeWindow({
<ChevronDown className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[220px]">
<DropdownMenuContent align="start" className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}>
<DropdownMenuItem
onSelect={() => {
void sendScheduledFromEditNow()
@ -1964,7 +2031,7 @@ export function ComposeWindow({
<Clock className="h-4 w-4 text-[#5f6368]" strokeWidth={1.5} />
Planifier
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[220px]">
<DropdownMenuSubContent className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}>
<DropdownMenuItem
onSelect={() => {
void applyScheduledPlanAt(
@ -1999,7 +2066,7 @@ export function ComposeWindow({
>
Envoyer
</button>
<DropdownMenu open={sendMenuOpen} onOpenChange={setSendMenuOpen}>
<DropdownMenu modal={false} open={sendMenuOpen} onOpenChange={setSendMenuOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
@ -2008,7 +2075,7 @@ export function ComposeWindow({
<ChevronDown className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[220px]">
<DropdownMenuContent align="start" className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}>
<DropdownMenuItem
onSelect={() => {
void submitScheduledSendAt(
@ -2108,7 +2175,7 @@ export function ComposeWindow({
</div>
)
if (compose.minimized && !isInline) {
if (compose.minimized && !isInline && !isXsSheet) {
return (
<div
className="flex h-9 w-[280px] cursor-pointer items-center rounded-t-lg bg-[#f2f6fc] px-3 shadow-lg transition-shadow hover:shadow-xl"
@ -2143,7 +2210,7 @@ export function ComposeWindow({
)
}
if (compose.maximized && !isInline) {
if (compose.maximized && !isInline && !isXsSheet) {
return (
<>
<div
@ -2160,12 +2227,28 @@ export function ComposeWindow({
export function ComposeModalManager() {
const { composeWindows } = useCompose()
const isXs = useIsXs()
const nonMaximized = composeWindows.filter(
(w) => !w.maximized && w.placement !== "inline"
)
const maximized = composeWindows.filter((w) => w.maximized && w.placement !== "inline")
const xsSheetCloseRef = useRef<(() => void) | null>(null)
const bindXsSheetClose = useCallback((fn: (() => void) | null) => {
xsSheetCloseRef.current = fn
}, [])
/** Une seule fenêtre dock visible en xs : la plus récente (comportement type pile). */
const xsActiveDock =
isXs && nonMaximized.length > 0 ? nonMaximized[nonMaximized.length - 1] : null
const handleXsSheetOpenChange = useCallback((open: boolean) => {
if (!open) {
xsSheetCloseRef.current?.()
}
}, [])
const MODAL_WIDTH = 500
const MINIMIZED_WIDTH = 280
const GAP = 12
@ -2188,6 +2271,39 @@ export function ComposeModalManager() {
return result
}, [nonMaximized])
if (isXs) {
return (
<>
<Sheet open={xsActiveDock != null} onOpenChange={handleXsSheetOpenChange}>
<SheetContent
side="bottom"
hideClose
overlayClassName="z-[60]"
className="z-[61] h-[100dvh] max-h-[100dvh] w-full gap-0 rounded-none border-0 p-0 shadow-none duration-300 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:slide-in-from-bottom data-[state=closed]:slide-out-to-bottom overflow-hidden pb-[env(safe-area-inset-bottom)]"
>
<SheetTitle className="sr-only">
{(xsActiveDock?.subject ?? "").trim() || "Nouveau message"}
</SheetTitle>
{xsActiveDock ? (
<ComposeWindow
key={xsActiveDock.id}
compose={xsActiveDock}
isXsSheet
bindXsSheetClose={bindXsSheetClose}
/>
) : null}
</SheetContent>
</Sheet>
{maximized.map((compose) => (
<div key={compose.id} className="pointer-events-auto">
<ComposeWindow compose={compose} />
</div>
))}
</>
)
}
return (
<>
{nonMaximized.map((compose) => {

View File

@ -56,6 +56,7 @@ import {
CalendarClock,
X,
CheckSquare,
Inbox as InboxIcon,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
@ -125,10 +126,11 @@ import { computeFolderUnreadCounts } from "@/lib/mail-nav-metrics"
import {
effectiveLabels,
mergeEmailLabelEdits,
mergeEmailNotSpam,
} from "@/lib/label-edits"
import type { LabelEditState } from "@/lib/stores/mail-store"
import type { MailRouteState } from "@/lib/mail-url"
import { useIsXs } from "@/hooks/use-xs"
import { readXsMatches, useIsXs } from "@/hooks/use-xs"
addCollection(mdiIcons)
@ -574,6 +576,14 @@ function listRowCheckboxClass(circular: boolean) {
)
}
function listRowQuickHoverTrayToneClass(isSelected: boolean, isRead: boolean) {
return isSelected
? "bg-[#e8f0fe]"
: isRead
? "bg-[#f5f5f5]"
: "bg-white"
}
export function EmailList({
selectedFolder,
inboxTab,
@ -603,6 +613,8 @@ export function EmailList({
requestRescheduleScheduled,
requestGetScheduledEditPayload,
requestSendScheduledNow,
requestSnoozeMailboxEmail,
requestRestoreSnoozedToInbox,
} = useScheduledMail()
const allEmails = useMemo(
@ -723,6 +735,7 @@ export function EmailList({
}, [allEmails])
const [labelPickerQuery, setLabelPickerQuery] = useState("")
const hiddenEmailIds = useMailStore((s) => s.hiddenEmailIds)
const notSpamEmailIds = useMailStore((s) => s.notSpamEmailIds)
const recentMoveTargets = useMailStore((s) => s.recentMoveTargets)
const rowContextMenuOpenedAtRef = useRef(0)
const contextMenuTargetIdsRef = useRef<string[]>([])
@ -873,7 +886,9 @@ export function EmailList({
const filteredEmails = useMemo(() => {
const visible = allEmails
.filter((email) => !hiddenEmailIds.includes(email.id))
.map((e) => mergeEmailLabelEdits(e, labelEdits))
.map((e) =>
mergeEmailNotSpam(mergeEmailLabelEdits(e, labelEdits), notSpamEmailIds)
)
let rows = visible.filter((email) =>
emailMatchesFolder(email, selectedFolder, folderFilterCtx, navMaps)
)
@ -887,6 +902,7 @@ export function EmailList({
hiddenEmailIds,
folderFilterCtx,
labelEdits,
notSpamEmailIds,
allEmails,
navMaps,
])
@ -1116,13 +1132,16 @@ export function EmailList({
for (const l of collectTreeLabels(sidebarNav.folderTree)) s.add(l)
for (const row of sidebarNav.labelRows) s.add(row.label)
for (const e of allEmails) {
const eff = mergeEmailLabelEdits(e, labelEdits)
const eff = mergeEmailNotSpam(
mergeEmailLabelEdits(e, labelEdits),
notSpamEmailIds
)
for (const lab of eff.labels ?? []) {
if (!LABEL_PICKER_EXCLUDE.has(lab)) s.add(lab)
}
}
return [...s].sort((a, b) => a.localeCompare(b, "fr"))
}, [sidebarNav.folderTree, sidebarNav.labelRows, allEmails, labelEdits])
}, [sidebarNav.folderTree, sidebarNav.labelRows, allEmails, labelEdits, notSpamEmailIds])
const resolveLabelCasing = useCallback(
(raw: string) => {
@ -1264,9 +1283,18 @@ export function EmailList({
hiddenEmailIds,
readOverrides,
navMaps,
labelEdits
labelEdits,
notSpamEmailIds
),
[folderFilterCtx, hiddenEmailIds, readOverrides, allEmails, navMaps, labelEdits]
[
folderFilterCtx,
hiddenEmailIds,
readOverrides,
allEmails,
navMaps,
labelEdits,
notSpamEmailIds,
]
)
const pageIds = useMemo(() => listEmails.map((e) => e.id), [listEmails])
@ -1338,7 +1366,9 @@ export function EmailList({
const hidden = new Set(hiddenEmailIds)
const visible = allEmails
.filter((email) => !hidden.has(email.id))
.map((e) => mergeEmailLabelEdits(e, labelEdits))
.map((e) =>
mergeEmailNotSpam(mergeEmailLabelEdits(e, labelEdits), notSpamEmailIds)
)
const inboxPool = visible.filter((e) =>
emailMatchesFolder(e, "inbox", folderFilterCtx, navMaps)
)
@ -1361,7 +1391,7 @@ export function EmailList({
preview[tab.id] = chain.join(", ")
}
return { unseenInTabById: counts, tabUnseenSenderLineById: preview }
}, [folderFilterCtx, hiddenEmailIds, labelEdits, seenSerialized, allEmails, navMaps])
}, [folderFilterCtx, hiddenEmailIds, labelEdits, seenSerialized, allEmails, navMaps, notSpamEmailIds])
const effectiveStarred = (email: Email) =>
starredEmails.includes(email.id) || email.starred
@ -1512,8 +1542,8 @@ export function EmailList({
const raw = allEmails.find((e) => e.id === openMailId) ?? null
if (!raw) return null
if (raw.labels?.includes("scheduled")) return null
return mergeEmailLabelEdits(raw, labelEdits)
}, [openMailId, labelEdits, allEmails])
return mergeEmailNotSpam(mergeEmailLabelEdits(raw, labelEdits), notSpamEmailIds)
}, [openMailId, labelEdits, allEmails, notSpamEmailIds])
const openMailIndex = useMemo(
() => (openMailId ? filteredEmails.findIndex((e) => e.id === openMailId) : -1),
[openMailId, filteredEmails]
@ -1544,6 +1574,33 @@ export function EmailList({
const goBack = useCallback(() => navigateToMail(null), [navigateToMail])
const closeViewIfShowingEmail = useCallback(
(emailId: string) => {
if (openMailId === emailId) goBack()
},
[openMailId, goBack]
)
const restoreSnoozedRowToMailbox = useCallback(
(emailRow: Email) => {
void requestRestoreSnoozedToInbox(emailRow)
if (emailRow.id.startsWith("snz-")) {
const baseId = emailRow.id.slice(4)
if (baseId.length > 0) mailActions.unhideEmail(baseId)
onSelectFolder?.("inbox")
} else {
onSelectFolder?.("scheduled")
}
closeViewIfShowingEmail(emailRow.id)
},
[
requestRestoreSnoozedToInbox,
mailActions,
closeViewIfShowingEmail,
onSelectFolder,
]
)
const handleCategoryInboxTabClick = useCallback(
(tabId: string) => {
onMailRouteNavigate({
@ -1656,7 +1713,7 @@ export function EmailList({
const singleNotSpam = useCallback(() => {
if (!openMailId) return
mailActions.hideEmail(openMailId)
mailActions.markNotSpam(openMailId)
onSelectFolder?.("inbox")
goBack()
}, [openMailId, goBack, onSelectFolder, mailActions])
@ -1738,8 +1795,8 @@ export function EmailList({
return (
<div className="flex h-full min-h-0 flex-1 flex-col">
{/* Mobile xs top bar */}
{isXs && !isViewMode && (
<div className="relative z-20 flex shrink-0 items-center gap-2 border-b border-gray-200 bg-white px-4 py-2.5">
{!isViewMode && (
<div className="relative z-20 flex shrink-0 items-center gap-2 border-b border-gray-200 bg-white px-4 py-2.5 sm:hidden">
<div className="min-w-0 flex-1">
<h1 className="truncate text-base font-semibold text-[#1f1f1f] leading-tight">
{mobileFolderLabel}
@ -1891,7 +1948,7 @@ export function EmailList({
className={cn(
"relative z-20 flex shrink-0 min-h-12 gap-2 rounded-t-2xl border-b border-gray-200 bg-white py-1.5 pl-2 pr-4",
isViewMode ? "items-start" : "items-center",
isXs && !isViewMode && "hidden"
!isViewMode && "max-sm:hidden"
)}
>
@ -2429,7 +2486,7 @@ export function EmailList({
<div
className={cn(
"flex shrink-0 items-center gap-2 whitespace-nowrap text-sm text-gray-600",
!isViewMode && isXs && "hidden sm:flex"
!isViewMode && "max-sm:hidden sm:flex"
)}
>
{filteredEmails.length === 0 ? (
@ -2601,11 +2658,11 @@ export function EmailList({
<div
ref={listViewportRef}
className={cn(mainScrollClass, "relative overscroll-y-none", isXs && "pb-16")}
className={cn(mainScrollClass, "relative overscroll-y-none", !isViewMode && "max-sm:pb-16")}
>
{isXs && !isViewMode ? (
{!isViewMode && (
<div
className="pointer-events-none absolute inset-x-0 top-0 z-10 flex items-center justify-center pt-2"
className="pointer-events-none absolute inset-x-0 top-0 z-10 flex items-center justify-center pt-2 sm:hidden"
style={{ height: PULL_HOLD_HEIGHT }}
aria-hidden
>
@ -2618,12 +2675,11 @@ export function EmailList({
style={{ opacity: 0 }}
/>
</div>
) : null}
)}
<div
ref={pullContentRef}
className={cn(
isXs && !isViewMode && "min-h-full [transform:translateZ(0)]",
!isXs && !isViewMode && "flex min-h-full flex-col"
!isViewMode && "flex min-h-full flex-col max-sm:[transform:translateZ(0)]"
)}
>
{isViewMode && openEmail ? (
@ -2739,6 +2795,8 @@ export function EmailList({
})
const isRescheduleOpenThisRow =
rescheduleTarget?.id === email.id
const spamRowHoverNoArchive = selectedFolder === "spam"
const snoozedFolderRow = selectedFolder === "snoozed"
return (
<ContextMenu
@ -2768,7 +2826,7 @@ export function EmailList({
draggable={!isXs}
onDragStart={isXs ? undefined : (e) => startRowDrag(email.id, e)}
onClick={() => {
if (isXs && mobileSelectionMode) {
if (readXsMatches() && mobileSelectionMode) {
toggleSelect(email.id)
lastSelectionAnchorIdRef.current = email.id
return
@ -2776,7 +2834,7 @@ export function EmailList({
handleRowActivate(email)
}}
className={cn(
"group relative z-0 w-full cursor-pointer pl-3 pr-2 py-2 transition-[background-color,box-shadow] duration-150 md:flex md:items-start md:gap-2 md:px-2 md:py-1.5",
"group relative z-0 w-full cursor-pointer pl-3 pr-2 py-2 transition-[background-color,box-shadow] duration-[50ms] ease-out md:flex md:items-start md:gap-2 md:px-2 md:py-1.5",
isSelected
? "bg-[#e8f0fe]"
: isRead
@ -2788,13 +2846,14 @@ export function EmailList({
{/* Compact < md */}
<div
className={cn(
"flex w-full min-w-0 md:hidden",
isXs && mobileSelectionMode ? "items-center gap-2" : "flex-col gap-0.5"
"flex w-full min-w-0 flex-col gap-0.5 md:hidden",
mobileSelectionMode &&
"max-sm:flex-row max-sm:items-center max-sm:gap-2"
)}
>
{isXs && mobileSelectionMode && (
{mobileSelectionMode && (
<div
className="flex shrink-0 self-center"
className="flex shrink-0 self-center sm:hidden"
onClick={(e) => e.stopPropagation()}
onClickCapture={(e) => handleRowCheckboxClickCapture(email.id, e)}
>
@ -2811,18 +2870,17 @@ export function EmailList({
<div
className={cn(
"flex min-w-0 flex-1 flex-col gap-0.5",
isXs && mobileSelectionMode && "pointer-events-none"
mobileSelectionMode && "max-sm:pointer-events-none"
)}
>
<div className="flex w-full min-w-0 items-center gap-2">
{!isXs && (
<div
className="flex shrink-0 items-center"
className="hidden shrink-0 items-center sm:flex"
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"
className={listRowCheckboxClass(false)}
checked={isSelected}
onCheckedChange={() => {
toggleSelect(email.id)
@ -2830,7 +2888,6 @@ export function EmailList({
}}
/>
</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
@ -2941,7 +2998,7 @@ export function EmailList({
</div>
</div>
<div className={cn("flex min-w-0 flex-wrap items-center gap-1", !isXs && "pl-6")}>
<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}
@ -2967,7 +3024,7 @@ export function EmailList({
</span>
</div>
<div className={cn("flex min-w-0 items-start gap-1.5", !isXs && "pl-6")}>
<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>
@ -3077,7 +3134,7 @@ export function EmailList({
<div
className={cn(
"w-48 shrink-0 truncate pl-2",
"w-44 shrink-0 truncate pl-2 lg:w-40",
attachmentList.length === 0 ? "pt-px" : "pt-0"
)}
>
@ -3147,8 +3204,7 @@ export function EmailList({
<div
className={cn(
"flex shrink-0 flex-col items-end gap-1 self-start whitespace-nowrap pr-2 text-right",
isScheduled ? "md:min-w-[200px] lg:min-w-[280px]" : "",
"flex shrink-0 flex-col items-end gap-1 self-start pr-2 text-right md:max-w-[150px] md:min-w-0",
attachmentList.length === 0 ? "pt-1" : "pt-0.5"
)}
>
@ -3156,8 +3212,8 @@ export function EmailList({
<div className="relative flex w-full min-w-0 shrink-0 items-center justify-end">
<span
className={cn(
"block text-sm font-semibold tabular-nums text-[#c65308]",
"transition-opacity duration-150",
"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"
@ -3167,17 +3223,14 @@ export function EmailList({
</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-150",
isSelected
? "bg-[#e8f0fe]"
: isRead
? "bg-[#f5f5f5]"
: "bg-white",
"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
@ -3198,6 +3251,7 @@ export function EmailList({
Archiver
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
@ -3441,32 +3495,200 @@ export function EmailList({
</div>
</div>
) : (
<div className="flex items-center gap-1.5">
{(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"
}
/>
)}
<span
className={cn(
"whitespace-nowrap text-sm tabular-nums",
!isRead ? "font-semibold text-gray-900" : "text-gray-600"
)}
>
{email.date}
</span>
<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"
}
/>
)}
<span
className={cn(
"min-w-0 truncate text-sm tabular-nums",
!isRead ? "font-semibold text-gray-900" : "text-gray-600"
)}
>
{email.date}
</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>
@ -3817,8 +4039,8 @@ export function EmailList({
</>
</TooltipProvider>
)}
{!isXs && !isViewMode ? (
<div className="sticky bottom-0 left-0 z-20 mt-auto flex w-fit max-w-full shrink-0 pt-2">
{!isViewMode ? (
<div className="sticky bottom-0 left-0 z-20 mt-auto hidden w-fit max-w-full shrink-0 pt-2 sm:flex">
<MailFolderStackIndicator
currentKey={mailNavVisitKey(selectedFolder, inboxTab)}
folderTree={sidebarNav.folderTree}

View File

@ -106,10 +106,10 @@ export function Header({
</div>
)}
<Button variant="ghost" size="icon" className="hidden text-gray-600 sm:inline-flex" aria-label="Aide">
<Icon icon="mdi:help-circle" className="size-6 shrink-0" aria-hidden />
<Icon icon="mdi:help-circle-outline" className="size-6 shrink-0" aria-hidden />
</Button>
<Button variant="ghost" size="icon" className="text-gray-600" aria-label="Réglages">
<Icon icon="mdi:cog" className="size-6 shrink-0" aria-hidden />
<Icon icon="mdi:cog-outline" className="size-6 shrink-0" aria-hidden />
</Button>
{/* Google Apps Menu */}
@ -121,7 +121,7 @@ export function Header({
aria-label="Applications"
onClick={() => setAppsMenuOpen(!appsMenuOpen)}
>
<Icon icon="mdi:apps" className="size-6 shrink-0" aria-hidden />
<Icon icon="mdi:view-grid-outline" className="size-6 shrink-0" aria-hidden />
</Button>
{appsMenuOpen && (

View File

@ -31,6 +31,7 @@ import {
Check,
} from "lucide-react"
import { cn, formatCount } from "@/lib/utils"
import { readXsMatches } from "@/hooks/use-xs"
import { useState, useRef, useEffect, useMemo, type ReactNode, type CSSProperties } from "react"
import { useEmailDropTarget } from "@/lib/drag-context"
import { useCompose } from "@/lib/compose-context"
@ -107,8 +108,6 @@ interface SidebarProps {
selectedFolder: string
onSelectFolder: (folder: string) => void
collapsed: boolean
/** Viewport below `sm` — drawer overlay, no rail. */
isXs?: boolean
/** Nombre de messages non lus par id de ligne (boîte, catégorie, dossier, libellé). */
folderUnreadCounts?: Record<string, number>
}
@ -503,7 +502,6 @@ export function Sidebar({
selectedFolder,
onSelectFolder,
collapsed,
isXs = false,
folderUnreadCounts = {},
}: SidebarProps) {
const { openCompose } = useCompose()
@ -514,8 +512,7 @@ export function Sidebar({
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const sidebarRef = useRef<HTMLElement>(null)
const isOverlayOpen = isXs && !collapsed
const isExpanded = isOverlayOpen || !collapsed || hoverExpanded
const isExpanded = !collapsed || hoverExpanded
const {
folderTree,
@ -666,7 +663,7 @@ export function Sidebar({
}, [selectedFolder])
const handleMouseEnter = () => {
if (isXs) return
if (readXsMatches()) return
if (collapsed) {
hoverTimeoutRef.current = setTimeout(() => {
setHoverExpanded(true)
@ -675,11 +672,11 @@ export function Sidebar({
}
const handleMouseLeave = () => {
if (isXs) return
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current)
hoverTimeoutRef.current = null
}
if (readXsMatches()) return
setHoverExpanded(false)
}
@ -1731,48 +1728,44 @@ export function Sidebar({
className={cn(
"absolute left-0 top-0 bottom-0 flex flex-col overflow-hidden bg-app-canvas transition-[width,transform] duration-200 z-40",
isExpanded ? "w-60" : "w-[68px]",
(hoverExpanded || isOverlayOpen) && "shadow-xl border-r border-gray-200",
isOverlayOpen && "z-50",
isXs && collapsed && "-translate-x-full pointer-events-none"
hoverExpanded && "shadow-xl border-r border-gray-200",
!collapsed && "max-sm:z-50 max-sm:shadow-xl max-sm:border-r max-sm:border-gray-200",
collapsed && "max-sm:-translate-x-full max-sm:pointer-events-none"
)}
>
{isXs && (
<div className="flex shrink-0 items-center justify-between px-4 pt-4 pb-4">
<UltiMailLogo className="min-h-8" />
<Button variant="ghost" size="icon" className="size-9 shrink-0 text-gray-600" aria-label="Réglages">
<Icon icon="mdi:cog" className="size-5 shrink-0" aria-hidden />
</Button>
</div>
)}
<div className="flex shrink-0 items-center justify-between px-4 pt-4 pb-4 sm:hidden">
<UltiMailLogo className="min-h-8" />
<Button variant="ghost" size="icon" className="size-9 shrink-0 text-gray-600" aria-label="Réglages">
<Icon icon="mdi:cog" className="size-5 shrink-0" aria-hidden />
</Button>
</div>
{!isXs && (
<div
<div
className={cn(
"hidden shrink-0 bg-app-canvas z-10 pt-1 pb-3 pl-2 sm:flex",
isExpanded ? "pr-3.5" : "pr-2"
)}
>
<button
type="button"
title={!isExpanded ? "Nouveau message" : undefined}
aria-label={!isExpanded ? "Nouveau message" : undefined}
onClick={openCompose}
className={cn(
"flex shrink-0 bg-app-canvas z-10 pt-1 pb-3 pl-2",
isExpanded ? "pr-3.5" : "pr-2"
"inline-flex h-[52px] min-w-0 shrink-0 cursor-pointer items-center rounded-2xl border border-gray-200 bg-white text-sm font-medium text-gray-700 shadow-sm outline-none transition-[box-shadow,background-color,border-color,color] duration-200 hover:bg-gray-50 hover:text-gray-900 hover:shadow-md focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 [&_svg]:pointer-events-none [&_svg]:size-5 [&_svg]:shrink-0",
isExpanded
? "w-auto max-w-full justify-start gap-3 self-start pl-4 pr-8"
: "w-[52px] justify-center px-0 py-0"
)}
>
<button
type="button"
title={!isExpanded ? "Nouveau message" : undefined}
aria-label={!isExpanded ? "Nouveau message" : undefined}
onClick={openCompose}
className={cn(
"inline-flex h-[52px] min-w-0 shrink-0 cursor-pointer items-center rounded-2xl border border-gray-200 bg-white text-sm font-medium text-gray-700 shadow-sm outline-none transition-[box-shadow,background-color,border-color,color] duration-200 hover:bg-gray-50 hover:text-gray-900 hover:shadow-md focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 [&_svg]:pointer-events-none [&_svg]:size-5 [&_svg]:shrink-0",
isExpanded
? "w-auto max-w-full justify-start gap-3 self-start pl-4 pr-8"
: "w-[52px] justify-center px-0 py-0"
)}
>
<Pencil className="size-5 shrink-0" />
{isExpanded && (
<span className="min-w-0 truncate text-sm font-medium">
Nouveau message
</span>
)}
</button>
</div>
)}
<Pencil className="size-5 shrink-0" />
{isExpanded && (
<span className="min-w-0 truncate text-sm font-medium">
Nouveau message
</span>
)}
</button>
</div>
<div
className={cn(
@ -1973,7 +1966,7 @@ export function Sidebar({
{/* Sortbot */}
<div className={cn(
"z-30 mt-auto bg-app-canvas pt-2",
isXs ? "pb-16" : "sticky bottom-0 border-t border-gray-200 pb-3"
"max-sm:pb-16 sm:sticky sm:bottom-0 sm:border-t sm:border-gray-200 sm:pb-3"
)}>
<button
type="button"

View File

@ -48,13 +48,17 @@ function SheetContent({
className,
children,
side = 'right',
overlayClassName,
hideClose = false,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: 'top' | 'right' | 'bottom' | 'left'
overlayClassName?: string
hideClose?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetOverlay className={overlayClassName} />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
@ -72,10 +76,12 @@ function SheetContent({
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{!hideClose ? (
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
) : null}
</SheetPrimitive.Content>
</SheetPortal>
)

View File

@ -1,13 +1,21 @@
import { useEffect, useState } from "react"
import { useLayoutEffect, useState } from "react"
/** Tailwind `sm` breakpoint — viewports below are treated as xs. */
const XS_MAX_PX = 639
export const XS_MAX_PX = 639
const XS_MQ = `(max-width: ${XS_MAX_PX}px)`
/** Sync read for event handlers / layout (no hook delay). */
export function readXsMatches(): boolean {
if (typeof window === "undefined") return false
return window.matchMedia(XS_MQ).matches
}
export function useIsXs() {
const [isXs, setIsXs] = useState(false)
useEffect(() => {
const mql = window.matchMedia(`(max-width: ${XS_MAX_PX}px)`)
useLayoutEffect(() => {
const mql = window.matchMedia(XS_MQ)
const update = () => setIsXs(mql.matches)
update()
mql.addEventListener("change", update)

View File

@ -31,6 +31,20 @@ export function mergeEmailLabelEdits(
return { ...email, labels }
}
/** Marquage local « non-spam » : retire le flag spam, enlève le libellé spam et assure la boîte de réception. */
export function mergeEmailNotSpam(
email: Email,
notSpamEmailIds: readonly string[]
): Email {
const set = new Set(notSpamEmailIds)
if (!set.has(email.id)) return email
const ls = [...(email.labels ?? [])]
const noSpam = ls.filter((l) => l.toLowerCase() !== "spam")
const hasInbox = noSpam.some((l) => l.toLowerCase() === "inbox")
const labels = hasInbox ? noSpam : [...noSpam, "inbox"]
return { ...email, spam: false, labels }
}
export function applyLabelEditsToEmails(
emails: Email[],
edits: LabelEditState

View File

@ -1,5 +1,5 @@
import type { Email } from "@/lib/email-data"
import { applyLabelEditsToEmails } from "@/lib/label-edits"
import { applyLabelEditsToEmails, mergeEmailNotSpam } from "@/lib/label-edits"
import {
emailMatchesFolder,
type MailFolderFilterCtx,
@ -103,14 +103,16 @@ export function computeFolderUnreadCounts(
hiddenEmailIds: string[],
readOverrides: Record<string, boolean>,
maps?: MailNavFolderMaps | null,
labelEdits?: LabelEditState
labelEdits?: LabelEditState,
notSpamEmailIds?: readonly string[]
): Record<string, number> {
const pool =
let pool =
labelEdits &&
(Object.keys(labelEdits.additions).length > 0 ||
Object.keys(labelEdits.removals).length > 0)
? applyLabelEditsToEmails(allEmails, labelEdits)
: allEmails
pool = pool.map((e) => mergeEmailNotSpam(e, notSpamEmailIds ?? []))
const hidden = new Set(hiddenEmailIds)
const out: Record<string, number> = {}
for (const id of allSidebarNavFolderIds(maps)) {

View File

@ -29,6 +29,8 @@ type ScheduledMailContextValue = {
requestGetScheduledEditPayload: (id: string) => Promise<ScheduleSendPayload | null>
requestUpdateScheduledSend: (id: string, payload: ScheduleSendPayload) => Promise<void>
requestSendScheduledNow: (id: string) => Promise<void>
requestSnoozeMailboxEmail: (row: Email) => Promise<void>
requestRestoreSnoozedToInbox: (row: Email) => Promise<void>
}
const ScheduledMailContext = createContext<ScheduledMailContextValue | null>(null)
@ -57,6 +59,12 @@ export function ScheduledMailProvider({ children }: { children: ReactNode }) {
requestGetScheduledEditPayload: async (id) => actions.getScheduledEditPayload(id),
requestUpdateScheduledSend: async (id, payload) => { actions.updateScheduledSend(id, payload) },
requestSendScheduledNow: async (id) => { actions.sendScheduledNow(id) },
requestSnoozeMailboxEmail: async (row) => {
actions.snoozeMailboxEmail(row)
},
requestRestoreSnoozedToInbox: async (row) => {
actions.restoreSnoozedToInbox(row)
},
}
}, [scheduledEmails, snoozedEmails, sentPlaceholderEmails])

View File

@ -21,6 +21,8 @@ type MailStoreState = {
labelEdits: LabelEditState
hiddenEmailIds: string[]
seenEmailIds: string[]
/** Ids marqués comme non-spam (réintégration boîte de réception dans lUI). */
notSpamEmailIds: string[]
recentMoveTargets: string[]
/** Dernières boîtes visitées (clés `mailNavVisitKey`), la plus récente en tête. */
recentFolderVisits: string[]
@ -40,6 +42,8 @@ type MailStoreActions = {
hideEmails: (ids: string[]) => void
unhideEmail: (id: string) => void
markSeen: (id: string) => void
/** Réintègre le message comme non-spam (liste / boîte de réception). */
markNotSpam: (id: string) => void
resetHidden: () => void
pushRecentMoveTarget: (targetId: string) => void
pushRecentFolderVisit: (visitKey: string) => void
@ -54,6 +58,7 @@ export const useMailStore = create<MailStoreState & MailStoreActions>()(
labelEdits: { additions: {}, removals: {} },
hiddenEmailIds: [],
seenEmailIds: [],
notSpamEmailIds: [],
recentMoveTargets: [],
recentFolderVisits: [],
@ -149,6 +154,13 @@ export const useMailStore = create<MailStoreState & MailStoreActions>()(
hiddenEmailIds: s.hiddenEmailIds.filter((x) => x !== id),
})),
markNotSpam: (id) =>
set((s) =>
s.notSpamEmailIds.includes(id)
? s
: { notSpamEmailIds: [...s.notSpamEmailIds, id] }
),
markSeen: (id) =>
set((s) => ({
seenEmailIds: s.seenEmailIds.includes(id)
@ -174,11 +186,14 @@ export const useMailStore = create<MailStoreState & MailStoreActions>()(
}),
{
name: "ultimail-mail-state",
version: 2,
version: 3,
migrate: (persisted, version) => {
const state = persisted as MailStoreState
const state = persisted as MailStoreState & { notSpamEmailIds?: string[] }
if (version < 2) {
return { ...state, recentFolderVisits: [] }
return { ...state, recentFolderVisits: [], notSpamEmailIds: [] }
}
if (version < 3) {
return { ...state, notSpamEmailIds: state.notSpamEmailIds ?? [] }
}
return state
},

View File

@ -41,6 +41,10 @@ type ScheduledStoreActions = {
updateScheduledSend: (id: string, payload: ScheduleSendPayload) => void
sendScheduledNow: (id: string) => void
removeScheduledLocal: (id: string) => void
/** Mettre en attente depuis la boîte (clone id `snz-…` dans En attente ; lappelant masque lid source). */
snoozeMailboxEmail: (row: Email) => void
/** Quitter « En attente » : réaffiche dans la Boîte (snz-) ou parmi Planifiés (ex-envoi différé snoozé). */
restoreSnoozedToInbox: (row: Email) => void
}
export const useScheduledStore = create<ScheduledStoreState & ScheduledStoreActions>()(
@ -182,6 +186,59 @@ export const useScheduledStore = create<ScheduledStoreState & ScheduledStoreActi
set((s) => ({
scheduledEmails: s.scheduledEmails.filter((e) => e.id !== id),
})),
snoozeMailboxEmail: (row) =>
set((s) => {
const persistedId = row.id.startsWith("snz-") ? row.id : `snz-${row.id}`
const wake = new Date(Date.now() + 24 * 60 * 60 * 1000)
const wakeIso = wake.toISOString()
const copy: Email = {
...row,
id: persistedId,
labels: ["snoozed"],
snoozeWakeAt: wakeIso,
scheduledSendAt: undefined,
scheduledToName: undefined,
date: wake.toLocaleString("fr-FR", {
dateStyle: "medium",
timeStyle: "short",
}),
read: true,
}
return {
snoozedEmails: [
copy,
...s.snoozedEmails.filter((e) => e.id !== persistedId),
],
}
}),
restoreSnoozedToInbox: (row) =>
set((s) => {
const nextSnoozed = s.snoozedEmails.filter((e) => e.id !== row.id)
if (row.id.startsWith("snz-")) {
return { snoozedEmails: nextSnoozed }
}
const resumeAt =
row.snoozeWakeAt ??
new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
const back: Email = {
...row,
labels: ["scheduled"],
scheduledSendAt: resumeAt,
scheduledToName: row.sender,
snoozeWakeAt: undefined,
date: "",
read: true,
}
return {
snoozedEmails: nextSnoozed,
scheduledEmails: [
back,
...s.scheduledEmails.filter((e) => e.id !== row.id),
],
}
}),
}),
{
name: "ultimail-scheduled-state",

File diff suppressed because one or more lines are too long