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, Suspense,
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect,
useMemo, useMemo,
useState, useState,
type CSSProperties, type CSSProperties,
} from "react" } from "react"
import dynamic from "next/dynamic" import { readXsMatches, useIsXs } from "@/hooks/use-xs"
import { useIsXs } from "@/hooks/use-xs" import { MobileBottomBar } from "@/components/gmail/mobile-bottom-bar"
import { Toaster } from "sonner" import { Toaster } from "sonner"
import { useRouter, usePathname } from "next/navigation" import { useRouter, usePathname } from "next/navigation"
import { Sidebar } from "@/components/gmail/sidebar" 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 { SidebarNavProvider } from "@/lib/sidebar-nav-context"
import { mailNavVisitKey } from "@/lib/mail-folder-display" import { mailNavVisitKey } from "@/lib/mail-folder-display"
import { useMailStore } from "@/lib/stores/mail-store" import { useMailStore } from "@/lib/stores/mail-store"
const MobileBottomBar = dynamic(
() =>
import("@/components/gmail/mobile-bottom-bar").then(
(m) => m.MobileBottomBar
),
{ ssr: false }
)
import { import {
parseMailSegments, parseMailSegments,
buildMailPath, buildMailPath,
@ -54,7 +47,12 @@ function MailAppInner() {
const isXs = useIsXs() const isXs = useIsXs()
const pushRecentFolderVisit = useMailStore((s) => s.pushRecentFolderVisit) 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(() => { useEffect(() => {
if (isXs) setSidebarCollapsed(true) if (isXs) setSidebarCollapsed(true)
@ -91,9 +89,9 @@ function MailAppInner() {
page: 1, page: 1,
mailId: null, mailId: null,
}) })
if (isXs) setSidebarCollapsed(true) if (readXsMatches()) setSidebarCollapsed(true)
}, },
[navigateRoute, isXs] [navigateRoute]
) )
return ( return (
@ -109,36 +107,33 @@ function MailAppInner() {
} }
> >
<div className="flex h-screen flex-col bg-app-canvas"> <div className="flex h-screen flex-col bg-app-canvas">
{!isXs && ( <div className="hidden sm:block">
<Header <Header
isXs={false} isXs={false}
sidebarCollapsed={sidebarCollapsed} sidebarCollapsed={sidebarCollapsed}
onToggleSidebar={() => setSidebarCollapsed(!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"> <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 <button
type="button" type="button"
aria-label="Fermer le menu" 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)} onClick={() => setSidebarCollapsed(true)}
/> />
)} )}
<div <div
className={ className={
isXs sidebarCollapsed
? "w-0 shrink-0" ? "w-0 shrink-0 sm:w-[68px]"
: sidebarCollapsed : "w-0 shrink-0 sm:w-60"
? "w-[68px] shrink-0"
: "w-60 shrink-0"
} }
/> />
<Sidebar <Sidebar
selectedFolder={route.folderId} selectedFolder={route.folderId}
onSelectFolder={handleSelectFolder} onSelectFolder={handleSelectFolder}
collapsed={sidebarCollapsed} collapsed={sidebarCollapsed}
isXs={isXs}
folderUnreadCounts={folderUnreadCounts} folderUnreadCounts={folderUnreadCounts}
/> />
<main className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-none bg-white shadow-sm sm:rounded-2xl"> <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> </main>
<RightPanel /> <RightPanel />
</div> </div>
{isXs && (
<MobileBottomBar <MobileBottomBar
sidebarOpen={!sidebarCollapsed} sidebarOpen={!sidebarCollapsed}
onToggleSidebar={() => setSidebarCollapsed((c) => !c)} onToggleSidebar={() => setSidebarCollapsed((c) => !c)}
/> />
)}
</div> </div>
</SidebarNavProvider> </SidebarNavProvider>
) )
@ -179,7 +172,7 @@ export function MailAppShell({
<Suspense <Suspense
fallback={ fallback={
<div className="flex h-screen flex-col bg-app-canvas"> <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 className="min-h-0 flex-1 bg-app-canvas" />
</div> </div>
} }

View File

@ -10,6 +10,8 @@ import {
lazy, lazy,
Suspense, Suspense,
} from "react" } from "react"
import { useIsXs } from "@/hooks/use-xs"
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
import { useEditor, EditorContent } from "@tiptap/react" import { useEditor, EditorContent } from "@tiptap/react"
import { Editor, Node as TipTapNode, mergeAttributes, type Extensions } from "@tiptap/core" import { Editor, Node as TipTapNode, mergeAttributes, type Extensions } from "@tiptap/core"
import StarterKit from "@tiptap/starter-kit" 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>` 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@]+$/ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
function RecipientField({ function RecipientField({
@ -408,7 +413,10 @@ function AlignmentDropdown({
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[160px]"> <DropdownMenuContent
align="start"
className={cn("min-w-[160px]", COMPOSE_PORTAL_Z)}
>
<DropdownMenuItem <DropdownMenuItem
onSelect={() => editor.chain().focus().setTextAlign("left").run()} onSelect={() => editor.chain().focus().setTextAlign("left").run()}
className={cn(editor.isActive({ textAlign: "left" }) && "bg-[#e8eaed]")} className={cn(editor.isActive({ textAlign: "left" }) && "bg-[#e8eaed]")}
@ -483,7 +491,10 @@ function FontDropdown({
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
</button> </button>
</DropdownMenuTrigger> </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) => ( {FONT_FAMILIES.map((f) => (
<DropdownMenuItem <DropdownMenuItem
key={f.value} key={f.value}
@ -516,7 +527,10 @@ function FontSizeDropdown({
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[140px]"> <DropdownMenuContent
align="start"
className={cn("min-w-[140px]", COMPOSE_PORTAL_Z)}
>
{FONT_SIZES.map((s) => ( {FONT_SIZES.map((s) => (
<DropdownMenuItem <DropdownMenuItem
key={s.label} key={s.label}
@ -557,7 +571,11 @@ function ColorDropdown({
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
</button> </button>
</DropdownMenuTrigger> </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"> <div className="mb-2 flex gap-1 border-b border-[#eef0f2] pb-2">
<button <button
type="button" type="button"
@ -772,7 +790,7 @@ function EmojiButton({
<PopoverContent <PopoverContent
align="start" align="start"
side="top" 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()} onOpenAutoFocus={(e) => e.preventDefault()}
> >
<EmojiPicker onSelect={handleSelect} /> <EmojiPicker onSelect={handleSelect} />
@ -892,7 +910,7 @@ function LinkButton({
<PopoverContent <PopoverContent
align="start" align="start"
side="top" side="top"
className="w-[340px] p-3" className={cn("w-[340px] p-3", COMPOSE_PORTAL_Z)}
onOpenAutoFocus={(e) => e.preventDefault()} onOpenAutoFocus={(e) => e.preventDefault()}
> >
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
@ -995,7 +1013,7 @@ function SignatureButton({
if (!editor) return null if (!editor) return null
return ( return (
<DropdownMenu> <DropdownMenu modal={false}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
type="button" type="button"
@ -1005,7 +1023,11 @@ function SignatureButton({
<PenTool className="h-[18px] w-[18px]" /> <PenTool className="h-[18px] w-[18px]" />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" side="top" className="min-w-[220px]"> <DropdownMenuContent
align="start"
side="top"
className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}
>
<DropdownMenuItem <DropdownMenuItem
onSelect={(e) => { onSelect={(e) => {
e.preventDefault() e.preventDefault()
@ -1076,7 +1098,7 @@ function ComposeRecipientFields({
{showFromField && ( {showFromField && (
<div className="flex min-w-0 items-center gap-2 overflow-hidden px-3 py-1.5"> <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> <span className="shrink-0 text-sm text-[#5f6368]">De</span>
<DropdownMenu> <DropdownMenu modal={false}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
type="button" type="button"
@ -1089,7 +1111,7 @@ function ComposeRecipientFields({
<ChevronDown className="h-3 w-3 shrink-0 text-[#5f6368]" /> <ChevronDown className="h-3 w-3 shrink-0 text-[#5f6368]" />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[300px]"> <DropdownMenuContent align="start" className={cn("min-w-[300px]", COMPOSE_PORTAL_Z)}>
{DEFAULT_IDENTITIES.map((id) => ( {DEFAULT_IDENTITIES.map((id) => (
<DropdownMenuItem <DropdownMenuItem
key={id.email} key={id.email}
@ -1194,10 +1216,15 @@ function ComposeRecipientFields({
export function ComposeWindow({ export function ComposeWindow({
compose, compose,
threadSourceEmail = null, threadSourceEmail = null,
isXsSheet = false,
bindXsSheetClose,
}: { }: {
compose: ComposeState compose: ComposeState
/** Fil courant : nécessaire pour le menu Répondre / Transférer en inline */ /** Fil courant : nécessaire pour le menu Répondre / Transférer en inline */
threadSourceEmail?: Email | null threadSourceEmail?: Email | null
/** Plein écran dans une bottom sheet (xs) — pas de file ni réduction */
isXsSheet?: boolean
bindXsSheetClose?: (fn: (() => void) | null) => void
}) { }) {
const { const {
closeCompose, closeCompose,
@ -1262,7 +1289,11 @@ export function ComposeWindow({
attributes: { attributes: {
class: cn( class: cn(
"prose prose-sm max-w-none px-3 py-2 text-sm text-[#202124] outline-none focus:outline-none", "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) => { const htmlToPreviewText = useCallback((html: string) => {
return html return html
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, " ") .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, " ")
@ -1500,7 +1542,7 @@ export function ComposeWindow({
} }
}, []) }, [])
const showFromField = recipientsFocused const showFromField = recipientsFocused || isXsSheet
useLayoutEffect(() => { useLayoutEffect(() => {
if (!isInline || !compose.focusToOnMount) return if (!isInline || !compose.focusToOnMount) return
@ -1509,10 +1551,14 @@ export function ComposeWindow({
useEffect(() => { useEffect(() => {
if (!recipientsFocused) return if (!recipientsFocused) return
const handleClickOutside = (e: MouseEvent) => { const handleClickOutside = (e: Event) => {
const target = e.target as Node
const root = isInline ? inlineRecipientShellRef.current : fieldsRef.current const root = isInline ? inlineRecipientShellRef.current : fieldsRef.current
if (root && !root.contains(e.target as Node)) { if (root && !root.contains(target)) {
const portal = (e.target as HTMLElement)?.closest?.("[data-radix-popper-content-wrapper]") 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 if (portal) return
setRecipientsFocused(false) setRecipientsFocused(false)
if (compose.showCc && compose.cc.length === 0) { if (compose.showCc && compose.cc.length === 0) {
@ -1523,8 +1569,8 @@ export function ComposeWindow({
} }
} }
} }
document.addEventListener("mousedown", handleClickOutside) document.addEventListener("pointerdown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside) return () => document.removeEventListener("pointerdown", handleClickOutside)
}, [ }, [
recipientsFocused, recipientsFocused,
isInline, isInline,
@ -1645,6 +1691,8 @@ export function ComposeWindow({
"relative flex flex-col overflow-hidden bg-white", "relative flex flex-col overflow-hidden bg-white",
isInline 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)]" ? "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)]"
: isXsSheet
? "h-full min-h-0 w-full max-w-none flex-1 rounded-none shadow-none"
: cn( : 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)]", "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 compose.maximized
@ -1702,7 +1750,7 @@ export function ComposeWindow({
: undefined : undefined
} }
> >
<DropdownMenu> <DropdownMenu modal={false}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
type="button" type="button"
@ -1715,7 +1763,7 @@ export function ComposeWindow({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align="start" align="start"
className="min-w-[260px]" className={cn("min-w-[260px]", COMPOSE_PORTAL_Z)}
onCloseAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()}
> >
<DropdownMenuItem <DropdownMenuItem
@ -1809,6 +1857,25 @@ export function ComposeWindow({
<ComposeRecipientFields {...recipientFieldsProps} /> <ComposeRecipientFields {...recipientFieldsProps} />
</div> </div>
</div> </div>
) : 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={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-4 w-4" />
</button>
</div>
) : ( ) : (
<> <>
{/* Title bar */} {/* Title bar */}
@ -1941,7 +2008,7 @@ export function ComposeWindow({
> >
Enregistrer Enregistrer
</button> </button>
<DropdownMenu open={sendMenuOpen} onOpenChange={setSendMenuOpen}> <DropdownMenu modal={false} open={sendMenuOpen} onOpenChange={setSendMenuOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
type="button" type="button"
@ -1950,7 +2017,7 @@ export function ComposeWindow({
<ChevronDown className="h-4 w-4" /> <ChevronDown className="h-4 w-4" />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[220px]"> <DropdownMenuContent align="start" className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}>
<DropdownMenuItem <DropdownMenuItem
onSelect={() => { onSelect={() => {
void sendScheduledFromEditNow() void sendScheduledFromEditNow()
@ -1964,7 +2031,7 @@ export function ComposeWindow({
<Clock className="h-4 w-4 text-[#5f6368]" strokeWidth={1.5} /> <Clock className="h-4 w-4 text-[#5f6368]" strokeWidth={1.5} />
Planifier Planifier
</DropdownMenuSubTrigger> </DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[220px]"> <DropdownMenuSubContent className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}>
<DropdownMenuItem <DropdownMenuItem
onSelect={() => { onSelect={() => {
void applyScheduledPlanAt( void applyScheduledPlanAt(
@ -1999,7 +2066,7 @@ export function ComposeWindow({
> >
Envoyer Envoyer
</button> </button>
<DropdownMenu open={sendMenuOpen} onOpenChange={setSendMenuOpen}> <DropdownMenu modal={false} open={sendMenuOpen} onOpenChange={setSendMenuOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
type="button" type="button"
@ -2008,7 +2075,7 @@ export function ComposeWindow({
<ChevronDown className="h-4 w-4" /> <ChevronDown className="h-4 w-4" />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[220px]"> <DropdownMenuContent align="start" className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}>
<DropdownMenuItem <DropdownMenuItem
onSelect={() => { onSelect={() => {
void submitScheduledSendAt( void submitScheduledSendAt(
@ -2108,7 +2175,7 @@ export function ComposeWindow({
</div> </div>
) )
if (compose.minimized && !isInline) { if (compose.minimized && !isInline && !isXsSheet) {
return ( return (
<div <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" 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 ( return (
<> <>
<div <div
@ -2160,12 +2227,28 @@ export function ComposeWindow({
export function ComposeModalManager() { export function ComposeModalManager() {
const { composeWindows } = useCompose() const { composeWindows } = useCompose()
const isXs = useIsXs()
const nonMaximized = composeWindows.filter( const nonMaximized = composeWindows.filter(
(w) => !w.maximized && w.placement !== "inline" (w) => !w.maximized && w.placement !== "inline"
) )
const maximized = 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 MODAL_WIDTH = 500
const MINIMIZED_WIDTH = 280 const MINIMIZED_WIDTH = 280
const GAP = 12 const GAP = 12
@ -2188,6 +2271,39 @@ export function ComposeModalManager() {
return result return result
}, [nonMaximized]) }, [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 ( return (
<> <>
{nonMaximized.map((compose) => { {nonMaximized.map((compose) => {

View File

@ -56,6 +56,7 @@ import {
CalendarClock, CalendarClock,
X, X,
CheckSquare, CheckSquare,
Inbox as InboxIcon,
} from "lucide-react" } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
@ -125,10 +126,11 @@ import { computeFolderUnreadCounts } from "@/lib/mail-nav-metrics"
import { import {
effectiveLabels, effectiveLabels,
mergeEmailLabelEdits, mergeEmailLabelEdits,
mergeEmailNotSpam,
} from "@/lib/label-edits" } from "@/lib/label-edits"
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 { useIsXs } from "@/hooks/use-xs" import { readXsMatches, useIsXs } from "@/hooks/use-xs"
addCollection(mdiIcons) 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({ export function EmailList({
selectedFolder, selectedFolder,
inboxTab, inboxTab,
@ -603,6 +613,8 @@ export function EmailList({
requestRescheduleScheduled, requestRescheduleScheduled,
requestGetScheduledEditPayload, requestGetScheduledEditPayload,
requestSendScheduledNow, requestSendScheduledNow,
requestSnoozeMailboxEmail,
requestRestoreSnoozedToInbox,
} = useScheduledMail() } = useScheduledMail()
const allEmails = useMemo( const allEmails = useMemo(
@ -723,6 +735,7 @@ export function EmailList({
}, [allEmails]) }, [allEmails])
const [labelPickerQuery, setLabelPickerQuery] = useState("") const [labelPickerQuery, setLabelPickerQuery] = useState("")
const hiddenEmailIds = useMailStore((s) => s.hiddenEmailIds) const hiddenEmailIds = useMailStore((s) => s.hiddenEmailIds)
const notSpamEmailIds = useMailStore((s) => s.notSpamEmailIds)
const recentMoveTargets = useMailStore((s) => s.recentMoveTargets) const recentMoveTargets = useMailStore((s) => s.recentMoveTargets)
const rowContextMenuOpenedAtRef = useRef(0) const rowContextMenuOpenedAtRef = useRef(0)
const contextMenuTargetIdsRef = useRef<string[]>([]) const contextMenuTargetIdsRef = useRef<string[]>([])
@ -873,7 +886,9 @@ export function EmailList({
const filteredEmails = useMemo(() => { const filteredEmails = useMemo(() => {
const visible = allEmails const visible = allEmails
.filter((email) => !hiddenEmailIds.includes(email.id)) .filter((email) => !hiddenEmailIds.includes(email.id))
.map((e) => mergeEmailLabelEdits(e, labelEdits)) .map((e) =>
mergeEmailNotSpam(mergeEmailLabelEdits(e, labelEdits), notSpamEmailIds)
)
let rows = visible.filter((email) => let rows = visible.filter((email) =>
emailMatchesFolder(email, selectedFolder, folderFilterCtx, navMaps) emailMatchesFolder(email, selectedFolder, folderFilterCtx, navMaps)
) )
@ -887,6 +902,7 @@ export function EmailList({
hiddenEmailIds, hiddenEmailIds,
folderFilterCtx, folderFilterCtx,
labelEdits, labelEdits,
notSpamEmailIds,
allEmails, allEmails,
navMaps, navMaps,
]) ])
@ -1116,13 +1132,16 @@ export function EmailList({
for (const l of collectTreeLabels(sidebarNav.folderTree)) s.add(l) for (const l of collectTreeLabels(sidebarNav.folderTree)) s.add(l)
for (const row of sidebarNav.labelRows) s.add(row.label) for (const row of sidebarNav.labelRows) s.add(row.label)
for (const e of allEmails) { for (const e of allEmails) {
const eff = mergeEmailLabelEdits(e, labelEdits) const eff = mergeEmailNotSpam(
mergeEmailLabelEdits(e, labelEdits),
notSpamEmailIds
)
for (const lab of eff.labels ?? []) { for (const lab of eff.labels ?? []) {
if (!LABEL_PICKER_EXCLUDE.has(lab)) s.add(lab) if (!LABEL_PICKER_EXCLUDE.has(lab)) s.add(lab)
} }
} }
return [...s].sort((a, b) => a.localeCompare(b, "fr")) 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( const resolveLabelCasing = useCallback(
(raw: string) => { (raw: string) => {
@ -1264,9 +1283,18 @@ export function EmailList({
hiddenEmailIds, hiddenEmailIds,
readOverrides, readOverrides,
navMaps, 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]) const pageIds = useMemo(() => listEmails.map((e) => e.id), [listEmails])
@ -1338,7 +1366,9 @@ export function EmailList({
const hidden = new Set(hiddenEmailIds) const hidden = new Set(hiddenEmailIds)
const visible = allEmails const visible = allEmails
.filter((email) => !hidden.has(email.id)) .filter((email) => !hidden.has(email.id))
.map((e) => mergeEmailLabelEdits(e, labelEdits)) .map((e) =>
mergeEmailNotSpam(mergeEmailLabelEdits(e, labelEdits), notSpamEmailIds)
)
const inboxPool = visible.filter((e) => const inboxPool = visible.filter((e) =>
emailMatchesFolder(e, "inbox", folderFilterCtx, navMaps) emailMatchesFolder(e, "inbox", folderFilterCtx, navMaps)
) )
@ -1361,7 +1391,7 @@ export function EmailList({
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]) }, [folderFilterCtx, hiddenEmailIds, labelEdits, seenSerialized, allEmails, navMaps, notSpamEmailIds])
const effectiveStarred = (email: Email) => const effectiveStarred = (email: Email) =>
starredEmails.includes(email.id) || email.starred starredEmails.includes(email.id) || email.starred
@ -1512,8 +1542,8 @@ export function EmailList({
const raw = allEmails.find((e) => e.id === openMailId) ?? null const raw = allEmails.find((e) => e.id === openMailId) ?? null
if (!raw) return null if (!raw) return null
if (raw.labels?.includes("scheduled")) return null if (raw.labels?.includes("scheduled")) return null
return mergeEmailLabelEdits(raw, labelEdits) return mergeEmailNotSpam(mergeEmailLabelEdits(raw, labelEdits), notSpamEmailIds)
}, [openMailId, labelEdits, allEmails]) }, [openMailId, labelEdits, allEmails, notSpamEmailIds])
const openMailIndex = useMemo( const openMailIndex = useMemo(
() => (openMailId ? filteredEmails.findIndex((e) => e.id === openMailId) : -1), () => (openMailId ? filteredEmails.findIndex((e) => e.id === openMailId) : -1),
[openMailId, filteredEmails] [openMailId, filteredEmails]
@ -1544,6 +1574,33 @@ export function EmailList({
const goBack = useCallback(() => navigateToMail(null), [navigateToMail]) 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( const handleCategoryInboxTabClick = useCallback(
(tabId: string) => { (tabId: string) => {
onMailRouteNavigate({ onMailRouteNavigate({
@ -1656,7 +1713,7 @@ export function EmailList({
const singleNotSpam = useCallback(() => { const singleNotSpam = useCallback(() => {
if (!openMailId) return if (!openMailId) return
mailActions.hideEmail(openMailId) mailActions.markNotSpam(openMailId)
onSelectFolder?.("inbox") onSelectFolder?.("inbox")
goBack() goBack()
}, [openMailId, goBack, onSelectFolder, mailActions]) }, [openMailId, goBack, onSelectFolder, mailActions])
@ -1738,8 +1795,8 @@ export function EmailList({
return ( return (
<div className="flex h-full min-h-0 flex-1 flex-col"> <div className="flex h-full min-h-0 flex-1 flex-col">
{/* Mobile xs top bar */} {/* Mobile xs top bar */}
{isXs && !isViewMode && ( {!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"> <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"> <div className="min-w-0 flex-1">
<h1 className="truncate text-base font-semibold text-[#1f1f1f] leading-tight"> <h1 className="truncate text-base font-semibold text-[#1f1f1f] leading-tight">
{mobileFolderLabel} {mobileFolderLabel}
@ -1891,7 +1948,7 @@ export function EmailList({
className={cn( 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", "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", isViewMode ? "items-start" : "items-center",
isXs && !isViewMode && "hidden" !isViewMode && "max-sm:hidden"
)} )}
> >
@ -2429,7 +2486,7 @@ export function EmailList({
<div <div
className={cn( className={cn(
"flex shrink-0 items-center gap-2 whitespace-nowrap text-sm text-gray-600", "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 ? ( {filteredEmails.length === 0 ? (
@ -2601,11 +2658,11 @@ export function EmailList({
<div <div
ref={listViewportRef} 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 <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 }} style={{ height: PULL_HOLD_HEIGHT }}
aria-hidden aria-hidden
> >
@ -2618,12 +2675,11 @@ export function EmailList({
style={{ opacity: 0 }} style={{ opacity: 0 }}
/> />
</div> </div>
) : null} )}
<div <div
ref={pullContentRef} ref={pullContentRef}
className={cn( className={cn(
isXs && !isViewMode && "min-h-full [transform:translateZ(0)]", !isViewMode && "flex min-h-full flex-col max-sm:[transform:translateZ(0)]"
!isXs && !isViewMode && "flex min-h-full flex-col"
)} )}
> >
{isViewMode && openEmail ? ( {isViewMode && openEmail ? (
@ -2739,6 +2795,8 @@ export function EmailList({
}) })
const isRescheduleOpenThisRow = const isRescheduleOpenThisRow =
rescheduleTarget?.id === email.id rescheduleTarget?.id === email.id
const spamRowHoverNoArchive = selectedFolder === "spam"
const snoozedFolderRow = selectedFolder === "snoozed"
return ( return (
<ContextMenu <ContextMenu
@ -2768,7 +2826,7 @@ export function EmailList({
draggable={!isXs} draggable={!isXs}
onDragStart={isXs ? undefined : (e) => startRowDrag(email.id, e)} onDragStart={isXs ? undefined : (e) => startRowDrag(email.id, e)}
onClick={() => { onClick={() => {
if (isXs && mobileSelectionMode) { if (readXsMatches() && mobileSelectionMode) {
toggleSelect(email.id) toggleSelect(email.id)
lastSelectionAnchorIdRef.current = email.id lastSelectionAnchorIdRef.current = email.id
return return
@ -2776,7 +2834,7 @@ export function EmailList({
handleRowActivate(email) handleRowActivate(email)
}} }}
className={cn( 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 isSelected
? "bg-[#e8f0fe]" ? "bg-[#e8f0fe]"
: isRead : isRead
@ -2788,13 +2846,14 @@ export function EmailList({
{/* Compact < md */} {/* Compact < md */}
<div <div
className={cn( className={cn(
"flex w-full min-w-0 md:hidden", "flex w-full min-w-0 flex-col gap-0.5 md:hidden",
isXs && mobileSelectionMode ? "items-center gap-2" : "flex-col gap-0.5" mobileSelectionMode &&
"max-sm:flex-row max-sm:items-center max-sm:gap-2"
)} )}
> >
{isXs && mobileSelectionMode && ( {mobileSelectionMode && (
<div <div
className="flex shrink-0 self-center" className="flex shrink-0 self-center sm:hidden"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onClickCapture={(e) => handleRowCheckboxClickCapture(email.id, e)} onClickCapture={(e) => handleRowCheckboxClickCapture(email.id, e)}
> >
@ -2811,18 +2870,17 @@ export function EmailList({
<div <div
className={cn( className={cn(
"flex min-w-0 flex-1 flex-col gap-0.5", "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"> <div className="flex w-full min-w-0 items-center gap-2">
{!isXs && (
<div <div
className="flex shrink-0 items-center" className="hidden shrink-0 items-center sm:flex"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onClickCapture={(e) => handleRowCheckboxClickCapture(email.id, e)} onClickCapture={(e) => handleRowCheckboxClickCapture(email.id, e)}
> >
<Checkbox <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} checked={isSelected}
onCheckedChange={() => { onCheckedChange={() => {
toggleSelect(email.id) toggleSelect(email.id)
@ -2830,7 +2888,6 @@ export function EmailList({
}} }}
/> />
</div> </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 justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-1"> <div className="flex min-w-0 flex-1 items-center gap-1">
<button <button
@ -2941,7 +2998,7 @@ export function EmailList({
</div> </div>
</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 && ( {email.tag && (
<span className="shrink-0 rounded bg-gray-200 px-1.5 py-0.5 text-xs text-gray-600 opacity-[0.92]"> <span className="shrink-0 rounded bg-gray-200 px-1.5 py-0.5 text-xs text-gray-600 opacity-[0.92]">
{email.tag} {email.tag}
@ -2967,7 +3024,7 @@ export function EmailList({
</span> </span>
</div> </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"> <p className="min-w-0 flex-1 text-sm leading-snug text-[#5f6368] line-clamp-1">
{email.preview} {email.preview}
</p> </p>
@ -3077,7 +3134,7 @@ export function EmailList({
<div <div
className={cn( 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" attachmentList.length === 0 ? "pt-px" : "pt-0"
)} )}
> >
@ -3147,8 +3204,7 @@ export function EmailList({
<div <div
className={cn( className={cn(
"flex shrink-0 flex-col items-end gap-1 self-start whitespace-nowrap pr-2 text-right", "flex shrink-0 flex-col items-end gap-1 self-start pr-2 text-right md:max-w-[150px] md:min-w-0",
isScheduled ? "md:min-w-[200px] lg:min-w-[280px]" : "",
attachmentList.length === 0 ? "pt-1" : "pt-0.5" 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"> <div className="relative flex w-full min-w-0 shrink-0 items-center justify-end">
<span <span
className={cn( className={cn(
"block text-sm font-semibold tabular-nums text-[#c65308]", "block max-w-full truncate text-sm font-semibold tabular-nums text-[#c65308]",
"transition-opacity duration-150", "transition-opacity duration-[50ms] ease-out",
isRescheduleOpenThisRow isRescheduleOpenThisRow
? "opacity-0" ? "opacity-0"
: "opacity-100 group-hover:opacity-0" : "opacity-100 group-hover:opacity-0"
@ -3167,17 +3223,14 @@ export function EmailList({
</span> </span>
<div <div
className={cn( 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", "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",
isSelected listRowQuickHoverTrayToneClass(isSelected, isRead),
? "bg-[#e8f0fe]"
: isRead
? "bg-[#f5f5f5]"
: "bg-white",
isRescheduleOpenThisRow isRescheduleOpenThisRow
? "pointer-events-auto opacity-100" ? "pointer-events-auto opacity-100"
: "group-hover:pointer-events-auto group-hover:opacity-100" : "group-hover:pointer-events-auto group-hover:opacity-100"
)} )}
> >
{!spamRowHoverNoArchive && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
@ -3198,6 +3251,7 @@ export function EmailList({
Archiver Archiver
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)}
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
@ -3441,7 +3495,14 @@ export function EmailList({
</div> </div>
</div> </div>
) : ( ) : (
<div className="flex items-center gap-1.5"> <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) && ( {(parsedInvitation || hasInvitation) && (
<Icon <Icon
icon={ icon={
@ -3461,13 +3522,174 @@ export function EmailList({
)} )}
<span <span
className={cn( className={cn(
"whitespace-nowrap text-sm tabular-nums", "min-w-0 truncate text-sm tabular-nums",
!isRead ? "font-semibold text-gray-900" : "text-gray-600" !isRead ? "font-semibold text-gray-900" : "text-gray-600"
)} )}
> >
{email.date} {email.date}
</span> </span>
</div> </div>
<div
className={cn(
"pointer-events-none absolute right-0 top-1/2 z-[1] flex w-max -translate-y-1/2 flex-nowrap items-center gap-0.5 rounded-md py-0.5 pl-1 opacity-0 transition-opacity duration-[50ms] ease-out",
listRowQuickHoverTrayToneClass(isSelected, isRead),
"group-hover:pointer-events-auto group-hover:opacity-100"
)}
>
{!spamRowHoverNoArchive && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label="Archiver"
onClick={(e) => {
e.stopPropagation()
mailActions.hideEmail(email.id)
closeViewIfShowingEmail(email.id)
}}
>
<Archive className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Archiver
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label="Supprimer"
onClick={(e) => {
e.stopPropagation()
mailActions.hideEmail(email.id)
closeViewIfShowingEmail(email.id)
}}
>
<Trash2 className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Supprimer
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label={isRead ? "Marquer comme non lu" : "Marquer comme lu"}
onClick={(e) => {
e.stopPropagation()
const next = !isRead
setReadOverrides((prev) => ({ ...prev, [email.id]: next }))
}}
>
{isRead ? (
<Mail className="h-[18px] w-[18px]" strokeWidth={1.5} />
) : (
<MailOpen className="h-[18px] w-[18px]" strokeWidth={1.5} />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{isRead ? "Marquer comme non lu" : "Marquer comme lu"}
</TooltipContent>
</Tooltip>
{spamRowHoverNoArchive && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label="Déplacer vers la boîte de réception"
onClick={(e) => {
e.stopPropagation()
mailActions.markNotSpam(email.id)
onSelectFolder?.("inbox")
closeViewIfShowingEmail(email.id)
}}
>
<InboxIcon
className="h-[18px] w-[18px]"
strokeWidth={1.5}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Boîte de réception
</TooltipContent>
</Tooltip>
)}
{!spamRowHoverNoArchive &&
(snoozedFolderRow ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label={
email.id.startsWith("snz-")
? "Déplacer vers la boîte de réception"
: "Remettre dans les mails planifiés"
}
onClick={(e) => {
e.stopPropagation()
restoreSnoozedRowToMailbox(email)
}}
>
<InboxIcon
className="h-[18px] w-[18px]"
strokeWidth={1.5}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{email.id.startsWith("snz-")
? "Boîte de réception"
: "Planifiés"}
</TooltipContent>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
aria-label="Mettre en attente"
onClick={(e) => {
e.stopPropagation()
void requestSnoozeMailboxEmail(email)
if (email.labels?.includes("snoozed")) return
mailActions.hideEmail(email.id)
closeViewIfShowingEmail(email.id)
}}
>
<Clock className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Mettre en attente
</TooltipContent>
</Tooltip>
))}
</div>
</div>
)} )}
</div> </div>
</div> </div>
@ -3817,8 +4039,8 @@ export function EmailList({
</> </>
</TooltipProvider> </TooltipProvider>
)} )}
{!isXs && !isViewMode ? ( {!isViewMode ? (
<div className="sticky bottom-0 left-0 z-20 mt-auto flex w-fit max-w-full shrink-0 pt-2"> <div className="sticky bottom-0 left-0 z-20 mt-auto hidden w-fit max-w-full shrink-0 pt-2 sm:flex">
<MailFolderStackIndicator <MailFolderStackIndicator
currentKey={mailNavVisitKey(selectedFolder, inboxTab)} currentKey={mailNavVisitKey(selectedFolder, inboxTab)}
folderTree={sidebarNav.folderTree} folderTree={sidebarNav.folderTree}

View File

@ -106,10 +106,10 @@ export function Header({
</div> </div>
)} )}
<Button variant="ghost" size="icon" className="hidden text-gray-600 sm:inline-flex" aria-label="Aide"> <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>
<Button variant="ghost" size="icon" className="text-gray-600" aria-label="Réglages"> <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> </Button>
{/* Google Apps Menu */} {/* Google Apps Menu */}
@ -121,7 +121,7 @@ export function Header({
aria-label="Applications" aria-label="Applications"
onClick={() => setAppsMenuOpen(!appsMenuOpen)} 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> </Button>
{appsMenuOpen && ( {appsMenuOpen && (

View File

@ -31,6 +31,7 @@ import {
Check, Check,
} from "lucide-react" } from "lucide-react"
import { cn, formatCount } from "@/lib/utils" import { cn, formatCount } from "@/lib/utils"
import { readXsMatches } from "@/hooks/use-xs"
import { useState, useRef, useEffect, useMemo, type ReactNode, type CSSProperties } from "react" import { useState, useRef, useEffect, useMemo, type ReactNode, type CSSProperties } from "react"
import { useEmailDropTarget } from "@/lib/drag-context" import { useEmailDropTarget } from "@/lib/drag-context"
import { useCompose } from "@/lib/compose-context" import { useCompose } from "@/lib/compose-context"
@ -107,8 +108,6 @@ interface SidebarProps {
selectedFolder: string selectedFolder: string
onSelectFolder: (folder: string) => void onSelectFolder: (folder: string) => void
collapsed: boolean 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é). */ /** Nombre de messages non lus par id de ligne (boîte, catégorie, dossier, libellé). */
folderUnreadCounts?: Record<string, number> folderUnreadCounts?: Record<string, number>
} }
@ -503,7 +502,6 @@ export function Sidebar({
selectedFolder, selectedFolder,
onSelectFolder, onSelectFolder,
collapsed, collapsed,
isXs = false,
folderUnreadCounts = {}, folderUnreadCounts = {},
}: SidebarProps) { }: SidebarProps) {
const { openCompose } = useCompose() const { openCompose } = useCompose()
@ -514,8 +512,7 @@ export function Sidebar({
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null) const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const sidebarRef = useRef<HTMLElement>(null) const sidebarRef = useRef<HTMLElement>(null)
const isOverlayOpen = isXs && !collapsed const isExpanded = !collapsed || hoverExpanded
const isExpanded = isOverlayOpen || !collapsed || hoverExpanded
const { const {
folderTree, folderTree,
@ -666,7 +663,7 @@ export function Sidebar({
}, [selectedFolder]) }, [selectedFolder])
const handleMouseEnter = () => { const handleMouseEnter = () => {
if (isXs) return if (readXsMatches()) return
if (collapsed) { if (collapsed) {
hoverTimeoutRef.current = setTimeout(() => { hoverTimeoutRef.current = setTimeout(() => {
setHoverExpanded(true) setHoverExpanded(true)
@ -675,11 +672,11 @@ export function Sidebar({
} }
const handleMouseLeave = () => { const handleMouseLeave = () => {
if (isXs) return
if (hoverTimeoutRef.current) { if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current) clearTimeout(hoverTimeoutRef.current)
hoverTimeoutRef.current = null hoverTimeoutRef.current = null
} }
if (readXsMatches()) return
setHoverExpanded(false) setHoverExpanded(false)
} }
@ -1731,24 +1728,21 @@ export function Sidebar({
className={cn( className={cn(
"absolute left-0 top-0 bottom-0 flex flex-col overflow-hidden bg-app-canvas transition-[width,transform] duration-200 z-40", "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]", isExpanded ? "w-60" : "w-[68px]",
(hoverExpanded || isOverlayOpen) && "shadow-xl border-r border-gray-200", hoverExpanded && "shadow-xl border-r border-gray-200",
isOverlayOpen && "z-50", !collapsed && "max-sm:z-50 max-sm:shadow-xl max-sm:border-r max-sm:border-gray-200",
isXs && collapsed && "-translate-x-full pointer-events-none" 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 sm:hidden">
<div className="flex shrink-0 items-center justify-between px-4 pt-4 pb-4">
<UltiMailLogo className="min-h-8" /> <UltiMailLogo className="min-h-8" />
<Button variant="ghost" size="icon" className="size-9 shrink-0 text-gray-600" aria-label="Réglages"> <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 /> <Icon icon="mdi:cog" className="size-5 shrink-0" aria-hidden />
</Button> </Button>
</div> </div>
)}
{!isXs && (
<div <div
className={cn( className={cn(
"flex shrink-0 bg-app-canvas z-10 pt-1 pb-3 pl-2", "hidden shrink-0 bg-app-canvas z-10 pt-1 pb-3 pl-2 sm:flex",
isExpanded ? "pr-3.5" : "pr-2" isExpanded ? "pr-3.5" : "pr-2"
)} )}
> >
@ -1772,7 +1766,6 @@ export function Sidebar({
)} )}
</button> </button>
</div> </div>
)}
<div <div
className={cn( className={cn(
@ -1973,7 +1966,7 @@ export function Sidebar({
{/* Sortbot */} {/* Sortbot */}
<div className={cn( <div className={cn(
"z-30 mt-auto bg-app-canvas pt-2", "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 <button
type="button" type="button"

View File

@ -48,13 +48,17 @@ function SheetContent({
className, className,
children, children,
side = 'right', side = 'right',
overlayClassName,
hideClose = false,
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & { }: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: 'top' | 'right' | 'bottom' | 'left' side?: 'top' | 'right' | 'bottom' | 'left'
overlayClassName?: string
hideClose?: boolean
}) { }) {
return ( return (
<SheetPortal> <SheetPortal>
<SheetOverlay /> <SheetOverlay className={overlayClassName} />
<SheetPrimitive.Content <SheetPrimitive.Content
data-slot="sheet-content" data-slot="sheet-content"
className={cn( className={cn(
@ -72,10 +76,12 @@ function SheetContent({
{...props} {...props}
> >
{children} {children}
{!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"> <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" /> <XIcon className="size-4" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</SheetPrimitive.Close> </SheetPrimitive.Close>
) : null}
</SheetPrimitive.Content> </SheetPrimitive.Content>
</SheetPortal> </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. */ /** 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() { export function useIsXs() {
const [isXs, setIsXs] = useState(false) const [isXs, setIsXs] = useState(false)
useEffect(() => { useLayoutEffect(() => {
const mql = window.matchMedia(`(max-width: ${XS_MAX_PX}px)`) const mql = window.matchMedia(XS_MQ)
const update = () => setIsXs(mql.matches) const update = () => setIsXs(mql.matches)
update() update()
mql.addEventListener("change", update) mql.addEventListener("change", update)

View File

@ -31,6 +31,20 @@ export function mergeEmailLabelEdits(
return { ...email, labels } 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( export function applyLabelEditsToEmails(
emails: Email[], emails: Email[],
edits: LabelEditState edits: LabelEditState

View File

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

View File

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

View File

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

View File

@ -41,6 +41,10 @@ type ScheduledStoreActions = {
updateScheduledSend: (id: string, payload: ScheduleSendPayload) => void updateScheduledSend: (id: string, payload: ScheduleSendPayload) => void
sendScheduledNow: (id: string) => void sendScheduledNow: (id: string) => void
removeScheduledLocal: (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>()( export const useScheduledStore = create<ScheduledStoreState & ScheduledStoreActions>()(
@ -182,6 +186,59 @@ export const useScheduledStore = create<ScheduledStoreState & ScheduledStoreActi
set((s) => ({ set((s) => ({
scheduledEmails: s.scheduledEmails.filter((e) => e.id !== id), 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", name: "ultimail-scheduled-state",

File diff suppressed because one or more lines are too long