Major improvements on mobile
This commit is contained in:
parent
53d5c76d76
commit
22e7b8e1d2
@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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 && (
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)) {
|
||||||
|
|||||||
@ -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])
|
||||||
|
|
||||||
|
|||||||
@ -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 l’UI). */
|
||||||
|
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
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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 ; l’appelant masque l’id 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
Loading…
Reference in New Issue
Block a user