Major improvements on mobile
This commit is contained in:
parent
53d5c76d76
commit
22e7b8e1d2
@ -4,12 +4,13 @@ import {
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
} from "react"
|
||||
import dynamic from "next/dynamic"
|
||||
import { useIsXs } from "@/hooks/use-xs"
|
||||
import { readXsMatches, useIsXs } from "@/hooks/use-xs"
|
||||
import { MobileBottomBar } from "@/components/gmail/mobile-bottom-bar"
|
||||
import { Toaster } from "sonner"
|
||||
import { useRouter, usePathname } from "next/navigation"
|
||||
import { Sidebar } from "@/components/gmail/sidebar"
|
||||
@ -24,14 +25,6 @@ import { ComposeModalManager } from "@/components/gmail/compose-modal"
|
||||
import { SidebarNavProvider } from "@/lib/sidebar-nav-context"
|
||||
import { mailNavVisitKey } from "@/lib/mail-folder-display"
|
||||
import { useMailStore } from "@/lib/stores/mail-store"
|
||||
|
||||
const MobileBottomBar = dynamic(
|
||||
() =>
|
||||
import("@/components/gmail/mobile-bottom-bar").then(
|
||||
(m) => m.MobileBottomBar
|
||||
),
|
||||
{ ssr: false }
|
||||
)
|
||||
import {
|
||||
parseMailSegments,
|
||||
buildMailPath,
|
||||
@ -54,7 +47,12 @@ function MailAppInner() {
|
||||
|
||||
const isXs = useIsXs()
|
||||
const pushRecentFolderVisit = useMailStore((s) => s.pushRecentFolderVisit)
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
/** Start closed so narrow viewports match SSR/CSS before JS runs; desktop opens in layout. */
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(true)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!readXsMatches()) setSidebarCollapsed(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isXs) setSidebarCollapsed(true)
|
||||
@ -91,9 +89,9 @@ function MailAppInner() {
|
||||
page: 1,
|
||||
mailId: null,
|
||||
})
|
||||
if (isXs) setSidebarCollapsed(true)
|
||||
if (readXsMatches()) setSidebarCollapsed(true)
|
||||
},
|
||||
[navigateRoute, isXs]
|
||||
[navigateRoute]
|
||||
)
|
||||
|
||||
return (
|
||||
@ -109,36 +107,33 @@ function MailAppInner() {
|
||||
}
|
||||
>
|
||||
<div className="flex h-screen flex-col bg-app-canvas">
|
||||
{!isXs && (
|
||||
<div className="hidden sm:block">
|
||||
<Header
|
||||
isXs={false}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative flex min-h-0 flex-1 gap-0 overflow-hidden bg-app-canvas pl-0 pr-0 pb-1 pt-1 sm:gap-1 sm:pl-1">
|
||||
{isXs && !sidebarCollapsed && (
|
||||
{!sidebarCollapsed && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Fermer le menu"
|
||||
className="absolute inset-0 z-30 bg-black/20"
|
||||
className="absolute inset-0 z-30 bg-black/20 sm:hidden"
|
||||
onClick={() => setSidebarCollapsed(true)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={
|
||||
isXs
|
||||
? "w-0 shrink-0"
|
||||
: sidebarCollapsed
|
||||
? "w-[68px] shrink-0"
|
||||
: "w-60 shrink-0"
|
||||
sidebarCollapsed
|
||||
? "w-0 shrink-0 sm:w-[68px]"
|
||||
: "w-0 shrink-0 sm:w-60"
|
||||
}
|
||||
/>
|
||||
<Sidebar
|
||||
selectedFolder={route.folderId}
|
||||
onSelectFolder={handleSelectFolder}
|
||||
collapsed={sidebarCollapsed}
|
||||
isXs={isXs}
|
||||
folderUnreadCounts={folderUnreadCounts}
|
||||
/>
|
||||
<main className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-none bg-white shadow-sm sm:rounded-2xl">
|
||||
@ -156,12 +151,10 @@ function MailAppInner() {
|
||||
</main>
|
||||
<RightPanel />
|
||||
</div>
|
||||
{isXs && (
|
||||
<MobileBottomBar
|
||||
sidebarOpen={!sidebarCollapsed}
|
||||
onToggleSidebar={() => setSidebarCollapsed((c) => !c)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SidebarNavProvider>
|
||||
)
|
||||
@ -179,7 +172,7 @@ export function MailAppShell({
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-screen flex-col bg-app-canvas">
|
||||
<div className="h-14 shrink-0 border-b border-gray-200 bg-white" />
|
||||
<div className="hidden h-14 shrink-0 border-b border-gray-200 bg-white sm:block" />
|
||||
<div className="min-h-0 flex-1 bg-app-canvas" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@ -10,6 +10,8 @@ import {
|
||||
lazy,
|
||||
Suspense,
|
||||
} from "react"
|
||||
import { useIsXs } from "@/hooks/use-xs"
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
|
||||
import { useEditor, EditorContent } from "@tiptap/react"
|
||||
import { Editor, Node as TipTapNode, mergeAttributes, type Extensions } from "@tiptap/core"
|
||||
import StarterKit from "@tiptap/starter-kit"
|
||||
@ -141,6 +143,9 @@ function insertSignatureHtml(html: string, sigId: string | null) {
|
||||
return clean + `<div id="ultimail-signature"><p>--</p>${sig.html}</div>`
|
||||
}
|
||||
|
||||
/** Menus/popovers Radix default z-50 ; compose sheet content uses z-61+. */
|
||||
const COMPOSE_PORTAL_Z = "z-[100]"
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
function RecipientField({
|
||||
@ -408,7 +413,10 @@ function AlignmentDropdown({
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-[160px]">
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className={cn("min-w-[160px]", COMPOSE_PORTAL_Z)}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => editor.chain().focus().setTextAlign("left").run()}
|
||||
className={cn(editor.isActive({ textAlign: "left" }) && "bg-[#e8eaed]")}
|
||||
@ -483,7 +491,10 @@ function FontDropdown({
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-[280px] min-w-[180px] overflow-y-auto">
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className={cn("max-h-[280px] min-w-[180px] overflow-y-auto", COMPOSE_PORTAL_Z)}
|
||||
>
|
||||
{FONT_FAMILIES.map((f) => (
|
||||
<DropdownMenuItem
|
||||
key={f.value}
|
||||
@ -516,7 +527,10 @@ function FontSizeDropdown({
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-[140px]">
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className={cn("min-w-[140px]", COMPOSE_PORTAL_Z)}
|
||||
>
|
||||
{FONT_SIZES.map((s) => (
|
||||
<DropdownMenuItem
|
||||
key={s.label}
|
||||
@ -557,7 +571,11 @@ function ColorDropdown({
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[268px] p-2" onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className={cn("w-[268px] p-2", COMPOSE_PORTAL_Z)}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="mb-2 flex gap-1 border-b border-[#eef0f2] pb-2">
|
||||
<button
|
||||
type="button"
|
||||
@ -772,7 +790,7 @@ function EmojiButton({
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="top"
|
||||
className="w-auto border-0 p-0 shadow-xl"
|
||||
className={cn("w-auto border-0 p-0 shadow-xl", COMPOSE_PORTAL_Z)}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<EmojiPicker onSelect={handleSelect} />
|
||||
@ -892,7 +910,7 @@ function LinkButton({
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="top"
|
||||
className="w-[340px] p-3"
|
||||
className={cn("w-[340px] p-3", COMPOSE_PORTAL_Z)}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
@ -995,7 +1013,7 @@ function SignatureButton({
|
||||
if (!editor) return null
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@ -1005,7 +1023,11 @@ function SignatureButton({
|
||||
<PenTool className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" side="top" className="min-w-[220px]">
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
side="top"
|
||||
className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
@ -1076,7 +1098,7 @@ function ComposeRecipientFields({
|
||||
{showFromField && (
|
||||
<div className="flex min-w-0 items-center gap-2 overflow-hidden px-3 py-1.5">
|
||||
<span className="shrink-0 text-sm text-[#5f6368]">De</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@ -1089,7 +1111,7 @@ function ComposeRecipientFields({
|
||||
<ChevronDown className="h-3 w-3 shrink-0 text-[#5f6368]" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-[300px]">
|
||||
<DropdownMenuContent align="start" className={cn("min-w-[300px]", COMPOSE_PORTAL_Z)}>
|
||||
{DEFAULT_IDENTITIES.map((id) => (
|
||||
<DropdownMenuItem
|
||||
key={id.email}
|
||||
@ -1194,10 +1216,15 @@ function ComposeRecipientFields({
|
||||
export function ComposeWindow({
|
||||
compose,
|
||||
threadSourceEmail = null,
|
||||
isXsSheet = false,
|
||||
bindXsSheetClose,
|
||||
}: {
|
||||
compose: ComposeState
|
||||
/** Fil courant : nécessaire pour le menu Répondre / Transférer en inline */
|
||||
threadSourceEmail?: Email | null
|
||||
/** Plein écran dans une bottom sheet (xs) — pas de file ni réduction */
|
||||
isXsSheet?: boolean
|
||||
bindXsSheetClose?: (fn: (() => void) | null) => void
|
||||
}) {
|
||||
const {
|
||||
closeCompose,
|
||||
@ -1262,7 +1289,11 @@ export function ComposeWindow({
|
||||
attributes: {
|
||||
class: cn(
|
||||
"prose prose-sm max-w-none px-3 py-2 text-sm text-[#202124] outline-none focus:outline-none",
|
||||
isInline ? "min-h-[200px]" : "min-h-[150px]"
|
||||
isInline
|
||||
? "min-h-[200px]"
|
||||
: isXsSheet
|
||||
? "min-h-[min(36vh,280px)]"
|
||||
: "min-h-[150px]"
|
||||
),
|
||||
},
|
||||
},
|
||||
@ -1307,6 +1338,17 @@ export function ComposeWindow({
|
||||
}
|
||||
}
|
||||
|
||||
const handleCloseRef = useRef(handleClose)
|
||||
handleCloseRef.current = handleClose
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isXsSheet || !bindXsSheetClose) return
|
||||
bindXsSheetClose(() => {
|
||||
handleCloseRef.current()
|
||||
})
|
||||
return () => bindXsSheetClose(null)
|
||||
}, [isXsSheet, bindXsSheetClose, compose.id])
|
||||
|
||||
const htmlToPreviewText = useCallback((html: string) => {
|
||||
return html
|
||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, " ")
|
||||
@ -1500,7 +1542,7 @@ export function ComposeWindow({
|
||||
}
|
||||
}, [])
|
||||
|
||||
const showFromField = recipientsFocused
|
||||
const showFromField = recipientsFocused || isXsSheet
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isInline || !compose.focusToOnMount) return
|
||||
@ -1509,10 +1551,14 @@ export function ComposeWindow({
|
||||
|
||||
useEffect(() => {
|
||||
if (!recipientsFocused) return
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const handleClickOutside = (e: Event) => {
|
||||
const target = e.target as Node
|
||||
const root = isInline ? inlineRecipientShellRef.current : fieldsRef.current
|
||||
if (root && !root.contains(e.target as Node)) {
|
||||
const portal = (e.target as HTMLElement)?.closest?.("[data-radix-popper-content-wrapper]")
|
||||
if (root && !root.contains(target)) {
|
||||
const el = e.target as HTMLElement | null
|
||||
const portal = el?.closest?.(
|
||||
"[data-radix-popper-content-wrapper], [data-radix-dropdown-menu-content], [data-slot='dropdown-menu-content'], [data-slot='popover-content']"
|
||||
)
|
||||
if (portal) return
|
||||
setRecipientsFocused(false)
|
||||
if (compose.showCc && compose.cc.length === 0) {
|
||||
@ -1523,8 +1569,8 @@ export function ComposeWindow({
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
document.addEventListener("pointerdown", handleClickOutside)
|
||||
return () => document.removeEventListener("pointerdown", handleClickOutside)
|
||||
}, [
|
||||
recipientsFocused,
|
||||
isInline,
|
||||
@ -1645,6 +1691,8 @@ export function ComposeWindow({
|
||||
"relative flex flex-col overflow-hidden bg-white",
|
||||
isInline
|
||||
? "min-h-[360px] w-full rounded-xl border border-[#dadce0] shadow-none transition-shadow focus-within:shadow-[0_1px_4px_rgba(60,64,67,0.12)]"
|
||||
: isXsSheet
|
||||
? "h-full min-h-0 w-full max-w-none flex-1 rounded-none shadow-none"
|
||||
: cn(
|
||||
"rounded-t-lg shadow-[0_-2px_8px_rgba(0,0,0,0.08),_-4px_0_12px_rgba(0,0,0,0.12),_4px_0_12px_rgba(0,0,0,0.12)]",
|
||||
compose.maximized
|
||||
@ -1702,7 +1750,7 @@ export function ComposeWindow({
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@ -1715,7 +1763,7 @@ export function ComposeWindow({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="min-w-[260px]"
|
||||
className={cn("min-w-[260px]", COMPOSE_PORTAL_Z)}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
@ -1809,6 +1857,25 @@ export function ComposeWindow({
|
||||
<ComposeRecipientFields {...recipientFieldsProps} />
|
||||
</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 */}
|
||||
@ -1941,7 +2008,7 @@ export function ComposeWindow({
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
<DropdownMenu open={sendMenuOpen} onOpenChange={setSendMenuOpen}>
|
||||
<DropdownMenu modal={false} open={sendMenuOpen} onOpenChange={setSendMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@ -1950,7 +2017,7 @@ export function ComposeWindow({
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-[220px]">
|
||||
<DropdownMenuContent align="start" className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
void sendScheduledFromEditNow()
|
||||
@ -1964,7 +2031,7 @@ export function ComposeWindow({
|
||||
<Clock className="h-4 w-4 text-[#5f6368]" strokeWidth={1.5} />
|
||||
Planifier
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="min-w-[220px]">
|
||||
<DropdownMenuSubContent className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
void applyScheduledPlanAt(
|
||||
@ -1999,7 +2066,7 @@ export function ComposeWindow({
|
||||
>
|
||||
Envoyer
|
||||
</button>
|
||||
<DropdownMenu open={sendMenuOpen} onOpenChange={setSendMenuOpen}>
|
||||
<DropdownMenu modal={false} open={sendMenuOpen} onOpenChange={setSendMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@ -2008,7 +2075,7 @@ export function ComposeWindow({
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-[220px]">
|
||||
<DropdownMenuContent align="start" className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
void submitScheduledSendAt(
|
||||
@ -2108,7 +2175,7 @@ export function ComposeWindow({
|
||||
</div>
|
||||
)
|
||||
|
||||
if (compose.minimized && !isInline) {
|
||||
if (compose.minimized && !isInline && !isXsSheet) {
|
||||
return (
|
||||
<div
|
||||
className="flex h-9 w-[280px] cursor-pointer items-center rounded-t-lg bg-[#f2f6fc] px-3 shadow-lg transition-shadow hover:shadow-xl"
|
||||
@ -2143,7 +2210,7 @@ export function ComposeWindow({
|
||||
)
|
||||
}
|
||||
|
||||
if (compose.maximized && !isInline) {
|
||||
if (compose.maximized && !isInline && !isXsSheet) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@ -2160,12 +2227,28 @@ export function ComposeWindow({
|
||||
|
||||
export function ComposeModalManager() {
|
||||
const { composeWindows } = useCompose()
|
||||
const isXs = useIsXs()
|
||||
|
||||
const nonMaximized = composeWindows.filter(
|
||||
(w) => !w.maximized && w.placement !== "inline"
|
||||
)
|
||||
const maximized = composeWindows.filter((w) => w.maximized && w.placement !== "inline")
|
||||
|
||||
const xsSheetCloseRef = useRef<(() => void) | null>(null)
|
||||
const bindXsSheetClose = useCallback((fn: (() => void) | null) => {
|
||||
xsSheetCloseRef.current = fn
|
||||
}, [])
|
||||
|
||||
/** Une seule fenêtre dock visible en xs : la plus récente (comportement type pile). */
|
||||
const xsActiveDock =
|
||||
isXs && nonMaximized.length > 0 ? nonMaximized[nonMaximized.length - 1] : null
|
||||
|
||||
const handleXsSheetOpenChange = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
xsSheetCloseRef.current?.()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const MODAL_WIDTH = 500
|
||||
const MINIMIZED_WIDTH = 280
|
||||
const GAP = 12
|
||||
@ -2188,6 +2271,39 @@ export function ComposeModalManager() {
|
||||
return result
|
||||
}, [nonMaximized])
|
||||
|
||||
if (isXs) {
|
||||
return (
|
||||
<>
|
||||
<Sheet open={xsActiveDock != null} onOpenChange={handleXsSheetOpenChange}>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
hideClose
|
||||
overlayClassName="z-[60]"
|
||||
className="z-[61] h-[100dvh] max-h-[100dvh] w-full gap-0 rounded-none border-0 p-0 shadow-none duration-300 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:slide-in-from-bottom data-[state=closed]:slide-out-to-bottom overflow-hidden pb-[env(safe-area-inset-bottom)]"
|
||||
>
|
||||
<SheetTitle className="sr-only">
|
||||
{(xsActiveDock?.subject ?? "").trim() || "Nouveau message"}
|
||||
</SheetTitle>
|
||||
{xsActiveDock ? (
|
||||
<ComposeWindow
|
||||
key={xsActiveDock.id}
|
||||
compose={xsActiveDock}
|
||||
isXsSheet
|
||||
bindXsSheetClose={bindXsSheetClose}
|
||||
/>
|
||||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{maximized.map((compose) => (
|
||||
<div key={compose.id} className="pointer-events-auto">
|
||||
<ComposeWindow compose={compose} />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{nonMaximized.map((compose) => {
|
||||
|
||||
@ -56,6 +56,7 @@ import {
|
||||
CalendarClock,
|
||||
X,
|
||||
CheckSquare,
|
||||
Inbox as InboxIcon,
|
||||
} from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
@ -125,10 +126,11 @@ import { computeFolderUnreadCounts } from "@/lib/mail-nav-metrics"
|
||||
import {
|
||||
effectiveLabels,
|
||||
mergeEmailLabelEdits,
|
||||
mergeEmailNotSpam,
|
||||
} from "@/lib/label-edits"
|
||||
import type { LabelEditState } from "@/lib/stores/mail-store"
|
||||
import type { MailRouteState } from "@/lib/mail-url"
|
||||
import { useIsXs } from "@/hooks/use-xs"
|
||||
import { readXsMatches, useIsXs } from "@/hooks/use-xs"
|
||||
|
||||
addCollection(mdiIcons)
|
||||
|
||||
@ -574,6 +576,14 @@ function listRowCheckboxClass(circular: boolean) {
|
||||
)
|
||||
}
|
||||
|
||||
function listRowQuickHoverTrayToneClass(isSelected: boolean, isRead: boolean) {
|
||||
return isSelected
|
||||
? "bg-[#e8f0fe]"
|
||||
: isRead
|
||||
? "bg-[#f5f5f5]"
|
||||
: "bg-white"
|
||||
}
|
||||
|
||||
export function EmailList({
|
||||
selectedFolder,
|
||||
inboxTab,
|
||||
@ -603,6 +613,8 @@ export function EmailList({
|
||||
requestRescheduleScheduled,
|
||||
requestGetScheduledEditPayload,
|
||||
requestSendScheduledNow,
|
||||
requestSnoozeMailboxEmail,
|
||||
requestRestoreSnoozedToInbox,
|
||||
} = useScheduledMail()
|
||||
|
||||
const allEmails = useMemo(
|
||||
@ -723,6 +735,7 @@ export function EmailList({
|
||||
}, [allEmails])
|
||||
const [labelPickerQuery, setLabelPickerQuery] = useState("")
|
||||
const hiddenEmailIds = useMailStore((s) => s.hiddenEmailIds)
|
||||
const notSpamEmailIds = useMailStore((s) => s.notSpamEmailIds)
|
||||
const recentMoveTargets = useMailStore((s) => s.recentMoveTargets)
|
||||
const rowContextMenuOpenedAtRef = useRef(0)
|
||||
const contextMenuTargetIdsRef = useRef<string[]>([])
|
||||
@ -873,7 +886,9 @@ export function EmailList({
|
||||
const filteredEmails = useMemo(() => {
|
||||
const visible = allEmails
|
||||
.filter((email) => !hiddenEmailIds.includes(email.id))
|
||||
.map((e) => mergeEmailLabelEdits(e, labelEdits))
|
||||
.map((e) =>
|
||||
mergeEmailNotSpam(mergeEmailLabelEdits(e, labelEdits), notSpamEmailIds)
|
||||
)
|
||||
let rows = visible.filter((email) =>
|
||||
emailMatchesFolder(email, selectedFolder, folderFilterCtx, navMaps)
|
||||
)
|
||||
@ -887,6 +902,7 @@ export function EmailList({
|
||||
hiddenEmailIds,
|
||||
folderFilterCtx,
|
||||
labelEdits,
|
||||
notSpamEmailIds,
|
||||
allEmails,
|
||||
navMaps,
|
||||
])
|
||||
@ -1116,13 +1132,16 @@ export function EmailList({
|
||||
for (const l of collectTreeLabels(sidebarNav.folderTree)) s.add(l)
|
||||
for (const row of sidebarNav.labelRows) s.add(row.label)
|
||||
for (const e of allEmails) {
|
||||
const eff = mergeEmailLabelEdits(e, labelEdits)
|
||||
const eff = mergeEmailNotSpam(
|
||||
mergeEmailLabelEdits(e, labelEdits),
|
||||
notSpamEmailIds
|
||||
)
|
||||
for (const lab of eff.labels ?? []) {
|
||||
if (!LABEL_PICKER_EXCLUDE.has(lab)) s.add(lab)
|
||||
}
|
||||
}
|
||||
return [...s].sort((a, b) => a.localeCompare(b, "fr"))
|
||||
}, [sidebarNav.folderTree, sidebarNav.labelRows, allEmails, labelEdits])
|
||||
}, [sidebarNav.folderTree, sidebarNav.labelRows, allEmails, labelEdits, notSpamEmailIds])
|
||||
|
||||
const resolveLabelCasing = useCallback(
|
||||
(raw: string) => {
|
||||
@ -1264,9 +1283,18 @@ export function EmailList({
|
||||
hiddenEmailIds,
|
||||
readOverrides,
|
||||
navMaps,
|
||||
labelEdits
|
||||
labelEdits,
|
||||
notSpamEmailIds
|
||||
),
|
||||
[folderFilterCtx, hiddenEmailIds, readOverrides, allEmails, navMaps, labelEdits]
|
||||
[
|
||||
folderFilterCtx,
|
||||
hiddenEmailIds,
|
||||
readOverrides,
|
||||
allEmails,
|
||||
navMaps,
|
||||
labelEdits,
|
||||
notSpamEmailIds,
|
||||
]
|
||||
)
|
||||
|
||||
const pageIds = useMemo(() => listEmails.map((e) => e.id), [listEmails])
|
||||
@ -1338,7 +1366,9 @@ export function EmailList({
|
||||
const hidden = new Set(hiddenEmailIds)
|
||||
const visible = allEmails
|
||||
.filter((email) => !hidden.has(email.id))
|
||||
.map((e) => mergeEmailLabelEdits(e, labelEdits))
|
||||
.map((e) =>
|
||||
mergeEmailNotSpam(mergeEmailLabelEdits(e, labelEdits), notSpamEmailIds)
|
||||
)
|
||||
const inboxPool = visible.filter((e) =>
|
||||
emailMatchesFolder(e, "inbox", folderFilterCtx, navMaps)
|
||||
)
|
||||
@ -1361,7 +1391,7 @@ export function EmailList({
|
||||
preview[tab.id] = chain.join(", ")
|
||||
}
|
||||
return { unseenInTabById: counts, tabUnseenSenderLineById: preview }
|
||||
}, [folderFilterCtx, hiddenEmailIds, labelEdits, seenSerialized, allEmails, navMaps])
|
||||
}, [folderFilterCtx, hiddenEmailIds, labelEdits, seenSerialized, allEmails, navMaps, notSpamEmailIds])
|
||||
|
||||
const effectiveStarred = (email: Email) =>
|
||||
starredEmails.includes(email.id) || email.starred
|
||||
@ -1512,8 +1542,8 @@ export function EmailList({
|
||||
const raw = allEmails.find((e) => e.id === openMailId) ?? null
|
||||
if (!raw) return null
|
||||
if (raw.labels?.includes("scheduled")) return null
|
||||
return mergeEmailLabelEdits(raw, labelEdits)
|
||||
}, [openMailId, labelEdits, allEmails])
|
||||
return mergeEmailNotSpam(mergeEmailLabelEdits(raw, labelEdits), notSpamEmailIds)
|
||||
}, [openMailId, labelEdits, allEmails, notSpamEmailIds])
|
||||
const openMailIndex = useMemo(
|
||||
() => (openMailId ? filteredEmails.findIndex((e) => e.id === openMailId) : -1),
|
||||
[openMailId, filteredEmails]
|
||||
@ -1544,6 +1574,33 @@ export function EmailList({
|
||||
|
||||
const goBack = useCallback(() => navigateToMail(null), [navigateToMail])
|
||||
|
||||
const closeViewIfShowingEmail = useCallback(
|
||||
(emailId: string) => {
|
||||
if (openMailId === emailId) goBack()
|
||||
},
|
||||
[openMailId, goBack]
|
||||
)
|
||||
|
||||
const restoreSnoozedRowToMailbox = useCallback(
|
||||
(emailRow: Email) => {
|
||||
void requestRestoreSnoozedToInbox(emailRow)
|
||||
if (emailRow.id.startsWith("snz-")) {
|
||||
const baseId = emailRow.id.slice(4)
|
||||
if (baseId.length > 0) mailActions.unhideEmail(baseId)
|
||||
onSelectFolder?.("inbox")
|
||||
} else {
|
||||
onSelectFolder?.("scheduled")
|
||||
}
|
||||
closeViewIfShowingEmail(emailRow.id)
|
||||
},
|
||||
[
|
||||
requestRestoreSnoozedToInbox,
|
||||
mailActions,
|
||||
closeViewIfShowingEmail,
|
||||
onSelectFolder,
|
||||
]
|
||||
)
|
||||
|
||||
const handleCategoryInboxTabClick = useCallback(
|
||||
(tabId: string) => {
|
||||
onMailRouteNavigate({
|
||||
@ -1656,7 +1713,7 @@ export function EmailList({
|
||||
|
||||
const singleNotSpam = useCallback(() => {
|
||||
if (!openMailId) return
|
||||
mailActions.hideEmail(openMailId)
|
||||
mailActions.markNotSpam(openMailId)
|
||||
onSelectFolder?.("inbox")
|
||||
goBack()
|
||||
}, [openMailId, goBack, onSelectFolder, mailActions])
|
||||
@ -1738,8 +1795,8 @@ export function EmailList({
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-1 flex-col">
|
||||
{/* Mobile xs top bar */}
|
||||
{isXs && !isViewMode && (
|
||||
<div className="relative z-20 flex shrink-0 items-center gap-2 border-b border-gray-200 bg-white px-4 py-2.5">
|
||||
{!isViewMode && (
|
||||
<div className="relative z-20 flex shrink-0 items-center gap-2 border-b border-gray-200 bg-white px-4 py-2.5 sm:hidden">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="truncate text-base font-semibold text-[#1f1f1f] leading-tight">
|
||||
{mobileFolderLabel}
|
||||
@ -1891,7 +1948,7 @@ export function EmailList({
|
||||
className={cn(
|
||||
"relative z-20 flex shrink-0 min-h-12 gap-2 rounded-t-2xl border-b border-gray-200 bg-white py-1.5 pl-2 pr-4",
|
||||
isViewMode ? "items-start" : "items-center",
|
||||
isXs && !isViewMode && "hidden"
|
||||
!isViewMode && "max-sm:hidden"
|
||||
)}
|
||||
>
|
||||
|
||||
@ -2429,7 +2486,7 @@ export function EmailList({
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-2 whitespace-nowrap text-sm text-gray-600",
|
||||
!isViewMode && isXs && "hidden sm:flex"
|
||||
!isViewMode && "max-sm:hidden sm:flex"
|
||||
)}
|
||||
>
|
||||
{filteredEmails.length === 0 ? (
|
||||
@ -2601,11 +2658,11 @@ export function EmailList({
|
||||
|
||||
<div
|
||||
ref={listViewportRef}
|
||||
className={cn(mainScrollClass, "relative overscroll-y-none", isXs && "pb-16")}
|
||||
className={cn(mainScrollClass, "relative overscroll-y-none", !isViewMode && "max-sm:pb-16")}
|
||||
>
|
||||
{isXs && !isViewMode ? (
|
||||
{!isViewMode && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 top-0 z-10 flex items-center justify-center pt-2"
|
||||
className="pointer-events-none absolute inset-x-0 top-0 z-10 flex items-center justify-center pt-2 sm:hidden"
|
||||
style={{ height: PULL_HOLD_HEIGHT }}
|
||||
aria-hidden
|
||||
>
|
||||
@ -2618,12 +2675,11 @@ export function EmailList({
|
||||
style={{ opacity: 0 }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
<div
|
||||
ref={pullContentRef}
|
||||
className={cn(
|
||||
isXs && !isViewMode && "min-h-full [transform:translateZ(0)]",
|
||||
!isXs && !isViewMode && "flex min-h-full flex-col"
|
||||
!isViewMode && "flex min-h-full flex-col max-sm:[transform:translateZ(0)]"
|
||||
)}
|
||||
>
|
||||
{isViewMode && openEmail ? (
|
||||
@ -2739,6 +2795,8 @@ export function EmailList({
|
||||
})
|
||||
const isRescheduleOpenThisRow =
|
||||
rescheduleTarget?.id === email.id
|
||||
const spamRowHoverNoArchive = selectedFolder === "spam"
|
||||
const snoozedFolderRow = selectedFolder === "snoozed"
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
@ -2768,7 +2826,7 @@ export function EmailList({
|
||||
draggable={!isXs}
|
||||
onDragStart={isXs ? undefined : (e) => startRowDrag(email.id, e)}
|
||||
onClick={() => {
|
||||
if (isXs && mobileSelectionMode) {
|
||||
if (readXsMatches() && mobileSelectionMode) {
|
||||
toggleSelect(email.id)
|
||||
lastSelectionAnchorIdRef.current = email.id
|
||||
return
|
||||
@ -2776,7 +2834,7 @@ export function EmailList({
|
||||
handleRowActivate(email)
|
||||
}}
|
||||
className={cn(
|
||||
"group relative z-0 w-full cursor-pointer pl-3 pr-2 py-2 transition-[background-color,box-shadow] duration-150 md:flex md:items-start md:gap-2 md:px-2 md:py-1.5",
|
||||
"group relative z-0 w-full cursor-pointer pl-3 pr-2 py-2 transition-[background-color,box-shadow] duration-[50ms] ease-out md:flex md:items-start md:gap-2 md:px-2 md:py-1.5",
|
||||
isSelected
|
||||
? "bg-[#e8f0fe]"
|
||||
: isRead
|
||||
@ -2788,13 +2846,14 @@ export function EmailList({
|
||||
{/* Compact < md */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full min-w-0 md:hidden",
|
||||
isXs && mobileSelectionMode ? "items-center gap-2" : "flex-col gap-0.5"
|
||||
"flex w-full min-w-0 flex-col gap-0.5 md:hidden",
|
||||
mobileSelectionMode &&
|
||||
"max-sm:flex-row max-sm:items-center max-sm:gap-2"
|
||||
)}
|
||||
>
|
||||
{isXs && mobileSelectionMode && (
|
||||
{mobileSelectionMode && (
|
||||
<div
|
||||
className="flex shrink-0 self-center"
|
||||
className="flex shrink-0 self-center sm:hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClickCapture={(e) => handleRowCheckboxClickCapture(email.id, e)}
|
||||
>
|
||||
@ -2811,18 +2870,17 @@ export function EmailList({
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 flex-col gap-0.5",
|
||||
isXs && mobileSelectionMode && "pointer-events-none"
|
||||
mobileSelectionMode && "max-sm:pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full min-w-0 items-center gap-2">
|
||||
{!isXs && (
|
||||
<div
|
||||
className="flex shrink-0 items-center"
|
||||
className="hidden shrink-0 items-center sm:flex"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClickCapture={(e) => handleRowCheckboxClickCapture(email.id, e)}
|
||||
>
|
||||
<Checkbox
|
||||
className="size-4 min-h-4 min-w-4 shrink-0 rounded-[2.5px] border-[1.5px] border-[#c2c2c2] bg-transparent shadow-none dark:bg-transparent focus-visible:ring-[#c2c2c2]/30 data-[state=checked]:border-[#1a73e8] data-[state=checked]:bg-[#1a73e8] data-[state=checked]:text-white"
|
||||
className={listRowCheckboxClass(false)}
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => {
|
||||
toggleSelect(email.id)
|
||||
@ -2830,7 +2888,6 @@ export function EmailList({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
<button
|
||||
@ -2941,7 +2998,7 @@ export function EmailList({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn("flex min-w-0 flex-wrap items-center gap-1", !isXs && "pl-6")}>
|
||||
<div className={cn("flex min-w-0 flex-wrap items-center gap-1 sm:pl-6")}>
|
||||
{email.tag && (
|
||||
<span className="shrink-0 rounded bg-gray-200 px-1.5 py-0.5 text-xs text-gray-600 opacity-[0.92]">
|
||||
{email.tag}
|
||||
@ -2967,7 +3024,7 @@ export function EmailList({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={cn("flex min-w-0 items-start gap-1.5", !isXs && "pl-6")}>
|
||||
<div className={cn("flex min-w-0 items-start gap-1.5 sm:pl-6")}>
|
||||
<p className="min-w-0 flex-1 text-sm leading-snug text-[#5f6368] line-clamp-1">
|
||||
{email.preview}
|
||||
</p>
|
||||
@ -3077,7 +3134,7 @@ export function EmailList({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"w-48 shrink-0 truncate pl-2",
|
||||
"w-44 shrink-0 truncate pl-2 lg:w-40",
|
||||
attachmentList.length === 0 ? "pt-px" : "pt-0"
|
||||
)}
|
||||
>
|
||||
@ -3147,8 +3204,7 @@ export function EmailList({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 flex-col items-end gap-1 self-start whitespace-nowrap pr-2 text-right",
|
||||
isScheduled ? "md:min-w-[200px] lg:min-w-[280px]" : "",
|
||||
"flex shrink-0 flex-col items-end gap-1 self-start pr-2 text-right md:max-w-[150px] md:min-w-0",
|
||||
attachmentList.length === 0 ? "pt-1" : "pt-0.5"
|
||||
)}
|
||||
>
|
||||
@ -3156,8 +3212,8 @@ export function EmailList({
|
||||
<div className="relative flex w-full min-w-0 shrink-0 items-center justify-end">
|
||||
<span
|
||||
className={cn(
|
||||
"block text-sm font-semibold tabular-nums text-[#c65308]",
|
||||
"transition-opacity duration-150",
|
||||
"block max-w-full truncate text-sm font-semibold tabular-nums text-[#c65308]",
|
||||
"transition-opacity duration-[50ms] ease-out",
|
||||
isRescheduleOpenThisRow
|
||||
? "opacity-0"
|
||||
: "opacity-100 group-hover:opacity-0"
|
||||
@ -3167,17 +3223,14 @@ export function EmailList({
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-0 top-1/2 z-[1] flex w-max -translate-y-1/2 flex-nowrap items-center gap-0.5 rounded-md py-0.5 pl-1 opacity-0 transition-opacity duration-150",
|
||||
isSelected
|
||||
? "bg-[#e8f0fe]"
|
||||
: isRead
|
||||
? "bg-[#f5f5f5]"
|
||||
: "bg-white",
|
||||
"pointer-events-none absolute right-0 top-1/2 z-[1] flex w-max -translate-y-1/2 flex-nowrap items-center gap-0.5 rounded-md py-0.5 pl-1 opacity-0 transition-opacity duration-[50ms] ease-out",
|
||||
listRowQuickHoverTrayToneClass(isSelected, isRead),
|
||||
isRescheduleOpenThisRow
|
||||
? "pointer-events-auto opacity-100"
|
||||
: "group-hover:pointer-events-auto group-hover:opacity-100"
|
||||
)}
|
||||
>
|
||||
{!spamRowHoverNoArchive && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@ -3198,6 +3251,7 @@ export function EmailList({
|
||||
Archiver
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@ -3441,7 +3495,14 @@ export function EmailList({
|
||||
</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) && (
|
||||
<Icon
|
||||
icon={
|
||||
@ -3461,13 +3522,174 @@ export function EmailList({
|
||||
)}
|
||||
<span
|
||||
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"
|
||||
)}
|
||||
>
|
||||
{email.date}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-0 top-1/2 z-[1] flex w-max -translate-y-1/2 flex-nowrap items-center gap-0.5 rounded-md py-0.5 pl-1 opacity-0 transition-opacity duration-[50ms] ease-out",
|
||||
listRowQuickHoverTrayToneClass(isSelected, isRead),
|
||||
"group-hover:pointer-events-auto group-hover:opacity-100"
|
||||
)}
|
||||
>
|
||||
{!spamRowHoverNoArchive && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
|
||||
aria-label="Archiver"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
mailActions.hideEmail(email.id)
|
||||
closeViewIfShowingEmail(email.id)
|
||||
}}
|
||||
>
|
||||
<Archive className="h-[18px] w-[18px]" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
Archiver
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
|
||||
aria-label="Supprimer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
mailActions.hideEmail(email.id)
|
||||
closeViewIfShowingEmail(email.id)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-[18px] w-[18px]" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
Supprimer
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
|
||||
aria-label={isRead ? "Marquer comme non lu" : "Marquer comme lu"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const next = !isRead
|
||||
setReadOverrides((prev) => ({ ...prev, [email.id]: next }))
|
||||
}}
|
||||
>
|
||||
{isRead ? (
|
||||
<Mail className="h-[18px] w-[18px]" strokeWidth={1.5} />
|
||||
) : (
|
||||
<MailOpen className="h-[18px] w-[18px]" strokeWidth={1.5} />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
{isRead ? "Marquer comme non lu" : "Marquer comme lu"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{spamRowHoverNoArchive && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
|
||||
aria-label="Déplacer vers la boîte de réception"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
mailActions.markNotSpam(email.id)
|
||||
onSelectFolder?.("inbox")
|
||||
closeViewIfShowingEmail(email.id)
|
||||
}}
|
||||
>
|
||||
<InboxIcon
|
||||
className="h-[18px] w-[18px]"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
Boîte de réception
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!spamRowHoverNoArchive &&
|
||||
(snoozedFolderRow ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
|
||||
aria-label={
|
||||
email.id.startsWith("snz-")
|
||||
? "Déplacer vers la boîte de réception"
|
||||
: "Remettre dans les mails planifiés"
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
restoreSnoozedRowToMailbox(email)
|
||||
}}
|
||||
>
|
||||
<InboxIcon
|
||||
className="h-[18px] w-[18px]"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
{email.id.startsWith("snz-")
|
||||
? "Boîte de réception"
|
||||
: "Planifiés"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-[#444746] hover:bg-[#f1f3f4]"
|
||||
aria-label="Mettre en attente"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void requestSnoozeMailboxEmail(email)
|
||||
if (email.labels?.includes("snoozed")) return
|
||||
mailActions.hideEmail(email.id)
|
||||
closeViewIfShowingEmail(email.id)
|
||||
}}
|
||||
>
|
||||
<Clock className="h-[18px] w-[18px]" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
Mettre en attente
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -3817,8 +4039,8 @@ export function EmailList({
|
||||
</>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{!isXs && !isViewMode ? (
|
||||
<div className="sticky bottom-0 left-0 z-20 mt-auto flex w-fit max-w-full shrink-0 pt-2">
|
||||
{!isViewMode ? (
|
||||
<div className="sticky bottom-0 left-0 z-20 mt-auto hidden w-fit max-w-full shrink-0 pt-2 sm:flex">
|
||||
<MailFolderStackIndicator
|
||||
currentKey={mailNavVisitKey(selectedFolder, inboxTab)}
|
||||
folderTree={sidebarNav.folderTree}
|
||||
|
||||
@ -106,10 +106,10 @@ export function Header({
|
||||
</div>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="hidden text-gray-600 sm:inline-flex" aria-label="Aide">
|
||||
<Icon icon="mdi:help-circle" className="size-6 shrink-0" aria-hidden />
|
||||
<Icon icon="mdi:help-circle-outline" className="size-6 shrink-0" aria-hidden />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="text-gray-600" aria-label="Réglages">
|
||||
<Icon icon="mdi:cog" className="size-6 shrink-0" aria-hidden />
|
||||
<Icon icon="mdi:cog-outline" className="size-6 shrink-0" aria-hidden />
|
||||
</Button>
|
||||
|
||||
{/* Google Apps Menu */}
|
||||
@ -121,7 +121,7 @@ export function Header({
|
||||
aria-label="Applications"
|
||||
onClick={() => setAppsMenuOpen(!appsMenuOpen)}
|
||||
>
|
||||
<Icon icon="mdi:apps" className="size-6 shrink-0" aria-hidden />
|
||||
<Icon icon="mdi:view-grid-outline" className="size-6 shrink-0" aria-hidden />
|
||||
</Button>
|
||||
|
||||
{appsMenuOpen && (
|
||||
|
||||
@ -31,6 +31,7 @@ import {
|
||||
Check,
|
||||
} from "lucide-react"
|
||||
import { cn, formatCount } from "@/lib/utils"
|
||||
import { readXsMatches } from "@/hooks/use-xs"
|
||||
import { useState, useRef, useEffect, useMemo, type ReactNode, type CSSProperties } from "react"
|
||||
import { useEmailDropTarget } from "@/lib/drag-context"
|
||||
import { useCompose } from "@/lib/compose-context"
|
||||
@ -107,8 +108,6 @@ interface SidebarProps {
|
||||
selectedFolder: string
|
||||
onSelectFolder: (folder: string) => void
|
||||
collapsed: boolean
|
||||
/** Viewport below `sm` — drawer overlay, no rail. */
|
||||
isXs?: boolean
|
||||
/** Nombre de messages non lus par id de ligne (boîte, catégorie, dossier, libellé). */
|
||||
folderUnreadCounts?: Record<string, number>
|
||||
}
|
||||
@ -503,7 +502,6 @@ export function Sidebar({
|
||||
selectedFolder,
|
||||
onSelectFolder,
|
||||
collapsed,
|
||||
isXs = false,
|
||||
folderUnreadCounts = {},
|
||||
}: SidebarProps) {
|
||||
const { openCompose } = useCompose()
|
||||
@ -514,8 +512,7 @@ export function Sidebar({
|
||||
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const sidebarRef = useRef<HTMLElement>(null)
|
||||
|
||||
const isOverlayOpen = isXs && !collapsed
|
||||
const isExpanded = isOverlayOpen || !collapsed || hoverExpanded
|
||||
const isExpanded = !collapsed || hoverExpanded
|
||||
|
||||
const {
|
||||
folderTree,
|
||||
@ -666,7 +663,7 @@ export function Sidebar({
|
||||
}, [selectedFolder])
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (isXs) return
|
||||
if (readXsMatches()) return
|
||||
if (collapsed) {
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
setHoverExpanded(true)
|
||||
@ -675,11 +672,11 @@ export function Sidebar({
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (isXs) return
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current)
|
||||
hoverTimeoutRef.current = null
|
||||
}
|
||||
if (readXsMatches()) return
|
||||
setHoverExpanded(false)
|
||||
}
|
||||
|
||||
@ -1731,24 +1728,21 @@ export function Sidebar({
|
||||
className={cn(
|
||||
"absolute left-0 top-0 bottom-0 flex flex-col overflow-hidden bg-app-canvas transition-[width,transform] duration-200 z-40",
|
||||
isExpanded ? "w-60" : "w-[68px]",
|
||||
(hoverExpanded || isOverlayOpen) && "shadow-xl border-r border-gray-200",
|
||||
isOverlayOpen && "z-50",
|
||||
isXs && collapsed && "-translate-x-full pointer-events-none"
|
||||
hoverExpanded && "shadow-xl border-r border-gray-200",
|
||||
!collapsed && "max-sm:z-50 max-sm:shadow-xl max-sm:border-r max-sm:border-gray-200",
|
||||
collapsed && "max-sm:-translate-x-full max-sm:pointer-events-none"
|
||||
)}
|
||||
>
|
||||
{isXs && (
|
||||
<div className="flex shrink-0 items-center justify-between px-4 pt-4 pb-4">
|
||||
<div className="flex shrink-0 items-center justify-between px-4 pt-4 pb-4 sm:hidden">
|
||||
<UltiMailLogo className="min-h-8" />
|
||||
<Button variant="ghost" size="icon" className="size-9 shrink-0 text-gray-600" aria-label="Réglages">
|
||||
<Icon icon="mdi:cog" className="size-5 shrink-0" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isXs && (
|
||||
<div
|
||||
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"
|
||||
)}
|
||||
>
|
||||
@ -1772,7 +1766,6 @@ export function Sidebar({
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
@ -1973,7 +1966,7 @@ export function Sidebar({
|
||||
{/* Sortbot */}
|
||||
<div className={cn(
|
||||
"z-30 mt-auto bg-app-canvas pt-2",
|
||||
isXs ? "pb-16" : "sticky bottom-0 border-t border-gray-200 pb-3"
|
||||
"max-sm:pb-16 sm:sticky sm:bottom-0 sm:border-t sm:border-gray-200 sm:pb-3"
|
||||
)}>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -48,13 +48,17 @@ function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = 'right',
|
||||
overlayClassName,
|
||||
hideClose = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
overlayClassName?: string
|
||||
hideClose?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetOverlay className={overlayClassName} />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
@ -72,10 +76,12 @@ function SheetContent({
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{!hideClose ? (
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
) : null}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
|
||||
@ -1,13 +1,21 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useLayoutEffect, useState } from "react"
|
||||
|
||||
/** Tailwind `sm` breakpoint — viewports below are treated as xs. */
|
||||
const XS_MAX_PX = 639
|
||||
export const XS_MAX_PX = 639
|
||||
|
||||
const XS_MQ = `(max-width: ${XS_MAX_PX}px)`
|
||||
|
||||
/** Sync read for event handlers / layout (no hook delay). */
|
||||
export function readXsMatches(): boolean {
|
||||
if (typeof window === "undefined") return false
|
||||
return window.matchMedia(XS_MQ).matches
|
||||
}
|
||||
|
||||
export function useIsXs() {
|
||||
const [isXs, setIsXs] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${XS_MAX_PX}px)`)
|
||||
useLayoutEffect(() => {
|
||||
const mql = window.matchMedia(XS_MQ)
|
||||
const update = () => setIsXs(mql.matches)
|
||||
update()
|
||||
mql.addEventListener("change", update)
|
||||
|
||||
@ -31,6 +31,20 @@ export function mergeEmailLabelEdits(
|
||||
return { ...email, labels }
|
||||
}
|
||||
|
||||
/** Marquage local « non-spam » : retire le flag spam, enlève le libellé spam et assure la boîte de réception. */
|
||||
export function mergeEmailNotSpam(
|
||||
email: Email,
|
||||
notSpamEmailIds: readonly string[]
|
||||
): Email {
|
||||
const set = new Set(notSpamEmailIds)
|
||||
if (!set.has(email.id)) return email
|
||||
const ls = [...(email.labels ?? [])]
|
||||
const noSpam = ls.filter((l) => l.toLowerCase() !== "spam")
|
||||
const hasInbox = noSpam.some((l) => l.toLowerCase() === "inbox")
|
||||
const labels = hasInbox ? noSpam : [...noSpam, "inbox"]
|
||||
return { ...email, spam: false, labels }
|
||||
}
|
||||
|
||||
export function applyLabelEditsToEmails(
|
||||
emails: Email[],
|
||||
edits: LabelEditState
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { Email } from "@/lib/email-data"
|
||||
import { applyLabelEditsToEmails } from "@/lib/label-edits"
|
||||
import { applyLabelEditsToEmails, mergeEmailNotSpam } from "@/lib/label-edits"
|
||||
import {
|
||||
emailMatchesFolder,
|
||||
type MailFolderFilterCtx,
|
||||
@ -103,14 +103,16 @@ export function computeFolderUnreadCounts(
|
||||
hiddenEmailIds: string[],
|
||||
readOverrides: Record<string, boolean>,
|
||||
maps?: MailNavFolderMaps | null,
|
||||
labelEdits?: LabelEditState
|
||||
labelEdits?: LabelEditState,
|
||||
notSpamEmailIds?: readonly string[]
|
||||
): Record<string, number> {
|
||||
const pool =
|
||||
let pool =
|
||||
labelEdits &&
|
||||
(Object.keys(labelEdits.additions).length > 0 ||
|
||||
Object.keys(labelEdits.removals).length > 0)
|
||||
? applyLabelEditsToEmails(allEmails, labelEdits)
|
||||
: allEmails
|
||||
pool = pool.map((e) => mergeEmailNotSpam(e, notSpamEmailIds ?? []))
|
||||
const hidden = new Set(hiddenEmailIds)
|
||||
const out: Record<string, number> = {}
|
||||
for (const id of allSidebarNavFolderIds(maps)) {
|
||||
|
||||
@ -29,6 +29,8 @@ type ScheduledMailContextValue = {
|
||||
requestGetScheduledEditPayload: (id: string) => Promise<ScheduleSendPayload | null>
|
||||
requestUpdateScheduledSend: (id: string, payload: ScheduleSendPayload) => Promise<void>
|
||||
requestSendScheduledNow: (id: string) => Promise<void>
|
||||
requestSnoozeMailboxEmail: (row: Email) => Promise<void>
|
||||
requestRestoreSnoozedToInbox: (row: Email) => Promise<void>
|
||||
}
|
||||
|
||||
const ScheduledMailContext = createContext<ScheduledMailContextValue | null>(null)
|
||||
@ -57,6 +59,12 @@ export function ScheduledMailProvider({ children }: { children: ReactNode }) {
|
||||
requestGetScheduledEditPayload: async (id) => actions.getScheduledEditPayload(id),
|
||||
requestUpdateScheduledSend: async (id, payload) => { actions.updateScheduledSend(id, payload) },
|
||||
requestSendScheduledNow: async (id) => { actions.sendScheduledNow(id) },
|
||||
requestSnoozeMailboxEmail: async (row) => {
|
||||
actions.snoozeMailboxEmail(row)
|
||||
},
|
||||
requestRestoreSnoozedToInbox: async (row) => {
|
||||
actions.restoreSnoozedToInbox(row)
|
||||
},
|
||||
}
|
||||
}, [scheduledEmails, snoozedEmails, sentPlaceholderEmails])
|
||||
|
||||
|
||||
@ -21,6 +21,8 @@ type MailStoreState = {
|
||||
labelEdits: LabelEditState
|
||||
hiddenEmailIds: string[]
|
||||
seenEmailIds: string[]
|
||||
/** Ids marqués comme non-spam (réintégration boîte de réception dans l’UI). */
|
||||
notSpamEmailIds: string[]
|
||||
recentMoveTargets: string[]
|
||||
/** Dernières boîtes visitées (clés `mailNavVisitKey`), la plus récente en tête. */
|
||||
recentFolderVisits: string[]
|
||||
@ -40,6 +42,8 @@ type MailStoreActions = {
|
||||
hideEmails: (ids: string[]) => void
|
||||
unhideEmail: (id: string) => void
|
||||
markSeen: (id: string) => void
|
||||
/** Réintègre le message comme non-spam (liste / boîte de réception). */
|
||||
markNotSpam: (id: string) => void
|
||||
resetHidden: () => void
|
||||
pushRecentMoveTarget: (targetId: string) => void
|
||||
pushRecentFolderVisit: (visitKey: string) => void
|
||||
@ -54,6 +58,7 @@ export const useMailStore = create<MailStoreState & MailStoreActions>()(
|
||||
labelEdits: { additions: {}, removals: {} },
|
||||
hiddenEmailIds: [],
|
||||
seenEmailIds: [],
|
||||
notSpamEmailIds: [],
|
||||
recentMoveTargets: [],
|
||||
recentFolderVisits: [],
|
||||
|
||||
@ -149,6 +154,13 @@ export const useMailStore = create<MailStoreState & MailStoreActions>()(
|
||||
hiddenEmailIds: s.hiddenEmailIds.filter((x) => x !== id),
|
||||
})),
|
||||
|
||||
markNotSpam: (id) =>
|
||||
set((s) =>
|
||||
s.notSpamEmailIds.includes(id)
|
||||
? s
|
||||
: { notSpamEmailIds: [...s.notSpamEmailIds, id] }
|
||||
),
|
||||
|
||||
markSeen: (id) =>
|
||||
set((s) => ({
|
||||
seenEmailIds: s.seenEmailIds.includes(id)
|
||||
@ -174,11 +186,14 @@ export const useMailStore = create<MailStoreState & MailStoreActions>()(
|
||||
}),
|
||||
{
|
||||
name: "ultimail-mail-state",
|
||||
version: 2,
|
||||
version: 3,
|
||||
migrate: (persisted, version) => {
|
||||
const state = persisted as MailStoreState
|
||||
const state = persisted as MailStoreState & { notSpamEmailIds?: string[] }
|
||||
if (version < 2) {
|
||||
return { ...state, recentFolderVisits: [] }
|
||||
return { ...state, recentFolderVisits: [], notSpamEmailIds: [] }
|
||||
}
|
||||
if (version < 3) {
|
||||
return { ...state, notSpamEmailIds: state.notSpamEmailIds ?? [] }
|
||||
}
|
||||
return state
|
||||
},
|
||||
|
||||
@ -41,6 +41,10 @@ type ScheduledStoreActions = {
|
||||
updateScheduledSend: (id: string, payload: ScheduleSendPayload) => void
|
||||
sendScheduledNow: (id: string) => void
|
||||
removeScheduledLocal: (id: string) => void
|
||||
/** Mettre en attente depuis la boîte (clone id `snz-…` dans En attente ; 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>()(
|
||||
@ -182,6 +186,59 @@ export const useScheduledStore = create<ScheduledStoreState & ScheduledStoreActi
|
||||
set((s) => ({
|
||||
scheduledEmails: s.scheduledEmails.filter((e) => e.id !== id),
|
||||
})),
|
||||
|
||||
snoozeMailboxEmail: (row) =>
|
||||
set((s) => {
|
||||
const persistedId = row.id.startsWith("snz-") ? row.id : `snz-${row.id}`
|
||||
const wake = new Date(Date.now() + 24 * 60 * 60 * 1000)
|
||||
const wakeIso = wake.toISOString()
|
||||
const copy: Email = {
|
||||
...row,
|
||||
id: persistedId,
|
||||
labels: ["snoozed"],
|
||||
snoozeWakeAt: wakeIso,
|
||||
scheduledSendAt: undefined,
|
||||
scheduledToName: undefined,
|
||||
date: wake.toLocaleString("fr-FR", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}),
|
||||
read: true,
|
||||
}
|
||||
return {
|
||||
snoozedEmails: [
|
||||
copy,
|
||||
...s.snoozedEmails.filter((e) => e.id !== persistedId),
|
||||
],
|
||||
}
|
||||
}),
|
||||
|
||||
restoreSnoozedToInbox: (row) =>
|
||||
set((s) => {
|
||||
const nextSnoozed = s.snoozedEmails.filter((e) => e.id !== row.id)
|
||||
if (row.id.startsWith("snz-")) {
|
||||
return { snoozedEmails: nextSnoozed }
|
||||
}
|
||||
const resumeAt =
|
||||
row.snoozeWakeAt ??
|
||||
new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
|
||||
const back: Email = {
|
||||
...row,
|
||||
labels: ["scheduled"],
|
||||
scheduledSendAt: resumeAt,
|
||||
scheduledToName: row.sender,
|
||||
snoozeWakeAt: undefined,
|
||||
date: "",
|
||||
read: true,
|
||||
}
|
||||
return {
|
||||
snoozedEmails: nextSnoozed,
|
||||
scheduledEmails: [
|
||||
back,
|
||||
...s.scheduledEmails.filter((e) => e.id !== row.id),
|
||||
],
|
||||
}
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: "ultimail-scheduled-state",
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user