refactoed and sidebar better

This commit is contained in:
R3D347HR4Y 2026-05-20 18:22:36 +02:00
parent 8551150ffe
commit c36793e440
56 changed files with 11508 additions and 9662 deletions

View File

@ -429,6 +429,58 @@ html[data-mail-background]:not([data-mail-background='none'])
background-color: var(--mail-invitation); background-color: var(--mail-invitation);
} }
/**
* Sidebar frosted strips backdrop blur hides children scrolling behind sticky parents (sm+).
* Background stays transparent so there is no tint the blur alone conceals the text.
*/
.ultimail-app .mail-sidebar-blur-surface {
background-color: transparent;
-webkit-backdrop-filter: blur(24px) saturate(150%);
backdrop-filter: blur(24px) saturate(150%);
}
/**
* Hover-expanded sidebar frosted panel uses ::before so it doesn't create
* a backdrop-filter stacking context that would clip children's backdrop-filters.
*/
.ultimail-app .mail-sidebar-hover-frosted::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
-webkit-backdrop-filter: blur(24px) saturate(150%);
backdrop-filter: blur(24px) saturate(150%);
pointer-events: none;
}
/**
* Sticky sidebar strip blur spans full rail width (not just indented label).
* Set --sidebar-sticky-pad-left on the element (px indent of row content).
*/
.ultimail-app .mail-sidebar-blur-sticky-strip {
position: relative;
isolation: isolate;
}
.ultimail-app .mail-sidebar-blur-sticky-strip::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: calc(-1 * var(--sidebar-sticky-pad-left, 0px));
right: calc(-1 * var(--sidebar-nav-rail-inset, 14px));
z-index: 0;
background-color: transparent;
-webkit-backdrop-filter: blur(24px) saturate(150%);
backdrop-filter: blur(24px) saturate(150%);
pointer-events: none;
}
.ultimail-app .mail-sidebar-blur-sticky-strip > * {
position: relative;
z-index: 1;
}
/** /**
* Sidebar overlay (touch / xs) fond opaque. * Sidebar overlay (touch / xs) fond opaque.
* Nom hors préfixe bg-* pour éviter quun utility Tailwind écrase la règle. * Nom hors préfixe bg-* pour éviter quun utility Tailwind écrase la règle.

View File

@ -119,14 +119,13 @@ function MailAppInner() {
onClick={() => setSidebarCollapsed(true)} onClick={() => setSidebarCollapsed(true)}
/> />
)} )}
{/* xs: overlay (w-0). sm+: spacer matches rail; hover-expand can grow over main without shifting layout */}
<div <div
className={ className={cn(
touchNav && isXs "shrink-0 transition-[width] duration-200 ease-linear",
? "w-0 shrink-0" isXs ? "w-0" : sidebarCollapsed ? "w-[68px]" : "w-60"
: touchNav || sidebarCollapsed )}
? "w-0 shrink-0 sm:w-[68px]" aria-hidden
: "w-0 shrink-0 sm:w-60"
}
/> />
<Sidebar <Sidebar
selectedFolder={route.folderId} selectedFolder={route.folderId}

View File

@ -0,0 +1,55 @@
# Composants Gmail — arborescence
Point dentrée publics (re-exports à la racine) :
| Fichier racine | Module réel |
|----------------|-------------|
| `email-list.tsx` | `email-list/` |
| `sidebar.tsx` | `sidebar/` |
| `compose-modal.tsx` | `compose/` |
| `compose-toolbar.tsx` | `compose/` |
| `email-view.tsx` | `email-view/` + lecture |
## Dossiers par fonctionnalité
```
components/gmail/
├── compose/ # Rédaction (fenêtre, manager, toolbar, destinataires)
├── email-list/ # Liste des messages
│ ├── hooks/ # data, labels, selection, reading
│ ├── attachments/
│ └── …
├── email-view/ # Lecture dun message
├── mail-search/ # Recherche avancée (desktop + mobile)
├── sidebar/ # Navigation latérale
├── contacts/ # Carnet dadresses
├── quick-settings/ # Réglages rapides
└── *.tsx # Chrome global (header, search bar, swipe, …)
```
## email-list/
- `email-list.tsx` — orchestration (hooks → layout)
- `hooks/use-email-list-data.ts` — filtrage, pagination, pull-refresh
- `hooks/use-email-list-labels.ts` — libellés / déplacer
- `hooks/use-email-list-selection.ts` — sélection, actions bulk
- `hooks/use-email-list-reading.ts` — vue message, navigation clavier
- `email-list-layout.tsx` — structure split / toolbars
- `email-list-body.tsx` — zone scroll + lignes
- `email-list-toolbar.tsx` — barre doutils
- `email-list-row.tsx` — une ligne
## sidebar/
- `sidebar.tsx` — shell `<aside>`
- `use-sidebar-state.ts` — état local + effets
- `sidebar-header.tsx` — logo, compose, réglages
- `sidebar-nav-panel.tsx` — nav principale, dossiers, libellés
- `sidebar-folder-row-expanded.tsx`, `sidebar-label-item-row.tsx`, …
## compose/
- `compose-window.tsx` — UI fenêtre
- `use-compose-window.ts` — éditeur TipTap, envoi, pièces jointes
- `compose-modal-manager.tsx` — pile de fenêtres / sheet mobile
- `compose-recipients.tsx`, `compose-editor-chrome.tsx`, `compose-toolbar.tsx`

View File

@ -1,815 +1 @@
"use client" export { ComposeWindow, ComposeModalManager } from "./compose"
import {
useState,
useRef,
useEffect,
useLayoutEffect,
useCallback,
useMemo,
} from "react"
import { useIsXs } from "@/hooks/use-xs"
import { readCoarsePointerMatches } from "@/hooks/use-touch-nav"
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
import { useEditor, EditorContent } from "@tiptap/react"
import { type Extensions } from "@tiptap/core"
import StarterKit from "@tiptap/starter-kit"
import Underline from "@tiptap/extension-underline"
import Link from "@tiptap/extension-link"
import TextAlign from "@tiptap/extension-text-align"
import { TextStyle, FontFamily, FontSize, BackgroundColor } from "@tiptap/extension-text-style"
import Color from "@tiptap/extension-color"
import {
Reply,
ReplyAll,
Forward,
Maximize2,
Minimize2,
X,
} from "lucide-react"
import {
type ComposeState,
cloneComposeForPendingSend,
DEFAULT_IDENTITIES,
useComposeActions,
useComposeWindows,
} from "@/lib/compose-context"
import { useScheduledMail } from "@/lib/scheduled-mail-context"
import type { ScheduleSendPayload } from "@/lib/api/scheduled-mail"
import type { Email } from "@/lib/email-data"
import {
buildThreadComposePreset,
collectThreadParticipants,
} from "@/lib/thread-compose-preset"
import { toast } from "sonner"
import { showPendingSendToast } from "@/lib/pending-send-toast"
import { cn } from "@/lib/utils"
import {
MAIL_COMPOSE_TITLEBAR_CLASS,
MAIL_ICON_BTN,
} from "@/lib/mail-chrome-classes"
import { ComposeRecipientFields } from "@/components/gmail/compose/compose-recipients"
import {
ComposeBottomToolbar,
FormattingToolbar,
} from "@/components/gmail/compose/compose-toolbar"
import {
ComposeAttachmentsList,
ComposeDockTitleBar,
ComposeDropOverlay,
ComposeInlineRecipientHeader,
ComposeXsSheetHeader,
} from "@/components/gmail/compose/compose-editor-chrome"
import { SignatureBlock, stripSignature, insertSignatureHtml } from "@/components/gmail/compose/compose-shared"
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,
updateCompose,
applyComposePreset,
toggleMinimize,
toggleMaximize,
restoreComposeFromSnapshot,
} = useComposeActions()
const { scheduleSend, requestUpdateScheduledSend, requestSendScheduledNow } =
useScheduledMail()
const isInline = compose.placement === "inline"
const isEditingScheduled = compose.editingScheduledId != null
const [showFormatting, setShowFormatting] = useState(false)
const [recipientsFocused, setRecipientsFocused] = useState(false)
const [sendMenuOpen, setSendMenuOpen] = useState(false)
const [isDragOver, setIsDragOver] = useState(false)
const fieldsRef = useRef<HTMLDivElement>(null)
const inlineRecipientShellRef = useRef<HTMLDivElement>(null)
const subjectInputRef = useRef<HTMLInputElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const imageInputRef = useRef<HTMLInputElement>(null)
const snapBodyCaretToStartOnHeaderTab =
!isInline &&
!compose.threadEmailId &&
!compose.threadKind &&
!compose.editingScheduledId
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit,
Underline,
Link.configure({ openOnClick: false }),
TextStyle,
Color,
BackgroundColor,
FontFamily,
FontSize,
TextAlign.configure({ types: ["heading", "paragraph"], alignments: ["left", "center", "right", "justify"] }),
SignatureBlock,
] as Extensions,
content: compose.bodyHtml,
onUpdate: ({ editor: ed }) => {
updateCompose(compose.id, { bodyHtml: ed.getHTML() })
},
onFocus: ({ editor: ed, event }) => {
if (!snapBodyCaretToStartOnHeaderTab) return
const rt = event.relatedTarget as Node | null
if (!rt || !fieldsRef.current?.contains(rt)) return
window.requestAnimationFrame(() => {
if (!ed.view.hasFocus()) return
try {
ed.chain().setTextSelection(1).run()
} catch {
/* empty doc edge */
}
})
},
editorProps: {
attributes: {
class: cn(
"prose prose-sm max-w-none px-3 py-2 text-sm text-foreground outline-none focus:outline-none",
isInline
? "min-h-[200px]"
: isXsSheet
? "min-h-[min(36vh,280px)]"
: "min-h-[150px]"
),
},
},
})
const titleText = compose.subject || "Nouveau message"
const bodyWithoutSig = stripSignature(compose.bodyHtml)
.replace(/<p><\/p>/g, "")
.trim()
const hasContent =
compose.subject.trim() !== "" ||
compose.to.length > 0 ||
compose.cc.length > 0 ||
compose.bcc.length > 0 ||
compose.attachments.length > 0 ||
bodyWithoutSig !== ""
const handleIdentityChange = useCallback(
(identity: (typeof DEFAULT_IDENTITIES)[number]) => {
if (compose.autoInsertSignature && editor) {
const sigId = identity.defaultSignatureId
const newHtml = insertSignatureHtml(editor.getHTML(), sigId)
editor.commands.setContent(newHtml)
updateCompose(compose.id, { from: identity, bodyHtml: newHtml, signatureId: sigId })
} else {
updateCompose(compose.id, { from: identity })
}
},
[compose.id, compose.autoInsertSignature, editor, updateCompose]
)
const handleClose = () => {
const threadInlineDiscard =
isInline && compose.threadEmailId
? ({ discardThreadReplyDraft: true } as const)
: undefined
if (!hasContent) {
closeCompose(compose.id, threadInlineDiscard)
} else {
updateCompose(compose.id, { savedAt: Date.now() })
closeCompose(compose.id, threadInlineDiscard)
}
}
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, " ")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim()
}, [])
const handleSend = useCallback(() => {
if (compose.to.length === 0) return
const bodyHtml = editor?.getHTML() ?? compose.bodyHtml
const snapshot = cloneComposeForPendingSend({ ...compose, bodyHtml })
closeCompose(compose.id, { sent: true })
showPendingSendToast({
onCommit: async () => {},
onCancel: () => restoreComposeFromSnapshot(snapshot),
})
}, [
closeCompose,
compose,
editor,
restoreComposeFromSnapshot,
])
const submitScheduledSendAt = useCallback(
async (sendAt: Date) => {
if (isEditingScheduled) return
if (compose.to.length === 0) return
setSendMenuOpen(false)
const bodyHtml = editor?.getHTML() ?? compose.bodyHtml
await scheduleSend({
sendAtIso: sendAt.toISOString(),
to: compose.to.map((c) => ({ name: c.name, email: c.email })),
subject: compose.subject,
previewText: htmlToPreviewText(bodyHtml).slice(0, 500),
bodyHtml,
})
const whenLabel = sendAt.toLocaleString("fr-FR", {
dateStyle: "medium",
timeStyle: "short",
})
toast.message(`Ce mail sera envoyé le ${whenLabel}`)
closeCompose(compose.id, { sent: true })
},
[
isEditingScheduled,
compose.bodyHtml,
compose.id,
compose.subject,
compose.to,
closeCompose,
editor,
htmlToPreviewText,
scheduleSend,
]
)
const buildSchedulePayload = useCallback(
(sendAtIso: string): ScheduleSendPayload | null => {
if (compose.to.length === 0) return null
const bodyHtml = editor?.getHTML() ?? compose.bodyHtml
return {
sendAtIso,
to: compose.to.map((c) => ({ name: c.name, email: c.email })),
subject: compose.subject,
previewText: htmlToPreviewText(bodyHtml).slice(0, 500),
bodyHtml,
}
},
[compose.to, compose.subject, compose.bodyHtml, editor, htmlToPreviewText]
)
const saveScheduledEdit = useCallback(async () => {
const id = compose.editingScheduledId
if (!id) return
const iso =
compose.scheduledSendAtIso ?? new Date().toISOString()
const payload = buildSchedulePayload(iso)
if (!payload) return
await requestUpdateScheduledSend(id, payload)
toast.message("Modifications enregistrées")
closeCompose(compose.id)
}, [
buildSchedulePayload,
closeCompose,
compose.editingScheduledId,
compose.id,
compose.scheduledSendAtIso,
requestUpdateScheduledSend,
])
const sendScheduledFromEditNow = useCallback(async () => {
const id = compose.editingScheduledId
if (!id) return
setSendMenuOpen(false)
const bodyHtml = editor?.getHTML() ?? compose.bodyHtml
const snapshot = cloneComposeForPendingSend({ ...compose, bodyHtml })
closeCompose(compose.id, { sent: true })
showPendingSendToast({
onCommit: async () => {
const schedId = snapshot.editingScheduledId
if (!schedId || snapshot.to.length === 0) return
const iso = snapshot.scheduledSendAtIso ?? new Date().toISOString()
const body = snapshot.bodyHtml
const payload = {
sendAtIso: iso,
to: snapshot.to.map((c) => ({ name: c.name, email: c.email })),
subject: snapshot.subject,
previewText: htmlToPreviewText(body).slice(0, 500),
bodyHtml: body,
}
await requestUpdateScheduledSend(schedId, payload)
await requestSendScheduledNow(schedId)
},
onCancel: () => restoreComposeFromSnapshot(snapshot),
})
}, [
closeCompose,
compose,
editor,
htmlToPreviewText,
requestSendScheduledNow,
requestUpdateScheduledSend,
restoreComposeFromSnapshot,
])
const applyScheduledPlanAt = useCallback(
async (sendAt: Date) => {
const id = compose.editingScheduledId
if (!id) return
setSendMenuOpen(false)
const iso = sendAt.toISOString()
const payload = buildSchedulePayload(iso)
if (!payload) return
await requestUpdateScheduledSend(id, payload)
updateCompose(compose.id, { scheduledSendAtIso: iso })
const whenLabel = sendAt.toLocaleString("fr-FR", {
dateStyle: "medium",
timeStyle: "short",
})
toast.message(`Envoi planifié le ${whenLabel}`)
},
[
buildSchedulePayload,
compose.editingScheduledId,
compose.id,
requestUpdateScheduledSend,
updateCompose,
]
)
const addFiles = useCallback((files: FileList | File[]) => {
const newAttachments = Array.from(files).map((file) => ({
id: `att-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
file,
name: file.name,
size: file.size,
type: file.type,
}))
updateCompose(compose.id, {
attachments: [...compose.attachments, ...newAttachments],
})
}, [compose.id, compose.attachments, updateCompose])
const removeAttachment = useCallback((attId: string) => {
updateCompose(compose.id, {
attachments: compose.attachments.filter((a) => a.id !== attId),
})
}, [compose.id, compose.attachments, updateCompose])
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragOver(false)
if (e.dataTransfer.files.length > 0) {
addFiles(e.dataTransfer.files)
}
}, [addFiles])
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragOver(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) {
setIsDragOver(false)
}
}, [])
const showFromField = recipientsFocused || isXsSheet
useLayoutEffect(() => {
if (!isInline || !compose.focusToOnMount) return
setRecipientsFocused(true)
}, [isInline, compose.focusToOnMount])
useEffect(() => {
if (!recipientsFocused) return
const handleClickOutside = (e: Event) => {
const target = e.target as Node
const root = isInline ? inlineRecipientShellRef.current : fieldsRef.current
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) {
updateCompose(compose.id, { showCc: false })
}
if (compose.showBcc && compose.bcc.length === 0) {
updateCompose(compose.id, { showBcc: false })
}
}
}
document.addEventListener("pointerdown", handleClickOutside)
return () => document.removeEventListener("pointerdown", handleClickOutside)
}, [
recipientsFocused,
isInline,
compose.showCc,
compose.showBcc,
compose.cc.length,
compose.bcc.length,
compose.id,
updateCompose,
])
useEffect(() => {
if (!editor || editor.isDestroyed) return
const next = compose.bodyHtml
if (editor.getHTML() === next) return
editor.commands.setContent(next, { emitUpdate: false })
}, [compose.bodyHtml, compose.threadKind, editor])
useEffect(() => {
if (!compose.focusSubjectOnMount || isInline) return
const id = window.requestAnimationFrame(() => {
subjectInputRef.current?.focus()
updateCompose(compose.id, { focusSubjectOnMount: false })
})
return () => window.cancelAnimationFrame(id)
}, [compose.focusSubjectOnMount, isInline, compose.id, updateCompose])
useEffect(() => {
if (!compose.focusBodyOnMount || !editor || editor.isDestroyed) return
let cancelled = false
const outer = window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
if (cancelled || !editor || editor.isDestroyed) return
try {
editor.chain().focus().setTextSelection(1).run()
} catch {
editor.chain().focus().run()
}
updateCompose(compose.id, { focusBodyOnMount: false })
})
})
return () => {
cancelled = true
window.cancelAnimationFrame(outer)
}
}, [compose.focusBodyOnMount, compose.id, editor, updateCompose])
const clearFocusToMount = useCallback(() => {
updateCompose(compose.id, { focusToOnMount: false })
}, [compose.id, updateCompose])
const ThreadKindIcon =
compose.threadKind === "forward"
? Forward
: compose.threadKind === "replyAll"
? ReplyAll
: Reply
const recipientSummary =
compose.to.length === 0
? "Destinataires"
: compose.to.length === 1 && compose.to[0]
? compose.to[0].name === compose.to[0].email
? compose.to[0].email
: `${compose.to[0].name} <${compose.to[0].email}>`
: `${compose.to.length} destinataires`
const showReplyAllInMenu = useMemo(
() =>
Boolean(
threadSourceEmail &&
collectThreadParticipants(threadSourceEmail).length > 1
),
[threadSourceEmail]
)
const openInlinePreset = useCallback(
(kind: "reply" | "replyAll" | "forward") => {
if (!threadSourceEmail) return
applyComposePreset(
compose.id,
buildThreadComposePreset(threadSourceEmail, kind)
)
},
[threadSourceEmail, applyComposePreset, compose.id]
)
const openDockFromInline = useCallback(
(opts?: { focusSubject?: boolean }) => {
setRecipientsFocused(false)
updateCompose(compose.id, {
placement: "dock",
threadEmailId: null,
focusToOnMount: false,
focusBodyOnMount: false,
minimized: false,
maximized: false,
focusSubjectOnMount: Boolean(opts?.focusSubject),
})
},
[compose.id, updateCompose]
)
const recipientFieldsProps = {
compose,
isInline,
showFromField,
updateCompose,
handleIdentityChange,
clearFocusToMount,
subjectInputRef,
onRecipientsActivate: () => setRecipientsFocused(true),
}
const modalContent = (
<div
data-compose-window
className={cn(
"relative flex flex-col overflow-hidden bg-mail-surface text-foreground",
isInline
? "min-h-[360px] w-full rounded-xl border border-border 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
? readCoarsePointerMatches()
? "fixed inset-0 z-60 rounded-none"
: "fixed inset-12 z-60 rounded-lg"
: "h-[480px] w-[500px]"
)
)}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
{/* Hidden file inputs */}
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files && e.target.files.length > 0) {
addFiles(e.target.files)
e.target.value = ""
}
}}
/>
<input
ref={imageInputRef}
type="file"
multiple
accept="image/*"
className="hidden"
onChange={(e) => {
if (e.target.files && e.target.files.length > 0) {
addFiles(e.target.files)
e.target.value = ""
}
}}
/>
{/* Drop overlay */}
{isDragOver ? <ComposeDropOverlay /> : null}
{isInline ? (
<ComposeInlineRecipientHeader
compose={compose}
threadSourceEmail={threadSourceEmail}
recipientSummary={recipientSummary}
recipientsFocused={recipientsFocused}
showReplyAllInMenu={showReplyAllInMenu}
ThreadKindIcon={ThreadKindIcon}
onOpenInlinePreset={openInlinePreset}
onOpenDockFromInline={openDockFromInline}
onActivateRecipients={() => setRecipientsFocused(true)}
updateCompose={updateCompose}
recipientFieldsProps={recipientFieldsProps}
fieldsRef={fieldsRef}
inlineRecipientShellRef={inlineRecipientShellRef}
/>
) : isXsSheet ? (
<ComposeXsSheetHeader titleText={titleText} onClose={handleClose} />
) : (
<>
{/* Title bar */}
<ComposeDockTitleBar
titleText={titleText}
maximized={compose.maximized}
onMinimize={() => toggleMinimize(compose.id)}
onMaximize={() => toggleMaximize(compose.id)}
onClose={handleClose}
/>
</>
)}
{!isInline && (
<div ref={fieldsRef} className="flex shrink-0 flex-col">
<ComposeRecipientFields {...recipientFieldsProps} />
</div>
)}
{/* Editor */}
<div className="min-h-0 flex-1 overflow-y-auto">
<EditorContent editor={editor} />
</div>
<ComposeAttachmentsList
attachments={compose.attachments}
onRemove={removeAttachment}
/>
{showFormatting ? <FormattingToolbar editor={editor} /> : null}
<ComposeBottomToolbar
compose={compose}
editor={editor}
isEditingScheduled={isEditingScheduled}
showFormatting={showFormatting}
sendMenuOpen={sendMenuOpen}
setShowFormatting={setShowFormatting}
setSendMenuOpen={setSendMenuOpen}
handleSend={handleSend}
saveScheduledEdit={saveScheduledEdit}
sendScheduledFromEditNow={sendScheduledFromEditNow}
applyScheduledPlanAt={applyScheduledPlanAt}
submitScheduledSendAt={submitScheduledSendAt}
handleClose={handleClose}
fileInputRef={fileInputRef}
imageInputRef={imageInputRef}
/>
</div>
)
if (compose.minimized && !isInline && !isXsSheet) {
return (
<div
className={cn(
MAIL_COMPOSE_TITLEBAR_CLASS,
"h-9 w-[280px] cursor-pointer shadow-lg transition-shadow hover:shadow-xl"
)}
onClick={() => toggleMinimize(compose.id)}
>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
{titleText}
</span>
<div className="flex items-center gap-0.5">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
toggleMaximize(compose.id)
}}
className={cn("flex h-6 w-6 items-center justify-center rounded-full", MAIL_ICON_BTN)}
>
<Maximize2 className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleClose()
}}
className={cn("flex h-6 w-6 items-center justify-center rounded-full", MAIL_ICON_BTN)}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
}
if (compose.maximized && !isInline && !isXsSheet) {
return (
<>
<div
className="fixed inset-0 z-55 bg-black/50"
onClick={() => toggleMaximize(compose.id)}
/>
{modalContent}
</>
)
}
return modalContent
}
export function ComposeModalManager() {
const { composeWindows } = useComposeWindows()
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
const RIGHT_OFFSET = 80
const positions = useMemo(() => {
const reversed = [...nonMaximized].reverse()
const result: { id: string; right: number; hidden: boolean }[] = []
let cursor = RIGHT_OFFSET
for (let i = 0; i < reversed.length; i++) {
const w = reversed[i]
const width = w.minimized ? MINIMIZED_WIDTH : MODAL_WIDTH
result.push({
id: w.id,
right: cursor,
hidden: i >= 2 && !w.minimized,
})
cursor += width + GAP
}
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) => {
const pos = positions.find((p) => p.id === compose.id)
if (!pos) return null
return (
<div
key={compose.id}
className={cn(
"pointer-events-auto fixed bottom-0 z-50 transition-all duration-300",
pos.hidden && "invisible"
)}
style={{ right: pos.right }}
>
<ComposeWindow compose={compose} />
</div>
)
})}
{maximized.map((compose) => (
<div key={compose.id} className="pointer-events-auto">
<ComposeWindow compose={compose} />
</div>
))}
</>
)
}

View File

@ -0,0 +1,533 @@
"use client"
import { useState, useCallback } from "react"
import { type Editor } from "@tiptap/react"
import {
ChevronDown,
Paperclip,
Link as LinkIcon,
HardDrive,
Image as ImageIcon,
Lock,
PenTool,
MoreVertical,
Trash2,
Type,
Clock,
Send,
} from "lucide-react"
import {
type ComposeState,
SIGNATURES,
useComposeActions,
} from "@/lib/compose-context"
import { cn, getNextLocalWallClockDate } from "@/lib/utils"
import {
MAIL_COMPOSE_BOTTOM_ICON_BTN,
MAIL_COMPOSE_BOTTOM_ICON_BTN_ACTIVE,
MAIL_COMPOSE_MENU_SELECTED_CLASS,
MAIL_COMPOSE_POPOVER_CLASS,
MAIL_COMPOSE_PRIMARY_SEND_BTN,
MAIL_MENU_SURFACE_CLASS,
} from "@/lib/mail-chrome-classes"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { COMPOSE_PORTAL_Z, insertSignatureHtml } from "./compose-shared"
import { ComposeEmojiButton } from "./compose-emoji-picker"
function ComposeLinkButton({
editor,
}: {
editor: Editor | null
}) {
const [open, setOpen] = useState(false)
const [url, setUrl] = useState("")
const [text, setText] = useState("")
if (!editor) return null
const isLinkActive = editor.isActive("link")
const handleToggle = () => {
if (isLinkActive) {
editor.chain().focus().extendMarkRange("link").unsetLink().run()
return
}
setOpen(true)
}
const handleOpen = (isOpen: boolean) => {
if (isOpen) {
const { from, to, empty } = editor.state.selection
if (isLinkActive) {
const attrs = editor.getAttributes("link")
setUrl(attrs.href || "")
const selectedText = editor.state.doc.textBetween(from, to, " ")
setText(selectedText)
} else if (!empty) {
const selectedText = editor.state.doc.textBetween(from, to, " ")
setText(selectedText)
setUrl("")
} else {
setText("")
setUrl("")
}
}
setOpen(isOpen)
}
const handleInsert = () => {
if (!url.trim()) return
const href = url.match(/^https?:\/\//) ? url : `https://${url}`
const { empty } = editor.state.selection
if (empty && !isLinkActive) {
const displayText = text.trim() || href
editor
.chain()
.focus()
.insertContent(`<a href="${href}">${displayText}</a>`)
.run()
} else {
if (text.trim() && text.trim() !== editor.state.doc.textBetween(
editor.state.selection.from,
editor.state.selection.to,
" "
)) {
editor
.chain()
.focus()
.deleteSelection()
.insertContent(`<a href="${href}">${text.trim()}</a>`)
.run()
} else {
editor
.chain()
.focus()
.extendMarkRange("link")
.setLink({ href })
.run()
}
}
setOpen(false)
setUrl("")
setText("")
}
const handleRemoveLink = () => {
editor.chain().focus().extendMarkRange("link").unsetLink().run()
setOpen(false)
setUrl("")
setText("")
}
return (
<Popover open={open} onOpenChange={handleOpen}>
<PopoverTrigger asChild>
<button
type="button"
onClick={(e) => {
if (isLinkActive) {
e.preventDefault()
handleToggle()
}
}}
className={cn(
MAIL_COMPOSE_BOTTOM_ICON_BTN,
isLinkActive && MAIL_COMPOSE_BOTTOM_ICON_BTN_ACTIVE
)}
title={isLinkActive ? "Supprimer le lien" : "Insérer un lien"}
>
<LinkIcon className="h-[18px] w-[18px]" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
className={cn("w-[340px]", MAIL_COMPOSE_POPOVER_CLASS, COMPOSE_PORTAL_Z)}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="flex flex-col gap-2.5">
<div className="text-sm font-medium text-foreground">
{isLinkActive ? "Modifier le lien" : "Insérer un lien"}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Texte à afficher</label>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Texte du lien"
className="h-8 rounded border border-border bg-mail-surface px-2 text-sm text-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">URL</label>
<input
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleInsert()
}
}}
className="h-8 rounded border border-border bg-mail-surface px-2 text-sm text-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring"
autoFocus
/>
</div>
<div className="flex items-center justify-between pt-1">
{isLinkActive ? (
<button
type="button"
onClick={handleRemoveLink}
className="text-sm text-destructive hover:text-destructive/90 transition-colors"
>
Supprimer le lien
</button>
) : (
<span />
)}
<div className="flex gap-2">
<button
type="button"
onClick={() => setOpen(false)}
className="rounded px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent transition-colors"
>
Annuler
</button>
<button
type="button"
onClick={handleInsert}
disabled={!url.trim()}
className={cn("rounded px-3 py-1.5 text-sm font-medium disabled:opacity-50", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
>
{isLinkActive ? "Modifier" : "Insérer"}
</button>
</div>
</div>
</div>
</PopoverContent>
</Popover>
)
}
export function ComposeSignatureButton({
editor,
compose,
}: {
editor: Editor | null
compose: ComposeState
}) {
const { updateCompose } = useComposeActions()
const replaceSignature = useCallback(
(sigId: string | null) => {
if (!editor) return
const newHtml = insertSignatureHtml(editor.getHTML(), sigId)
editor.commands.setContent(newHtml)
updateCompose(compose.id, { bodyHtml: newHtml, signatureId: sigId })
},
[editor, compose.id, updateCompose]
)
const toggleAutoInsert = useCallback(() => {
const newVal = !compose.autoInsertSignature
updateCompose(compose.id, { autoInsertSignature: newVal })
if (!newVal) {
replaceSignature(null)
} else {
const sigId = compose.from.defaultSignatureId
if (sigId) replaceSignature(sigId)
}
}, [compose.autoInsertSignature, compose.from.defaultSignatureId, compose.id, updateCompose, replaceSignature])
if (!editor) return null
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
title="Insérer une signature"
>
<PenTool className="h-[18px] w-[18px]" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="top"
className={cn(MAIL_MENU_SURFACE_CLASS, "min-w-[220px]", COMPOSE_PORTAL_Z)}
>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
toggleAutoInsert()
}}
className="gap-2"
>
<span className="flex h-4 w-4 items-center justify-center">
{compose.autoInsertSignature && <span className="text-xs"></span>}
</span>
Insérer automatiquement
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => replaceSignature(null)}
className={cn("gap-2", !compose.signatureId && MAIL_COMPOSE_MENU_SELECTED_CLASS)}
>
<span className="flex h-4 w-4 items-center justify-center">
{!compose.signatureId && <span className="text-xs"></span>}
</span>
Aucune signature
</DropdownMenuItem>
{SIGNATURES.map((sig) => (
<DropdownMenuItem
key={sig.id}
onSelect={() => replaceSignature(sig.id)}
className={cn("gap-2", compose.signatureId === sig.id && MAIL_COMPOSE_MENU_SELECTED_CLASS)}
>
<span className="flex h-4 w-4 items-center justify-center">
{compose.signatureId === sig.id && <span className="text-xs"></span>}
</span>
{sig.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
export interface ComposeBottomToolbarProps {
compose: ComposeState
editor: Editor | null
isEditingScheduled: boolean
showFormatting: boolean
sendMenuOpen: boolean
setShowFormatting: (v: boolean | ((prev: boolean) => boolean)) => void
setSendMenuOpen: (v: boolean) => void
handleSend: () => void
saveScheduledEdit: () => void | Promise<void>
sendScheduledFromEditNow: () => void | Promise<void>
applyScheduledPlanAt: (sendAt: Date) => void | Promise<void>
submitScheduledSendAt: (sendAt: Date) => void | Promise<void>
handleClose: () => void
fileInputRef: React.RefObject<HTMLInputElement | null>
imageInputRef: React.RefObject<HTMLInputElement | null>
}
export function ComposeBottomToolbar(props: ComposeBottomToolbarProps) {
const {
compose,
editor,
isEditingScheduled,
showFormatting,
sendMenuOpen,
setShowFormatting,
setSendMenuOpen,
handleSend,
saveScheduledEdit,
sendScheduledFromEditNow,
applyScheduledPlanAt,
submitScheduledSendAt,
handleClose,
fileInputRef,
imageInputRef,
} = props
return (
<div className="flex shrink-0 items-center gap-1 border-t border-border px-2 py-1.5">
{/* Send / save + dropdown */}
<div className="flex items-center">
{isEditingScheduled ? (
<>
<button
type="button"
onClick={() => void saveScheduledEdit()}
className={cn("rounded-l-full px-5 text-sm font-medium", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
>
Enregistrer
</button>
<DropdownMenu modal={false} open={sendMenuOpen} onOpenChange={setSendMenuOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn("rounded-r-full border-l border-primary-foreground/30 px-1.5", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
>
<ChevronDown className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}>
<DropdownMenuItem
onSelect={() => {
void sendScheduledFromEditNow()
}}
>
<Send className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
Envoyer maintenant
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="[&>svg:last-child]:text-muted-foreground">
<Clock className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
Planifier
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}>
<DropdownMenuItem
onSelect={() => {
void applyScheduledPlanAt(
new Date(Date.now() + 60 * 60 * 1000)
)
}}
>
<Clock className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
Envoyer dans une heure
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
void applyScheduledPlanAt(
getNextLocalWallClockDate(9, 0)
)
}}
>
<Clock className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
Envoyer à 9h
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<>
<button
type="button"
onClick={handleSend}
className={cn("rounded-l-full px-5 text-sm font-medium", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
>
Envoyer
</button>
<DropdownMenu modal={false} open={sendMenuOpen} onOpenChange={setSendMenuOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn("rounded-r-full border-l border-primary-foreground/30 px-1.5", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
>
<ChevronDown className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}>
<DropdownMenuItem
onSelect={() => {
void submitScheduledSendAt(
new Date(Date.now() + 60 * 60 * 1000)
)
}}
>
<Clock className="h-4 w-4 text-muted-foreground" />
Envoyer dans une heure
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
void submitScheduledSendAt(
getNextLocalWallClockDate(9, 0)
)
}}
>
<Clock className="h-4 w-4 text-muted-foreground" />
Envoyer à 9h
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setSendMenuOpen(false)}>
<Clock className="h-4 w-4 text-muted-foreground" />
Programmer l&apos;envoi
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
{/* Toolbar icons */}
<div className="flex items-center gap-0.5 ml-1">
<button
type="button"
onClick={() => setShowFormatting(!showFormatting)}
className={cn(
MAIL_COMPOSE_BOTTOM_ICON_BTN,
showFormatting && MAIL_COMPOSE_BOTTOM_ICON_BTN_ACTIVE
)}
title="Options de mise en forme"
>
<Type className="h-[18px] w-[18px]" />
</button>
<button
type="button"
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
title="Joindre des fichiers"
onClick={() => fileInputRef.current?.click()}
>
<Paperclip className="h-[18px] w-[18px]" />
</button>
<ComposeLinkButton editor={editor} />
<ComposeEmojiButton editor={editor} />
<button
type="button"
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
title="Insérer des fichiers avec Google Drive"
>
<HardDrive className="h-[18px] w-[18px]" />
</button>
<button
type="button"
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
title="Insérer une photo"
onClick={() => imageInputRef.current?.click()}
>
<ImageIcon className="h-[18px] w-[18px]" />
</button>
<button
type="button"
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
title="Activer le mode confidentiel"
>
<Lock className="h-[18px] w-[18px]" />
</button>
<ComposeSignatureButton editor={editor} compose={compose} />
<button
type="button"
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
title="Plus d'options"
>
<MoreVertical className="h-[18px] w-[18px]" />
</button>
</div>
<div className="flex-1" />
<button
type="button"
onClick={handleClose}
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
title="Supprimer le brouillon"
>
<Trash2 className="h-[18px] w-[18px]" />
</button>
</div>
)
}

View File

@ -0,0 +1,79 @@
"use client"
import {
useState,
useCallback,
lazy,
Suspense,
} from "react"
import { type Editor } from "@tiptap/react"
import { Smile } from "lucide-react"
import { useTheme } from "next-themes"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import data from "@emoji-mart/data"
import { cn } from "@/lib/utils"
import { MAIL_COMPOSE_BOTTOM_ICON_BTN } from "@/lib/mail-chrome-classes"
import { COMPOSE_PORTAL_Z } from "./compose-shared"
const LazyPicker = lazy(() => import("@emoji-mart/react"))
function ComposeEmojiPicker({ onSelect }: { onSelect: (emoji: { native: string }) => void }) {
const { resolvedTheme } = useTheme()
return (
<Suspense fallback={<div className="flex h-[435px] w-[352px] items-center justify-center text-sm text-muted-foreground">Chargement</div>}>
<LazyPicker
data={data}
onEmojiSelect={onSelect}
locale="fr"
theme={resolvedTheme === "dark" ? "dark" : "light"}
previewPosition="none"
skinTonePosition="search"
set="native"
/>
</Suspense>
)
}
export function ComposeEmojiButton({
editor,
}: {
editor: Editor | null
}) {
const [open, setOpen] = useState(false)
const handleSelect = useCallback(
(emoji: { native: string }) => {
editor?.chain().focus().insertContent(emoji.native).run()
setOpen(false)
},
[editor]
)
if (!editor) return null
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
title="Insérer un emoji"
>
<Smile className="h-[18px] w-[18px]" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
className={cn("w-auto border-0 bg-popover p-0 shadow-xl", COMPOSE_PORTAL_Z)}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<ComposeEmojiPicker onSelect={handleSelect} />
</PopoverContent>
</Popover>
)
}

View File

@ -0,0 +1,277 @@
"use client"
import { useState } from "react"
import { useEditor } from "@tiptap/react"
import {
ChevronDown,
AlignLeft,
AlignCenter,
AlignRight,
AlignJustify,
Palette,
ALargeSmall,
CaseSensitive,
} from "lucide-react"
import { cn } from "@/lib/utils"
import {
MAIL_COMPOSE_MENU_SELECTED_CLASS,
MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE,
} from "@/lib/mail-chrome-classes"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { COMPOSE_PORTAL_Z } from "./compose-shared"
export const FONT_FAMILIES = [
{ label: "Sans Serif", value: "sans-serif" },
{ label: "Serif", value: "serif" },
{ label: "Monospace", value: "monospace" },
{ label: "Cursive", value: "cursive" },
{ label: "Comic Sans MS", value: "Comic Sans MS, cursive" },
{ label: "Garamond", value: "Garamond, serif" },
{ label: "Georgia", value: "Georgia, serif" },
{ label: "Impact", value: "Impact, sans-serif" },
{ label: "Tahoma", value: "Tahoma, sans-serif" },
{ label: "Trebuchet MS", value: "Trebuchet MS, sans-serif" },
{ label: "Verdana", value: "Verdana, sans-serif" },
]
export const FONT_SIZES = [
{ label: "Très petit", value: "10px" },
{ label: "Petit", value: "13px" },
{ label: "Normal", value: "" },
{ label: "Grand", value: "18px" },
{ label: "Très grand", value: "24px" },
{ label: "Énorme", value: "32px" },
]
export const TEXT_COLORS = [
"#000000", "#434343", "#666666", "#999999", "#cccccc", "#efefef", "#f3f3f3", "#ffffff",
"#fb4934", "#fe8019", "#fabd2f", "#b8bb26", "#8ec07c", "#83a598", "#d3869b", "#ebdbb2",
"#cc241d", "#d65d0e", "#d79921", "#98971a", "#689d6a", "#458588", "#b16286", "#a89984",
"#9d0006", "#af3a03", "#b57614", "#79740e", "#427b58", "#076678", "#8f3f71", "#7c6f64",
]
export function AlignmentDropdown({
editor,
btnClass,
activeClass,
}: {
editor: NonNullable<ReturnType<typeof useEditor>>
btnClass: string
activeClass: string
}) {
const currentIcon = editor.isActive({ textAlign: "center" })
? AlignCenter
: editor.isActive({ textAlign: "right" })
? AlignRight
: editor.isActive({ textAlign: "justify" })
? AlignJustify
: AlignLeft
const CurrentIcon = currentIcon
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(btnClass, "w-auto gap-0.5 px-1")}
title="Alignement"
>
<CurrentIcon className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<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" }) && MAIL_COMPOSE_MENU_SELECTED_CLASS)}
>
<AlignLeft className="h-4 w-4" /> Aligner à gauche
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => editor.chain().focus().setTextAlign("center").run()}
className={cn(editor.isActive({ textAlign: "center" }) && MAIL_COMPOSE_MENU_SELECTED_CLASS)}
>
<AlignCenter className="h-4 w-4" /> Centrer
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => editor.chain().focus().setTextAlign("right").run()}
className={cn(editor.isActive({ textAlign: "right" }) && MAIL_COMPOSE_MENU_SELECTED_CLASS)}
>
<AlignRight className="h-4 w-4" /> Aligner à droite
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => editor.chain().focus().setTextAlign("justify").run()}
className={cn(editor.isActive({ textAlign: "justify" }) && MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE)}
>
<AlignJustify className="h-4 w-4" /> Justifier
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
export function FontDropdown({
editor,
btnClass,
}: {
editor: NonNullable<ReturnType<typeof useEditor>>
btnClass: string
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={cn(btnClass, "w-auto gap-0.5 px-1")} title="Police">
<CaseSensitive className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<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}
onSelect={() => editor.chain().focus().setMark("textStyle", { fontFamily: f.value }).run()}
style={{ fontFamily: f.value }}
className={cn(
editor.isActive("textStyle", { fontFamily: f.value }) && MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE
)}
>
{f.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
export function FontSizeDropdown({
editor,
btnClass,
}: {
editor: NonNullable<ReturnType<typeof useEditor>>
btnClass: string
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={cn(btnClass, "w-auto gap-0.5 px-1")} title="Taille du texte">
<ALargeSmall className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={cn("min-w-[140px]", COMPOSE_PORTAL_Z)}
>
{FONT_SIZES.map((s) => (
<DropdownMenuItem
key={s.label}
onSelect={() => {
if (s.value) {
editor.chain().focus().setMark("textStyle", { fontSize: s.value }).run()
} else {
editor.chain().focus().setMark("textStyle", { fontSize: null }).removeEmptyTextStyle().run()
}
}}
style={s.value ? { fontSize: s.value } : undefined}
className={cn(
s.value && editor.isActive("textStyle", { fontSize: s.value }) && MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE
)}
>
{s.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
export function ColorDropdown({
editor,
btnClass,
}: {
editor: NonNullable<ReturnType<typeof useEditor>>
btnClass: string
}) {
const [tab, setTab] = useState<"text" | "bg">("text")
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={cn(btnClass, "w-auto gap-0.5 px-1")} title="Couleur du texte">
<Palette className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<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-border pb-2">
<button
type="button"
className={cn(
"flex-1 rounded px-2 py-1 text-xs font-medium transition-colors",
tab === "text" ? MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE : "text-[#5f6368] hover:bg-[#f1f3f4]"
)}
onClick={() => setTab("text")}
>
Couleur du texte
</button>
<button
type="button"
className={cn(
"flex-1 rounded px-2 py-1 text-xs font-medium transition-colors",
tab === "bg" ? MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE : "text-[#5f6368] hover:bg-[#f1f3f4]"
)}
onClick={() => setTab("bg")}
>
Couleur de fond
</button>
</div>
<div className="grid grid-cols-8 gap-1">
{TEXT_COLORS.map((color) => (
<button
key={`${tab}-${color}`}
type="button"
className="h-6 w-6 rounded border border-border hover:scale-110 transition-transform"
style={{ backgroundColor: color }}
title={color}
onClick={() => {
if (tab === "text") {
editor.chain().focus().setColor(color).run()
} else {
editor.chain().focus().setMark("textStyle", { backgroundColor: color }).run()
}
}}
/>
))}
</div>
<button
type="button"
className="mt-2 w-full rounded px-2 py-1 text-xs text-muted-foreground hover:bg-accent transition-colors"
onClick={() => {
if (tab === "text") {
editor.chain().focus().unsetColor().run()
} else {
editor.chain().focus().setMark("textStyle", { backgroundColor: null }).removeEmptyTextStyle().run()
}
}}
>
Réinitialiser
</button>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -0,0 +1,151 @@
"use client"
import { type Editor } from "@tiptap/react"
import {
Bold,
Italic,
Underline as UnderlineIcon,
List,
ListOrdered,
Undo,
Redo,
Indent,
Outdent,
RemoveFormatting,
} from "lucide-react"
import { cn } from "@/lib/utils"
import {
MAIL_COMPOSE_TOOLBAR_BTN,
MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE,
MAIL_COMPOSE_TOOLBAR_SEP,
} from "@/lib/mail-chrome-classes"
import {
AlignmentDropdown,
FontDropdown,
FontSizeDropdown,
ColorDropdown,
} from "./compose-formatting-dropdowns"
export function FormattingToolbar({
editor,
}: {
editor: Editor | null
}) {
if (!editor) return null
const btnClass = MAIL_COMPOSE_TOOLBAR_BTN
const activeClass = MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE
const sep = <span className={MAIL_COMPOSE_TOOLBAR_SEP} aria-hidden />
return (
<div className="compose-toolbar flex flex-wrap items-center border-t border-border bg-muted px-1 py-1">
{/* Undo / Redo */}
<button
type="button"
className={btnClass}
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="Annuler"
>
<Undo className="h-4 w-4" />
</button>
<button
type="button"
className={btnClass}
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="Rétablir"
>
<Redo className="h-4 w-4" />
</button>
{sep}
{/* Font */}
<FontDropdown editor={editor} btnClass={btnClass} />
{sep}
{/* Font size */}
<FontSizeDropdown editor={editor} btnClass={btnClass} />
{sep}
{/* Bold, Italic, Underline, Colors */}
<button
type="button"
className={cn(btnClass, editor.isActive("bold") && activeClass)}
onClick={() => editor.chain().focus().toggleMark("bold").run()}
title="Gras"
>
<Bold className="h-4 w-4" />
</button>
<button
type="button"
className={cn(btnClass, editor.isActive("italic") && activeClass)}
onClick={() => editor.chain().focus().toggleMark("italic").run()}
title="Italique"
>
<Italic className="h-4 w-4" />
</button>
<button
type="button"
className={cn(btnClass, editor.isActive("underline") && activeClass)}
onClick={() => editor.chain().focus().toggleUnderline().run()}
title="Souligné"
>
<UnderlineIcon className="h-4 w-4" />
</button>
<ColorDropdown editor={editor} btnClass={btnClass} />
{sep}
{/* Alignment dropdown, lists, indent/outdent, remove formatting */}
<AlignmentDropdown editor={editor} btnClass={btnClass} activeClass={activeClass} />
<button
type="button"
className={cn(btnClass, editor.isActive("orderedList") && activeClass)}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
title="Liste numérotée"
>
<ListOrdered className="h-4 w-4" />
</button>
<button
type="button"
className={cn(btnClass, editor.isActive("bulletList") && activeClass)}
onClick={() => editor.chain().focus().toggleBulletList().run()}
title="Liste à puces"
>
<List className="h-4 w-4" />
</button>
<button
type="button"
className={btnClass}
onClick={() => {
try { editor.chain().focus().liftListItem("listItem").run() } catch { /* not in list */ }
}}
title="Désindenter"
>
<Outdent className="h-4 w-4" />
</button>
<button
type="button"
className={btnClass}
onClick={() => {
try { editor.chain().focus().sinkListItem("listItem").run() } catch { /* not in list */ }
}}
title="Indenter"
>
<Indent className="h-4 w-4" />
</button>
<button
type="button"
className={btnClass}
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
title="Supprimer la mise en forme"
>
<RemoveFormatting className="h-4 w-4" />
</button>
</div>
)
}

View File

@ -0,0 +1,115 @@
"use client"
import { useRef, useCallback, useMemo } from "react"
import { useIsXs } from "@/hooks/use-xs"
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
import { useComposeWindows } from "@/lib/compose-context"
import { cn } from "@/lib/utils"
import { ComposeWindow } from "@/components/gmail/compose/compose-window"
export function ComposeModalManager() {
const { composeWindows } = useComposeWindows()
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
const RIGHT_OFFSET = 80
const positions = useMemo(() => {
const reversed = [...nonMaximized].reverse()
const result: { id: string; right: number; hidden: boolean }[] = []
let cursor = RIGHT_OFFSET
for (let i = 0; i < reversed.length; i++) {
const w = reversed[i]
const width = w.minimized ? MINIMIZED_WIDTH : MODAL_WIDTH
result.push({
id: w.id,
right: cursor,
hidden: i >= 2 && !w.minimized,
})
cursor += width + GAP
}
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) => {
const pos = positions.find((p) => p.id === compose.id)
if (!pos) return null
return (
<div
key={compose.id}
className={cn(
"pointer-events-auto fixed bottom-0 z-50 transition-all duration-300",
pos.hidden && "invisible"
)}
style={{ right: pos.right }}
>
<ComposeWindow compose={compose} />
</div>
)
})}
{maximized.map((compose) => (
<div key={compose.id} className="pointer-events-auto">
<ComposeWindow compose={compose} />
</div>
))}
</>
)
}

View File

@ -1,994 +1,3 @@
"use client" export { FormattingToolbar } from "./compose-formatting-toolbar"
export { ComposeBottomToolbar } from "./compose-bottom-toolbar"
import { export type { ComposeBottomToolbarProps } from "./compose-bottom-toolbar"
useState,
useCallback,
lazy,
Suspense,
} from "react"
import { useEditor, type Editor } from "@tiptap/react"
import {
ChevronDown,
Paperclip,
Link as LinkIcon,
Smile,
HardDrive,
Image as ImageIcon,
Lock,
PenTool,
MoreVertical,
Trash2,
Bold,
Italic,
Underline as UnderlineIcon,
AlignLeft,
AlignCenter,
AlignRight,
AlignJustify,
List,
ListOrdered,
Undo,
Redo,
Type,
Clock,
Indent,
Outdent,
RemoveFormatting,
Palette,
ALargeSmall,
CaseSensitive,
Send,
} from "lucide-react"
import {
type ComposeState,
SIGNATURES,
useComposeActions,
} from "@/lib/compose-context"
import { cn, getNextLocalWallClockDate } from "@/lib/utils"
import {
MAIL_COMPOSE_BOTTOM_ICON_BTN,
MAIL_COMPOSE_BOTTOM_ICON_BTN_ACTIVE,
MAIL_COMPOSE_MENU_SELECTED_CLASS,
MAIL_COMPOSE_POPOVER_CLASS,
MAIL_COMPOSE_PRIMARY_SEND_BTN,
MAIL_COMPOSE_TOOLBAR_BTN,
MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE,
MAIL_COMPOSE_TOOLBAR_SEP,
MAIL_MENU_SURFACE_CLASS,
} from "@/lib/mail-chrome-classes"
import { useTheme } from "next-themes"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import data from "@emoji-mart/data"
import { COMPOSE_PORTAL_Z, insertSignatureHtml } from "./compose-shared"
const LazyPicker = lazy(() => import("@emoji-mart/react"))
function ComposeEmojiPicker({ onSelect }: { onSelect: (emoji: { native: string }) => void }) {
const { resolvedTheme } = useTheme()
return (
<Suspense fallback={<div className="flex h-[435px] w-[352px] items-center justify-center text-sm text-muted-foreground">Chargement</div>}>
<LazyPicker
data={data}
onEmojiSelect={onSelect}
locale="fr"
theme={resolvedTheme === "dark" ? "dark" : "light"}
previewPosition="none"
skinTonePosition="search"
set="native"
/>
</Suspense>
)
}
function AlignmentDropdown({
editor,
btnClass,
activeClass,
}: {
editor: NonNullable<ReturnType<typeof useEditor>>
btnClass: string
activeClass: string
}) {
const currentIcon = editor.isActive({ textAlign: "center" })
? AlignCenter
: editor.isActive({ textAlign: "right" })
? AlignRight
: editor.isActive({ textAlign: "justify" })
? AlignJustify
: AlignLeft
const CurrentIcon = currentIcon
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(btnClass, "w-auto gap-0.5 px-1")}
title="Alignement"
>
<CurrentIcon className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<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" }) && MAIL_COMPOSE_MENU_SELECTED_CLASS)}
>
<AlignLeft className="h-4 w-4" /> Aligner à gauche
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => editor.chain().focus().setTextAlign("center").run()}
className={cn(editor.isActive({ textAlign: "center" }) && MAIL_COMPOSE_MENU_SELECTED_CLASS)}
>
<AlignCenter className="h-4 w-4" /> Centrer
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => editor.chain().focus().setTextAlign("right").run()}
className={cn(editor.isActive({ textAlign: "right" }) && MAIL_COMPOSE_MENU_SELECTED_CLASS)}
>
<AlignRight className="h-4 w-4" /> Aligner à droite
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => editor.chain().focus().setTextAlign("justify").run()}
className={cn(editor.isActive({ textAlign: "justify" }) && MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE)}
>
<AlignJustify className="h-4 w-4" /> Justifier
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
const FONT_FAMILIES = [
{ label: "Sans Serif", value: "sans-serif" },
{ label: "Serif", value: "serif" },
{ label: "Monospace", value: "monospace" },
{ label: "Cursive", value: "cursive" },
{ label: "Comic Sans MS", value: "Comic Sans MS, cursive" },
{ label: "Garamond", value: "Garamond, serif" },
{ label: "Georgia", value: "Georgia, serif" },
{ label: "Impact", value: "Impact, sans-serif" },
{ label: "Tahoma", value: "Tahoma, sans-serif" },
{ label: "Trebuchet MS", value: "Trebuchet MS, sans-serif" },
{ label: "Verdana", value: "Verdana, sans-serif" },
]
const FONT_SIZES = [
{ label: "Très petit", value: "10px" },
{ label: "Petit", value: "13px" },
{ label: "Normal", value: "" },
{ label: "Grand", value: "18px" },
{ label: "Très grand", value: "24px" },
{ label: "Énorme", value: "32px" },
]
const TEXT_COLORS = [
"#000000", "#434343", "#666666", "#999999", "#cccccc", "#efefef", "#f3f3f3", "#ffffff",
"#fb4934", "#fe8019", "#fabd2f", "#b8bb26", "#8ec07c", "#83a598", "#d3869b", "#ebdbb2",
"#cc241d", "#d65d0e", "#d79921", "#98971a", "#689d6a", "#458588", "#b16286", "#a89984",
"#9d0006", "#af3a03", "#b57614", "#79740e", "#427b58", "#076678", "#8f3f71", "#7c6f64",
]
function FontDropdown({
editor,
btnClass,
}: {
editor: NonNullable<ReturnType<typeof useEditor>>
btnClass: string
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={cn(btnClass, "w-auto gap-0.5 px-1")} title="Police">
<CaseSensitive className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<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}
onSelect={() => editor.chain().focus().setMark("textStyle", { fontFamily: f.value }).run()}
style={{ fontFamily: f.value }}
className={cn(
editor.isActive("textStyle", { fontFamily: f.value }) && MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE
)}
>
{f.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
function FontSizeDropdown({
editor,
btnClass,
}: {
editor: NonNullable<ReturnType<typeof useEditor>>
btnClass: string
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={cn(btnClass, "w-auto gap-0.5 px-1")} title="Taille du texte">
<ALargeSmall className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={cn("min-w-[140px]", COMPOSE_PORTAL_Z)}
>
{FONT_SIZES.map((s) => (
<DropdownMenuItem
key={s.label}
onSelect={() => {
if (s.value) {
editor.chain().focus().setMark("textStyle", { fontSize: s.value }).run()
} else {
editor.chain().focus().setMark("textStyle", { fontSize: null }).removeEmptyTextStyle().run()
}
}}
style={s.value ? { fontSize: s.value } : undefined}
className={cn(
s.value && editor.isActive("textStyle", { fontSize: s.value }) && MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE
)}
>
{s.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
function ColorDropdown({
editor,
btnClass,
}: {
editor: NonNullable<ReturnType<typeof useEditor>>
btnClass: string
}) {
const [tab, setTab] = useState<"text" | "bg">("text")
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={cn(btnClass, "w-auto gap-0.5 px-1")} title="Couleur du texte">
<Palette className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<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-border pb-2">
<button
type="button"
className={cn(
"flex-1 rounded px-2 py-1 text-xs font-medium transition-colors",
tab === "text" ? MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE : "text-[#5f6368] hover:bg-[#f1f3f4]"
)}
onClick={() => setTab("text")}
>
Couleur du texte
</button>
<button
type="button"
className={cn(
"flex-1 rounded px-2 py-1 text-xs font-medium transition-colors",
tab === "bg" ? MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE : "text-[#5f6368] hover:bg-[#f1f3f4]"
)}
onClick={() => setTab("bg")}
>
Couleur de fond
</button>
</div>
<div className="grid grid-cols-8 gap-1">
{TEXT_COLORS.map((color) => (
<button
key={`${tab}-${color}`}
type="button"
className="h-6 w-6 rounded border border-border hover:scale-110 transition-transform"
style={{ backgroundColor: color }}
title={color}
onClick={() => {
if (tab === "text") {
editor.chain().focus().setColor(color).run()
} else {
editor.chain().focus().setMark("textStyle", { backgroundColor: color }).run()
}
}}
/>
))}
</div>
<button
type="button"
className="mt-2 w-full rounded px-2 py-1 text-xs text-muted-foreground hover:bg-accent transition-colors"
onClick={() => {
if (tab === "text") {
editor.chain().focus().unsetColor().run()
} else {
editor.chain().focus().setMark("textStyle", { backgroundColor: null }).removeEmptyTextStyle().run()
}
}}
>
Réinitialiser
</button>
</DropdownMenuContent>
</DropdownMenu>
)
}
export function FormattingToolbar({
editor,
}: {
editor: Editor | null
}) {
if (!editor) return null
const btnClass = MAIL_COMPOSE_TOOLBAR_BTN
const activeClass = MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE
const sep = <span className={MAIL_COMPOSE_TOOLBAR_SEP} aria-hidden />
return (
<div className="compose-toolbar flex flex-wrap items-center border-t border-border bg-muted px-1 py-1">
{/* Undo / Redo */}
<button
type="button"
className={btnClass}
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="Annuler"
>
<Undo className="h-4 w-4" />
</button>
<button
type="button"
className={btnClass}
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="Rétablir"
>
<Redo className="h-4 w-4" />
</button>
{sep}
{/* Font */}
<FontDropdown editor={editor} btnClass={btnClass} />
{sep}
{/* Font size */}
<FontSizeDropdown editor={editor} btnClass={btnClass} />
{sep}
{/* Bold, Italic, Underline, Colors */}
<button
type="button"
className={cn(btnClass, editor.isActive("bold") && activeClass)}
onClick={() => editor.chain().focus().toggleMark("bold").run()}
title="Gras"
>
<Bold className="h-4 w-4" />
</button>
<button
type="button"
className={cn(btnClass, editor.isActive("italic") && activeClass)}
onClick={() => editor.chain().focus().toggleMark("italic").run()}
title="Italique"
>
<Italic className="h-4 w-4" />
</button>
<button
type="button"
className={cn(btnClass, editor.isActive("underline") && activeClass)}
onClick={() => editor.chain().focus().toggleUnderline().run()}
title="Souligné"
>
<UnderlineIcon className="h-4 w-4" />
</button>
<ColorDropdown editor={editor} btnClass={btnClass} />
{sep}
{/* Alignment dropdown, lists, indent/outdent, remove formatting */}
<AlignmentDropdown editor={editor} btnClass={btnClass} activeClass={activeClass} />
<button
type="button"
className={cn(btnClass, editor.isActive("orderedList") && activeClass)}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
title="Liste numérotée"
>
<ListOrdered className="h-4 w-4" />
</button>
<button
type="button"
className={cn(btnClass, editor.isActive("bulletList") && activeClass)}
onClick={() => editor.chain().focus().toggleBulletList().run()}
title="Liste à puces"
>
<List className="h-4 w-4" />
</button>
<button
type="button"
className={btnClass}
onClick={() => {
try { editor.chain().focus().liftListItem("listItem").run() } catch { /* not in list */ }
}}
title="Désindenter"
>
<Outdent className="h-4 w-4" />
</button>
<button
type="button"
className={btnClass}
onClick={() => {
try { editor.chain().focus().sinkListItem("listItem").run() } catch { /* not in list */ }
}}
title="Indenter"
>
<Indent className="h-4 w-4" />
</button>
<button
type="button"
className={btnClass}
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
title="Supprimer la mise en forme"
>
<RemoveFormatting className="h-4 w-4" />
</button>
</div>
)
}
function ComposeEmojiButton({
editor,
}: {
editor: Editor | null
}) {
const [open, setOpen] = useState(false)
const handleSelect = useCallback(
(emoji: { native: string }) => {
editor?.chain().focus().insertContent(emoji.native).run()
setOpen(false)
},
[editor]
)
if (!editor) return null
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
title="Insérer un emoji"
>
<Smile className="h-[18px] w-[18px]" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
className={cn("w-auto border-0 bg-popover p-0 shadow-xl", COMPOSE_PORTAL_Z)}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<ComposeEmojiPicker onSelect={handleSelect} />
</PopoverContent>
</Popover>
)
}
function ComposeLinkButton({
editor,
}: {
editor: Editor | null
}) {
const [open, setOpen] = useState(false)
const [url, setUrl] = useState("")
const [text, setText] = useState("")
if (!editor) return null
const isLinkActive = editor.isActive("link")
const handleToggle = () => {
if (isLinkActive) {
editor.chain().focus().extendMarkRange("link").unsetLink().run()
return
}
setOpen(true)
}
const handleOpen = (isOpen: boolean) => {
if (isOpen) {
const { from, to, empty } = editor.state.selection
if (isLinkActive) {
const attrs = editor.getAttributes("link")
setUrl(attrs.href || "")
const selectedText = editor.state.doc.textBetween(from, to, " ")
setText(selectedText)
} else if (!empty) {
const selectedText = editor.state.doc.textBetween(from, to, " ")
setText(selectedText)
setUrl("")
} else {
setText("")
setUrl("")
}
}
setOpen(isOpen)
}
const handleInsert = () => {
if (!url.trim()) return
const href = url.match(/^https?:\/\//) ? url : `https://${url}`
const { empty } = editor.state.selection
if (empty && !isLinkActive) {
const displayText = text.trim() || href
editor
.chain()
.focus()
.insertContent(`<a href="${href}">${displayText}</a>`)
.run()
} else {
if (text.trim() && text.trim() !== editor.state.doc.textBetween(
editor.state.selection.from,
editor.state.selection.to,
" "
)) {
editor
.chain()
.focus()
.deleteSelection()
.insertContent(`<a href="${href}">${text.trim()}</a>`)
.run()
} else {
editor
.chain()
.focus()
.extendMarkRange("link")
.setLink({ href })
.run()
}
}
setOpen(false)
setUrl("")
setText("")
}
const handleRemoveLink = () => {
editor.chain().focus().extendMarkRange("link").unsetLink().run()
setOpen(false)
setUrl("")
setText("")
}
return (
<Popover open={open} onOpenChange={handleOpen}>
<PopoverTrigger asChild>
<button
type="button"
onClick={(e) => {
if (isLinkActive) {
e.preventDefault()
handleToggle()
}
}}
className={cn(
MAIL_COMPOSE_BOTTOM_ICON_BTN,
isLinkActive && MAIL_COMPOSE_BOTTOM_ICON_BTN_ACTIVE
)}
title={isLinkActive ? "Supprimer le lien" : "Insérer un lien"}
>
<LinkIcon className="h-[18px] w-[18px]" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
className={cn("w-[340px]", MAIL_COMPOSE_POPOVER_CLASS, COMPOSE_PORTAL_Z)}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="flex flex-col gap-2.5">
<div className="text-sm font-medium text-foreground">
{isLinkActive ? "Modifier le lien" : "Insérer un lien"}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Texte à afficher</label>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Texte du lien"
className="h-8 rounded border border-border bg-mail-surface px-2 text-sm text-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">URL</label>
<input
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleInsert()
}
}}
className="h-8 rounded border border-border bg-mail-surface px-2 text-sm text-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring"
autoFocus
/>
</div>
<div className="flex items-center justify-between pt-1">
{isLinkActive ? (
<button
type="button"
onClick={handleRemoveLink}
className="text-sm text-destructive hover:text-destructive/90 transition-colors"
>
Supprimer le lien
</button>
) : (
<span />
)}
<div className="flex gap-2">
<button
type="button"
onClick={() => setOpen(false)}
className="rounded px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent transition-colors"
>
Annuler
</button>
<button
type="button"
onClick={handleInsert}
disabled={!url.trim()}
className={cn("rounded px-3 py-1.5 text-sm font-medium disabled:opacity-50", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
>
{isLinkActive ? "Modifier" : "Insérer"}
</button>
</div>
</div>
</div>
</PopoverContent>
</Popover>
)
}
export function ComposeSignatureButton({
editor,
compose,
}: {
editor: Editor | null
compose: ComposeState
}) {
const { updateCompose } = useComposeActions()
const replaceSignature = useCallback(
(sigId: string | null) => {
if (!editor) return
const newHtml = insertSignatureHtml(editor.getHTML(), sigId)
editor.commands.setContent(newHtml)
updateCompose(compose.id, { bodyHtml: newHtml, signatureId: sigId })
},
[editor, compose.id, updateCompose]
)
const toggleAutoInsert = useCallback(() => {
const newVal = !compose.autoInsertSignature
updateCompose(compose.id, { autoInsertSignature: newVal })
if (!newVal) {
replaceSignature(null)
} else {
const sigId = compose.from.defaultSignatureId
if (sigId) replaceSignature(sigId)
}
}, [compose.autoInsertSignature, compose.from.defaultSignatureId, compose.id, updateCompose, replaceSignature])
if (!editor) return null
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
title="Insérer une signature"
>
<PenTool className="h-[18px] w-[18px]" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="top"
className={cn(MAIL_MENU_SURFACE_CLASS, "min-w-[220px]", COMPOSE_PORTAL_Z)}
>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
toggleAutoInsert()
}}
className="gap-2"
>
<span className="flex h-4 w-4 items-center justify-center">
{compose.autoInsertSignature && <span className="text-xs"></span>}
</span>
Insérer automatiquement
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => replaceSignature(null)}
className={cn("gap-2", !compose.signatureId && MAIL_COMPOSE_MENU_SELECTED_CLASS)}
>
<span className="flex h-4 w-4 items-center justify-center">
{!compose.signatureId && <span className="text-xs"></span>}
</span>
Aucune signature
</DropdownMenuItem>
{SIGNATURES.map((sig) => (
<DropdownMenuItem
key={sig.id}
onSelect={() => replaceSignature(sig.id)}
className={cn("gap-2", compose.signatureId === sig.id && MAIL_COMPOSE_MENU_SELECTED_CLASS)}
>
<span className="flex h-4 w-4 items-center justify-center">
{compose.signatureId === sig.id && <span className="text-xs"></span>}
</span>
{sig.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
export interface ComposeBottomToolbarProps {
compose: ComposeState
editor: Editor | null
isEditingScheduled: boolean
showFormatting: boolean
sendMenuOpen: boolean
setShowFormatting: (v: boolean | ((prev: boolean) => boolean)) => void
setSendMenuOpen: (v: boolean) => void
handleSend: () => void
saveScheduledEdit: () => void | Promise<void>
sendScheduledFromEditNow: () => void | Promise<void>
applyScheduledPlanAt: (sendAt: Date) => void | Promise<void>
submitScheduledSendAt: (sendAt: Date) => void | Promise<void>
handleClose: () => void
fileInputRef: React.RefObject<HTMLInputElement | null>
imageInputRef: React.RefObject<HTMLInputElement | null>
}
export function ComposeBottomToolbar(props: ComposeBottomToolbarProps) {
const {
compose,
editor,
isEditingScheduled,
showFormatting,
sendMenuOpen,
setShowFormatting,
setSendMenuOpen,
handleSend,
saveScheduledEdit,
sendScheduledFromEditNow,
applyScheduledPlanAt,
submitScheduledSendAt,
handleClose,
fileInputRef,
imageInputRef,
} = props
return (
<div className="flex shrink-0 items-center gap-1 border-t border-border px-2 py-1.5">
{/* Send / save + dropdown */}
<div className="flex items-center">
{isEditingScheduled ? (
<>
<button
type="button"
onClick={() => void saveScheduledEdit()}
className={cn("rounded-l-full px-5 text-sm font-medium", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
>
Enregistrer
</button>
<DropdownMenu modal={false} open={sendMenuOpen} onOpenChange={setSendMenuOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn("rounded-r-full border-l border-primary-foreground/30 px-1.5", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
>
<ChevronDown className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}>
<DropdownMenuItem
onSelect={() => {
void sendScheduledFromEditNow()
}}
>
<Send className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
Envoyer maintenant
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="[&>svg:last-child]:text-muted-foreground">
<Clock className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
Planifier
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}>
<DropdownMenuItem
onSelect={() => {
void applyScheduledPlanAt(
new Date(Date.now() + 60 * 60 * 1000)
)
}}
>
<Clock className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
Envoyer dans une heure
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
void applyScheduledPlanAt(
getNextLocalWallClockDate(9, 0)
)
}}
>
<Clock className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
Envoyer à 9h
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<>
<button
type="button"
onClick={handleSend}
className={cn("rounded-l-full px-5 text-sm font-medium", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
>
Envoyer
</button>
<DropdownMenu modal={false} open={sendMenuOpen} onOpenChange={setSendMenuOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn("rounded-r-full border-l border-primary-foreground/30 px-1.5", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
>
<ChevronDown className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}>
<DropdownMenuItem
onSelect={() => {
void submitScheduledSendAt(
new Date(Date.now() + 60 * 60 * 1000)
)
}}
>
<Clock className="h-4 w-4 text-muted-foreground" />
Envoyer dans une heure
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
void submitScheduledSendAt(
getNextLocalWallClockDate(9, 0)
)
}}
>
<Clock className="h-4 w-4 text-muted-foreground" />
Envoyer à 9h
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setSendMenuOpen(false)}>
<Clock className="h-4 w-4 text-muted-foreground" />
Programmer l&apos;envoi
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
{/* Toolbar icons */}
<div className="flex items-center gap-0.5 ml-1">
<button
type="button"
onClick={() => setShowFormatting(!showFormatting)}
className={cn(
MAIL_COMPOSE_BOTTOM_ICON_BTN,
showFormatting && MAIL_COMPOSE_BOTTOM_ICON_BTN_ACTIVE
)}
title="Options de mise en forme"
>
<Type className="h-[18px] w-[18px]" />
</button>
<button
type="button"
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
title="Joindre des fichiers"
onClick={() => fileInputRef.current?.click()}
>
<Paperclip className="h-[18px] w-[18px]" />
</button>
<ComposeLinkButton editor={editor} />
<ComposeEmojiButton editor={editor} />
<button
type="button"
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
title="Insérer des fichiers avec Google Drive"
>
<HardDrive className="h-[18px] w-[18px]" />
</button>
<button
type="button"
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
title="Insérer une photo"
onClick={() => imageInputRef.current?.click()}
>
<ImageIcon className="h-[18px] w-[18px]" />
</button>
<button
type="button"
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
title="Activer le mode confidentiel"
>
<Lock className="h-[18px] w-[18px]" />
</button>
<ComposeSignatureButton editor={editor} compose={compose} />
<button
type="button"
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
title="Plus d'options"
>
<MoreVertical className="h-[18px] w-[18px]" />
</button>
</div>
<div className="flex-1" />
<button
type="button"
onClick={handleClose}
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
title="Supprimer le brouillon"
>
<Trash2 className="h-[18px] w-[18px]" />
</button>
</div>
)
}

View File

@ -0,0 +1,249 @@
"use client"
import { EditorContent } from "@tiptap/react"
import { Maximize2, X } from "lucide-react"
import { readCoarsePointerMatches } from "@/hooks/use-touch-nav"
import type { ComposeState } from "@/lib/compose-context"
import type { Email } from "@/lib/email-data"
import { cn } from "@/lib/utils"
import {
MAIL_COMPOSE_TITLEBAR_CLASS,
MAIL_ICON_BTN,
} from "@/lib/mail-chrome-classes"
import { ComposeRecipientFields } from "@/components/gmail/compose/compose-recipients"
import {
ComposeBottomToolbar,
FormattingToolbar,
} from "@/components/gmail/compose/compose-toolbar"
import {
ComposeAttachmentsList,
ComposeDockTitleBar,
ComposeDropOverlay,
ComposeInlineRecipientHeader,
ComposeXsSheetHeader,
} from "@/components/gmail/compose/compose-editor-chrome"
import { useComposeWindow } from "@/components/gmail/compose/use-compose-window"
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 {
isInline,
isEditingScheduled,
editor,
titleText,
showFormatting,
setShowFormatting,
recipientsFocused,
setRecipientsFocused,
sendMenuOpen,
setSendMenuOpen,
isDragOver,
fieldsRef,
inlineRecipientShellRef,
fileInputRef,
imageInputRef,
handleClose,
handleSend,
saveScheduledEdit,
sendScheduledFromEditNow,
applyScheduledPlanAt,
submitScheduledSendAt,
addFiles,
removeAttachment,
handleDrop,
handleDragOver,
handleDragLeave,
recipientSummary,
showReplyAllInMenu,
ThreadKindIcon,
openInlinePreset,
openDockFromInline,
recipientFieldsProps,
toggleMinimize,
toggleMaximize,
updateCompose,
} = useComposeWindow(compose, threadSourceEmail, isXsSheet, bindXsSheetClose)
const modalContent = (
<div
data-compose-window
className={cn(
"relative flex flex-col overflow-hidden bg-mail-surface text-foreground",
isInline
? "min-h-[360px] w-full rounded-xl border border-border 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
? readCoarsePointerMatches()
? "fixed inset-0 z-60 rounded-none"
: "fixed inset-12 z-60 rounded-lg"
: "h-[480px] w-[500px]"
)
)}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
{/* Hidden file inputs */}
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files && e.target.files.length > 0) {
addFiles(e.target.files)
e.target.value = ""
}
}}
/>
<input
ref={imageInputRef}
type="file"
multiple
accept="image/*"
className="hidden"
onChange={(e) => {
if (e.target.files && e.target.files.length > 0) {
addFiles(e.target.files)
e.target.value = ""
}
}}
/>
{/* Drop overlay */}
{isDragOver ? <ComposeDropOverlay /> : null}
{isInline ? (
<ComposeInlineRecipientHeader
compose={compose}
threadSourceEmail={threadSourceEmail}
recipientSummary={recipientSummary}
recipientsFocused={recipientsFocused}
showReplyAllInMenu={showReplyAllInMenu}
ThreadKindIcon={ThreadKindIcon}
onOpenInlinePreset={openInlinePreset}
onOpenDockFromInline={openDockFromInline}
onActivateRecipients={() => setRecipientsFocused(true)}
updateCompose={updateCompose}
recipientFieldsProps={recipientFieldsProps}
fieldsRef={fieldsRef}
inlineRecipientShellRef={inlineRecipientShellRef}
/>
) : isXsSheet ? (
<ComposeXsSheetHeader titleText={titleText} onClose={handleClose} />
) : (
<>
{/* Title bar */}
<ComposeDockTitleBar
titleText={titleText}
maximized={compose.maximized}
onMinimize={() => toggleMinimize(compose.id)}
onMaximize={() => toggleMaximize(compose.id)}
onClose={handleClose}
/>
</>
)}
{!isInline && (
<div ref={fieldsRef} className="flex shrink-0 flex-col">
<ComposeRecipientFields {...recipientFieldsProps} />
</div>
)}
{/* Editor */}
<div className="min-h-0 flex-1 overflow-y-auto">
<EditorContent editor={editor} />
</div>
<ComposeAttachmentsList
attachments={compose.attachments}
onRemove={removeAttachment}
/>
{showFormatting ? <FormattingToolbar editor={editor} /> : null}
<ComposeBottomToolbar
compose={compose}
editor={editor}
isEditingScheduled={isEditingScheduled}
showFormatting={showFormatting}
sendMenuOpen={sendMenuOpen}
setShowFormatting={setShowFormatting}
setSendMenuOpen={setSendMenuOpen}
handleSend={handleSend}
saveScheduledEdit={saveScheduledEdit}
sendScheduledFromEditNow={sendScheduledFromEditNow}
applyScheduledPlanAt={applyScheduledPlanAt}
submitScheduledSendAt={submitScheduledSendAt}
handleClose={handleClose}
fileInputRef={fileInputRef}
imageInputRef={imageInputRef}
/>
</div>
)
if (compose.minimized && !isInline && !isXsSheet) {
return (
<div
className={cn(
MAIL_COMPOSE_TITLEBAR_CLASS,
"h-9 w-[280px] cursor-pointer shadow-lg transition-shadow hover:shadow-xl"
)}
onClick={() => toggleMinimize(compose.id)}
>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
{titleText}
</span>
<div className="flex items-center gap-0.5">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
toggleMaximize(compose.id)
}}
className={cn("flex h-6 w-6 items-center justify-center rounded-full", MAIL_ICON_BTN)}
>
<Maximize2 className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleClose()
}}
className={cn("flex h-6 w-6 items-center justify-center rounded-full", MAIL_ICON_BTN)}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
}
if (compose.maximized && !isInline && !isXsSheet) {
return (
<>
<div
className="fixed inset-0 z-55 bg-black/50"
onClick={() => toggleMaximize(compose.id)}
/>
{modalContent}
</>
)
}
return modalContent
}

View File

@ -0,0 +1,2 @@
export { ComposeWindow } from "./compose-window"
export { ComposeModalManager } from "./compose-modal-manager"

View File

@ -0,0 +1,549 @@
"use client"
import {
useState,
useRef,
useEffect,
useLayoutEffect,
useCallback,
useMemo,
} from "react"
import { useEditor } from "@tiptap/react"
import { type Extensions } from "@tiptap/core"
import StarterKit from "@tiptap/starter-kit"
import Underline from "@tiptap/extension-underline"
import Link from "@tiptap/extension-link"
import TextAlign from "@tiptap/extension-text-align"
import { TextStyle, FontFamily, FontSize, BackgroundColor } from "@tiptap/extension-text-style"
import Color from "@tiptap/extension-color"
import {
Reply,
ReplyAll,
Forward,
} from "lucide-react"
import {
type ComposeState,
cloneComposeForPendingSend,
DEFAULT_IDENTITIES,
useComposeActions,
} from "@/lib/compose-context"
import { useScheduledMail } from "@/lib/scheduled-mail-context"
import type { ScheduleSendPayload } from "@/lib/api/scheduled-mail"
import type { Email } from "@/lib/email-data"
import {
buildThreadComposePreset,
collectThreadParticipants,
} from "@/lib/thread-compose-preset"
import { toast } from "sonner"
import { showPendingSendToast } from "@/lib/pending-send-toast"
import { cn } from "@/lib/utils"
import { insertSignatureHtml, SignatureBlock, stripSignature } from "@/components/gmail/compose/compose-shared"
export function useComposeWindow(
compose: ComposeState,
threadSourceEmail: Email | null = null,
isXsSheet = false,
bindXsSheetClose?: (fn: (() => void) | null) => void
) {
const {
closeCompose,
updateCompose,
applyComposePreset,
toggleMinimize,
toggleMaximize,
restoreComposeFromSnapshot,
} = useComposeActions()
const { scheduleSend, requestUpdateScheduledSend, requestSendScheduledNow } =
useScheduledMail()
const isInline = compose.placement === "inline"
const isEditingScheduled = compose.editingScheduledId != null
const [showFormatting, setShowFormatting] = useState(false)
const [recipientsFocused, setRecipientsFocused] = useState(false)
const [sendMenuOpen, setSendMenuOpen] = useState(false)
const [isDragOver, setIsDragOver] = useState(false)
const fieldsRef = useRef<HTMLDivElement>(null)
const inlineRecipientShellRef = useRef<HTMLDivElement>(null)
const subjectInputRef = useRef<HTMLInputElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const imageInputRef = useRef<HTMLInputElement>(null)
const snapBodyCaretToStartOnHeaderTab =
!isInline &&
!compose.threadEmailId &&
!compose.threadKind &&
!compose.editingScheduledId
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit,
Underline,
Link.configure({ openOnClick: false }),
TextStyle,
Color,
BackgroundColor,
FontFamily,
FontSize,
TextAlign.configure({ types: ["heading", "paragraph"], alignments: ["left", "center", "right", "justify"] }),
SignatureBlock,
] as Extensions,
content: compose.bodyHtml,
onUpdate: ({ editor: ed }) => {
updateCompose(compose.id, { bodyHtml: ed.getHTML() })
},
onFocus: ({ editor: ed, event }) => {
if (!snapBodyCaretToStartOnHeaderTab) return
const rt = event.relatedTarget as Node | null
if (!rt || !fieldsRef.current?.contains(rt)) return
window.requestAnimationFrame(() => {
if (!ed.view.hasFocus()) return
try {
ed.chain().setTextSelection(1).run()
} catch {
/* empty doc edge */
}
})
},
editorProps: {
attributes: {
class: cn(
"prose prose-sm max-w-none px-3 py-2 text-sm text-foreground outline-none focus:outline-none",
isInline
? "min-h-[200px]"
: isXsSheet
? "min-h-[min(36vh,280px)]"
: "min-h-[150px]"
),
},
},
})
const titleText = compose.subject || "Nouveau message"
const bodyWithoutSig = stripSignature(compose.bodyHtml)
.replace(/<p><\/p>/g, "")
.trim()
const hasContent =
compose.subject.trim() !== "" ||
compose.to.length > 0 ||
compose.cc.length > 0 ||
compose.bcc.length > 0 ||
compose.attachments.length > 0 ||
bodyWithoutSig !== ""
const handleIdentityChange = useCallback(
(identity: (typeof DEFAULT_IDENTITIES)[number]) => {
if (compose.autoInsertSignature && editor) {
const sigId = identity.defaultSignatureId
const newHtml = insertSignatureHtml(editor.getHTML(), sigId)
editor.commands.setContent(newHtml)
updateCompose(compose.id, { from: identity, bodyHtml: newHtml, signatureId: sigId })
} else {
updateCompose(compose.id, { from: identity })
}
},
[compose.id, compose.autoInsertSignature, editor, updateCompose]
)
const handleClose = () => {
const threadInlineDiscard =
isInline && compose.threadEmailId
? ({ discardThreadReplyDraft: true } as const)
: undefined
if (!hasContent) {
closeCompose(compose.id, threadInlineDiscard)
} else {
updateCompose(compose.id, { savedAt: Date.now() })
closeCompose(compose.id, threadInlineDiscard)
}
}
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, " ")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim()
}, [])
const handleSend = useCallback(() => {
if (compose.to.length === 0) return
const bodyHtml = editor?.getHTML() ?? compose.bodyHtml
const snapshot = cloneComposeForPendingSend({ ...compose, bodyHtml })
closeCompose(compose.id, { sent: true })
showPendingSendToast({
onCommit: async () => {},
onCancel: () => restoreComposeFromSnapshot(snapshot),
})
}, [
closeCompose,
compose,
editor,
restoreComposeFromSnapshot,
])
const submitScheduledSendAt = useCallback(
async (sendAt: Date) => {
if (isEditingScheduled) return
if (compose.to.length === 0) return
setSendMenuOpen(false)
const bodyHtml = editor?.getHTML() ?? compose.bodyHtml
await scheduleSend({
sendAtIso: sendAt.toISOString(),
to: compose.to.map((c) => ({ name: c.name, email: c.email })),
subject: compose.subject,
previewText: htmlToPreviewText(bodyHtml).slice(0, 500),
bodyHtml,
})
const whenLabel = sendAt.toLocaleString("fr-FR", {
dateStyle: "medium",
timeStyle: "short",
})
toast.message(`Ce mail sera envoyé le ${whenLabel}`)
closeCompose(compose.id, { sent: true })
},
[
isEditingScheduled,
compose.bodyHtml,
compose.id,
compose.subject,
compose.to,
closeCompose,
editor,
htmlToPreviewText,
scheduleSend,
]
)
const buildSchedulePayload = useCallback(
(sendAtIso: string): ScheduleSendPayload | null => {
if (compose.to.length === 0) return null
const bodyHtml = editor?.getHTML() ?? compose.bodyHtml
return {
sendAtIso,
to: compose.to.map((c) => ({ name: c.name, email: c.email })),
subject: compose.subject,
previewText: htmlToPreviewText(bodyHtml).slice(0, 500),
bodyHtml,
}
},
[compose.to, compose.subject, compose.bodyHtml, editor, htmlToPreviewText]
)
const saveScheduledEdit = useCallback(async () => {
const id = compose.editingScheduledId
if (!id) return
const iso =
compose.scheduledSendAtIso ?? new Date().toISOString()
const payload = buildSchedulePayload(iso)
if (!payload) return
await requestUpdateScheduledSend(id, payload)
toast.message("Modifications enregistrées")
closeCompose(compose.id)
}, [
buildSchedulePayload,
closeCompose,
compose.editingScheduledId,
compose.id,
compose.scheduledSendAtIso,
requestUpdateScheduledSend,
])
const sendScheduledFromEditNow = useCallback(async () => {
const id = compose.editingScheduledId
if (!id) return
setSendMenuOpen(false)
const bodyHtml = editor?.getHTML() ?? compose.bodyHtml
const snapshot = cloneComposeForPendingSend({ ...compose, bodyHtml })
closeCompose(compose.id, { sent: true })
showPendingSendToast({
onCommit: async () => {
const schedId = snapshot.editingScheduledId
if (!schedId || snapshot.to.length === 0) return
const iso = snapshot.scheduledSendAtIso ?? new Date().toISOString()
const body = snapshot.bodyHtml
const payload = {
sendAtIso: iso,
to: snapshot.to.map((c) => ({ name: c.name, email: c.email })),
subject: snapshot.subject,
previewText: htmlToPreviewText(body).slice(0, 500),
bodyHtml: body,
}
await requestUpdateScheduledSend(schedId, payload)
await requestSendScheduledNow(schedId)
},
onCancel: () => restoreComposeFromSnapshot(snapshot),
})
}, [
closeCompose,
compose,
editor,
htmlToPreviewText,
requestSendScheduledNow,
requestUpdateScheduledSend,
restoreComposeFromSnapshot,
])
const applyScheduledPlanAt = useCallback(
async (sendAt: Date) => {
const id = compose.editingScheduledId
if (!id) return
setSendMenuOpen(false)
const iso = sendAt.toISOString()
const payload = buildSchedulePayload(iso)
if (!payload) return
await requestUpdateScheduledSend(id, payload)
updateCompose(compose.id, { scheduledSendAtIso: iso })
const whenLabel = sendAt.toLocaleString("fr-FR", {
dateStyle: "medium",
timeStyle: "short",
})
toast.message(`Envoi planifié le ${whenLabel}`)
},
[
buildSchedulePayload,
compose.editingScheduledId,
compose.id,
requestUpdateScheduledSend,
updateCompose,
]
)
const addFiles = useCallback((files: FileList | File[]) => {
const newAttachments = Array.from(files).map((file) => ({
id: `att-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
file,
name: file.name,
size: file.size,
type: file.type,
}))
updateCompose(compose.id, {
attachments: [...compose.attachments, ...newAttachments],
})
}, [compose.id, compose.attachments, updateCompose])
const removeAttachment = useCallback((attId: string) => {
updateCompose(compose.id, {
attachments: compose.attachments.filter((a) => a.id !== attId),
})
}, [compose.id, compose.attachments, updateCompose])
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragOver(false)
if (e.dataTransfer.files.length > 0) {
addFiles(e.dataTransfer.files)
}
}, [addFiles])
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragOver(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) {
setIsDragOver(false)
}
}, [])
const showFromField = recipientsFocused || isXsSheet
useLayoutEffect(() => {
if (!isInline || !compose.focusToOnMount) return
setRecipientsFocused(true)
}, [isInline, compose.focusToOnMount])
useEffect(() => {
if (!recipientsFocused) return
const handleClickOutside = (e: Event) => {
const target = e.target as Node
const root = isInline ? inlineRecipientShellRef.current : fieldsRef.current
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) {
updateCompose(compose.id, { showCc: false })
}
if (compose.showBcc && compose.bcc.length === 0) {
updateCompose(compose.id, { showBcc: false })
}
}
}
document.addEventListener("pointerdown", handleClickOutside)
return () => document.removeEventListener("pointerdown", handleClickOutside)
}, [
recipientsFocused,
isInline,
compose.showCc,
compose.showBcc,
compose.cc.length,
compose.bcc.length,
compose.id,
updateCompose,
])
useEffect(() => {
if (!editor || editor.isDestroyed) return
const next = compose.bodyHtml
if (editor.getHTML() === next) return
editor.commands.setContent(next, { emitUpdate: false })
}, [compose.bodyHtml, compose.threadKind, editor])
useEffect(() => {
if (!compose.focusSubjectOnMount || isInline) return
const id = window.requestAnimationFrame(() => {
subjectInputRef.current?.focus()
updateCompose(compose.id, { focusSubjectOnMount: false })
})
return () => window.cancelAnimationFrame(id)
}, [compose.focusSubjectOnMount, isInline, compose.id, updateCompose])
useEffect(() => {
if (!compose.focusBodyOnMount || !editor || editor.isDestroyed) return
let cancelled = false
const outer = window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
if (cancelled || !editor || editor.isDestroyed) return
try {
editor.chain().focus().setTextSelection(1).run()
} catch {
editor.chain().focus().run()
}
updateCompose(compose.id, { focusBodyOnMount: false })
})
})
return () => {
cancelled = true
window.cancelAnimationFrame(outer)
}
}, [compose.focusBodyOnMount, compose.id, editor, updateCompose])
const clearFocusToMount = useCallback(() => {
updateCompose(compose.id, { focusToOnMount: false })
}, [compose.id, updateCompose])
const ThreadKindIcon =
compose.threadKind === "forward"
? Forward
: compose.threadKind === "replyAll"
? ReplyAll
: Reply
const recipientSummary =
compose.to.length === 0
? "Destinataires"
: compose.to.length === 1 && compose.to[0]
? compose.to[0].name === compose.to[0].email
? compose.to[0].email
: `${compose.to[0].name} <${compose.to[0].email}>`
: `${compose.to.length} destinataires`
const showReplyAllInMenu = useMemo(
() =>
Boolean(
threadSourceEmail &&
collectThreadParticipants(threadSourceEmail).length > 1
),
[threadSourceEmail]
)
const openInlinePreset = useCallback(
(kind: "reply" | "replyAll" | "forward") => {
if (!threadSourceEmail) return
applyComposePreset(
compose.id,
buildThreadComposePreset(threadSourceEmail, kind)
)
},
[threadSourceEmail, applyComposePreset, compose.id]
)
const openDockFromInline = useCallback(
(opts?: { focusSubject?: boolean }) => {
setRecipientsFocused(false)
updateCompose(compose.id, {
placement: "dock",
threadEmailId: null,
focusToOnMount: false,
focusBodyOnMount: false,
minimized: false,
maximized: false,
focusSubjectOnMount: Boolean(opts?.focusSubject),
})
},
[compose.id, updateCompose]
)
const recipientFieldsProps = {
compose,
isInline,
showFromField,
updateCompose,
handleIdentityChange,
clearFocusToMount,
subjectInputRef,
onRecipientsActivate: () => setRecipientsFocused(true),
}
return {
compose,
threadSourceEmail,
isXsSheet,
isInline,
isEditingScheduled,
editor,
titleText,
showFormatting,
setShowFormatting,
recipientsFocused,
setRecipientsFocused,
sendMenuOpen,
setSendMenuOpen,
isDragOver,
fieldsRef,
inlineRecipientShellRef,
subjectInputRef,
fileInputRef,
imageInputRef,
showFromField,
handleClose,
handleSend,
saveScheduledEdit,
sendScheduledFromEditNow,
applyScheduledPlanAt,
submitScheduledSendAt,
addFiles,
removeAttachment,
handleDrop,
handleDragOver,
handleDragLeave,
recipientSummary,
showReplyAllInMenu,
ThreadKindIcon,
openInlinePreset,
openDockFromInline,
recipientFieldsProps,
toggleMinimize,
toggleMaximize,
updateCompose,
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,274 @@
"use client"
import { ChevronLeft, ChevronUp, ChevronDown, RefreshCw } from "lucide-react"
import { Button } from "@/components/ui/button"
import { TooltipProvider } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { MailFolderStackIndicator } from "@/components/gmail/mail-folder-stack-indicator"
import { mailNavVisitKey } from "@/lib/mail-folder-display"
import { MAIL_LIST_ROW_DIVIDER_CLASS } from "@/lib/mail-chrome-classes"
import {
PULL_HOLD_HEIGHT,
REFRESH_SPIN_CLASS,
} from "@/components/gmail/email-list/email-list-helpers"
import { EmailListRow } from "@/components/gmail/email-list/email-list-row"
import {
EmailListEmpty,
EmailListScheduledBanner,
} from "@/components/gmail/email-list/email-list-empty"
import { EmailListEmailViewPane } from "@/components/gmail/email-list/email-list-email-view-pane"
import type { EmailListData } from "@/components/gmail/email-list/hooks/use-email-list-data"
import type { EmailListLabels } from "@/components/gmail/email-list/hooks/use-email-list-labels"
import type { EmailListSelection } from "@/components/gmail/email-list/hooks/use-email-list-selection"
import type { EmailListReading } from "@/components/gmail/email-list/hooks/use-email-list-reading"
const MAIN_SCROLL_CLASS =
"min-h-0 flex-1 overflow-y-auto overflow-x-hidden border-0 bg-mail-surface shadow-none outline-none sm:rounded-b-2xl " +
"[scrollbar-color:#9aa0a6_#ffffff] [scrollbar-width:auto] " +
"[&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar]:border-0 [&::-webkit-scrollbar]:bg-white " +
"[&::-webkit-scrollbar-track]:border-0 [&::-webkit-scrollbar-track]:bg-white [&::-webkit-scrollbar-track]:shadow-none " +
"[&::-webkit-scrollbar-thumb]:rounded-none [&::-webkit-scrollbar-thumb]:border-0 [&::-webkit-scrollbar-thumb]:shadow-none " +
"[&::-webkit-scrollbar-thumb]:bg-[#9aa0a6] hover:[&::-webkit-scrollbar-thumb]:bg-[#5f6368] " +
"[&::-webkit-scrollbar-corner]:border-0 [&::-webkit-scrollbar-corner]:bg-white"
type EmailListBodyProps = {
data: EmailListData
labels: EmailListLabels
selection: EmailListSelection
reading: EmailListReading
onSelectFolder?: (folder: string) => void
}
export function EmailListBody({
data,
labels,
selection,
reading,
onSelectFolder,
}: EmailListBodyProps) {
const {
splitView,
isViewMode,
isSearchMode,
selectedFolder,
listToolbarMode,
isRefreshing,
listViewportRef,
pullContentRef,
pullIconRef,
displayListEmails,
listEmails,
inboxCategoryTabLabel,
sidebarNav,
searchParams,
} = data
const {
openEmail,
openMailIndex,
goBack,
goToPrev,
goToNext,
handleBreadcrumbNavigate,
handleCategoryInboxTabClick,
} = reading
const rowPropsBase = {
allEmails: data.allEmails,
emailById: data.emailById,
listMailIndex: data.listMailIndex,
listRowExtras: data.listRowExtras,
starredEmails: data.starredEmails,
importantEmails: data.importantEmails,
readOverrides: data.readOverrides,
conversationMode: data.conversationMode,
savedThreadReplyDrafts: data.savedThreadReplyDrafts,
selectedEmails: selection.selectedEmails,
selectedFolder: data.selectedFolder,
splitView: data.splitView,
openMailId: data.openMailId,
isXs: data.isXs,
isMd: data.isMd,
density: data.density,
mobileSelectionMode: selection.mobileSelectionMode,
touchListSwipeEnabled: selection.touchListSwipeEnabled,
openSwipeRowId: selection.openSwipeRowId,
setOpenSwipeRowId: selection.setOpenSwipeRowId,
listRowLabelBgByTextLower: data.listRowLabelBgByTextLower,
sidebarNav: data.sidebarNav,
rescheduleTarget: data.rescheduleTarget,
setRescheduleTarget: data.setRescheduleTarget,
rescheduleDismissTimeoutsRef: data.rescheduleDismissTimeoutsRef,
scheduleReschedulePopoverDismiss: data.scheduleReschedulePopoverDismiss,
rowContextMenuOpenedAtRef: selection.rowContextMenuOpenedAtRef,
contextMenuTargetIdsRef: selection.contextMenuTargetIdsRef,
lastSelectionAnchorIdRef: selection.lastSelectionAnchorIdRef,
setSelectedEmails: selection.setSelectedEmails,
setLabelPickerQuery: data.setLabelPickerQuery,
labelPickerQuery: data.labelPickerQuery,
catalogLabels: labels.catalogLabels,
resolveLabelVisual: labels.resolveLabelVisual,
getCatalogLabelPresence: labels.getCatalogLabelPresence,
toggleLabelOnEmails: labels.toggleLabelOnEmails,
addLabelToEmails: labels.addLabelToEmails,
moveTargets: data.moveTargets,
moveEmailsToTarget: labels.moveEmailsToTarget,
cmScheduledRescheduleValue: data.cmScheduledRescheduleValue,
setCmScheduledRescheduleValue: data.setCmScheduledRescheduleValue,
mailActions: data.mailActions,
setReadOverrides: data.setReadOverrides,
onSelectFolder,
toggleSelect: selection.toggleSelect,
handleRowCheckboxClickCapture: selection.handleRowCheckboxClickCapture,
handleRowActivate: reading.handleRowActivate,
startRowDrag: selection.startRowDrag,
archiveListRow: reading.archiveListRow,
deleteListRow: reading.deleteListRow,
toggleStar: selection.toggleStar,
toggleImportant: selection.toggleImportant,
openSwipeRowLabelSheet: selection.openSwipeRowLabelSheet,
handleNavigateToLabel: reading.handleNavigateToLabel,
handleCategoryInboxTabClick,
closeViewIfShowingEmail: reading.closeViewIfShowingEmail,
restoreSnoozedRowToMailbox: reading.restoreSnoozedRowToMailbox,
handleEditScheduledMail: data.handleEditScheduledMail,
requestArchiveScheduled: data.requestArchiveScheduled,
requestDeleteScheduled: data.requestDeleteScheduled,
requestToggleReadScheduled: data.requestToggleReadScheduled,
requestSnoozeScheduled: data.requestSnoozeScheduled,
requestRescheduleScheduled: data.requestRescheduleScheduled,
requestSendScheduledNow: data.requestSendScheduledNow,
requestSnoozeMailboxEmail: data.requestSnoozeMailboxEmail,
}
return (
<div className={cn("relative flex min-h-0 flex-1 flex-col")}>
<div
ref={listViewportRef}
className={cn(
"max-sm:pb-16",
!splitView && isViewMode && openEmail
? "relative flex min-h-0 flex-1 flex-col overflow-hidden"
: MAIN_SCROLL_CLASS,
"relative min-h-0 flex-1 overscroll-y-none"
)}
>
{listToolbarMode && (
<div
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
>
<RefreshCw
ref={pullIconRef}
className={cn(
"h-5 w-5 text-[#5f6368]",
isRefreshing && REFRESH_SPIN_CLASS
)}
style={{ opacity: 0 }}
/>
</div>
)}
<div
ref={pullContentRef}
className={cn(
!splitView && isViewMode && openEmail && "relative flex min-h-0 flex-1 flex-col ",
listToolbarMode && "max-sm:[transform:translateZ(0)]"
)}
>
{!splitView && isViewMode && openEmail ? (
<>
<div className="pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center justify-between gap-2 px-3 py-2 sm:hidden">
<Button
type="button"
variant="ghost"
size="icon"
className="pointer-events-auto size-9 shrink-0 rounded-full border border-gray-200 bg-white/80 text-[#444746] shadow-md backdrop-blur hover:bg-white"
aria-label="Retour à la boîte de réception"
onClick={goBack}
>
<ChevronLeft className="size-5" strokeWidth={1.5} />
</Button>
<div className="pointer-events-auto flex shrink-0 overflow-hidden rounded-full border border-gray-200 bg-white/80 shadow-md backdrop-blur">
<Button
type="button"
variant="ghost"
size="icon"
className="size-9 rounded-none text-[#444746] hover:bg-[#f1f3f4] disabled:opacity-40"
disabled={openMailIndex <= 0}
onClick={goToPrev}
aria-label="Message plus récent"
>
<ChevronUp className="size-5" strokeWidth={1.5} />
</Button>
<span className="w-px shrink-0 self-stretch bg-border" aria-hidden />
<Button
type="button"
variant="ghost"
size="icon"
className="size-9 rounded-none text-[#444746] hover:bg-[#f1f3f4] disabled:opacity-40"
disabled={openMailIndex >= displayListEmails.length - 1}
onClick={goToNext}
aria-label="Message plus ancien"
>
<ChevronDown className="size-5" strokeWidth={1.5} />
</Button>
</div>
</div>
<EmailListEmailViewPane
data={data}
reading={reading}
selection={selection}
/>
</>
) : (
<TooltipProvider delayDuration={400}>
<>
{selectedFolder === "scheduled" && <EmailListScheduledBanner />}
{displayListEmails.length === 0 ? (
selectedFolder === "scheduled" ? (
<EmailListEmpty variant="scheduled" />
) : isSearchMode && searchParams ? (
<EmailListEmpty variant="search" searchParams={searchParams} />
) : (
<EmailListEmpty
variant="folder"
selectedFolder={selectedFolder}
inboxCategoryTabLabel={inboxCategoryTabLabel}
folderIdToLabel={sidebarNav.folderIdToLabel}
/>
)
) : (
<div
className={cn(
MAIL_LIST_ROW_DIVIDER_CLASS,
listToolbarMode && "sm:pb-14"
)}
>
{listEmails.map((email) => (
<EmailListRow key={email.id} email={email} {...rowPropsBase} />
))}
</div>
)}
</>
</TooltipProvider>
)}
</div>
</div>
{listToolbarMode ? (
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 hidden sm:flex sm:justify-start">
<MailFolderStackIndicator
currentKey={mailNavVisitKey(selectedFolder, data.inboxTab)}
folderTree={sidebarNav.folderTree}
folderIdToLabel={sidebarNav.folderIdToLabel}
labelRows={sidebarNav.labelRows}
onNavigate={handleBreadcrumbNavigate}
className="pointer-events-auto"
/>
</div>
) : null}
</div>
)
}
export type { EmailListBodyProps }

View File

@ -0,0 +1,70 @@
"use client"
import { mailLabelShouldShowInListStrip } from "@/components/gmail/mail-label-pills"
import { EmailView } from "@/components/gmail/email-view"
import { LABEL_PICKER_EXCLUDE } from "@/lib/mail-list/label-actions"
import { threadStoreId } from "@/lib/mail-settings/list-row-id"
import type { EmailListData } from "@/components/gmail/email-list/hooks/use-email-list-data"
import type { EmailListReading } from "@/components/gmail/email-list/hooks/use-email-list-reading"
import type { EmailListSelection } from "@/components/gmail/email-list/hooks/use-email-list-selection"
type EmailListEmailViewPaneProps = {
data: EmailListData
reading: EmailListReading
selection: EmailListSelection
}
export function EmailListEmailViewPane({
data,
reading,
selection,
}: EmailListEmailViewPaneProps) {
const {
openEmail,
openEmailThreadRoot,
isSingleMessageView,
handleNavigateToLabel,
singleNotSpam,
} = reading
const { toggleStar } = selection
const {
starredEmails,
listRowLabelBgByTextLower,
sidebarNav,
selectedFolder,
} = data
if (!openEmail) return null
return (
<EmailView
email={openEmail}
threadRoot={openEmailThreadRoot}
isSingleMessageView={isSingleMessageView}
onToggleStar={toggleStar}
isStarred={
starredEmails.includes(threadStoreId(openEmail)) ||
openEmail.starred
}
onNavigateToLabel={handleNavigateToLabel}
onNotSpam={openEmail.spam === true ? singleNotSpam : undefined}
labelBgByText={listRowLabelBgByTextLower}
emailLabelToSidebarFolderId={sidebarNav.emailLabelToSidebarFolderId}
getNavItemPrefs={sidebarNav.getNavItemPrefs}
folderTree={sidebarNav.folderTree}
labelRows={sidebarNav.labelRows}
currentFolderId={selectedFolder}
showLabelChip={(lab) => {
if (LABEL_PICKER_EXCLUDE.has(lab)) return true
return mailLabelShouldShowInListStrip(
lab,
sidebarNav.emailLabelToSidebarFolderId,
sidebarNav.getNavItemPrefs,
sidebarNav.labelRows
)
}}
/>
)
}
export type { EmailListEmailViewPaneProps }

View File

@ -0,0 +1,135 @@
"use client"
import { Clock, Mail, Search } from "lucide-react"
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty"
import { getMailNavFolderLabel } from "@/lib/sidebar-nav-data"
import type { SearchParams } from "@/lib/mail-search/search-params"
export type EmailListEmptyProps = {
variant: "scheduled" | "search" | "folder" | "split-pane"
selectedFolder?: string
inboxCategoryTabLabel?: string
folderIdToLabel?: Record<string, string>
searchParams?: SearchParams | null
}
export function EmailListEmpty({
variant,
selectedFolder = "inbox",
inboxCategoryTabLabel = "",
folderIdToLabel = {},
searchParams = null,
}: EmailListEmptyProps) {
if (variant === "scheduled") {
return (
<div className="flex min-h-[220px] flex-col items-center justify-center px-4 py-12 text-center">
<p className="text-sm text-[#5f6368]">Aucun message planifié.</p>
</div>
)
}
if (variant === "search" && searchParams) {
return (
<Empty className="min-h-[240px] flex-1 border-0 bg-mail-surface py-10 shadow-none">
<EmptyHeader className="max-w-md">
<EmptyMedia
variant="icon"
className="mb-1 border-0 bg-[#f1f3f4] text-[#5f6368] [&_svg]:size-6"
>
<Search className="size-6" strokeWidth={1.5} aria-hidden />
</EmptyMedia>
<EmptyTitle className="text-[15px] font-medium text-[#3c4043]">
Aucun résultat
</EmptyTitle>
<EmptyDescription className="text-[13px] text-[#5f6368]">
Pas de résultats pour{" "}
<span className="font-medium text-[#3c4043]">
{searchParams.q || searchParams.hasWords || searchParams.from || searchParams.subject || "votre recherche"}
</span>
{(searchParams.has.length > 0 || searchParams.within || searchParams.from || searchParams.to || searchParams.subject) ? (
<> avec les filtres choisis</>
) : null}
.
</EmptyDescription>
</EmptyHeader>
</Empty>
)
}
if (variant === "split-pane") {
return (
<Empty className="min-h-[240px] flex-1 border-0 bg-mail-surface py-10 shadow-none">
<EmptyHeader className="max-w-md">
<EmptyMedia
variant="icon"
className="mb-1 border-0 bg-[#f1f3f4] text-[#5f6368] [&_svg]:size-6"
>
<Mail className="size-6" strokeWidth={1.5} aria-hidden />
</EmptyMedia>
<EmptyTitle className="text-[15px] font-medium text-[#3c4043]">
Aucun message sélectionné
</EmptyTitle>
<EmptyDescription className="text-[13px] text-[#5f6368]">
Choisissez un message dans la liste ou ouvrez une boîte contenant des messages.
</EmptyDescription>
</EmptyHeader>
</Empty>
)
}
return (
<Empty className="min-h-[240px] flex-1 border-0 bg-mail-surface py-10 shadow-none">
<EmptyHeader className="max-w-md">
<EmptyMedia
variant="icon"
className="mb-1 border-0 bg-[#f1f3f4] text-[#5f6368] [&_svg]:size-6"
>
<Mail className="size-6" strokeWidth={1.5} aria-hidden />
</EmptyMedia>
<EmptyTitle className="text-[15px] font-medium text-[#3c4043]">
Aucun message
</EmptyTitle>
<EmptyDescription className="text-[13px] text-[#5f6368]">
{selectedFolder === "inbox" ? (
<>
Aucun message dans l&apos;onglet{" "}
<span className="font-medium text-[#3c4043]">
{inboxCategoryTabLabel}
</span>{" "}
de la boîte de réception.
</>
) : (
<>
Aucun message dans{" "}
<span className="font-medium text-[#3c4043]">
{getMailNavFolderLabel(selectedFolder, folderIdToLabel)}
</span>
.
</>
)}
</EmptyDescription>
</EmptyHeader>
</Empty>
)
}
export function EmailListScheduledBanner() {
return (
<div className="flex shrink-0 items-start gap-3 border-b border-[#eceff1] bg-[#f8f9fa] px-4 py-3">
<Clock
className="h-5 w-5 shrink-0 text-[#5f6368]"
strokeWidth={1.5}
aria-hidden
/>
<p className="text-sm leading-snug text-[#3c4043]">
Les messages de la liste «&nbsp;Envois programmés&nbsp;» seront envoyés à l&apos;heure prévue pour chacun d&apos;eux.
</p>
</div>
)
}

View File

@ -0,0 +1,212 @@
"use client"
import { Pencil } from "lucide-react"
import { cn } from "@/lib/utils"
import { buildSearchUrl } from "@/lib/mail-search/search-params"
import { MobileXsBulkSheets } from "@/components/gmail/mobile-xs-bulk-sheets"
import { EmailListToolbar } from "@/components/gmail/email-list/email-list-toolbar"
import { EmailListBody } from "@/components/gmail/email-list/email-list-body"
import { EmailListEmailViewPane } from "@/components/gmail/email-list/email-list-email-view-pane"
import { EmailListEmpty } from "@/components/gmail/email-list/email-list-empty"
import type { EmailListProps } from "@/components/gmail/email-list/email-list-helpers"
import type { EmailListData } from "@/components/gmail/email-list/hooks/use-email-list-data"
import type { EmailListLabels } from "@/components/gmail/email-list/hooks/use-email-list-labels"
import type { EmailListSelection } from "@/components/gmail/email-list/hooks/use-email-list-selection"
import type { EmailListReading } from "@/components/gmail/email-list/hooks/use-email-list-reading"
type EmailListLayoutProps = {
props: EmailListProps
data: EmailListData
labels: EmailListLabels
selection: EmailListSelection
reading: EmailListReading
}
export function EmailListLayout({
props,
data,
labels,
selection,
reading,
}: EmailListLayoutProps) {
const { onToggleSidebar } = props
const {
splitView,
isViewMode,
isXs,
touchNav,
openCompose,
} = data
const {
mobileXsMoveSheetOpen,
mobileXsLabelSheetOpen,
handleMobileXsMoveSheetOpenChange,
handleLabelSheetOpenChange,
labelSheetTargetIds,
bulkMoveTo,
openMobileXsMoveSheet,
openMobileXsLabelSheet,
} = selection
const {
openEmail,
} = reading
const toolbarProps = {
isViewMode: data.isViewMode,
splitView: data.splitView,
listToolbarMode: data.listToolbarMode,
compactInboxTabs: data.compactInboxTabs,
isSearchMode: data.isSearchMode,
selectedFolder: data.selectedFolder,
mobileFolderLabel: data.mobileFolderLabel,
displayListEmails: data.displayListEmails,
mobileUnreadCount: data.mobileUnreadCount,
mobileSelectionMode: selection.mobileSelectionMode,
setMobileSelectionMode: selection.setMobileSelectionMode,
setSelectedEmails: selection.setSelectedEmails,
mobileXsMoreMenuOpen: selection.mobileXsMoreMenuOpen,
setMobileXsMoreMenuOpen: selection.setMobileXsMoreMenuOpen,
showBulkToolbar: selection.showBulkToolbar,
bulkSelectMenuOpen: selection.bulkSelectMenuOpen,
setBulkSelectMenuOpen: selection.setBulkSelectMenuOpen,
selectAllChecked: selection.selectAllChecked,
handleSelectAllChange: selection.handleSelectAllChange,
selectMenuAll: selection.selectMenuAll,
selectMenuNone: selection.selectMenuNone,
selectMenuRead: selection.selectMenuRead,
selectMenuUnread: selection.selectMenuUnread,
selectMenuStarred: selection.selectMenuStarred,
selectMenuUnstarred: selection.selectMenuUnstarred,
bulkArchive: selection.bulkArchive,
bulkDelete: selection.bulkDelete,
bulkSpam: selection.bulkSpam,
hasUnreadInSelection: selection.hasUnreadInSelection,
bulkMarkRead: selection.bulkMarkRead,
bulkMarkUnread: selection.bulkMarkUnread,
moveTargets: data.moveTargets,
bulkMoveTo: selection.bulkMoveTo,
labelPickerQuery: data.labelPickerQuery,
setLabelPickerQuery: data.setLabelPickerQuery,
catalogLabels: labels.catalogLabels,
resolveLabelVisual: labels.resolveLabelVisual,
bulkTargetIds: selection.bulkTargetIds,
getCatalogLabelPresence: labels.getCatalogLabelPresence,
toggleLabelOnEmails: labels.toggleLabelOnEmails,
addLabelToEmails: labels.addLabelToEmails,
isRefreshing: data.isRefreshing,
handleManualRefresh: data.handleManualRefresh,
markAllInViewAsRead: data.markAllInViewAsRead,
openMobileXsMoveSheet,
openMobileXsLabelSheet,
listPage: data.listPage,
totalPages: data.totalPages,
openMailIndex: reading.openMailIndex,
goListPrevPage: reading.goListPrevPage,
goListNextPage: reading.goListNextPage,
goToPrev: reading.goToPrev,
goToNext: reading.goToNext,
goBack: reading.goBack,
openEmail: reading.openEmail,
viewModeIsRead: reading.viewModeIsRead,
singleArchive: reading.singleArchive,
singleDelete: reading.singleDelete,
singleNotSpam: reading.singleNotSpam,
singleSpam: reading.singleSpam,
singleToggleRead: reading.singleToggleRead,
singleMoveTo: reading.singleMoveTo,
onToggleSidebar,
inboxTabBarItems: data.inboxTabBarItems,
activeInboxTabId: data.activeInboxTabId,
unseenInTabById: data.unseenInTabById,
tabUnseenSenderLineById: data.tabUnseenSenderLineById,
handleCategoryInboxTabClick: reading.handleCategoryInboxTabClick,
searchParams: data.searchParams,
searchAccount: data.searchAccount,
allEmails: data.allEmails,
setSearchFilter: data.setSearchFilter,
toggleSearchFilter: data.toggleSearchFilter,
setAdvancedOpen: data.setAdvancedOpen,
searchRouter: data.searchRouter,
buildSearchUrl,
}
return (
<div className="flex h-full min-h-0 flex-1 flex-col">
<EmailListToolbar {...toolbarProps} part="mobile" />
{!isViewMode && touchNav && (
<MobileXsBulkSheets
moveSheetOpen={isXs && mobileXsMoveSheetOpen}
onMoveSheetOpenChange={handleMobileXsMoveSheetOpenChange}
labelSheetOpen={mobileXsLabelSheetOpen}
onLabelSheetOpenChange={handleLabelSheetOpenChange}
labelPickerQuery={data.labelPickerQuery}
onLabelPickerQueryChange={data.setLabelPickerQuery}
catalogLabels={labels.catalogLabels}
resolveLabelVisual={labels.resolveLabelVisual}
moveTargets={data.moveTargets}
onMoveTo={bulkMoveTo}
getLabelPresence={(lab) => labels.getCatalogLabelPresence(labelSheetTargetIds, lab)}
onToggleCatalogLabel={(lab) => labels.toggleLabelOnEmails(labelSheetTargetIds, lab)}
onCreateLabel={(lab) => {
labels.addLabelToEmails(labelSheetTargetIds, lab)
data.setLabelPickerQuery("")
}}
/>
)}
<div className={cn("flex min-h-0 flex-1 flex-col", splitView && "min-h-0 flex-row overflow-hidden")}>
<div
className={cn(
"flex min-h-0 min-w-0 flex-col",
splitView
? "relative w-[min(42%,480px)] min-w-[280px] max-w-[480px] shrink-0 border-r border-gray-200"
: "min-h-0 flex-1"
)}
>
<EmailListToolbar {...toolbarProps} part="list" />
<EmailListBody
data={data}
labels={labels}
selection={selection}
reading={reading}
onSelectFolder={props.onSelectFolder}
/>
{splitView ? (
<button
type="button"
onClick={openCompose}
className="absolute bottom-4 right-4 z-30 flex size-14 cursor-pointer items-center justify-center rounded-2xl border border-border bg-mail-surface text-[#444746] shadow-[0_1px_3px_rgba(60,64,67,.3),0_4px_8px_rgba(60,64,67,.15)] transition-[box-shadow,background-color] hover:bg-[#f6f8fc] hover:shadow-[0_1px_3px_rgba(60,64,67,.35),0_6px_12px_rgba(60,64,67,.2)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40"
aria-label="Nouveau message"
>
<Pencil className="size-6" strokeWidth={1.5} />
</button>
) : null}
</div>
{splitView ? (
<section className="flex min-h-0 min-w-0 flex-1 flex-col bg-mail-surface">
{openEmail ? (
<>
<EmailListToolbar {...toolbarProps} variant="reading-pane" />
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-none">
<EmailListEmailViewPane
data={data}
reading={reading}
selection={selection}
/>
</div>
</>
) : (
<EmailListEmpty variant="split-pane" />
)}
</section>
) : null}
</div>
</div>
)
}
export type { EmailListLayoutProps }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,27 @@
"use client"
import type { EmailListProps } from "@/components/gmail/email-list/email-list-helpers"
import { useEmailListData } from "@/components/gmail/email-list/hooks/use-email-list-data"
import { useEmailListLabels } from "@/components/gmail/email-list/hooks/use-email-list-labels"
import { useEmailListSelection } from "@/components/gmail/email-list/hooks/use-email-list-selection"
import { useEmailListReading } from "@/components/gmail/email-list/hooks/use-email-list-reading"
import { EmailListLayout } from "@/components/gmail/email-list/email-list-layout"
export function EmailList(props: EmailListProps) {
const data = useEmailListData(props)
const labels = useEmailListLabels(data)
const selection = useEmailListSelection(data, labels)
const reading = useEmailListReading(props, data, labels)
return (
<EmailListLayout
props={props}
data={data}
labels={labels}
selection={selection}
reading={reading}
/>
)
}
export type { EmailListProps } from "@/components/gmail/email-list/email-list-helpers"

View File

@ -0,0 +1,785 @@
"use client"
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import { useSearchParams, useRouter } from "next/navigation"
import { buildLabelTextToNavColorClass } from "@/components/gmail/mail-label-pills"
import { emails } from "@/lib/email-data"
import {
isListRowRead,
isThreadHeadMessage,
readStateTargets,
} from "@/lib/mail-thread"
import { useScheduledMail } from "@/lib/scheduled-mail-context"
import { useMailStore } from "@/lib/stores/mail-store"
import { useScheduledStore } from "@/lib/stores/scheduled-store"
import { usePersistHydrated } from "@/hooks/use-persist-hydrated"
import { useIsMd } from "@/hooks/use-md-breakpoint"
import { sortEmailsForInbox } from "@/lib/mail-settings/sort-emails"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import { useActiveAccount } from "@/lib/stores/account-store"
import { useMailSearchStore } from "@/lib/stores/mail-search-store"
import {
emailMatchesFolder,
emailMatchesInboxPrimaryTab,
type MailNavFolderMaps,
} from "@/lib/mail-folder-filter"
import {
getMailNavFolderLabel,
inboxTabDisplayLabel,
} from "@/lib/sidebar-nav-data"
import { buildInboxCategoryTabIcons } from "@/lib/inbox-category-tabs"
import {
INBOX_ALL_TAB,
SEARCH_FOLDER_ID,
inboxTabShowsInactiveMeta,
normalizeInboxTabSegment,
} from "@/lib/mail-url"
import {
parseSearchParams,
buildSearchUrl,
type SearchParams,
} from "@/lib/mail-search/search-params"
import { filterEmailsBySearchParams } from "@/lib/mail-search/search-engine"
import { useSidebarNav, registerNavEmailSync } from "@/lib/sidebar-nav-context"
import { useMoveTargets } from "@/components/gmail/move-to-menu-items"
import { buildListMailIndex } from "@/components/gmail/email-list/list-mail-index"
import {
useComposeActions,
useComposeDrafts,
} from "@/lib/compose-context"
import { computeFolderUnreadCounts } from "@/lib/mail-nav-metrics"
import {
mergeEmailLabelEdits,
mergeEmailNotSpam,
} from "@/lib/label-edits"
import type { LabelEditState } from "@/lib/stores/mail-store"
import { useIsXs } from "@/hooks/use-xs"
import { useTouchNav } from "@/hooks/use-touch-nav"
import {
applyNavRenameToEdits,
applyNavRemoveLabelToEdits,
} from "@/lib/mail-list/label-actions"
import {
LIST_PAGE_SIZE,
type EmailListProps,
buildInboxTabBarItems,
} from "@/components/gmail/email-list/email-list-helpers"
import { useMailListPullRefresh } from "@/hooks/use-mail-list-pull-refresh"
import { ensureVcLogosCollection } from "@/lib/register-vc-logos"
import { attachmentsForEmailList } from "@/lib/attachment-display"
import { resolveParsedCalendarInvitation } from "@/lib/resolve-email-calendar-invitation"
import { resolveEmailInboxCategoryTabs } from "@/lib/inbox-category-tabs"
import type { Email, EmailAttachment } from "@/lib/email-data"
import { cleanSenderName } from "@/lib/sender-display"
import { threadStoreId } from "@/lib/mail-settings/list-row-id"
export function useEmailListData({
selectedFolder,
inboxTab,
listPage,
openMailId,
splitView = false,
onMailRouteNavigate,
onFolderUnreadCountsChange,
}: EmailListProps) {
const isViewMode = openMailId !== null && !splitView
const showSplitReadingPane = splitView && openMailId !== null
const isSearchMode = selectedFolder === SEARCH_FOLDER_ID
const searchRouter = useRouter()
const searchAccount = useActiveAccount()
const setAdvancedOpen = useMailSearchStore((s) => s.setAdvancedOpen)
const urlSearchParams = useSearchParams()
const searchParams = useMemo(
() => (isSearchMode ? parseSearchParams(urlSearchParams) : null),
[isSearchMode, urlSearchParams]
)
const setSearchFilter = useCallback(
(patch: Partial<SearchParams>) => {
if (!searchParams) return
searchRouter.push(buildSearchUrl({ ...searchParams, ...patch }))
},
[searchParams, searchRouter]
)
const toggleSearchFilter = useCallback(
(key: keyof SearchParams, value: string) => {
if (!searchParams) return
const next = { ...searchParams }
if (key === "has") {
const arr = [...next.has]
if (arr.includes(value)) next.has = arr.filter((v) => v !== value)
else next.has = [...arr, value]
} else if (key === "excludeChats") {
next.excludeChats = !next.excludeChats
} else {
const cur = (next as Record<string, unknown>)[key]
;(next as Record<string, unknown>)[key] = cur === value ? "" : value
}
searchRouter.push(buildSearchUrl(next))
},
[searchParams, searchRouter]
)
const { savedThreadReplyDrafts } = useComposeDrafts()
const {
openCompose,
openComposeWithInitial,
closeAllInlineComposes,
pruneInlineComposesToOpenThread,
} = useComposeActions()
const {
scheduledEmails,
snoozedEmails,
sentPlaceholderEmails,
requestDeleteScheduled,
requestArchiveScheduled,
requestSnoozeScheduled,
requestToggleReadScheduled,
requestRescheduleScheduled,
requestGetScheduledEditPayload,
requestSendScheduledNow,
requestSnoozeMailboxEmail,
requestRestoreSnoozedToInbox,
} = useScheduledMail()
const scheduledPersistHydrated = usePersistHydrated(useScheduledStore)
const allEmails = useMemo(
() =>
scheduledPersistHydrated
? [...emails, ...scheduledEmails, ...snoozedEmails, ...sentPlaceholderEmails]
: emails,
[scheduledPersistHydrated, scheduledEmails, snoozedEmails, sentPlaceholderEmails]
)
const emailById = useMemo(
() => new Map(allEmails.map((e) => [e.id, e])),
[allEmails]
)
const sidebarNav = useSidebarNav()
const navMaps = useMemo<MailNavFolderMaps>(
() => ({
folderIdToLabel: sidebarNav.folderIdToLabel,
folderTree: sidebarNav.folderTree,
labelRows: sidebarNav.labelRows,
}),
[sidebarNav.folderIdToLabel, sidebarNav.folderTree, sidebarNav.labelRows]
)
const inboxCategoryTabIconsCatalog = useMemo(
() => buildInboxCategoryTabIcons(sidebarNav.labelRows),
[sidebarNav.labelRows]
)
const inboxTabBarItems = useMemo(
() => buildInboxTabBarItems(sidebarNav.labelRows),
[sidebarNav.labelRows]
)
const listRowLabelBgByTextLower = useMemo(
() => buildLabelTextToNavColorClass(sidebarNav.folderTree, sidebarNav.labelRows),
[sidebarNav.folderTree, sidebarNav.labelRows]
)
const [rescheduleTarget, setRescheduleTarget] = useState<{
id: string
value: string
panelOpen: boolean
} | null>(null)
const rescheduleDismissTimeoutsRef = useRef<
Map<string, ReturnType<typeof setTimeout>>
>(new Map())
const scheduleReschedulePopoverDismiss = useCallback((rowId: string) => {
const existing = rescheduleDismissTimeoutsRef.current.get(rowId)
if (existing) clearTimeout(existing)
const t = setTimeout(() => {
rescheduleDismissTimeoutsRef.current.delete(rowId)
setRescheduleTarget((p) => (p?.id === rowId ? null : p))
}, 280)
rescheduleDismissTimeoutsRef.current.set(rowId, t)
}, [])
useEffect(() => {
const m = rescheduleDismissTimeoutsRef.current
return () => {
for (const t of m.values()) clearTimeout(t)
m.clear()
}
}, [])
useEffect(() => {
ensureVcLogosCollection()
}, [])
const [cmScheduledRescheduleValue, setCmScheduledRescheduleValue] =
useState("")
const handleEditScheduledMail = useCallback(
async (id: string) => {
const payload = await requestGetScheduledEditPayload(id)
if (!payload) return
openComposeWithInitial({
to: payload.to,
subject: payload.subject,
bodyHtml: payload.bodyHtml,
editingScheduledId: id,
scheduledSendAtIso: payload.sendAtIso,
focusToOnMount: false,
focusBodyOnMount: true,
})
},
[requestGetScheduledEditPayload, openComposeWithInitial]
)
useEffect(() => {
if (!openMailId) {
closeAllInlineComposes()
} else {
const msg = emailById.get(openMailId)
pruneInlineComposesToOpenThread(msg ? threadStoreId(msg) : openMailId)
}
}, [
openMailId,
emailById,
closeAllInlineComposes,
pruneInlineComposesToOpenThread,
])
const starredEmails = useMailStore((s) => s.starredIds)
const importantEmails = useMailStore((s) => s.importantIds)
const readOverrides = useMailStore((s) => s.readOverrides)
const conversationMode = useMailSettingsStore((s) => s.conversationMode)
const inboxSort = useMailSettingsStore((s) => s.inboxSort)
const density = useMailSettingsStore((s) => s.density)
const isMd = useIsMd()
const labelEdits = useMailStore((s) => s.labelEdits)
const mailActions = useRef(useMailStore.getState()).current
const setReadOverrides = useCallback(
(updater: (prev: Record<string, boolean>) => Record<string, boolean>) => {
const current = useMailStore.getState().readOverrides
const next = updater(current)
if (next !== current) mailActions.setReadOverrides(next)
},
[mailActions]
)
const setLabelEdits = useCallback(
(updater: (prev: LabelEditState) => LabelEditState) => {
mailActions.setLabelEdits(updater)
},
[mailActions]
)
useEffect(() => {
registerNavEmailSync({
renameLabel: (from, to) => {
setLabelEdits((prev) => applyNavRenameToEdits(allEmails, prev, from, to))
},
removeLabel: (label) => {
setLabelEdits((prev) => applyNavRemoveLabelToEdits(allEmails, prev, label))
},
})
return () => registerNavEmailSync(null)
}, [allEmails, setLabelEdits])
const [labelPickerQuery, setLabelPickerQuery] = useState("")
const hiddenEmailIds = useMailStore((s) => s.hiddenEmailIds)
const notSpamEmailIds = useMailStore((s) => s.notSpamEmailIds)
const recentMoveTargets = useMailStore((s) => s.recentMoveTargets)
const [mobileVisibleCount, setMobileVisibleCount] = useState(LIST_PAGE_SIZE)
const isXs = useIsXs()
const touchNav = useTouchNav()
const seenEmailIdsRaw = useMailStore((s) => s.seenEmailIds)
const seenEmailIds = useMemo(() => new Set(seenEmailIdsRaw), [seenEmailIdsRaw])
const handleRefreshMessages = useCallback(async () => {
await new Promise((resolve) => setTimeout(resolve, 900))
}, [])
const {
isRefreshing,
setIsRefreshing,
listViewportRef,
pullContentRef,
pullIconRef,
} = useMailListPullRefresh({
enabled: isXs && !isViewMode,
isViewMode,
onRefresh: handleRefreshMessages,
})
const handleManualRefresh = useCallback(async () => {
if (isRefreshing) return
setIsRefreshing(true)
try {
await handleRefreshMessages()
} finally {
setIsRefreshing(false)
}
}, [isRefreshing, handleRefreshMessages, setIsRefreshing])
const markEmailSeen = useCallback((id: string) => {
mailActions.markSeen(id)
}, [mailActions])
const folderFilterCtx = useMemo(
() => ({
starredEmailIds: starredEmails,
importantEmailIds: importantEmails,
}),
[starredEmails, importantEmails]
)
const filteredEmails = useMemo(() => {
const hiddenSet = new Set(hiddenEmailIds)
const subtreeIdsCache = new Map<string, string[] | null>()
let visible = allEmails.filter((email) => !hiddenSet.has(email.id))
const hasLabelEdits =
labelEdits &&
(Object.keys(labelEdits.additions).length > 0 ||
Object.keys(labelEdits.removals).length > 0)
if (hasLabelEdits || notSpamEmailIds.length > 0) {
visible = visible.map((e) =>
mergeEmailNotSpam(mergeEmailLabelEdits(e, labelEdits), notSpamEmailIds)
)
}
if (isSearchMode && searchParams) {
return filterEmailsBySearchParams(visible, searchParams, {
starredIds: starredEmails,
importantIds: importantEmails,
})
}
let rows = visible.filter((email) =>
emailMatchesFolder(
email,
selectedFolder,
folderFilterCtx,
navMaps,
subtreeIdsCache
)
)
if (selectedFolder === "inbox") {
const tab = normalizeInboxTabSegment(inboxTab)
if (tab === "primary") {
rows = rows.filter((email) =>
emailMatchesInboxPrimaryTab(
email,
folderFilterCtx,
navMaps,
subtreeIdsCache
)
)
} else if (tab !== INBOX_ALL_TAB) {
rows = rows.filter(
(email) =>
emailMatchesFolder(
email,
"inbox",
folderFilterCtx,
navMaps,
subtreeIdsCache
) &&
emailMatchesFolder(
email,
tab,
folderFilterCtx,
navMaps,
subtreeIdsCache
)
)
}
}
return rows
}, [
selectedFolder,
inboxTab,
hiddenEmailIds,
folderFilterCtx,
labelEdits,
notSpamEmailIds,
allEmails,
navMaps,
isSearchMode,
searchParams,
starredEmails,
importantEmails,
])
const displayListEmails = useMemo(() => {
let rows = filteredEmails
if (conversationMode) {
rows = rows.filter(isThreadHeadMessage)
}
return sortEmailsForInbox(
rows,
inboxSort,
{
readOverrides,
starredIds: starredEmails,
importantIds: importantEmails,
},
{ conversationMode, byId: emailById }
)
}, [
filteredEmails,
conversationMode,
inboxSort,
readOverrides,
starredEmails,
importantEmails,
emailById,
])
const inboxCategoryTabLabel = useMemo(
() =>
inboxTabDisplayLabel(
inboxTab,
sidebarNav.labelRows,
sidebarNav.folderIdToLabel
),
[inboxTab, sidebarNav.labelRows, sidebarNav.folderIdToLabel]
)
const mobileUnreadCount = useMemo(
() =>
displayListEmails.filter(
(e) => !isListRowRead(e, readOverrides, emailById, conversationMode)
).length,
[displayListEmails, readOverrides, emailById, conversationMode]
)
const mobileFolderLabel = useMemo(() => {
if (isSearchMode) return "Résultats de recherche"
const inboxTabNorm = normalizeInboxTabSegment(inboxTab)
return selectedFolder === "inbox" && inboxTabNorm !== "primary"
? inboxCategoryTabLabel
: getMailNavFolderLabel(selectedFolder, sidebarNav.folderIdToLabel)
}, [
selectedFolder,
inboxTab,
inboxCategoryTabLabel,
sidebarNav.folderIdToLabel,
isSearchMode,
])
const totalPages = useMemo(
() => Math.max(1, Math.ceil(displayListEmails.length / LIST_PAGE_SIZE)),
[displayListEmails.length]
)
const pagedEmails = useMemo(() => {
const start = (listPage - 1) * LIST_PAGE_SIZE
return displayListEmails.slice(start, start + LIST_PAGE_SIZE)
}, [displayListEmails, listPage])
const listEmails = useMemo(() => {
if (isXs && !isViewMode) {
return displayListEmails.slice(0, mobileVisibleCount)
}
return pagedEmails
}, [isXs, isViewMode, displayListEmails, mobileVisibleCount, pagedEmails])
const listMailIndex = useMemo(() => buildListMailIndex(allEmails), [allEmails])
const listRowExtras = useMemo(() => {
const invitationById = new Map<
string,
ReturnType<typeof resolveParsedCalendarInvitation>
>()
const attachmentsById = new Map<string, EmailAttachment[]>()
const categoryTabsById = new Map<
string,
ReturnType<typeof resolveEmailInboxCategoryTabs>
>()
const subtreeIdsCache = new Map<string, string[] | null>()
const showCategoryTabIcons =
selectedFolder === "inbox" &&
normalizeInboxTabSegment(inboxTab) === INBOX_ALL_TAB
for (const e of listEmails) {
invitationById.set(e.id, resolveParsedCalendarInvitation(e))
attachmentsById.set(e.id, attachmentsForEmailList(e))
if (showCategoryTabIcons) {
const tabs = resolveEmailInboxCategoryTabs(
e,
folderFilterCtx,
navMaps,
inboxCategoryTabIconsCatalog,
subtreeIdsCache
)
if (tabs.length > 0) categoryTabsById.set(e.id, tabs)
}
}
return { invitationById, attachmentsById, categoryTabsById }
}, [
listEmails,
selectedFolder,
inboxTab,
folderFilterCtx,
navMaps,
inboxCategoryTabIconsCatalog,
])
useEffect(() => {
if (isXs) return
if (listPage > totalPages) {
onMailRouteNavigate({ page: totalPages })
}
}, [isXs, listPage, totalPages, onMailRouteNavigate])
useEffect(() => {
if (isXs && !isViewMode) return
listViewportRef.current?.scrollTo(0, 0)
}, [listPage, selectedFolder, inboxTab, isXs, isViewMode, listViewportRef])
useEffect(() => {
if (!isXs) return
setMobileVisibleCount(LIST_PAGE_SIZE)
listViewportRef.current?.scrollTo(0, 0)
}, [selectedFolder, inboxTab, isXs, listViewportRef])
useEffect(() => {
const root = listViewportRef.current
if (!root || !isXs || isViewMode) return
const onScroll = () => {
if (mobileVisibleCount >= displayListEmails.length) return
const nearBottom =
root.scrollTop + root.clientHeight >= root.scrollHeight - 120
if (nearBottom) {
setMobileVisibleCount((prev) =>
Math.min(prev + LIST_PAGE_SIZE, displayListEmails.length)
)
}
}
root.addEventListener("scroll", onScroll, { passive: true })
return () => root.removeEventListener("scroll", onScroll)
}, [isXs, isViewMode, mobileVisibleCount, displayListEmails.length, listViewportRef])
const moveTargets = useMoveTargets({
folderTree: sidebarNav.folderTree,
recentMoveTargets,
currentFolderId: selectedFolder,
})
const folderUnreadCounts = useMemo(
() =>
computeFolderUnreadCounts(
allEmails,
folderFilterCtx,
hiddenEmailIds,
readOverrides,
navMaps,
labelEdits,
notSpamEmailIds
),
[
folderFilterCtx,
hiddenEmailIds,
readOverrides,
allEmails,
navMaps,
labelEdits,
notSpamEmailIds,
]
)
const seenSerialized = useMemo(
() => [...seenEmailIds].sort().join(","),
[seenEmailIds]
)
const { unseenInTabById, tabUnseenSenderLineById } = useMemo(() => {
const seen = new Set(
seenSerialized.length > 0 ? seenSerialized.split(",") : []
)
const hidden = new Set(hiddenEmailIds)
const visible = allEmails
.filter((email) => !hidden.has(email.id))
.map((e) =>
mergeEmailNotSpam(mergeEmailLabelEdits(e, labelEdits), notSpamEmailIds)
)
const inboxPool = visible.filter((e) =>
emailMatchesFolder(e, "inbox", folderFilterCtx, navMaps)
)
const counts: Record<string, number> = {}
const preview: Record<string, string> = {}
const tabCache = new Map<string, string[] | null>()
for (const tab of inboxTabBarItems) {
const rows = inboxPool.filter((e) => {
if (tab.id === "primary") {
return (
emailMatchesInboxPrimaryTab(e, folderFilterCtx, navMaps, tabCache) &&
!seen.has(e.id)
)
}
if (tab.id === INBOX_ALL_TAB) {
return !seen.has(e.id)
}
return (
emailMatchesFolder(e, "inbox", folderFilterCtx, navMaps, tabCache) &&
emailMatchesFolder(e, tab.id, folderFilterCtx, navMaps, tabCache) &&
!seen.has(e.id)
)
})
counts[tab.id] = rows.length
if (inboxTabShowsInactiveMeta(tab.id)) {
const chain: string[] = []
const used = new Set<string>()
for (const e of rows) {
const n = cleanSenderName(e.sender).trim()
if (!n || used.has(n)) continue
used.add(n)
chain.push(n)
if (chain.length >= 6) break
}
preview[tab.id] = chain.join(", ")
}
}
return { unseenInTabById: counts, tabUnseenSenderLineById: preview }
}, [folderFilterCtx, hiddenEmailIds, labelEdits, seenSerialized, allEmails, navMaps, notSpamEmailIds, inboxTabBarItems])
useEffect(() => {
onFolderUnreadCountsChange?.(folderUnreadCounts)
}, [folderUnreadCounts, onFolderUnreadCountsChange])
const listToolbarMode = splitView || !isViewMode
const compactInboxTabs = isXs || splitView
const activeInboxTabId = useMemo(
() => normalizeInboxTabSegment(inboxTab),
[inboxTab]
)
const pageIds = useMemo(() => listEmails.map((e) => e.id), [listEmails])
const listRowsDep = listEmails.map((e) => e.id).join(",")
const effectiveRead = useCallback(
(email: Email) =>
readOverrides[email.id] !== undefined ? readOverrides[email.id]! : email.read,
[readOverrides]
)
const effectiveStarred = useCallback(
(email: Email) =>
starredEmails.includes(email.id) || email.starred,
[starredEmails]
)
const markAllInViewAsRead = useCallback(() => {
setReadOverrides((prev) => {
const next = { ...prev }
for (const e of displayListEmails) {
for (const id of readStateTargets(e, conversationMode)) {
next[id] = true
}
}
return next
})
}, [displayListEmails, conversationMode, setReadOverrides])
return {
selectedFolder,
inboxTab,
listPage,
openMailId,
splitView,
isViewMode,
showSplitReadingPane,
isSearchMode,
searchRouter,
searchAccount,
setAdvancedOpen,
searchParams,
setSearchFilter,
toggleSearchFilter,
savedThreadReplyDrafts,
openCompose,
openComposeWithInitial,
allEmails,
emailById,
sidebarNav,
navMaps,
inboxCategoryTabIconsCatalog,
inboxTabBarItems,
listRowLabelBgByTextLower,
rescheduleTarget,
setRescheduleTarget,
rescheduleDismissTimeoutsRef,
scheduleReschedulePopoverDismiss,
cmScheduledRescheduleValue,
setCmScheduledRescheduleValue,
handleEditScheduledMail,
starredEmails,
importantEmails,
readOverrides,
conversationMode,
inboxSort,
density,
isMd,
labelEdits,
mailActions,
setReadOverrides,
setLabelEdits,
labelPickerQuery,
setLabelPickerQuery,
hiddenEmailIds,
notSpamEmailIds,
recentMoveTargets,
mobileVisibleCount,
isXs,
touchNav,
seenEmailIds,
isRefreshing,
listViewportRef,
pullContentRef,
pullIconRef,
handleManualRefresh,
markEmailSeen,
folderFilterCtx,
filteredEmails,
displayListEmails,
inboxCategoryTabLabel,
mobileUnreadCount,
mobileFolderLabel,
totalPages,
pagedEmails,
listEmails,
listMailIndex,
listRowExtras,
moveTargets,
folderUnreadCounts,
unseenInTabById,
tabUnseenSenderLineById,
listToolbarMode,
compactInboxTabs,
activeInboxTabId,
pageIds,
listRowsDep,
effectiveRead,
effectiveStarred,
markAllInViewAsRead,
requestDeleteScheduled,
requestArchiveScheduled,
requestSnoozeScheduled,
requestToggleReadScheduled,
requestRescheduleScheduled,
requestSendScheduledNow,
requestSnoozeMailboxEmail,
requestRestoreSnoozedToInbox,
}
}
export type EmailListData = ReturnType<typeof useEmailListData>

View File

@ -0,0 +1,290 @@
"use client"
import { useCallback, useMemo } from "react"
import type { CatalogLabelPresence } from "@/components/gmail/email-label-picker-block"
import { resolveLabelPickerVisual } from "@/lib/label-picker-visual"
import {
effectiveLabels,
mergeEmailLabelEdits,
mergeEmailNotSpam,
} from "@/lib/label-edits"
import type { FolderTreeNode } from "@/lib/sidebar-nav-data"
import {
LABEL_PICKER_EXCLUDE,
} from "@/lib/mail-list/label-actions"
import {
collectTreeLabels,
} from "@/components/gmail/email-list/email-list-helpers"
import type { EmailListData } from "@/components/gmail/email-list/hooks/use-email-list-data"
export function useEmailListLabels(data: EmailListData) {
const {
allEmails,
sidebarNav,
labelEdits,
notSpamEmailIds,
setLabelEdits,
mailActions,
} = data
const collectAllFolderLabels = useCallback((): Set<string> => {
const s = new Set<string>()
const walk = (nodes: FolderTreeNode[]) => {
for (const n of nodes) {
s.add(n.label.toLowerCase())
if (n.children?.length) walk(n.children)
}
}
walk(sidebarNav.folderTree)
return s
}, [sidebarNav.folderTree])
const moveEmailsToTarget = useCallback(
(emailIds: string[], targetId: string) => {
if (emailIds.length === 0) return
const folderLabel = sidebarNav.folderIdToLabel[targetId]
const isSystemTarget = ["inbox", "sent", "drafts", "spam", "trash"].includes(targetId)
const allFolderLabels = collectAllFolderLabels()
setLabelEdits((prev) => {
const nextAdd = { ...prev.additions }
const nextRem = { ...prev.removals }
for (const id of emailIds) {
const email = allEmails.find((e) => e.id === id)
const currentLabels = effectiveLabels(email, nextAdd, nextRem)
if (isSystemTarget) {
if (targetId === "inbox") {
for (const lab of currentLabels) {
if (allFolderLabels.has(lab.toLowerCase())) {
const cur = nextRem[id] ?? []
if (!cur.some((l) => l.toLowerCase() === lab.toLowerCase())) {
nextRem[id] = [...cur, lab]
}
if (nextAdd[id]?.length) {
nextAdd[id] = nextAdd[id].filter((l) => l.toLowerCase() !== lab.toLowerCase())
if (nextAdd[id].length === 0) delete nextAdd[id]
}
}
}
}
} else if (folderLabel) {
for (const lab of currentLabels) {
if (allFolderLabels.has(lab.toLowerCase()) && lab.toLowerCase() !== folderLabel.toLowerCase()) {
const cur = nextRem[id] ?? []
if (!cur.some((l) => l.toLowerCase() === lab.toLowerCase())) {
nextRem[id] = [...cur, lab]
}
if (nextAdd[id]?.length) {
nextAdd[id] = nextAdd[id].filter((l) => l.toLowerCase() !== lab.toLowerCase())
if (nextAdd[id].length === 0) delete nextAdd[id]
}
}
}
if (!currentLabels.some((l) => l.toLowerCase() === folderLabel.toLowerCase())) {
nextAdd[id] = [...(nextAdd[id] ?? []), folderLabel]
}
if (nextRem[id]?.length) {
nextRem[id] = nextRem[id].filter((l) => l.toLowerCase() !== folderLabel.toLowerCase())
if (nextRem[id].length === 0) delete nextRem[id]
}
const inboxIdx = currentLabels.findIndex((l) => l.toLowerCase() === "inbox")
if (inboxIdx >= 0 || !email?.labels?.length || email.labels.includes("inbox")) {
const cur = nextRem[id] ?? []
if (!cur.some((l) => l.toLowerCase() === "inbox")) {
nextRem[id] = [...cur, "inbox"]
}
}
}
}
return { additions: nextAdd, removals: nextRem }
})
if (!isSystemTarget || targetId === "inbox") {
mailActions.pushRecentMoveTarget(targetId)
}
if (isSystemTarget && targetId !== "inbox") {
mailActions.hideEmails(emailIds)
mailActions.pushRecentMoveTarget(targetId)
}
},
[allEmails, sidebarNav.folderIdToLabel, collectAllFolderLabels, setLabelEdits, mailActions]
)
const catalogLabels = useMemo(() => {
const s = new Set<string>()
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 = 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, notSpamEmailIds])
const resolveLabelVisual = useCallback(
(label: string) =>
resolveLabelPickerVisual(label, {
folderTree: sidebarNav.folderTree,
labelRows: sidebarNav.labelRows,
emailLabelToSidebarFolderId: sidebarNav.emailLabelToSidebarFolderId,
}),
[
sidebarNav.folderTree,
sidebarNav.labelRows,
sidebarNav.emailLabelToSidebarFolderId,
]
)
const resolveLabelCasing = useCallback(
(raw: string) => {
const t = raw.trim()
if (!t) return ""
const hit = catalogLabels.find((c) => c.toLowerCase() === t.toLowerCase())
return hit ?? t
},
[catalogLabels]
)
const addLabelToEmails = useCallback(
(ids: string[], label: string) => {
const resolved = resolveLabelCasing(label)
if (!resolved || ids.length === 0) return
sidebarNav.ensureLabelRowForLabelText(resolved)
setLabelEdits((prev) => {
const nextAdd = { ...prev.additions }
const nextRem = { ...prev.removals }
for (const id of ids) {
if (nextRem[id]?.length) {
nextRem[id] = nextRem[id].filter(
(x) => x.toLowerCase() !== resolved.toLowerCase()
)
if (nextRem[id].length === 0) delete nextRem[id]
}
const base = allEmails.find((e) => e.id === id)
const merged = effectiveLabels(base, nextAdd, nextRem)
if (merged.some((x) => x.toLowerCase() === resolved.toLowerCase())) {
continue
}
nextAdd[id] = [...(nextAdd[id] ?? []), resolved]
}
return { additions: nextAdd, removals: nextRem }
})
},
[resolveLabelCasing, allEmails, sidebarNav, setLabelEdits]
)
const getCatalogLabelPresence = useCallback(
(ids: string[], catalogLabel: string): CatalogLabelPresence => {
const resolved = resolveLabelCasing(catalogLabel)
if (!resolved || ids.length === 0) return "none"
const lc = resolved.toLowerCase()
let n = 0
for (const id of ids) {
const e = allEmails.find((x) => x.id === id)
const eff = effectiveLabels(e, labelEdits.additions, labelEdits.removals)
if (eff.some((l) => l.toLowerCase() === lc)) n++
}
if (n === 0) return "none"
if (n === ids.length) return "all"
return "some"
},
[allEmails, labelEdits, resolveLabelCasing]
)
const toggleLabelOnEmails = useCallback(
(ids: string[], label: string) => {
const resolved = resolveLabelCasing(label)
if (!resolved || ids.length === 0) return
setLabelEdits((prev) => {
const presence = (id: string) => {
const e = allEmails.find((x) => x.id === id)
if (!e) return false
return effectiveLabels(e, prev.additions, prev.removals).some(
(l) => l.toLowerCase() === resolved.toLowerCase()
)
}
const allHave = ids.every((id) => presence(id))
const nextAdd = { ...prev.additions }
const nextRem = { ...prev.removals }
if (allHave) {
for (const id of ids) {
if (nextAdd[id]?.length) {
const filtered = nextAdd[id].filter(
(l) => l.toLowerCase() !== resolved.toLowerCase()
)
if (filtered.length) nextAdd[id] = filtered
else delete nextAdd[id]
}
const e = allEmails.find((x) => x.id === id)
if (!e) continue
const still = effectiveLabels(e, nextAdd, nextRem).some(
(l) => l.toLowerCase() === resolved.toLowerCase()
)
if (still) {
const cur = nextRem[id] ?? []
if (!cur.some((l) => l.toLowerCase() === resolved.toLowerCase())) {
nextRem[id] = [...cur, resolved]
}
} else if (nextRem[id]?.length) {
const fr = nextRem[id].filter(
(l) => l.toLowerCase() !== resolved.toLowerCase()
)
if (fr.length) nextRem[id] = fr
else delete nextRem[id]
}
}
} else {
const anyMissing = ids.some((id) => !presence(id))
if (anyMissing) {
queueMicrotask(() => sidebarNav.ensureLabelRowForLabelText(resolved))
}
for (const id of ids) {
const e = allEmails.find((x) => x.id === id)
if (!e) continue
const had = effectiveLabels(e, prev.additions, prev.removals).some(
(l) => l.toLowerCase() === resolved.toLowerCase()
)
if (nextRem[id]?.length) {
const fr = nextRem[id].filter(
(l) => l.toLowerCase() !== resolved.toLowerCase()
)
if (fr.length) nextRem[id] = fr
else delete nextRem[id]
}
if (!had) {
if (!nextAdd[id]) nextAdd[id] = []
if (!nextAdd[id].some((l) => l.toLowerCase() === resolved.toLowerCase())) {
nextAdd[id] = [...nextAdd[id], resolved]
}
}
}
}
return { additions: nextAdd, removals: nextRem }
})
},
[allEmails, resolveLabelCasing, sidebarNav, setLabelEdits]
)
return {
collectAllFolderLabels,
moveEmailsToTarget,
catalogLabels,
resolveLabelVisual,
resolveLabelCasing,
addLabelToEmails,
toggleLabelOnEmails,
getCatalogLabelPresence,
}
}
export type EmailListLabels = ReturnType<typeof useEmailListLabels>

View File

@ -0,0 +1,564 @@
"use client"
import {
startTransition,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
} from "react"
import type { Email } from "@/lib/email-data"
import { readStateTargets } from "@/lib/mail-thread"
import { threadStoreId } from "@/lib/mail-settings/list-row-id"
import { resolveOpenEmailView } from "@/lib/mail-settings/resolve-open-email"
import {
mergeEmailLabelEdits,
mergeEmailNotSpam,
} from "@/lib/label-edits"
import {
DEFAULT_INBOX_TAB,
} from "@/lib/mail-url"
import {
mailNavVisitKey,
parseMailNavVisitKey,
} from "@/lib/mail-folder-display"
import {
LIST_PAGE_SIZE,
escapeHtml,
} from "@/components/gmail/email-list/email-list-helpers"
import type { Contact } from "@/lib/compose-context"
import {
buildThreadComposePreset,
withTouchFullscreenComposePreset,
} from "@/lib/thread-compose-preset"
import type { EmailListProps } from "@/components/gmail/email-list/email-list-helpers"
import type { EmailListData } from "@/components/gmail/email-list/hooks/use-email-list-data"
import type { EmailListLabels } from "@/components/gmail/email-list/hooks/use-email-list-labels"
export function useEmailListReading(
props: EmailListProps,
data: EmailListData,
labels: EmailListLabels
) {
const {
onMailRouteNavigate,
onSelectFolder,
onXsViewChromeChange,
} = props
const {
openMailId,
splitView,
isViewMode,
showSplitReadingPane,
isXs,
allEmails,
emailById,
displayListEmails,
listPage,
listRowsDep,
listViewportRef,
conversationMode,
labelEdits,
notSpamEmailIds,
readOverrides,
setReadOverrides,
markEmailSeen,
mailActions,
moveTargets,
selectedFolder,
inboxTab,
openComposeWithInitial,
} = data
const { moveEmailsToTarget } = labels
const openEmailView = useMemo(() => {
if (!openMailId) return null
const resolved = resolveOpenEmailView(
openMailId,
allEmails,
conversationMode
)
if (!resolved) return null
if (resolved.email.labels?.includes("scheduled")) return null
const email = mergeEmailNotSpam(
mergeEmailLabelEdits(resolved.email, labelEdits),
notSpamEmailIds
)
const threadRoot = mergeEmailNotSpam(
mergeEmailLabelEdits(resolved.threadRoot, labelEdits),
notSpamEmailIds
)
return {
email,
threadRoot,
isSingleMessageView: resolved.isSingleMessageView,
}
}, [openMailId, labelEdits, allEmails, notSpamEmailIds, conversationMode])
const openEmail = openEmailView?.email ?? null
const openEmailThreadRoot = openEmailView?.threadRoot ?? null
const isSingleMessageView = openEmailView?.isSingleMessageView ?? false
const openMailIndex = useMemo(
() =>
openMailId ? displayListEmails.findIndex((e) => e.id === openMailId) : -1,
[openMailId, displayListEmails]
)
useEffect(() => {
if (!openMailId) return
const message = emailById.get(openMailId)
if (!message) return
const targets = readStateTargets(message, conversationMode)
for (const id of targets) {
markEmailSeen(id)
}
setReadOverrides((prev) => {
let changed = false
const next = { ...prev }
for (const id of targets) {
if (next[id] === undefined) {
next[id] = true
changed = true
}
}
return changed ? next : prev
})
}, [openMailId, markEmailSeen, emailById, conversationMode, setReadOverrides])
const navigateToMail = useCallback(
(id: string | null) => {
if (id && splitView) {
const idx = displayListEmails.findIndex((e) => e.id === id)
if (idx >= 0) {
const page = Math.floor(idx / LIST_PAGE_SIZE) + 1
onMailRouteNavigate({ mailId: id, page })
return
}
}
onMailRouteNavigate({ mailId: id })
},
[splitView, displayListEmails, onMailRouteNavigate]
)
useEffect(() => {
if (!openMailId) return
const raw = allEmails.find((e) => e.id === openMailId)
if (raw?.labels?.includes("scheduled")) {
navigateToMail(null)
}
}, [openMailId, allEmails, navigateToMail])
const pickAdjacentMailId = useCallback(
(currentId: string) => {
const idx = displayListEmails.findIndex((e) => e.id === currentId)
if (idx < 0) return displayListEmails[0]?.id ?? null
if (idx < displayListEmails.length - 1) return displayListEmails[idx + 1]!.id
if (idx > 0) return displayListEmails[idx - 1]!.id
return null
},
[displayListEmails]
)
const leaveReadingPane = useCallback(() => {
if (!splitView) {
navigateToMail(null)
return
}
if (!openMailId) return
navigateToMail(pickAdjacentMailId(openMailId))
}, [splitView, openMailId, navigateToMail, pickAdjacentMailId])
const goBack = useCallback(() => {
if (splitView) leaveReadingPane()
else navigateToMail(null)
}, [splitView, leaveReadingPane, navigateToMail])
const closeViewIfShowingEmail = useCallback(
(emailId: string) => {
if (openMailId === emailId) goBack()
},
[openMailId, goBack]
)
const archiveListRow = useCallback(
(email: Email) => {
if (email.labels?.includes("scheduled")) {
void data.requestArchiveScheduled(email.id)
} else {
mailActions.hideEmail(email.id)
closeViewIfShowingEmail(email.id)
}
},
[closeViewIfShowingEmail, mailActions, data]
)
const deleteListRow = useCallback(
(email: Email) => {
if (email.labels?.includes("scheduled")) {
void data.requestDeleteScheduled(email.id)
} else {
mailActions.hideEmail(email.id)
closeViewIfShowingEmail(email.id)
}
},
[closeViewIfShowingEmail, mailActions, data]
)
const restoreSnoozedRowToMailbox = useCallback(
(emailRow: Email) => {
void data.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)
},
[
data,
mailActions,
closeViewIfShowingEmail,
onSelectFolder,
]
)
const handleCategoryInboxTabClick = useCallback(
(tabId: string) => {
startTransition(() => {
onMailRouteNavigate({
inboxTab: tabId,
page: 1,
mailId: null,
})
})
},
[onMailRouteNavigate]
)
const handleBreadcrumbNavigate = useCallback(
(visitKey: string) => {
if (visitKey === mailNavVisitKey(selectedFolder, inboxTab)) return
const { folderId, inboxTab: tab } = parseMailNavVisitKey(visitKey)
startTransition(() => {
if (folderId === "inbox" && tab && tab !== DEFAULT_INBOX_TAB) {
onMailRouteNavigate({
folderId: "inbox",
inboxTab: tab,
page: 1,
mailId: null,
})
return
}
if (onSelectFolder) {
onSelectFolder(folderId)
return
}
onMailRouteNavigate({
folderId,
inboxTab: DEFAULT_INBOX_TAB,
page: 1,
mailId: null,
})
})
},
[
selectedFolder,
inboxTab,
onMailRouteNavigate,
onSelectFolder,
]
)
const goListPrevPage = useCallback(() => {
if (listPage <= 1) return
onMailRouteNavigate({ page: listPage - 1 })
}, [listPage, onMailRouteNavigate])
const goListNextPage = useCallback(() => {
if (listPage >= data.totalPages) return
onMailRouteNavigate({ page: listPage + 1 })
}, [listPage, data.totalPages, onMailRouteNavigate])
const goToPrev = useCallback(() => {
if (openMailIndex > 0) {
const id = displayListEmails[openMailIndex - 1]!.id
markEmailSeen(id)
setReadOverrides((prev) => ({ ...prev, [id]: true }))
navigateToMail(id)
}
}, [openMailIndex, displayListEmails, navigateToMail, markEmailSeen, setReadOverrides])
const goToNext = useCallback(() => {
if (openMailIndex >= 0 && openMailIndex < displayListEmails.length - 1) {
const id = displayListEmails[openMailIndex + 1]!.id
markEmailSeen(id)
setReadOverrides((prev) => ({ ...prev, [id]: true }))
navigateToMail(id)
}
}, [openMailIndex, displayListEmails, navigateToMail, markEmailSeen, setReadOverrides])
const handleOpenEmail = useCallback(
(id: string) => {
const em = allEmails.find((e) => e.id === id)
if (em?.labels?.includes("scheduled")) return
markEmailSeen(id)
setReadOverrides((prev) => ({ ...prev, [id]: true }))
navigateToMail(id)
},
[navigateToMail, markEmailSeen, allEmails, setReadOverrides]
)
const openDraftInCompose = useCallback(
(email: Email) => {
markEmailSeen(email.id)
setReadOverrides((prev) => ({ ...prev, [email.id]: true }))
const to: Contact[] = email.senderEmail
? [{ name: email.sender.trim(), email: email.senderEmail }]
: []
const body =
email.body ??
(email.preview
? `<p style="color:#5f6368">${escapeHtml(email.preview)}</p>`
: "<p></p>")
openComposeWithInitial({
to,
subject: email.subject,
bodyHtml: body,
focusToOnMount: false,
focusBodyOnMount: true,
})
},
[markEmailSeen, openComposeWithInitial, setReadOverrides]
)
const handleRowActivate = useCallback(
(email: Email) => {
if (email.labels?.includes("scheduled")) return
if (email.labels?.includes("drafts")) {
openDraftInCompose(email)
return
}
handleOpenEmail(email.id)
},
[handleOpenEmail, openDraftInCompose]
)
const viewModeIsRead = useMemo(() => {
if (!openEmail) return true
return readOverrides[openEmail.id] !== undefined
? readOverrides[openEmail.id]!
: openEmail.read
}, [openEmail, readOverrides])
const afterSingleMessageRemoved = useCallback(
(removedId: string) => {
if (splitView) navigateToMail(pickAdjacentMailId(removedId))
else navigateToMail(null)
},
[splitView, navigateToMail, pickAdjacentMailId]
)
const singleArchive = useCallback(() => {
if (!openMailId) return
const id = openMailId
mailActions.hideEmail(id)
afterSingleMessageRemoved(id)
}, [openMailId, afterSingleMessageRemoved, mailActions])
const singleDelete = useCallback(() => {
if (!openMailId) return
const id = openMailId
mailActions.hideEmail(id)
afterSingleMessageRemoved(id)
}, [openMailId, afterSingleMessageRemoved, mailActions])
const singleSpam = useCallback(() => {
if (!openMailId) return
const id = openMailId
mailActions.hideEmail(id)
afterSingleMessageRemoved(id)
}, [openMailId, afterSingleMessageRemoved, mailActions])
const singleNotSpam = useCallback(() => {
if (!openMailId) return
const id = openMailId
mailActions.markNotSpam(id)
onSelectFolder?.("inbox")
afterSingleMessageRemoved(id)
}, [openMailId, afterSingleMessageRemoved, onSelectFolder, mailActions])
const singleToggleRead = useCallback(() => {
if (!openMailId) return
setReadOverrides((prev) => ({ ...prev, [openMailId]: !viewModeIsRead }))
}, [openMailId, viewModeIsRead, setReadOverrides])
const singleMoveTo = useCallback(
(targetId: string) => {
if (!openMailId) return
moveEmailsToTarget([openMailId], targetId)
const isSystemHide = ["sent", "drafts", "spam", "trash"].includes(targetId)
if (isSystemHide || targetId !== "inbox") {
afterSingleMessageRemoved(openMailId)
}
},
[openMailId, afterSingleMessageRemoved, moveEmailsToTarget]
)
const singleReply = useCallback(() => {
if (!openEmail) return
openComposeWithInitial(
withTouchFullscreenComposePreset(buildThreadComposePreset(openEmail, "reply"))
)
}, [openEmail, openComposeWithInitial])
useEffect(() => {
if (!onXsViewChromeChange) return
if (!isXs || !isViewMode || !openEmail) {
onXsViewChromeChange(null)
return
}
onXsViewChromeChange({
onArchive: singleArchive,
onReply: singleReply,
moveTargets,
onMoveTo: singleMoveTo,
})
return () => onXsViewChromeChange(null)
}, [
onXsViewChromeChange,
isXs,
isViewMode,
openEmail,
singleArchive,
singleReply,
singleMoveTo,
moveTargets,
])
useEffect(() => {
if (!splitView) return
const firstId = displayListEmails[0]?.id ?? null
if (!openMailId) {
if (firstId) navigateToMail(firstId)
return
}
const raw = allEmails.find((e) => e.id === openMailId)
if (raw?.labels?.includes("scheduled")) {
navigateToMail(firstId)
return
}
if (!displayListEmails.some((e) => e.id === openMailId)) {
navigateToMail(firstId)
}
}, [
splitView,
selectedFolder,
inboxTab,
listPage,
displayListEmails,
openMailId,
navigateToMail,
allEmails,
])
const handleNavigateToLabel = useCallback(
(label: string) => {
const folderId =
data.sidebarNav.emailLabelToSidebarFolderId[label] ?? label
onSelectFolder?.(folderId)
},
[onSelectFolder, data.sidebarNav.emailLabelToSidebarFolderId]
)
useLayoutEffect(() => {
if (!splitView || !openMailId) return
const scrollActiveRowIntoView = () => {
const root = listViewportRef.current
if (!root) return
const row = root.querySelector<HTMLElement>(
`[data-email-row-id="${openMailId}"]`
)
if (!row) return
row.scrollIntoView({ block: "nearest", behavior: "smooth" })
}
scrollActiveRowIntoView()
const frame = requestAnimationFrame(scrollActiveRowIntoView)
return () => cancelAnimationFrame(frame)
}, [splitView, openMailId, listPage, listRowsDep, listViewportRef])
useEffect(() => {
const root = listViewportRef.current
if (!root) return
const obs = new IntersectionObserver(
(entries) => {
for (const en of entries) {
if (!en.isIntersecting) continue
const id = (en.target as HTMLElement).dataset.emailRowId
if (id) markEmailSeen(id)
}
},
{ root, threshold: 0.12, rootMargin: "0px" }
)
root.querySelectorAll<HTMLElement>("[data-email-row-id]").forEach((el) => {
obs.observe(el)
})
return () => obs.disconnect()
}, [listRowsDep, markEmailSeen, listViewportRef])
useEffect(() => {
if (!isViewMode && !showSplitReadingPane) return
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
if (!splitView) goBack()
return
}
if (e.key === "ArrowLeft" || e.key === "k") {
goToPrev()
return
}
if (e.key === "ArrowRight" || e.key === "j") {
goToNext()
return
}
}
window.addEventListener("keydown", handler)
return () => window.removeEventListener("keydown", handler)
}, [isViewMode, showSplitReadingPane, splitView, goBack, goToPrev, goToNext])
return {
openEmail,
openEmailThreadRoot,
isSingleMessageView,
openMailIndex,
navigateToMail,
goBack,
closeViewIfShowingEmail,
archiveListRow,
deleteListRow,
restoreSnoozedRowToMailbox,
handleCategoryInboxTabClick,
handleBreadcrumbNavigate,
goListPrevPage,
goListNextPage,
goToPrev,
goToNext,
handleOpenEmail,
handleRowActivate,
viewModeIsRead,
singleArchive,
singleDelete,
singleSpam,
singleNotSpam,
singleToggleRead,
singleMoveTo,
singleReply,
handleNavigateToLabel,
}
}
export type EmailListReading = ReturnType<typeof useEmailListReading>

View File

@ -0,0 +1,337 @@
"use client"
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type DragEvent,
type MouseEvent,
} from "react"
import { useEmailDrag } from "@/lib/drag-context"
import type { EmailListData } from "@/components/gmail/email-list/hooks/use-email-list-data"
import type { EmailListLabels } from "@/components/gmail/email-list/hooks/use-email-list-labels"
export function useEmailListSelection(
data: EmailListData,
labels: EmailListLabels
) {
const {
selectedFolder,
isViewMode,
isXs,
touchNav,
pageIds,
listEmails,
effectiveRead,
effectiveStarred,
readOverrides,
allEmails,
setReadOverrides,
mailActions,
} = data
const { moveEmailsToTarget } = labels
const { beginDrag, registerOnDrop } = useEmailDrag()
const [selectedEmails, setSelectedEmails] = useState<string[]>([])
const rowContextMenuOpenedAtRef = useRef(0)
const contextMenuTargetIdsRef = useRef<string[]>([])
const lastSelectionAnchorIdRef = useRef<string | null>(null)
const [bulkSelectMenuOpen, setBulkSelectMenuOpen] = useState(false)
const [mobileSelectionMode, setMobileSelectionMode] = useState(false)
const [mobileXsMoreMenuOpen, setMobileXsMoreMenuOpen] = useState(false)
const [mobileXsMoveSheetOpen, setMobileXsMoveSheetOpen] = useState(false)
const [mobileXsLabelSheetOpen, setMobileXsLabelSheetOpen] = useState(false)
const [swipeLabelEmailId, setSwipeLabelEmailId] = useState<string | null>(null)
const [openSwipeRowId, setOpenSwipeRowId] = useState<string | null>(null)
const touchListSwipeEnabled = touchNav && !mobileSelectionMode && !isViewMode
useEffect(() => {
setMobileSelectionMode(false)
setSelectedEmails([])
}, [selectedFolder, data.inboxTab])
useEffect(() => {
if (!openSwipeRowId) return
const handler = (e: globalThis.TouchEvent) => {
const target = e.target as HTMLElement | null
if (!target) return
const swipeRow = target.closest(`[data-swipe-row-id="${openSwipeRowId}"]`)
if (!swipeRow) setOpenSwipeRowId(null)
}
document.addEventListener("touchstart", handler, { passive: true })
return () => document.removeEventListener("touchstart", handler)
}, [openSwipeRowId])
const openMobileXsMoveSheet = useCallback(() => {
setMobileXsMoreMenuOpen(false)
window.setTimeout(() => setMobileXsMoveSheetOpen(true), 0)
}, [])
const handleMobileXsMoveSheetOpenChange = useCallback((open: boolean) => {
setMobileXsMoveSheetOpen(open)
if (!open) {
setMobileSelectionMode(false)
setSelectedEmails([])
}
}, [])
const openMobileXsLabelSheet = useCallback(() => {
setMobileXsMoreMenuOpen(false)
setSwipeLabelEmailId(null)
window.setTimeout(() => setMobileXsLabelSheetOpen(true), 0)
}, [])
const handleLabelSheetOpenChange = useCallback((open: boolean) => {
setMobileXsLabelSheetOpen(open)
if (!open) setSwipeLabelEmailId(null)
}, [])
const selectedOnPageCount = useMemo(
() => pageIds.filter((id) => selectedEmails.includes(id)).length,
[pageIds, selectedEmails]
)
const allPageSelected = pageIds.length > 0 && selectedOnPageCount === pageIds.length
const somePageSelected = selectedOnPageCount > 0 && !allPageSelected
const selectAllChecked: boolean | "indeterminate" = allPageSelected
? true
: somePageSelected
? "indeterminate"
: false
const toggleStar = (id: string) => {
mailActions.toggleStar(id)
}
const toggleImportant = (id: string) => {
mailActions.toggleImportant(id)
}
const toggleSelect = (id: string) => {
setSelectedEmails(prev =>
prev.includes(id) ? prev.filter(e => e !== id) : [...prev, id]
)
}
const selectRangeInclusive = (fromId: string, toId: string) => {
const ids = pageIds
const i0 = ids.indexOf(fromId)
const i1 = ids.indexOf(toId)
if (i0 === -1 || i1 === -1) return
const lo = Math.min(i0, i1)
const hi = Math.max(i0, i1)
const range = ids.slice(lo, hi + 1)
setSelectedEmails((prev) => [...new Set([...prev, ...range])])
}
const handleSelectAllChange = (checked: boolean | "indeterminate") => {
if (checked === true) {
setSelectedEmails((prev) => [...new Set([...prev, ...pageIds])])
} else {
setSelectedEmails((prev) => prev.filter((id) => !pageIds.includes(id)))
}
}
const mergePageSelection = (subsetOfPageIds: string[]) => {
setSelectedEmails((prev) => {
const outsidePage = prev.filter((id) => !pageIds.includes(id))
return [...new Set([...outsidePage, ...subsetOfPageIds])]
})
}
const selectMenuAll = () => mergePageSelection(pageIds)
const selectMenuNone = () =>
setSelectedEmails((prev) => prev.filter((id) => !pageIds.includes(id)))
const selectMenuRead = () =>
mergePageSelection(
listEmails.filter((e) => effectiveRead(e)).map((e) => e.id)
)
const selectMenuUnread = () =>
mergePageSelection(
listEmails.filter((e) => !effectiveRead(e)).map((e) => e.id)
)
const selectMenuStarred = () =>
mergePageSelection(
listEmails.filter((e) => effectiveStarred(e)).map((e) => e.id)
)
const selectMenuUnstarred = () =>
mergePageSelection(
listEmails.filter((e) => !effectiveStarred(e)).map((e) => e.id)
)
const handleRowCheckboxClickCapture = (id: string, e: MouseEvent) => {
if (e.shiftKey && lastSelectionAnchorIdRef.current != null) {
e.preventDefault()
e.stopPropagation()
selectRangeInclusive(lastSelectionAnchorIdRef.current, id)
lastSelectionAnchorIdRef.current = id
}
}
const bulkTargetIds = useMemo(
() => pageIds.filter((id) => selectedEmails.includes(id)),
[pageIds, selectedEmails]
)
const hasUnreadInSelection = useMemo(() => {
for (const id of bulkTargetIds) {
const email = allEmails.find((e) => e.id === id)
if (!email) continue
const isRead =
readOverrides[id] !== undefined ? readOverrides[id]! : email.read
if (!isRead) return true
}
return false
}, [bulkTargetIds, readOverrides, allEmails])
const showBulkToolbar = bulkTargetIds.length > 0
const labelSheetTargetIds = useMemo(
() => (swipeLabelEmailId ? [swipeLabelEmailId] : bulkTargetIds),
[swipeLabelEmailId, bulkTargetIds]
)
const clearBulkSelection = (ids: string[]) => {
setSelectedEmails((prev) => prev.filter((id) => !ids.includes(id)))
}
const bulkHideFromList = (ids: string[]) => {
if (ids.length === 0) return
mailActions.hideEmails(ids)
clearBulkSelection(ids)
}
const bulkArchive = () => bulkHideFromList(bulkTargetIds)
const bulkDelete = () => bulkHideFromList(bulkTargetIds)
const bulkSpam = () => bulkHideFromList(bulkTargetIds)
const handleEmailsDroppedOnTarget = useCallback(
(targetId: string, _targetLabel: string, ids: string[]) => {
if (ids.length === 0) return
moveEmailsToTarget(ids, targetId)
setSelectedEmails((prev) => prev.filter((id) => !ids.includes(id)))
},
[moveEmailsToTarget]
)
useEffect(() => {
return registerOnDrop(handleEmailsDroppedOnTarget)
}, [registerOnDrop, handleEmailsDroppedOnTarget])
const startRowDrag = useCallback(
(rowId: string, e: DragEvent<HTMLDivElement>) => {
if (isXs) return
const inSelection = selectedEmails.includes(rowId)
const ids =
inSelection && bulkTargetIds.length > 0 ? bulkTargetIds : [rowId]
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move"
try {
e.dataTransfer.setData("text/plain", ids.join(","))
} catch {
/* some browsers throw if called outside dragstart context */
}
const ghost = document.createElement("div")
ghost.style.position = "fixed"
ghost.style.top = "-1000px"
ghost.style.left = "-1000px"
ghost.style.width = "1px"
ghost.style.height = "1px"
ghost.style.opacity = "0"
document.body.appendChild(ghost)
e.dataTransfer.setDragImage(ghost, 0, 0)
window.setTimeout(() => {
if (ghost.parentNode) ghost.parentNode.removeChild(ghost)
}, 0)
}
beginDrag(ids, selectedFolder, e.clientX, e.clientY)
},
[beginDrag, isXs, selectedEmails, bulkTargetIds, selectedFolder]
)
const bulkMarkRead = () => {
if (bulkTargetIds.length === 0) return
setReadOverrides((prev) => {
const next = { ...prev }
for (const id of bulkTargetIds) next[id] = true
return next
})
}
const bulkMarkUnread = () => {
if (bulkTargetIds.length === 0) return
setReadOverrides((prev) => {
const next = { ...prev }
for (const id of bulkTargetIds) next[id] = false
return next
})
}
const bulkMoveTo = useCallback(
(targetId: string) => {
if (bulkTargetIds.length === 0) return
moveEmailsToTarget(bulkTargetIds, targetId)
if (targetId !== "inbox") {
setSelectedEmails((prev) => prev.filter((id) => !bulkTargetIds.includes(id)))
}
},
[bulkTargetIds, moveEmailsToTarget]
)
const openSwipeRowLabelSheet = useCallback((emailId: string) => {
setSwipeLabelEmailId(emailId)
setMobileXsLabelSheetOpen(true)
}, [])
return {
selectedEmails,
setSelectedEmails,
rowContextMenuOpenedAtRef,
contextMenuTargetIdsRef,
lastSelectionAnchorIdRef,
bulkSelectMenuOpen,
setBulkSelectMenuOpen,
mobileSelectionMode,
setMobileSelectionMode,
mobileXsMoreMenuOpen,
setMobileXsMoreMenuOpen,
mobileXsMoveSheetOpen,
mobileXsLabelSheetOpen,
swipeLabelEmailId,
openSwipeRowId,
setOpenSwipeRowId,
touchListSwipeEnabled,
openMobileXsMoveSheet,
handleMobileXsMoveSheetOpenChange,
openMobileXsLabelSheet,
handleLabelSheetOpenChange,
selectAllChecked,
handleSelectAllChange,
selectMenuAll,
selectMenuNone,
selectMenuRead,
selectMenuUnread,
selectMenuStarred,
selectMenuUnstarred,
toggleStar,
toggleImportant,
toggleSelect,
handleRowCheckboxClickCapture,
bulkTargetIds,
hasUnreadInSelection,
showBulkToolbar,
labelSheetTargetIds,
bulkArchive,
bulkDelete,
bulkSpam,
bulkMarkRead,
bulkMarkUnread,
bulkMoveTo,
startRowDrag,
openSwipeRowLabelSheet,
}
}
export type EmailListSelection = ReturnType<typeof useEmailListSelection>

View File

@ -1 +1,2 @@
export { EmailList } from "@/components/gmail/email-list" export { EmailList } from "@/components/gmail/email-list/email-list"
export type { EmailListProps } from "@/components/gmail/email-list/email-list-helpers"

View File

@ -4,7 +4,7 @@ import { useCallback } from "react"
import type { Email } from "@/lib/email-data" import type { Email } from "@/lib/email-data"
import { useMailStore } from "@/lib/stores/mail-store" import { useMailStore } from "@/lib/stores/mail-store"
type ListMailIndex = { export type ListMailIndex = {
emailById: Map<string, Email> emailById: Map<string, Email>
scheduledIds: Set<string> scheduledIds: Set<string>
} }

View File

@ -8,17 +8,7 @@ import {
useState, useState,
type CSSProperties, type CSSProperties,
} from "react" } from "react"
import { import { Star, Reply, ReplyAll, Forward } from "lucide-react"
Star,
Reply,
ReplyAll,
Forward,
Info,
HardDrive,
File,
FileText,
Image as ImageIcon,
} from "lucide-react"
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@ -31,19 +21,8 @@ import {
cleanSenderName, cleanSenderName,
senderInitial, senderInitial,
} from "@/lib/sender-display" } from "@/lib/sender-display"
import { MailDateText } from "@/components/gmail/mail-date-text" import type { Email, EmailAttachment } from "@/lib/email-data"
import type {
Email,
ConversationMessage,
EmailAttachment,
EmailAttachmentKind,
} from "@/lib/email-data"
import type { FolderTreeNode, LabelRowItem } from "@/lib/sidebar-nav-data" import type { FolderTreeNode, LabelRowItem } from "@/lib/sidebar-nav-data"
import {
attachmentPreviewTooltip,
resolveAttachmentKind,
shouldUseAttachmentPillsInPreview,
} from "@/lib/attachment-display"
import { import {
useComposeActions, useComposeActions,
useComposeDrafts, useComposeDrafts,
@ -61,23 +40,17 @@ import { openConversationPrint } from "@/lib/print-conversation"
import { resolveParsedCalendarInvitation } from "@/lib/resolve-email-calendar-invitation" import { resolveParsedCalendarInvitation } from "@/lib/resolve-email-calendar-invitation"
import { ComposeWindow } from "@/components/gmail/compose-modal" import { ComposeWindow } from "@/components/gmail/compose-modal"
import { CalendarInvitationPreview } from "@/components/gmail/calendar-invitation-preview" import { CalendarInvitationPreview } from "@/components/gmail/calendar-invitation-preview"
import { ContactHoverCard } from "./contact-hover-card"
import { EmailViewSubjectHeader } from "./email-view/email-view-header" import { EmailViewSubjectHeader } from "./email-view/email-view-header"
import { EmailViewMessageToolbar } from "./email-view/email-view-toolbar"
import { import {
MAIL_MESSAGE_HOVER_CLASS,
MAIL_PREVIEW_SCROLL_CLASS, MAIL_PREVIEW_SCROLL_CLASS,
MAIL_REPLY_BAR_CLASS, MAIL_REPLY_BAR_CLASS,
MAIL_REPLY_BUTTON_CLASS, MAIL_REPLY_BUTTON_CLASS,
MAIL_TOOLTIP_CONTENT_CLASS,
} from "@/lib/mail-chrome-classes" } from "@/lib/mail-chrome-classes"
import { useTheme } from "next-themes"
import { import {
emailPreviewBaseCss, CollapsedMessage,
emailPreviewDarkOverrideCss, ExpandedMessage,
emailPreviewLightOverrideCss, SpamWhyBanner,
preprocessEmailHtmlForTheme, } from "@/components/gmail/email-view/email-view-messages"
} from "@/lib/email-preview-dark-styles"
interface EmailViewProps { interface EmailViewProps {
email: Email email: Email
@ -101,400 +74,6 @@ interface EmailViewProps {
isSingleMessageView?: boolean isSingleMessageView?: boolean
} }
const EMAIL_PREVIEW_IFRAME_STYLE: React.CSSProperties = {
display: "block",
background: "transparent",
}
function documentIsDark(): boolean {
return document.documentElement.classList.contains("dark")
}
/* ── Sandboxed iframe for HTML body ── */
function SandboxedContent({
html,
isSpam,
}: {
html: string
isSpam: boolean
}) {
const iframeRef = useRef<HTMLIFrameElement>(null)
const [height, setHeight] = useState(120)
const sandboxValue = isSpam
? "allow-same-origin"
: "allow-same-origin allow-popups"
const { resolvedTheme } = useTheme()
const injectContent = useCallback(() => {
const iframe = iframeRef.current
if (!iframe) return
const doc = iframe.contentDocument
if (!doc) return
const cspMeta = isSpam
? `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:;">`
: `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src https: data:;">`
const isDark = documentIsDark()
const processedHtml = preprocessEmailHtmlForTheme(html, isDark)
const themeOverrides = isDark
? emailPreviewDarkOverrideCss()
: emailPreviewLightOverrideCss()
doc.open()
doc.write(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
${cspMeta}
<style>
${emailPreviewBaseCss(isDark)}
${themeOverrides}
</style>
</head>
<body>${processedHtml}</body>
</html>`)
doc.close()
const resizeObserver = new ResizeObserver(() => {
const body = iframe.contentDocument?.body
if (body) {
setHeight(Math.max(60, body.scrollHeight + 2))
}
})
if (doc.body) {
resizeObserver.observe(doc.body)
setHeight(Math.max(60, doc.body.scrollHeight + 2))
}
return () => resizeObserver.disconnect()
}, [html, isSpam, resolvedTheme])
useEffect(() => {
const cleanup = injectContent()
return () => cleanup?.()
}, [injectContent])
return (
<iframe
ref={iframeRef}
sandbox={sandboxValue}
title="Contenu du message"
className="w-full border-0 bg-transparent"
style={{ ...EMAIL_PREVIEW_IFRAME_STYLE, height: `${height}px` }}
tabIndex={-1}
/>
)
}
function MessageAttachmentCard({ name, kind }: { name: string; kind: EmailAttachmentKind }) {
return (
<>
<div className="relative flex h-[132px] shrink-0 flex-col items-center justify-center bg-linear-to-b from-muted to-muted/70 dark:from-[#3c4043] dark:to-[#303134]">
{kind === "image" ? (
<ImageIcon className="size-11 text-[#9aa0a6]" strokeWidth={1.25} aria-hidden />
) : kind === "pdf" ? (
<div
className="rounded border border-border bg-mail-surface px-4 py-5 shadow-sm"
aria-hidden
>
<span className="text-[11px] font-bold leading-none text-[#d93025]">PDF</span>
</div>
) : (
<File className="size-11 text-[#9aa0a6]" strokeWidth={1.25} aria-hidden />
)}
</div>
<div className="flex min-h-[38px] items-center gap-2 border-t border-border bg-muted px-2 py-1.5">
{kind === "pdf" ? (
<FileText className="size-4 shrink-0 text-[#d93025]" strokeWidth={1.5} aria-hidden />
) : kind === "image" ? (
<ImageIcon className="size-4 shrink-0 text-[#1a73e8]" strokeWidth={1.5} aria-hidden />
) : (
<File className="size-4 shrink-0 text-[#5f6368]" strokeWidth={1.5} aria-hidden />
)}
<span className="min-w-0 flex-1 truncate text-xs leading-tight text-[#3c4043]">
{name}
</span>
</div>
</>
)
}
function MessageAttachmentPill({
name,
kind,
sizeBytes,
}: {
name: string
kind: EmailAttachmentKind
sizeBytes?: number
}) {
const tip = attachmentPreviewTooltip(name, sizeBytes)
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex max-w-[min(100%,320px)] min-w-0 shrink-0 items-center gap-2 rounded-full border border-border bg-muted py-1.5 pl-2.5 pr-3 text-left text-sm text-foreground shadow-sm transition hover:border-border hover:bg-accent hover:shadow focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
{kind === "pdf" ? (
<FileText className="size-4 shrink-0 text-[#d93025]" strokeWidth={1.5} aria-hidden />
) : kind === "image" ? (
<ImageIcon className="size-4 shrink-0 text-[#1a73e8]" strokeWidth={1.5} aria-hidden />
) : (
<File className="size-4 shrink-0 text-[#5f6368]" strokeWidth={1.5} aria-hidden />
)}
<span className="min-w-0 truncate font-medium">{name}</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs whitespace-pre-line text-xs")}>
{tip}
</TooltipContent>
</Tooltip>
)
}
function MessageAttachmentsSection({ attachments }: { attachments: EmailAttachment[] }) {
const n = attachments.length
if (n === 0) return null
const summary = n === 1 ? "Une pièce jointe" : `${n} pièces jointes`
const asPills = shouldUseAttachmentPillsInPreview(attachments)
return (
<div className="mt-4 border-t border-border px-4 pb-4 pl-[68px] pt-4 max-sm:pl-4 max-sm:pr-4">
<div className="mb-3 flex min-w-0 flex-wrap items-center justify-between gap-x-3 gap-y-2">
<div className="flex min-w-0 max-w-[min(100%,28rem)] items-center gap-1 text-sm text-muted-foreground">
<span className="min-w-0 truncate">
{summary}
<span aria-hidden> · </span>
<span>Analysé par VirusTotal</span>
</span>
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>
<button
type="button"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-accent"
aria-label="Informations sur l'analyse VirusTotal des pièces jointes"
>
<Info className="size-4" strokeWidth={1.75} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs text-xs")}>
VirusTotal analyse les pièces jointes et les compare à une base de signatures pour
repérer les virus et logiciels malveillants.
</TooltipContent>
</Tooltip>
</div>
<button
type="button"
className="flex shrink-0 items-center gap-2 rounded-md py-1 pl-1 pr-2 text-sm font-medium text-primary hover:bg-accent"
aria-label="Ajouter à UltiDrive"
>
<HardDrive className="size-[18px] shrink-0" strokeWidth={1.5} aria-hidden />
Ajouter à UltiDrive
</button>
</div>
<div
className={
asPills
? "flex flex-wrap gap-2 pb-1"
: "flex flex-nowrap gap-3 overflow-x-auto overflow-y-hidden pb-1 [-webkit-overflow-scrolling:touch]"
}
role="list"
aria-label="Pièces jointes"
>
{attachments.map((att, index) => {
const kind = resolveAttachmentKind(att.name, att.kind)
const tip = attachmentPreviewTooltip(att.name, att.sizeBytes)
if (asPills) {
return (
<div key={`${att.name}-${index}`} className="shrink-0" role="listitem">
<MessageAttachmentPill name={att.name} kind={kind} sizeBytes={att.sizeBytes} />
</div>
)
}
return (
<div key={`${att.name}-${index}`} className="shrink-0" role="listitem">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex w-[200px] flex-col overflow-hidden rounded border border-border bg-mail-surface text-left shadow-sm transition hover:border-border hover:shadow-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
<MessageAttachmentCard name={att.name} kind={kind} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs whitespace-pre-line text-xs")}>
{tip}
</TooltipContent>
</Tooltip>
</div>
)
})}
</div>
</div>
)
}
/* ── Collapsed conversation message (accordion header) ── */
function CollapsedMessage({
message,
onClick,
}: {
message: ConversationMessage
onClick: () => void
}) {
const name = cleanSenderName(message.sender)
const color = avatarColor(name)
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onClick()
}
}}
className={cn("group flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left transition-colors", MAIL_MESSAGE_HOVER_CLASS)}
>
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"
style={{ backgroundColor: color }}
>
{senderInitial(name)}
</div>
<div className="min-w-0 flex-1 flex flex-col gap-1" data-selectable-text>
<div className="flex min-w-0 items-center justify-between gap-2">
<ContactHoverCard displayName={message.sender} email={message.senderEmail} className="min-w-0">
<span className="truncate text-sm font-semibold text-foreground">{name}</span>
</ContactHoverCard>
<div className="flex shrink-0 items-center gap-1">
<MailDateText
iso={message.date}
variant="preview"
className="text-xs text-muted-foreground"
/>
<Star
strokeWidth={1.25}
className="ml-1 size-4 fill-transparent stroke-[#c2c2c2]"
/>
</div>
</div>
<p className="min-w-0 truncate text-sm leading-snug text-muted-foreground">{message.preview}</p>
</div>
</div>
)
}
/* ── Expanded message card (full body) ── */
function ExpandedMessage({
sender,
senderEmail,
dateIso,
body,
isSpam,
isLast,
starred,
attachments = [],
onToggleStar,
onCollapse,
onPrintConversation,
}: {
sender: string
senderEmail: string
dateIso: string
body: string
isSpam: boolean
isLast: boolean
starred: boolean
attachments?: EmailAttachment[]
onToggleStar?: () => void
onCollapse?: () => void
onPrintConversation?: () => void
}) {
return (
<div>
<EmailViewMessageToolbar
sender={sender}
senderEmail={senderEmail}
dateIso={dateIso}
isSpam={isSpam}
isLast={isLast}
starred={starred}
onToggleStar={onToggleStar}
onCollapse={onCollapse}
onPrintConversation={onPrintConversation}
/>
{/* Body */}
<div
className={cn(
"px-4 pl-[68px] max-sm:pl-4 max-sm:pr-4",
attachments.length > 0 ? "pb-0" : "pb-4"
)}
data-selectable-text
>
<SandboxedContent html={body} isSpam={isSpam} />
</div>
{attachments.length > 0 && (
<MessageAttachmentsSection attachments={attachments} />
)}
</div>
)
}
/* ── Spam explainer (preview) ── */
function SpamWhyBanner({ onNotSpam }: { onNotSpam?: () => void }) {
return (
<div className="mx-6 mb-4 flex items-start gap-3 rounded-lg border border-border bg-muted px-4 py-3.5 max-sm:mx-4">
<div className="min-w-0 flex-1 space-y-3">
<p className="text-sm leading-snug text-foreground/80">
<span className="font-medium text-foreground">Pourquoi ce message est-il dans le spam ?</span>{" "}
Ce message est semblable à des messages identifiés comme spam par le passé.
</p>
{onNotSpam && (
<button
type="button"
onClick={onNotSpam}
className="rounded-md border border-border bg-mail-surface px-4 py-2 text-sm font-medium text-primary shadow-sm transition-colors hover:bg-accent"
>
Signaler comme non-spam
</button>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-accent"
aria-label="En savoir plus sur le filtre anti-spam"
>
<Info className="h-[18px] w-[18px]" strokeWidth={1.75} />
</button>
</TooltipTrigger>
<TooltipContent side="left" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs text-xs")}>
Les filtres peuvent se tromper. Si le message est légitime, signalez-le comme non-spam pour
l&apos;améliorer.
</TooltipContent>
</Tooltip>
</div>
)
}
/* ── Main EmailView component ── */ /* ── Main EmailView component ── */
export function EmailView({ export function EmailView({

View File

@ -0,0 +1,172 @@
"use client"
import { Star, Info } from "lucide-react"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import {
avatarColor,
cleanSenderName,
senderInitial,
} from "@/lib/sender-display"
import { MailDateText } from "@/components/gmail/mail-date-text"
import type {
ConversationMessage,
EmailAttachment,
} from "@/lib/email-data"
import { ContactHoverCard } from "@/components/gmail/contact-hover-card"
import { EmailViewMessageToolbar } from "@/components/gmail/email-view/email-view-toolbar"
import { SandboxedContent } from "@/components/gmail/email-view/sandboxed-content"
import { MessageAttachmentsSection } from "@/components/gmail/email-view/message-attachments"
import {
MAIL_MESSAGE_HOVER_CLASS,
MAIL_TOOLTIP_CONTENT_CLASS,
} from "@/lib/mail-chrome-classes"
export function CollapsedMessage({
message,
onClick,
}: {
message: ConversationMessage
onClick: () => void
}) {
const name = cleanSenderName(message.sender)
const color = avatarColor(name)
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onClick()
}
}}
className={cn("group flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left transition-colors", MAIL_MESSAGE_HOVER_CLASS)}
>
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"
style={{ backgroundColor: color }}
>
{senderInitial(name)}
</div>
<div className="min-w-0 flex-1 flex flex-col gap-1" data-selectable-text>
<div className="flex min-w-0 items-center justify-between gap-2">
<ContactHoverCard displayName={message.sender} email={message.senderEmail} className="min-w-0">
<span className="truncate text-sm font-semibold text-foreground">{name}</span>
</ContactHoverCard>
<div className="flex shrink-0 items-center gap-1">
<MailDateText
iso={message.date}
variant="preview"
className="text-xs text-muted-foreground"
/>
<Star
strokeWidth={1.25}
className="ml-1 size-4 fill-transparent stroke-[#c2c2c2]"
/>
</div>
</div>
<p className="min-w-0 truncate text-sm leading-snug text-muted-foreground">{message.preview}</p>
</div>
</div>
)
}
export function ExpandedMessage({
sender,
senderEmail,
dateIso,
body,
isSpam,
isLast,
starred,
attachments = [],
onToggleStar,
onCollapse,
onPrintConversation,
}: {
sender: string
senderEmail: string
dateIso: string
body: string
isSpam: boolean
isLast: boolean
starred: boolean
attachments?: EmailAttachment[]
onToggleStar?: () => void
onCollapse?: () => void
onPrintConversation?: () => void
}) {
return (
<div>
<EmailViewMessageToolbar
sender={sender}
senderEmail={senderEmail}
dateIso={dateIso}
isSpam={isSpam}
isLast={isLast}
starred={starred}
onToggleStar={onToggleStar}
onCollapse={onCollapse}
onPrintConversation={onPrintConversation}
/>
<div
className={cn(
"px-4 pl-[68px] max-sm:pl-4 max-sm:pr-4",
attachments.length > 0 ? "pb-0" : "pb-4"
)}
data-selectable-text
>
<SandboxedContent html={body} isSpam={isSpam} />
</div>
{attachments.length > 0 && (
<MessageAttachmentsSection attachments={attachments} />
)}
</div>
)
}
export function SpamWhyBanner({ onNotSpam }: { onNotSpam?: () => void }) {
return (
<div className="mx-6 mb-4 flex items-start gap-3 rounded-lg border border-border bg-muted px-4 py-3.5 max-sm:mx-4">
<div className="min-w-0 flex-1 space-y-3">
<p className="text-sm leading-snug text-foreground/80">
<span className="font-medium text-foreground">Pourquoi ce message est-il dans le spam ?</span>{" "}
Ce message est semblable à des messages identifiés comme spam par le passé.
</p>
{onNotSpam && (
<button
type="button"
onClick={onNotSpam}
className="rounded-md border border-border bg-mail-surface px-4 py-2 text-sm font-medium text-primary shadow-sm transition-colors hover:bg-accent"
>
Signaler comme non-spam
</button>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-accent"
aria-label="En savoir plus sur le filtre anti-spam"
>
<Info className="h-[18px] w-[18px]" strokeWidth={1.75} />
</button>
</TooltipTrigger>
<TooltipContent side="left" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs text-xs")}>
Les filtres peuvent se tromper. Si le message est légitime, signalez-le comme non-spam pour
l&apos;améliorer.
</TooltipContent>
</Tooltip>
</div>
)
}

View File

@ -0,0 +1,173 @@
"use client"
import {
Info,
HardDrive,
File,
FileText,
Image as ImageIcon,
} from "lucide-react"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import type { EmailAttachment, EmailAttachmentKind } from "@/lib/email-data"
import {
attachmentPreviewTooltip,
resolveAttachmentKind,
shouldUseAttachmentPillsInPreview,
} from "@/lib/attachment-display"
import { MAIL_TOOLTIP_CONTENT_CLASS } from "@/lib/mail-chrome-classes"
function MessageAttachmentCard({ name, kind }: { name: string; kind: EmailAttachmentKind }) {
return (
<>
<div className="relative flex h-[132px] shrink-0 flex-col items-center justify-center bg-linear-to-b from-muted to-muted/70 dark:from-[#3c4043] dark:to-[#303134]">
{kind === "image" ? (
<ImageIcon className="size-11 text-[#9aa0a6]" strokeWidth={1.25} aria-hidden />
) : kind === "pdf" ? (
<div
className="rounded border border-border bg-mail-surface px-4 py-5 shadow-sm"
aria-hidden
>
<span className="text-[11px] font-bold leading-none text-[#d93025]">PDF</span>
</div>
) : (
<File className="size-11 text-[#9aa0a6]" strokeWidth={1.25} aria-hidden />
)}
</div>
<div className="flex min-h-[38px] items-center gap-2 border-t border-border bg-muted px-2 py-1.5">
{kind === "pdf" ? (
<FileText className="size-4 shrink-0 text-[#d93025]" strokeWidth={1.5} aria-hidden />
) : kind === "image" ? (
<ImageIcon className="size-4 shrink-0 text-[#1a73e8]" strokeWidth={1.5} aria-hidden />
) : (
<File className="size-4 shrink-0 text-[#5f6368]" strokeWidth={1.5} aria-hidden />
)}
<span className="min-w-0 flex-1 truncate text-xs leading-tight text-[#3c4043]">
{name}
</span>
</div>
</>
)
}
function MessageAttachmentPill({
name,
kind,
sizeBytes,
}: {
name: string
kind: EmailAttachmentKind
sizeBytes?: number
}) {
const tip = attachmentPreviewTooltip(name, sizeBytes)
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex max-w-[min(100%,320px)] min-w-0 shrink-0 items-center gap-2 rounded-full border border-border bg-muted py-1.5 pl-2.5 pr-3 text-left text-sm text-foreground shadow-sm transition hover:border-border hover:bg-accent hover:shadow focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
{kind === "pdf" ? (
<FileText className="size-4 shrink-0 text-[#d93025]" strokeWidth={1.5} aria-hidden />
) : kind === "image" ? (
<ImageIcon className="size-4 shrink-0 text-[#1a73e8]" strokeWidth={1.5} aria-hidden />
) : (
<File className="size-4 shrink-0 text-[#5f6368]" strokeWidth={1.5} aria-hidden />
)}
<span className="min-w-0 truncate font-medium">{name}</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs whitespace-pre-line text-xs")}>
{tip}
</TooltipContent>
</Tooltip>
)
}
export function MessageAttachmentsSection({ attachments }: { attachments: EmailAttachment[] }) {
const n = attachments.length
if (n === 0) return null
const summary = n === 1 ? "Une pièce jointe" : `${n} pièces jointes`
const asPills = shouldUseAttachmentPillsInPreview(attachments)
return (
<div className="mt-4 border-t border-border px-4 pb-4 pl-[68px] pt-4 max-sm:pl-4 max-sm:pr-4">
<div className="mb-3 flex min-w-0 flex-wrap items-center justify-between gap-x-3 gap-y-2">
<div className="flex min-w-0 max-w-[min(100%,28rem)] items-center gap-1 text-sm text-muted-foreground">
<span className="min-w-0 truncate">
{summary}
<span aria-hidden> · </span>
<span>Analysé par VirusTotal</span>
</span>
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>
<button
type="button"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-accent"
aria-label="Informations sur l'analyse VirusTotal des pièces jointes"
>
<Info className="size-4" strokeWidth={1.75} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs text-xs")}>
VirusTotal analyse les pièces jointes et les compare à une base de signatures pour
repérer les virus et logiciels malveillants.
</TooltipContent>
</Tooltip>
</div>
<button
type="button"
className="flex shrink-0 items-center gap-2 rounded-md py-1 pl-1 pr-2 text-sm font-medium text-primary hover:bg-accent"
aria-label="Ajouter à UltiDrive"
>
<HardDrive className="size-[18px] shrink-0" strokeWidth={1.5} aria-hidden />
Ajouter à UltiDrive
</button>
</div>
<div
className={
asPills
? "flex flex-wrap gap-2 pb-1"
: "flex flex-nowrap gap-3 overflow-x-auto overflow-y-hidden pb-1 [-webkit-overflow-scrolling:touch]"
}
role="list"
aria-label="Pièces jointes"
>
{attachments.map((att, index) => {
const kind = resolveAttachmentKind(att.name, att.kind)
const tip = attachmentPreviewTooltip(att.name, att.sizeBytes)
if (asPills) {
return (
<div key={`${att.name}-${index}`} className="shrink-0" role="listitem">
<MessageAttachmentPill name={att.name} kind={kind} sizeBytes={att.sizeBytes} />
</div>
)
}
return (
<div key={`${att.name}-${index}`} className="shrink-0" role="listitem">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex w-[200px] flex-col overflow-hidden rounded border border-border bg-mail-surface text-left shadow-sm transition hover:border-border hover:shadow-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
<MessageAttachmentCard name={att.name} kind={kind} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs whitespace-pre-line text-xs")}>
{tip}
</TooltipContent>
</Tooltip>
</div>
)
})}
</div>
</div>
)
}

View File

@ -0,0 +1,99 @@
"use client"
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"
import { useTheme } from "next-themes"
import {
emailPreviewBaseCss,
emailPreviewDarkOverrideCss,
emailPreviewLightOverrideCss,
preprocessEmailHtmlForTheme,
} from "@/lib/email-preview-dark-styles"
const EMAIL_PREVIEW_IFRAME_STYLE: CSSProperties = {
display: "block",
background: "transparent",
}
function documentIsDark(): boolean {
return document.documentElement.classList.contains("dark")
}
export function SandboxedContent({
html,
isSpam,
}: {
html: string
isSpam: boolean
}) {
const iframeRef = useRef<HTMLIFrameElement>(null)
const [height, setHeight] = useState(120)
const sandboxValue = isSpam
? "allow-same-origin"
: "allow-same-origin allow-popups"
const { resolvedTheme } = useTheme()
const injectContent = useCallback(() => {
const iframe = iframeRef.current
if (!iframe) return
const doc = iframe.contentDocument
if (!doc) return
const cspMeta = isSpam
? `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:;">`
: `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src https: data:;">`
const isDark = documentIsDark()
const processedHtml = preprocessEmailHtmlForTheme(html, isDark)
const themeOverrides = isDark
? emailPreviewDarkOverrideCss()
: emailPreviewLightOverrideCss()
doc.open()
doc.write(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
${cspMeta}
<style>
${emailPreviewBaseCss(isDark)}
${themeOverrides}
</style>
</head>
<body>${processedHtml}</body>
</html>`)
doc.close()
const resizeObserver = new ResizeObserver(() => {
const body = iframe.contentDocument?.body
if (body) {
setHeight(Math.max(60, body.scrollHeight + 2))
}
})
if (doc.body) {
resizeObserver.observe(doc.body)
setHeight(Math.max(60, doc.body.scrollHeight + 2))
}
return () => resizeObserver.disconnect()
}, [html, isSpam, resolvedTheme])
useEffect(() => {
const cleanup = injectContent()
return () => cleanup?.()
}, [injectContent])
return (
<iframe
ref={iframeRef}
sandbox={sandboxValue}
title="Contenu du message"
className="w-full border-0 bg-transparent"
style={{ ...EMAIL_PREVIEW_IFRAME_STYLE, height: `${height}px` }}
tabIndex={-1}
/>
)
}

View File

@ -18,16 +18,6 @@ import {
User, User,
} from "lucide-react" } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { emails } from "@/lib/email-data" import { emails } from "@/lib/email-data"
import { useContactsStore } from "@/lib/contacts/contacts-store" import { useContactsStore } from "@/lib/contacts/contacts-store"
@ -40,24 +30,13 @@ import {
type SearchSuggestion, type SearchSuggestion,
} from "@/lib/mail-search/search-engine" } from "@/lib/mail-search/search-engine"
import { import {
buildSearchUrl,
parseSearchParams, parseSearchParams,
EMPTY_SEARCH_PARAMS,
DATE_RANGE_OPTIONS,
SEARCH_IN_OPTIONS,
type SearchParams,
} from "@/lib/mail-search/search-params" } from "@/lib/mail-search/search-params"
import { import {
buildQuickSearchParams, buildQuickSearchParams,
submitMailSearch, submitMailSearch,
} from "@/lib/mail-search/navigate" } from "@/lib/mail-search/navigate"
import { import { MAIL_SEARCH_SUGGESTIONS_DROPDOWN_CLASS } from "@/lib/mail-chrome-classes"
MAIL_SEARCH_ADVANCED_PANEL_CLASS,
MAIL_SEARCH_CHECKBOX_CLASS,
MAIL_SEARCH_FIELD_CLASS,
MAIL_SEARCH_SECTION_DIVIDER_CLASS,
MAIL_SEARCH_SUGGESTIONS_DROPDOWN_CLASS,
} from "@/lib/mail-chrome-classes"
import { avatarColor, senderInitial } from "@/lib/sender-display" import { avatarColor, senderInitial } from "@/lib/sender-display"
interface MailSearchBarProps { interface MailSearchBarProps {
@ -65,220 +44,7 @@ interface MailSearchBarProps {
compact?: boolean compact?: boolean
} }
// ─── Advanced Search Panel ─────────────────────────────────────────────────── import { AdvancedSearchPanel } from "@/components/gmail/mail-search/advanced-search-panel"
function AdvancedSearchPanel({
onClose,
initialQuery,
currentParams,
}: {
onClose: () => void
initialQuery: string
currentParams: SearchParams | null
}) {
const router = useRouter()
const [from, setFrom] = useState(currentParams?.from ?? "")
const [to, setTo] = useState(currentParams?.to ?? "")
const [subject, setSubject] = useState(currentParams?.subject ?? "")
const [hasWords, setHasWords] = useState(
currentParams?.hasWords || currentParams?.q || initialQuery
)
const [doesNotHave, setDoesNotHave] = useState(currentParams?.doesNotHave ?? "")
const [sizeVal, setSizeVal] = useState(currentParams?.size ?? "")
const [sizeOp, setSizeOp] = useState<"gt" | "lt">(currentParams?.sizeOp ?? "gt")
const [sizeUnit, setSizeUnit] = useState<"Mo" | "Ko">(currentParams?.sizeUnit ?? "Mo")
const [within, setWithin] = useState(currentParams?.within ?? "")
const [dateAfter, setDateAfter] = useState(currentParams?.after ?? "")
const [searchIn, setSearchIn] = useState(currentParams?.in ?? "all")
const [hasAttachment, setHasAttachment] = useState(
currentParams?.has?.includes("attachment") ?? false
)
const [excludeChats, setExcludeChats] = useState(currentParams?.excludeChats ?? false)
const handleSubmit = () => {
const params: Partial<SearchParams> = {
...EMPTY_SEARCH_PARAMS,
q: "",
from,
to,
subject,
hasWords,
doesNotHave,
size: sizeVal,
sizeOp,
sizeUnit,
within,
after: dateAfter,
in: searchIn,
has: hasAttachment ? ["attachment"] : [],
excludeChats,
}
router.push(buildSearchUrl(params))
onClose()
}
return (
<div className={MAIL_SEARCH_ADVANCED_PANEL_CLASS}>
<div className="space-y-3 p-4">
<div className="flex items-center gap-3">
<Label className="w-36 shrink-0 text-sm text-muted-foreground">De</Label>
<Input
value={from}
onChange={(e) => setFrom(e.target.value)}
className={cn("h-8 flex-1 px-2 text-sm", MAIL_SEARCH_FIELD_CLASS)}
autoFocus
/>
</div>
<div className="flex items-center gap-3">
<Label className="w-36 shrink-0 text-sm text-muted-foreground">À</Label>
<Input
value={to}
onChange={(e) => setTo(e.target.value)}
className={cn("h-8 flex-1 px-2 text-sm", MAIL_SEARCH_FIELD_CLASS)}
/>
</div>
<div className="flex items-center gap-3">
<Label className="w-36 shrink-0 text-sm text-muted-foreground">Objet</Label>
<Input
value={subject}
onChange={(e) => setSubject(e.target.value)}
className={cn("h-8 flex-1 px-2 text-sm", MAIL_SEARCH_FIELD_CLASS)}
/>
</div>
<div className="flex items-center gap-3">
<Label className="w-36 shrink-0 text-sm text-muted-foreground">
Contient les mots
</Label>
<Input
value={hasWords}
onChange={(e) => setHasWords(e.target.value)}
className={cn("h-8 flex-1 px-2 text-sm", MAIL_SEARCH_FIELD_CLASS)}
/>
</div>
<div className="flex items-center gap-3">
<Label className="w-36 shrink-0 text-sm text-muted-foreground">
Ne contient pas
</Label>
<Input
value={doesNotHave}
onChange={(e) => setDoesNotHave(e.target.value)}
className={cn("h-8 flex-1 px-2 text-sm", MAIL_SEARCH_FIELD_CLASS)}
/>
</div>
<div className="flex items-center gap-3">
<Label className="w-36 shrink-0 text-sm text-muted-foreground">Taille</Label>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<Select value={sizeOp} onValueChange={(v) => setSizeOp(v as "gt" | "lt")}>
<SelectTrigger className={cn("h-8 w-32 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="gt">supérieure à</SelectItem>
<SelectItem value="lt">inférieure à</SelectItem>
</SelectContent>
</Select>
<Input
type="number"
value={sizeVal}
onChange={(e) => setSizeVal(e.target.value)}
className={cn("h-8 w-20 px-2 text-sm", MAIL_SEARCH_FIELD_CLASS)}
/>
<Select value={sizeUnit} onValueChange={(v) => setSizeUnit(v as "Mo" | "Ko")}>
<SelectTrigger className={cn("h-8 w-20 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Mo">Mo</SelectItem>
<SelectItem value="Ko">Ko</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-3">
<Label className="w-36 shrink-0 text-sm text-muted-foreground">
Plage de dates
</Label>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<Select value={within} onValueChange={setWithin}>
<SelectTrigger className={cn("h-8 w-32 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
<SelectValue placeholder="Sélectionner" />
</SelectTrigger>
<SelectContent>
{DATE_RANGE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="date"
value={dateAfter}
onChange={(e) => setDateAfter(e.target.value)}
className={cn("h-8 min-w-0 flex-1 px-2 text-sm", MAIL_SEARCH_FIELD_CLASS)}
/>
</div>
</div>
<div className="flex items-center gap-3">
<Label className="w-36 shrink-0 text-sm text-muted-foreground">
Rechercher
</Label>
<Select value={searchIn} onValueChange={setSearchIn}>
<SelectTrigger className={cn("h-8 flex-1 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SEARCH_IN_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-6 pt-1">
<label className="flex items-center gap-2 text-sm text-foreground">
<Checkbox
className={MAIL_SEARCH_CHECKBOX_CLASS}
checked={hasAttachment}
onCheckedChange={(v) => setHasAttachment(v === true)}
/>
Contenant une pièce jointe
</label>
<label className="flex items-center gap-2 text-sm text-foreground">
<Checkbox
className={MAIL_SEARCH_CHECKBOX_CLASS}
checked={excludeChats}
onCheckedChange={(v) => setExcludeChats(v === true)}
/>
Ne pas inclure les chats
</label>
</div>
<div
className={cn(
"flex items-center justify-end gap-3 border-t pt-3",
MAIL_SEARCH_SECTION_DIVIDER_CLASS
)}
>
<Button variant="ghost" className="text-sm text-blue-600" disabled>
Créer un filtre
</Button>
<Button
className="bg-[#1a73e8] text-sm text-white hover:bg-[#1765cc]"
onClick={handleSubmit}
>
Rechercher
</Button>
</div>
</div>
</div>
)
}
// ─── Main Search Bar ───────────────────────────────────────────────────────── // ─── Main Search Bar ─────────────────────────────────────────────────────────
@ -454,10 +220,10 @@ export function MailSearchBar({
className={cn("relative flex w-full min-w-0 flex-col overflow-visible", className)} className={cn("relative flex w-full min-w-0 flex-col overflow-visible", className)}
> >
{/* Input row */} {/* Input row */}
<div className="relative flex w-full min-w-0 items-center"> <div className="relative flex w-full min-w-0 items-center text-[#5f6368] dark:text-[#9aa0a6]">
<div <div
className={cn( className={cn(
"pointer-events-none absolute flex items-center text-gray-500", "pointer-events-none absolute flex items-center",
compact ? "left-4" : "left-3.5" compact ? "left-4" : "left-3.5"
)} )}
> >
@ -491,7 +257,7 @@ export function MailSearchBar({
onBlur={() => setFocused(false)} onBlur={() => setFocused(false)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className={cn( className={cn(
"h-12 w-full rounded-full border-0 bg-muted text-sm outline-none placeholder:text-gray-500", "h-12 w-full rounded-full border-0 bg-muted text-sm text-foreground outline-none placeholder-shown:text-inherit placeholder:opacity-100",
focused || advancedOpen focused || advancedOpen
? "bg-white shadow-md ring-1 ring-gray-300 dark:bg-gray-900 dark:ring-gray-600" ? "bg-white shadow-md ring-1 ring-gray-300 dark:bg-gray-900 dark:ring-gray-600"
: "", : "",

View File

@ -0,0 +1,321 @@
"use client"
import type { ReactNode } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils"
import {
DATE_RANGE_OPTIONS,
SEARCH_IN_OPTIONS,
} from "@/lib/mail-search/search-params"
import {
MAIL_SEARCH_ADVANCED_PANEL_CLASS,
MAIL_SEARCH_CHECKBOX_CLASS,
MAIL_SEARCH_FIELD_CLASS,
MAIL_SEARCH_SECTION_DIVIDER_CLASS,
} from "@/lib/mail-chrome-classes"
import type { useAdvancedSearchForm } from "@/lib/mail-search/use-advanced-search-form"
type Form = ReturnType<typeof useAdvancedSearchForm>
export function AdvancedSearchPanelDesktop({
form,
onSubmit,
onClose,
}: {
form: Form
onSubmit: () => void
onClose: () => void
}) {
const f = form
const labelClass = "w-36 shrink-0 text-sm text-muted-foreground"
const inputClass = cn("h-8 flex-1 px-2 text-sm", MAIL_SEARCH_FIELD_CLASS)
const rowClass = "flex items-center gap-3"
return (
<div className={MAIL_SEARCH_ADVANCED_PANEL_CLASS}>
<div className="space-y-3 p-4">
<div className={rowClass}>
<Label className={labelClass}>De</Label>
<Input value={f.from} onChange={(e) => f.setFrom(e.target.value)} className={inputClass} autoFocus />
</div>
<div className={rowClass}>
<Label className={labelClass}>À</Label>
<Input value={f.to} onChange={(e) => f.setTo(e.target.value)} className={inputClass} />
</div>
<div className={rowClass}>
<Label className={labelClass}>Objet</Label>
<Input value={f.subject} onChange={(e) => f.setSubject(e.target.value)} className={inputClass} />
</div>
<div className={rowClass}>
<Label className={labelClass}>Contient les mots</Label>
<Input value={f.hasWords} onChange={(e) => f.setHasWords(e.target.value)} className={inputClass} />
</div>
<div className={rowClass}>
<Label className={labelClass}>Ne contient pas</Label>
<Input value={f.doesNotHave} onChange={(e) => f.setDoesNotHave(e.target.value)} className={inputClass} />
</div>
<AdvancedSearchSizeRow form={f} compact={false} labelClass={labelClass} />
<AdvancedSearchDateRow form={f} compact={false} labelClass={labelClass} />
<div className={rowClass}>
<Label className={labelClass}>Rechercher</Label>
<Select value={f.searchIn} onValueChange={f.setSearchIn}>
<SelectTrigger className={cn("h-8 flex-1 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SEARCH_IN_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-6 pt-1">
<AdvancedSearchCheckboxes form={f} />
</div>
<div
className={cn(
"flex items-center justify-end gap-3 border-t pt-3",
MAIL_SEARCH_SECTION_DIVIDER_CLASS
)}
>
<Button variant="ghost" className="text-sm text-blue-600" disabled>
Créer un filtre
</Button>
<Button
className="bg-[#1a73e8] text-sm text-white hover:bg-[#1765cc]"
onClick={onSubmit}
>
Rechercher
</Button>
</div>
</div>
</div>
)
}
export function AdvancedSearchPanelMobile({
form,
onSubmit,
}: {
form: Form
onSubmit: () => void
}) {
const f = form
const fieldClass = cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)
return (
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
<div className="space-y-4">
<AdvancedSearchField label="De">
<Input value={f.from} onChange={(e) => f.setFrom(e.target.value)} className={fieldClass} />
</AdvancedSearchField>
<AdvancedSearchField label="À">
<Input value={f.to} onChange={(e) => f.setTo(e.target.value)} className={fieldClass} />
</AdvancedSearchField>
<AdvancedSearchField label="Objet">
<Input value={f.subject} onChange={(e) => f.setSubject(e.target.value)} className={fieldClass} />
</AdvancedSearchField>
<AdvancedSearchField label="Contient les mots">
<Input value={f.hasWords} onChange={(e) => f.setHasWords(e.target.value)} className={fieldClass} />
</AdvancedSearchField>
<AdvancedSearchField label="Ne contient pas">
<Input value={f.doesNotHave} onChange={(e) => f.setDoesNotHave(e.target.value)} className={fieldClass} />
</AdvancedSearchField>
<AdvancedSearchSizeRow form={f} compact labelClass="" />
<AdvancedSearchDateRow form={f} compact labelClass="" />
<AdvancedSearchField label="Rechercher dans">
<Select value={f.searchIn} onValueChange={f.setSearchIn}>
<SelectTrigger className={fieldClass}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SEARCH_IN_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</AdvancedSearchField>
<div className="space-y-3 pt-1">
<AdvancedSearchCheckboxes form={f} />
</div>
<Button
className="w-full bg-[#1a73e8] text-sm text-white hover:bg-[#1765cc]"
onClick={onSubmit}
>
Rechercher
</Button>
</div>
</div>
)
}
function AdvancedSearchField({
label,
children,
}: {
label: string
children: ReactNode
}) {
return (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">{label}</Label>
{children}
</div>
)
}
function AdvancedSearchSizeRow({
form,
compact,
labelClass,
}: {
form: Form
compact: boolean
labelClass: string
}) {
const triggerSm = cn(
compact ? "h-9 flex-1 text-sm" : "h-8 w-32 text-sm",
MAIL_SEARCH_FIELD_CLASS
)
const triggerUnit = cn(compact ? "h-9 w-20 text-sm" : "h-8 w-20 text-sm", MAIL_SEARCH_FIELD_CLASS)
const sizeInput = cn(
compact ? "h-9 w-20 text-sm" : "h-8 w-20 px-2 text-sm",
MAIL_SEARCH_FIELD_CLASS
)
const controls = (
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<Select value={form.sizeOp} onValueChange={(v) => form.setSizeOp(v as "gt" | "lt")}>
<SelectTrigger className={triggerSm}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="gt">supérieure à</SelectItem>
<SelectItem value="lt">inférieure à</SelectItem>
</SelectContent>
</Select>
<Input
type="number"
value={form.sizeVal}
onChange={(e) => form.setSizeVal(e.target.value)}
className={sizeInput}
/>
<Select value={form.sizeUnit} onValueChange={(v) => form.setSizeUnit(v as "Mo" | "Ko")}>
<SelectTrigger className={triggerUnit}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Mo">Mo</SelectItem>
<SelectItem value="Ko">Ko</SelectItem>
</SelectContent>
</Select>
</div>
)
if (compact) {
return (
<AdvancedSearchField label="Taille">
<div className="flex items-center gap-2">{controls}</div>
</AdvancedSearchField>
)
}
return (
<div className="flex items-center gap-3">
<Label className={labelClass}>Taille</Label>
{controls}
</div>
)
}
function AdvancedSearchDateRow({
form,
compact,
labelClass,
}: {
form: Form
compact: boolean
labelClass: string
}) {
const triggerClass = cn(
compact ? "h-9 text-sm" : "h-8 w-32 text-sm",
MAIL_SEARCH_FIELD_CLASS
)
const dateInput = cn(
compact ? "h-9 text-sm" : "h-8 min-w-0 flex-1 px-2 text-sm",
MAIL_SEARCH_FIELD_CLASS
)
const controls = (
<div className={cn("flex min-w-0 flex-1 flex-wrap items-center gap-2", compact && "flex-col items-stretch gap-2")}>
<Select value={form.within} onValueChange={form.setWithin}>
<SelectTrigger className={triggerClass}>
<SelectValue placeholder="Sélectionner" />
</SelectTrigger>
<SelectContent>
{DATE_RANGE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{!compact && (
<Input
type="date"
value={form.dateAfter}
onChange={(e) => form.setDateAfter(e.target.value)}
className={dateInput}
/>
)}
</div>
)
if (compact) {
return <AdvancedSearchField label="Plage de dates">{controls}</AdvancedSearchField>
}
return (
<div className="flex items-center gap-3">
<Label className={labelClass}>Plage de dates</Label>
{controls}
</div>
)
}
function AdvancedSearchCheckboxes({ form }: { form: Form }) {
return (
<>
<label className="flex items-center gap-2 text-sm text-foreground">
<Checkbox
className={MAIL_SEARCH_CHECKBOX_CLASS}
checked={form.hasAttachment}
onCheckedChange={(v) => form.setHasAttachment(v === true)}
/>
Contenant une pièce jointe
</label>
<label className="flex items-center gap-2 text-sm text-foreground">
<Checkbox
className={MAIL_SEARCH_CHECKBOX_CLASS}
checked={form.excludeChats}
onCheckedChange={(v) => form.setExcludeChats(v === true)}
/>
Ne pas inclure les chats
</label>
</>
)
}

View File

@ -0,0 +1,28 @@
"use client"
import { useRouter } from "next/navigation"
import { buildSearchUrl, type SearchParams } from "@/lib/mail-search/search-params"
import { useAdvancedSearchForm } from "@/lib/mail-search/use-advanced-search-form"
import { AdvancedSearchPanelDesktop } from "@/components/gmail/mail-search/advanced-search-fields"
export function AdvancedSearchPanel({
onClose,
initialQuery,
currentParams,
}: {
onClose: () => void
initialQuery: string
currentParams: SearchParams | null
}) {
const router = useRouter()
const form = useAdvancedSearchForm(initialQuery, currentParams)
const handleSubmit = () => {
router.push(buildSearchUrl(form.buildParams()))
onClose()
}
return (
<AdvancedSearchPanelDesktop form={form} onSubmit={handleSubmit} onClose={onClose} />
)
}

View File

@ -0,0 +1,21 @@
"use client"
import { buildSearchUrl, type SearchParams } from "@/lib/mail-search/search-params"
import { useAdvancedSearchForm } from "@/lib/mail-search/use-advanced-search-form"
import { AdvancedSearchPanelMobile } from "@/components/gmail/mail-search/advanced-search-fields"
export function MobileAdvancedSearch({
initialQuery,
onSubmit,
}: {
initialQuery: string
onSubmit: (url: string) => void
}) {
const form = useAdvancedSearchForm(initialQuery, null)
const handleSubmit = () => {
onSubmit(buildSearchUrl(form.buildParams()))
}
return <AdvancedSearchPanelMobile form={form} onSubmit={handleSubmit} />
}

View File

@ -20,16 +20,6 @@ import {
} from "lucide-react" } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet" import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { emails } from "@/lib/email-data" import { emails } from "@/lib/email-data"
import { useContactsStore } from "@/lib/contacts/contacts-store" import { useContactsStore } from "@/lib/contacts/contacts-store"
@ -40,13 +30,6 @@ import {
bestCompletion, bestCompletion,
type SearchSuggestion, type SearchSuggestion,
} from "@/lib/mail-search/search-engine" } from "@/lib/mail-search/search-engine"
import {
buildSearchUrl,
EMPTY_SEARCH_PARAMS,
DATE_RANGE_OPTIONS,
SEARCH_IN_OPTIONS,
type SearchParams,
} from "@/lib/mail-search/search-params"
import { import {
buildQuickSearchParams, buildQuickSearchParams,
submitMailSearch, submitMailSearch,
@ -55,12 +38,11 @@ import { useMailSearchStore } from "@/lib/stores/mail-search-store"
import { avatarColor, senderInitial } from "@/lib/sender-display" import { avatarColor, senderInitial } from "@/lib/sender-display"
import { import {
MAIL_MOBILE_SEARCH_SHEET_CLASS, MAIL_MOBILE_SEARCH_SHEET_CLASS,
MAIL_SEARCH_CHECKBOX_CLASS,
MAIL_SEARCH_CHIP_INACTIVE_CLASS, MAIL_SEARCH_CHIP_INACTIVE_CLASS,
MAIL_SEARCH_FIELD_CLASS,
MAIL_SEARCH_SECTION_DIVIDER_CLASS, MAIL_SEARCH_SECTION_DIVIDER_CLASS,
MAIL_SEARCH_SUGGESTIONS_SURFACE_CLASS, MAIL_SEARCH_SUGGESTIONS_SURFACE_CLASS,
} from "@/lib/mail-chrome-classes" } from "@/lib/mail-chrome-classes"
import { MobileAdvancedSearch } from "@/components/gmail/mail-search/mobile-advanced-search"
interface MobileSearchOverlayProps { interface MobileSearchOverlayProps {
open: boolean open: boolean
@ -394,176 +376,3 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
</Sheet> </Sheet>
) )
} }
// ─── Mobile Advanced Search ──────────────────────────────────────────────────
function MobileAdvancedSearch({
initialQuery,
onSubmit,
}: {
initialQuery: string
onSubmit: (url: string) => void
}) {
const [from, setFrom] = useState("")
const [to, setTo] = useState("")
const [subject, setSubject] = useState("")
const [hasWords, setHasWords] = useState(initialQuery)
const [doesNotHave, setDoesNotHave] = useState("")
const [sizeVal, setSizeVal] = useState("")
const [sizeOp, setSizeOp] = useState<"gt" | "lt">("gt")
const [sizeUnit, setSizeUnit] = useState<"Mo" | "Ko">("Mo")
const [within, setWithin] = useState("")
const [searchIn, setSearchIn] = useState("all")
const [hasAttachment, setHasAttachment] = useState(false)
const [excludeChats, setExcludeChats] = useState(false)
const handleSubmit = () => {
const params: Partial<SearchParams> = {
...EMPTY_SEARCH_PARAMS,
q: "",
from,
to,
subject,
hasWords,
doesNotHave,
size: sizeVal,
sizeOp,
sizeUnit,
within,
in: searchIn,
has: hasAttachment ? ["attachment"] : [],
excludeChats,
}
onSubmit(buildSearchUrl(params))
}
return (
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
<div className="space-y-4">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">De</Label>
<Input
value={from}
onChange={(e) => setFrom(e.target.value)}
className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">À</Label>
<Input
value={to}
onChange={(e) => setTo(e.target.value)}
className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Objet</Label>
<Input
value={subject}
onChange={(e) => setSubject(e.target.value)}
className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Contient les mots</Label>
<Input
value={hasWords}
onChange={(e) => setHasWords(e.target.value)}
className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Ne contient pas</Label>
<Input
value={doesNotHave}
onChange={(e) => setDoesNotHave(e.target.value)}
className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Taille</Label>
<div className="flex items-center gap-2">
<Select value={sizeOp} onValueChange={(v) => setSizeOp(v as "gt" | "lt")}>
<SelectTrigger className={cn("h-9 flex-1 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="gt">supérieure à</SelectItem>
<SelectItem value="lt">inférieure à</SelectItem>
</SelectContent>
</Select>
<Input
type="number"
value={sizeVal}
onChange={(e) => setSizeVal(e.target.value)}
className={cn("h-9 w-20 text-sm", MAIL_SEARCH_FIELD_CLASS)}
/>
<Select value={sizeUnit} onValueChange={(v) => setSizeUnit(v as "Mo" | "Ko")}>
<SelectTrigger className={cn("h-9 w-20 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Mo">Mo</SelectItem>
<SelectItem value="Ko">Ko</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Plage de dates</Label>
<Select value={within} onValueChange={setWithin}>
<SelectTrigger className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
<SelectValue placeholder="Sélectionner" />
</SelectTrigger>
<SelectContent>
{DATE_RANGE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Rechercher dans</Label>
<Select value={searchIn} onValueChange={setSearchIn}>
<SelectTrigger className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SEARCH_IN_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-3 pt-1">
<label className="flex items-center gap-2 text-sm text-foreground">
<Checkbox
className={MAIL_SEARCH_CHECKBOX_CLASS}
checked={hasAttachment}
onCheckedChange={(v) => setHasAttachment(v === true)}
/>
Contenant une pièce jointe
</label>
<label className="flex items-center gap-2 text-sm text-foreground">
<Checkbox
className={MAIL_SEARCH_CHECKBOX_CLASS}
checked={excludeChats}
onCheckedChange={(v) => setExcludeChats(v === true)}
/>
Ne pas inclure les chats
</label>
</div>
<Button
className="w-full bg-[#1a73e8] text-sm text-white hover:bg-[#1765cc]"
onClick={handleSubmit}
>
Rechercher
</Button>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -15,8 +15,8 @@ import {
import { import {
SidebarNavOptionsSheet, SidebarNavOptionsSheet,
SidebarNavSheetAction, SidebarNavSheetAction,
} from "@/components/gmail/sidebar-nav-options-sheet" } from "@/components/gmail/sidebar/sidebar-nav-options-sheet"
import { useSidebarTouchOptionsMenu } from "@/components/gmail/use-sidebar-touch-options" import { useSidebarTouchOptionsMenu } from "@/components/gmail/sidebar/use-sidebar-touch-options"
import type { CategoryNavSourceItem } from "@/components/gmail/sidebar/sidebar-nav-constants" import type { CategoryNavSourceItem } from "@/components/gmail/sidebar/sidebar-nav-constants"
import { import {
navRowRoundedWhenActive, navRowRoundedWhenActive,

View File

@ -0,0 +1,2 @@
export { Sidebar } from "@/components/gmail/sidebar/sidebar"
export type { SidebarProps } from "@/components/gmail/sidebar/use-sidebar-state"

View File

@ -0,0 +1,176 @@
"use client"
import type { RefObject } from "react"
import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
export type SidebarCreateDialogsProps = {
folderDialogOpen: boolean
setFolderDialogOpen: (open: boolean) => void
labelDialogOpen: boolean
setLabelDialogOpen: (open: boolean) => void
newFolderName: string
setNewFolderName: (v: string) => void
newFolderParent: string
setNewFolderParent: (v: string) => void
newLabelName: string
setNewLabelName: (v: string) => void
newFolderNameInputRef: RefObject<HTMLInputElement | null>
newLabelNameInputRef: RefObject<HTMLInputElement | null>
folderParentOptions: { value: string; label: string }[]
onSubmitNewFolder: () => void
onSubmitNewLabel: () => void
}
export function SidebarCreateDialogs({
folderDialogOpen,
setFolderDialogOpen,
labelDialogOpen,
setLabelDialogOpen,
newFolderName,
setNewFolderName,
newFolderParent,
setNewFolderParent,
newLabelName,
setNewLabelName,
newFolderNameInputRef,
newLabelNameInputRef,
folderParentOptions,
onSubmitNewFolder,
onSubmitNewLabel,
}: SidebarCreateDialogsProps) {
return (
<>
<Dialog open={folderDialogOpen} onOpenChange={setFolderDialogOpen}>
<DialogContent
className="sm:max-w-md"
showCloseButton
onOpenAutoFocus={(e) => {
e.preventDefault()
window.requestAnimationFrame(() =>
newFolderNameInputRef.current?.focus()
)
}}
>
<DialogHeader>
<DialogTitle>Nouveau dossier</DialogTitle>
<DialogDescription>
Choisissez lemplacement (racine ou dossier parent) puis le nom.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 py-1">
<div className="grid gap-2">
<Label htmlFor="new-folder-parent">Emplacement</Label>
<Select value={newFolderParent} onValueChange={setNewFolderParent}>
<SelectTrigger id="new-folder-parent" className="w-full min-w-0" size="sm">
<SelectValue placeholder="Parent" />
</SelectTrigger>
<SelectContent position="popper" className="max-h-72">
{folderParentOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="new-folder-name">Nom</Label>
<Input
id="new-folder-name"
ref={newFolderNameInputRef}
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder="Mon dossier"
autoComplete="off"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
onSubmitNewFolder()
}
}}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
type="button"
onClick={() => setFolderDialogOpen(false)}
>
Annuler
</Button>
<Button type="button" onClick={onSubmitNewFolder}>
Créer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={labelDialogOpen} onOpenChange={setLabelDialogOpen}>
<DialogContent
className="sm:max-w-md"
showCloseButton
onOpenAutoFocus={(e) => {
e.preventDefault()
window.requestAnimationFrame(() =>
newLabelNameInputRef.current?.focus()
)
}}
>
<DialogHeader>
<DialogTitle>Nouveau libellé</DialogTitle>
<DialogDescription>
Nom affiché dans la barre latérale et utilisé sur les messages.
</DialogDescription>
</DialogHeader>
<div className="grid gap-2 py-1">
<Label htmlFor="new-label-name">Nom</Label>
<Input
id="new-label-name"
ref={newLabelNameInputRef}
value={newLabelName}
onChange={(e) => setNewLabelName(e.target.value)}
placeholder="Libellé"
autoComplete="off"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
onSubmitNewLabel()
}
}}
/>
</div>
<DialogFooter>
<Button
variant="outline"
type="button"
onClick={() => setLabelDialogOpen(false)}
>
Annuler
</Button>
<Button type="button" onClick={onSubmitNewLabel}>
Créer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@ -0,0 +1,65 @@
"use client"
import { cn } from "@/lib/utils"
import { useEmailDropTarget } from "@/lib/drag-context"
import type { FolderTreeNode } from "@/lib/sidebar-nav-data"
import { folderSubtreeContainsId } from "@/lib/sidebar-folder-tree-utils"
import {
navRowRoundedWhenActive,
SidebarNavIconSlot,
FolderTreeNavIcon,
} from "@/components/gmail/sidebar/sidebar-nav-primitives"
export function SidebarFolderButtonCollapsed({
node,
isExpanded,
selectedFolder,
folderUnreadCounts,
onSelectFolder,
}: {
node: FolderTreeNode
isExpanded: boolean
selectedFolder: string
folderUnreadCounts: Record<string, number>
onSelectFolder: (id: string) => void
}) {
const { isOver, dropHandlers } = useEmailDropTarget(node.id, node.label)
const dotClass = node.color ?? "bg-gray-400"
const hasChildFolders = !!(node.children?.length)
const isHighlighted = folderSubtreeContainsId(node, selectedFolder)
const unread = folderUnreadCounts[node.id] ?? 0
const hasUnread = unread > 0
return (
<button
type="button"
title={
!isExpanded
? unread > 0
? `${node.label}${unread} non lus`
: node.label
: undefined
}
onClick={() => onSelectFolder(node.id)}
{...dropHandlers}
className={cn(
"relative flex h-8 w-full min-w-0 shrink-0 cursor-pointer items-center gap-4 pl-6 pr-3 text-sm transition-colors",
navRowRoundedWhenActive(isHighlighted || isOver),
isHighlighted
? "bg-mail-nav-selected text-mail-nav-selected font-medium"
: isOver
? "bg-mail-nav-drop text-foreground"
: hasUnread
? "text-gray-900 hover:bg-mail-nav-hover"
: "text-gray-700 hover:bg-mail-nav-hover"
)}
>
<SidebarNavIconSlot showUnreadDot={hasUnread}>
<FolderTreeNavIcon
hasChildren={hasChildFolders}
open={false}
colorBgClass={dotClass}
/>
</SidebarNavIconSlot>
</button>
)
}

View File

@ -0,0 +1,842 @@
"use client"
import {
useState,
useRef,
useEffect,
useMemo,
type CSSProperties,
type DragEvent,
} from "react"
import { MoreVertical } from "lucide-react"
import { cn } from "@/lib/utils"
import { useEmailDropTarget } from "@/lib/drag-context"
import {
MAIL_SIDEBAR_BLUR_SURFACE_CLASS,
MAIL_SIDEBAR_COLOR_PICKER_CLASS,
MAIL_SIDEBAR_COLOR_SWATCH_RING_CLASS,
MAIL_SIDEBAR_MENU_PLAIN_ITEM_CLASS,
MAIL_SIDEBAR_MENU_SEPARATOR_CLASS,
MAIL_SIDEBAR_MENU_SUB_TRIGGER_CLASS,
MAIL_SIDEBAR_MENU_SURFACE_CLASS,
} from "@/lib/mail-chrome-classes"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import type { FolderTreeNode } from "@/lib/sidebar-nav-data"
import { folderMoveParentOptions } from "@/lib/sidebar-nav-context"
import type {
LabelInMessageListVisibility,
LabelListSidebarVisibility,
NavItemPrefs,
} from "@/lib/sidebar-nav-context"
import { ancestorFolderIdsForTarget } from "@/lib/sidebar-folder-tree-utils"
import {
readSidebarNavDragData,
resolveNavDropPlacement,
setSidebarNavDragData,
} from "@/lib/sidebar-nav-dnd"
import {
LABEL_MENU_COLOR_SWATCHES,
mailSidebarFolderBranchStickyTopPx,
mailSidebarFolderBranchStickyZ,
} from "@/components/gmail/sidebar/sidebar-nav-constants"
import {
SidebarNavOptionsSheet,
SidebarNavSheetAction,
SidebarNavSheetCheckOption,
SidebarNavSheetColorPicker,
SidebarNavSheetDivider,
SidebarNavSheetSectionLabel,
} from "@/components/gmail/sidebar/sidebar-nav-options-sheet"
import { useSidebarTouchOptionsMenu } from "@/components/gmail/sidebar/use-sidebar-touch-options"
import {
LabelMenuOptionWithCheck,
ContextLabelMenuOptionWithCheck,
SidebarNavDragHandle,
SidebarNavIconSlot,
FolderTreeNavIcon,
SidebarOverflowColumn,
sidebarOverflowMenuButtonClass,
navRowActivate,
} from "@/components/gmail/sidebar/sidebar-nav-primitives"
import type { SidebarNavDragBindings } from "@/components/gmail/sidebar/sidebar-nav-drag-bindings"
export type SidebarFolderRowExpandedProps = SidebarNavDragBindings & {
node: FolderTreeNode
depth: number
selectedFolder: string
folderUnreadCounts: Record<string, number>
expandedFolderIds: Set<string>
isExpanded: boolean
isOverlayOpen: boolean
touchNav: boolean
folderTree: FolderTreeNode[]
onSelectFolder: (id: string) => void
toggleFolderExpanded: (id: string) => void
getNavItemPrefs: (id: string) => Required<Pick<NavItemPrefs, "sidebar" | "messages">>
setNavItemSidebarVisibility: (id: string, v: LabelListSidebarVisibility) => void
setNavItemMessageVisibility: (id: string, v: LabelInMessageListVisibility) => void
updateFolderOrLabelColor: (id: string, color: string) => void
renameFolderOrLabel: (id: string, name: string) => void
removeFolderOrLabelRow: (id: string) => void
moveFolder: (id: string, parentId: string | null) => void
addSubfolder: (parentId: string, name: string) => void
}
export function SidebarFolderRowExpanded({
node,
depth,
selectedFolder,
folderUnreadCounts,
expandedFolderIds,
isExpanded,
isOverlayOpen,
touchNav,
folderTree,
onSelectFolder,
toggleFolderExpanded,
getNavItemPrefs,
setNavItemSidebarVisibility,
setNavItemMessageVisibility,
updateFolderOrLabelColor,
renameFolderOrLabel,
removeFolderOrLabelRow,
moveFolder,
addSubfolder,
navDragRef,
navDropPlacementRef,
beginNavDrag,
clearNavDrag,
updateNavDropTarget,
clearNavDropTarget,
commitNavDrop,
}: SidebarFolderRowExpandedProps) {
const { isOver, dropHandlers } = useEmailDropTarget(node.id, node.label)
const hasChildren = !!(node.children?.length)
const isBranchOpen = expandedFolderIds.has(node.id)
const dotClass = node.color ?? "bg-gray-400"
const isSelected = selectedFolder === node.id
const unread = folderUnreadCounts[node.id] ?? 0
const hasUnread = unread > 0
const isStickyBranch = hasChildren && isBranchOpen
const stickyTopPx = mailSidebarFolderBranchStickyTopPx(depth)
const stickyZIndex = mailSidebarFolderBranchStickyZ(depth)
const [menuOpen, setMenuOpen] = useState(false)
const [contextMenuOpen, setContextMenuOpen] = useState(false)
const menuTriggerRef = useRef<HTMLButtonElement>(null)
const [renameOpen, setRenameOpen] = useState(false)
const [renameDraft, setRenameDraft] = useState(node.label)
const [moveOpen, setMoveOpen] = useState(false)
const [moveParent, setMoveParent] = useState("__root__")
const [subfolderOpen, setSubfolderOpen] = useState(false)
const [subfolderName, setSubfolderName] = useState("")
const folderRenameInputRef = useRef<HTMLInputElement>(null)
const subfolderNameInputRef = useRef<HTMLInputElement>(null)
const { sheetOpen, setSheetOpen, touchRowProps, touchRowClassName, closeSheet } =
useSidebarTouchOptionsMenu(touchNav && isExpanded)
useEffect(() => {
setRenameDraft(node.label)
}, [node.label])
const handleMenuOpenChange = (open: boolean) => {
setMenuOpen(open)
if (!open) {
queueMicrotask(() => menuTriggerRef.current?.blur())
}
}
const rowHoverHeld =
!isSelected && !isOver && (contextMenuOpen || menuOpen || sheetOpen)
const prefs = getNavItemPrefs(node.id)
const moveTargets = useMemo(
() => folderMoveParentOptions(folderTree, node.id),
[folderTree, node.id]
)
const folderMenuSurface =
MAIL_SIDEBAR_MENU_SURFACE_CLASS
const colorSub = (
subKind: "dropdown" | "context"
) => {
const Sub = subKind === "dropdown" ? DropdownMenuSub : ContextMenuSub
const SubTr =
subKind === "dropdown" ? DropdownMenuSubTrigger : ContextMenuSubTrigger
const SubCo =
subKind === "dropdown" ? DropdownMenuSubContent : ContextMenuSubContent
return (
<Sub>
<SubTr
className={cn(
MAIL_SIDEBAR_MENU_SUB_TRIGGER_CLASS,
subKind === "context" && "flex items-center gap-2"
)}
>
<span className="flex size-5 shrink-0 items-center justify-center rounded-full border border-border bg-mail-surface">
<span
className={cn(
"block size-3 rounded-sm border border-black/10",
dotClass
)}
aria-hidden
/>
</span>
<span className="flex-1 text-left text-sm">Couleur du dossier</span>
</SubTr>
<SubCo className={MAIL_SIDEBAR_COLOR_PICKER_CLASS}>
<div className="grid grid-cols-6 gap-1.5">
{LABEL_MENU_COLOR_SWATCHES.map((sw) => (
<button
key={sw}
type="button"
title={sw}
onClick={() => {
updateFolderOrLabelColor(node.id, sw)
setMenuOpen(false)
}}
className={cn(
cn(
"size-6 rounded-full border border-black/10 outline-none ring-offset-1 hover:ring-2",
MAIL_SIDEBAR_COLOR_SWATCH_RING_CLASS
),
sw
)}
/>
))}
</div>
</SubCo>
</Sub>
)
}
const rowClass = cn(
"group/folderrow relative flex h-8 w-full min-w-0 shrink-0 cursor-default items-center gap-2 pr-3 text-sm transition-colors",
isSelected || isOver || rowHoverHeld ? "rounded-r-full" : "rounded-r-none",
isStickyBranch && "sticky border-b border-gray-200/70",
isStickyBranch && MAIL_SIDEBAR_BLUR_SURFACE_CLASS,
isSelected && "bg-mail-nav-selected font-medium text-mail-nav-selected",
!isSelected && hasUnread && "text-gray-900",
isOver && "bg-mail-nav-drop text-foreground",
rowHoverHeld && "bg-mail-nav-hover text-foreground",
touchRowClassName
)
const rowStyle: CSSProperties = {
paddingLeft: 24 + depth * 16,
...(isStickyBranch ? { top: stickyTopPx, zIndex: stickyZIndex } : {}),
}
const overflowMenu = (
<SidebarOverflowColumn
unread={unread}
menuOpen={menuOpen || sheetOpen}
hoverGroup="folderrow"
isSelected={isSelected}
hasUnread={hasUnread}
className={cn(!isExpanded && "hidden", "mr-[-11px]")}
showMenuButton={!touchNav}
>
{!touchNav && (
<DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}>
<DropdownMenuTrigger asChild>
<button
ref={menuTriggerRef}
type="button"
className={cn(sidebarOverflowMenuButtonClass, isSelected && "text-gray-900")}
aria-label={`Options pour ${node.label}`}
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className={folderMenuSurface}>
{colorSub("dropdown")}
<DropdownMenuSeparator className={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<DropdownMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-foreground">
Dans la liste des dossiers
</DropdownMenuLabel>
<LabelMenuOptionWithCheck
checked={prefs.sidebar === "show"}
onPick={() => setNavItemSidebarVisibility(node.id, "show")}
>
Afficher
</LabelMenuOptionWithCheck>
<LabelMenuOptionWithCheck
checked={prefs.sidebar === "showUnread"}
onPick={() => setNavItemSidebarVisibility(node.id, "showUnread")}
>
Afficher si messages non lus
</LabelMenuOptionWithCheck>
<LabelMenuOptionWithCheck
checked={prefs.sidebar === "hide"}
onPick={() => setNavItemSidebarVisibility(node.id, "hide")}
>
Masquer
</LabelMenuOptionWithCheck>
<DropdownMenuSeparator className={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<DropdownMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-foreground">
Dans la liste des messages
</DropdownMenuLabel>
<LabelMenuOptionWithCheck
checked={prefs.messages === "show"}
onPick={() => setNavItemMessageVisibility(node.id, "show")}
>
Afficher
</LabelMenuOptionWithCheck>
<LabelMenuOptionWithCheck
checked={prefs.messages === "hide"}
onPick={() => setNavItemMessageVisibility(node.id, "hide")}
>
Masquer
</LabelMenuOptionWithCheck>
<DropdownMenuSeparator className={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<DropdownMenuItem
className={MAIL_SIDEBAR_MENU_PLAIN_ITEM_CLASS}
onClick={() => {
setRenameDraft(node.label)
setRenameOpen(true)
setMenuOpen(false)
}}
>
Renommer
</DropdownMenuItem>
<DropdownMenuItem
className={MAIL_SIDEBAR_MENU_PLAIN_ITEM_CLASS}
onClick={() => {
setMoveParent("__root__")
setMoveOpen(true)
setMenuOpen(false)
}}
>
Déplacer
</DropdownMenuItem>
<DropdownMenuItem
className={MAIL_SIDEBAR_MENU_PLAIN_ITEM_CLASS}
onClick={() => {
setSubfolderName("")
setSubfolderOpen(true)
setMenuOpen(false)
}}
>
Nouveau sous-dossier
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
className="mx-1 cursor-pointer px-3 py-2 text-sm focus:bg-destructive/15"
onClick={() => {
removeFolderOrLabelRow(node.id)
setMenuOpen(false)
}}
>
Supprimer le dossier
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</SidebarOverflowColumn>
)
const folderOptionsSheet = touchNav && isExpanded && (
<SidebarNavOptionsSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
title={node.label}
colorDotClass={dotClass}
>
<SidebarNavSheetColorPicker
title="Couleur du dossier"
dotClass={dotClass}
swatches={LABEL_MENU_COLOR_SWATCHES}
onPick={(sw) => {
updateFolderOrLabelColor(node.id, sw)
closeSheet()
}}
/>
<SidebarNavSheetDivider />
<SidebarNavSheetSectionLabel>Dans la liste des dossiers</SidebarNavSheetSectionLabel>
<SidebarNavSheetCheckOption
checked={prefs.sidebar === "show"}
onPick={() => {
setNavItemSidebarVisibility(node.id, "show")
closeSheet()
}}
>
Afficher
</SidebarNavSheetCheckOption>
<SidebarNavSheetCheckOption
checked={prefs.sidebar === "showUnread"}
onPick={() => {
setNavItemSidebarVisibility(node.id, "showUnread")
closeSheet()
}}
>
Afficher si messages non lus
</SidebarNavSheetCheckOption>
<SidebarNavSheetCheckOption
checked={prefs.sidebar === "hide"}
onPick={() => {
setNavItemSidebarVisibility(node.id, "hide")
closeSheet()
}}
>
Masquer
</SidebarNavSheetCheckOption>
<SidebarNavSheetDivider />
<SidebarNavSheetSectionLabel>Dans la liste des messages</SidebarNavSheetSectionLabel>
<SidebarNavSheetCheckOption
checked={prefs.messages === "show"}
onPick={() => {
setNavItemMessageVisibility(node.id, "show")
closeSheet()
}}
>
Afficher
</SidebarNavSheetCheckOption>
<SidebarNavSheetCheckOption
checked={prefs.messages === "hide"}
onPick={() => {
setNavItemMessageVisibility(node.id, "hide")
closeSheet()
}}
>
Masquer
</SidebarNavSheetCheckOption>
<SidebarNavSheetDivider />
<SidebarNavSheetAction
onClick={() => {
setRenameDraft(node.label)
setRenameOpen(true)
closeSheet()
}}
>
Renommer
</SidebarNavSheetAction>
<SidebarNavSheetAction
onClick={() => {
setMoveParent("__root__")
setMoveOpen(true)
closeSheet()
}}
>
Déplacer
</SidebarNavSheetAction>
<SidebarNavSheetAction
onClick={() => {
setSubfolderName("")
setSubfolderOpen(true)
closeSheet()
}}
>
Nouveau sous-dossier
</SidebarNavSheetAction>
<SidebarNavSheetAction
destructive
onClick={() => {
removeFolderOrLabelRow(node.id)
closeSheet()
}}
>
Supprimer le dossier
</SidebarNavSheetAction>
</SidebarNavOptionsSheet>
)
const onFolderRowDragEnter = (e: DragEvent) => {
const active = navDragRef.current
if (active?.kind === "folder" && active.id !== node.id) {
e.preventDefault()
return
}
dropHandlers.onDragEnter(e)
}
const onFolderRowDragOver = (e: DragEvent) => {
const active = navDragRef.current
if (active?.kind === "folder") {
e.preventDefault()
e.stopPropagation()
if (active.id === node.id) return
const ancestors = ancestorFolderIdsForTarget(folderTree, node.id)
if (ancestors?.includes(active.id)) return
e.dataTransfer.dropEffect = "move"
updateNavDropTarget(
e.currentTarget as HTMLElement,
resolveNavDropPlacement(e, true)
)
return
}
dropHandlers.onDragOver(e)
}
const onFolderRowDragLeave = (e: DragEvent) => {
if (navDragRef.current?.kind === "folder") {
const rt = e.relatedTarget as Node | null
if (rt && e.currentTarget instanceof Node && e.currentTarget.contains(rt)) return
clearNavDropTarget(e.currentTarget as HTMLElement)
return
}
dropHandlers.onDragLeave(e)
}
const onFolderRowDrop = (e: DragEvent) => {
const payload = readSidebarNavDragData(e, navDragRef.current)
if (payload?.kind === "folder") {
e.preventDefault()
e.stopPropagation()
const placement = navDropPlacementRef.current ?? resolveNavDropPlacement(e, true)
commitNavDrop(payload, node.id, placement, "folder")
return
}
dropHandlers.onDrop(e)
}
const onFolderDragHandleStart = (e: DragEvent<HTMLSpanElement>) => {
const payload = { kind: "folder" as const, id: node.id }
setSidebarNavDragData(e, payload)
const rowEl = (e.currentTarget as HTMLElement).closest("[data-nav-row]") as HTMLElement | null
beginNavDrag(payload, rowEl)
}
const folderRowEl = (
<div
data-nav-row
{...touchRowProps}
onDragEnter={onFolderRowDragEnter}
onDragOver={onFolderRowDragOver}
onDragLeave={onFolderRowDragLeave}
onDrop={onFolderRowDrop}
className={rowClass}
style={rowStyle}
>
{isExpanded ? (
<SidebarNavDragHandle
label={node.label}
onDragStart={onFolderDragHandleStart}
onDragEnd={clearNavDrag}
/>
) : null}
<div
role="button"
tabIndex={0}
onClick={() => onSelectFolder(node.id)}
onKeyDown={(e) => navRowActivate(e, () => onSelectFolder(node.id))}
className={cn(
"flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-3 py-0 pr-1 text-left transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
!isSelected &&
!isOver &&
!rowHoverHeld &&
"rounded-r-none hover:rounded-r-full hover:bg-mail-nav-hover",
rowHoverHeld && !isSelected && !isOver && "rounded-r-full",
isSelected
? "text-gray-900"
: isOver
? "text-gray-900"
: "text-gray-700"
)}
>
{hasChildren ? (
<button
type="button"
draggable={false}
className="flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded outline-none hover:bg-black/5 focus-visible:ring-2 focus-visible:ring-ring/50"
aria-expanded={isBranchOpen}
aria-label={
isBranchOpen
? `Replier le dossier ${node.label}`
: `Déplier le dossier ${node.label}`
}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
toggleFolderExpanded(node.id)
}}
>
<SidebarNavIconSlot showUnreadDot={hasUnread}>
<FolderTreeNavIcon
hasChildren
open={isBranchOpen}
colorBgClass={dotClass}
/>
</SidebarNavIconSlot>
</button>
) : (
<SidebarNavIconSlot showUnreadDot={hasUnread}>
<FolderTreeNavIcon
hasChildren={false}
open={false}
colorBgClass={dotClass}
/>
</SidebarNavIconSlot>
)}
<div className="flex min-w-0 flex-1 items-baseline gap-3">
<span className="min-w-0 flex-1 truncate leading-5">
<span
className={cn(
hasUnread && !isSelected && "font-semibold text-gray-900"
)}
>
{node.label}
</span>
</span>
</div>
</div>
{overflowMenu}
</div>
)
return (
<>
{touchNav ? (
folderRowEl
) : (
<ContextMenu onOpenChange={setContextMenuOpen}>
<ContextMenuTrigger asChild>{folderRowEl}</ContextMenuTrigger>
<ContextMenuContent className={folderMenuSurface}>
{colorSub("context")}
<ContextMenuSeparator className={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<ContextMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-foreground">
Dans la liste des dossiers
</ContextMenuLabel>
<ContextLabelMenuOptionWithCheck
checked={prefs.sidebar === "show"}
onPick={() => setNavItemSidebarVisibility(node.id, "show")}
>
Afficher
</ContextLabelMenuOptionWithCheck>
<ContextLabelMenuOptionWithCheck
checked={prefs.sidebar === "showUnread"}
onPick={() => setNavItemSidebarVisibility(node.id, "showUnread")}
>
Afficher si non lus
</ContextLabelMenuOptionWithCheck>
<ContextLabelMenuOptionWithCheck
checked={prefs.sidebar === "hide"}
onPick={() => setNavItemSidebarVisibility(node.id, "hide")}
>
Masquer
</ContextLabelMenuOptionWithCheck>
<ContextMenuSeparator className={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<ContextMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-foreground">
Dans la liste des messages
</ContextMenuLabel>
<ContextLabelMenuOptionWithCheck
checked={prefs.messages === "show"}
onPick={() => setNavItemMessageVisibility(node.id, "show")}
>
Afficher
</ContextLabelMenuOptionWithCheck>
<ContextLabelMenuOptionWithCheck
checked={prefs.messages === "hide"}
onPick={() => setNavItemMessageVisibility(node.id, "hide")}
>
Masquer
</ContextLabelMenuOptionWithCheck>
<ContextMenuSeparator className="my-1.5 bg-gray-200" />
<ContextMenuItem
className="mx-1 cursor-pointer px-3 py-2 text-sm"
onClick={() => {
setRenameDraft(node.label)
setRenameOpen(true)
}}
>
Renommer
</ContextMenuItem>
<ContextMenuItem
className="mx-1 cursor-pointer px-3 py-2 text-sm"
onClick={() => {
setMoveParent("__root__")
setMoveOpen(true)
}}
>
Déplacer
</ContextMenuItem>
<ContextMenuItem
className="mx-1 cursor-pointer px-3 py-2 text-sm"
onClick={() => {
setSubfolderName("")
setSubfolderOpen(true)
}}
>
Nouveau sous-dossier
</ContextMenuItem>
<ContextMenuItem
variant="destructive"
className="mx-1 cursor-pointer px-3 py-2 text-sm"
onClick={() => removeFolderOrLabelRow(node.id)}
>
Supprimer le dossier
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)}
{folderOptionsSheet}
<Dialog open={renameOpen} onOpenChange={setRenameOpen}>
<DialogContent
className="sm:max-w-md"
showCloseButton
onOpenAutoFocus={(e) => {
e.preventDefault()
window.requestAnimationFrame(() =>
folderRenameInputRef.current?.focus()
)
}}
>
<DialogHeader>
<DialogTitle>Renommer le dossier</DialogTitle>
<DialogDescription>Nouveau nom pour « {node.label} ».</DialogDescription>
</DialogHeader>
<Input
ref={folderRenameInputRef}
value={renameDraft}
onChange={(e) => setRenameDraft(e.target.value)}
autoComplete="off"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
renameFolderOrLabel(node.id, renameDraft)
setRenameOpen(false)
}
}}
/>
<DialogFooter>
<Button variant="outline" type="button" onClick={() => setRenameOpen(false)}>
Annuler
</Button>
<Button
type="button"
onClick={() => {
renameFolderOrLabel(node.id, renameDraft)
setRenameOpen(false)
}}
>
Enregistrer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={moveOpen} onOpenChange={setMoveOpen}>
<DialogContent className="sm:max-w-md" showCloseButton>
<DialogHeader>
<DialogTitle>Déplacer le dossier</DialogTitle>
<DialogDescription>Choisissez le dossier parent.</DialogDescription>
</DialogHeader>
<Select value={moveParent} onValueChange={setMoveParent}>
<SelectTrigger className="w-full min-w-0" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" className="max-h-72">
{moveTargets.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<DialogFooter>
<Button variant="outline" type="button" onClick={() => setMoveOpen(false)}>
Annuler
</Button>
<Button
type="button"
onClick={() => {
moveFolder(
node.id,
moveParent === "__root__" ? null : moveParent
)
setMoveOpen(false)
}}
>
Déplacer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={subfolderOpen} onOpenChange={setSubfolderOpen}>
<DialogContent
className="sm:max-w-md"
showCloseButton
onOpenAutoFocus={(e) => {
e.preventDefault()
window.requestAnimationFrame(() =>
subfolderNameInputRef.current?.focus()
)
}}
>
<DialogHeader>
<DialogTitle>Nouveau sous-dossier</DialogTitle>
<DialogDescription>Sous « {node.label} ».</DialogDescription>
</DialogHeader>
<Input
ref={subfolderNameInputRef}
value={subfolderName}
onChange={(e) => setSubfolderName(e.target.value)}
placeholder="Nom du dossier"
autoComplete="off"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
addSubfolder(node.id, subfolderName)
setSubfolderOpen(false)
}
}}
/>
<DialogFooter>
<Button variant="outline" type="button" onClick={() => setSubfolderOpen(false)}>
Annuler
</Button>
<Button
type="button"
onClick={() => {
addSubfolder(node.id, subfolderName)
setSubfolderOpen(false)
}}
>
Créer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@ -0,0 +1,94 @@
"use client"
import type { ReactNode } from "react"
import type { FolderTreeNode } from "@/lib/sidebar-nav-data"
import type { NavItemPrefs } from "@/lib/sidebar-nav-context"
import {
SidebarFolderRowExpanded,
type SidebarFolderRowExpandedProps,
} from "@/components/gmail/sidebar/sidebar-folder-row-expanded"
export type SidebarFolderRowContextProps = Omit<
SidebarFolderRowExpandedProps,
"node" | "depth"
>
import { SidebarFolderButtonCollapsed } from "@/components/gmail/sidebar/sidebar-folder-button-collapsed"
export function sidebarVisibleFolderNodes(
nodes: FolderTreeNode[],
getNavItemPrefs: (id: string) => Required<Pick<NavItemPrefs, "sidebar" | "messages">>,
folderUnreadCounts: Record<string, number>
): FolderTreeNode[] {
return nodes.filter((node) => {
const p = getNavItemPrefs(node.id)
if (p.sidebar === "hide") return false
if (
p.sidebar === "showUnread" &&
(folderUnreadCounts[node.id] ?? 0) === 0
) {
return false
}
return true
})
}
export function renderExpandedFolderSubtree(
nodes: FolderTreeNode[],
depth: number,
props: SidebarFolderRowContextProps
): ReactNode {
const { expandedFolderIds, getNavItemPrefs, folderUnreadCounts } = props
return sidebarVisibleFolderNodes(nodes, getNavItemPrefs, folderUnreadCounts).map(
(node) => {
const isBranchOpen = expandedFolderIds.has(node.id)
const kids = node.children
return (
<div key={node.id} className="min-w-0">
<SidebarFolderRowExpanded node={node} depth={depth} {...props} />
{kids?.length && isBranchOpen ? (
<div className="min-w-0">
{renderExpandedFolderSubtree(kids, depth + 1, props)}
</div>
) : null}
</div>
)
}
)
}
export function renderCollapsedFolderList(
nodes: FolderTreeNode[],
opts: {
getNavItemPrefs: (id: string) => Required<Pick<NavItemPrefs, "sidebar" | "messages">>
folderUnreadCounts: Record<string, number>
expandedFolderIds: Set<string>
isExpanded: boolean
selectedFolder: string
onSelectFolder: (id: string) => void
}
): ReactNode {
const walk = (list: FolderTreeNode[]): ReactNode[] => {
const out: ReactNode[] = []
for (const node of sidebarVisibleFolderNodes(
list,
opts.getNavItemPrefs,
opts.folderUnreadCounts
)) {
out.push(
<SidebarFolderButtonCollapsed
key={node.id}
node={node}
isExpanded={opts.isExpanded}
selectedFolder={opts.selectedFolder}
folderUnreadCounts={opts.folderUnreadCounts}
onSelectFolder={opts.onSelectFolder}
/>
)
if (node.children?.length && opts.expandedFolderIds.has(node.id)) {
out.push(...walk(node.children))
}
}
return out
}
return walk(nodes)
}

View File

@ -0,0 +1,101 @@
"use client"
import { Pencil } from "lucide-react"
import { cn } from "@/lib/utils"
import { useComposeActions } from "@/lib/compose-context"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import { Button } from "@/components/ui/button"
import { Icon } from "@iconify/react"
import { UltiMailLogo } from "@/components/ultimail-logo"
export function SidebarHeader({
splitView,
isExpanded,
panelSurfaceClass,
splitViewLogoHeaderClass,
splitViewLogoIconClass,
touchNav,
}: {
splitView: boolean
isExpanded: boolean
panelSurfaceClass: string
splitViewLogoHeaderClass: string
splitViewLogoIconClass: string
touchNav: boolean
}) {
const { openCompose } = useComposeActions()
return (
<>
<div
className={cn(
"flex shrink-0 items-center",
panelSurfaceClass,
splitView
? cn(
splitViewLogoHeaderClass,
isExpanded ? "justify-between" : "justify-start"
)
: "justify-between px-4 pt-4 pb-4 sm:hidden"
)}
>
{splitView && !isExpanded ? (
<UltiMailLogo variant="mark" className={splitViewLogoIconClass} />
) : (
<>
<UltiMailLogo
className={cn(
"shrink-0",
splitView
? "max-w-[140px] gap-4 [&_img]:size-9"
: "min-h-8"
)}
/>
{(splitView || touchNav) && isExpanded && (
<Button
variant="ghost"
size="icon"
className="size-9 shrink-0 text-gray-600"
aria-label="Réglages"
onClick={() =>
useMailSettingsStore.getState().setQuickSettingsOpen(true)
}
>
<Icon icon="mdi:cog" className="size-5 shrink-0" aria-hidden />
</Button>
)}
</>
)}
</div>
<div
className={cn(
"hidden shrink-0 z-10 pt-1 pl-2 max-sm:pb-3 sm:pb-0 sm:flex",
panelSurfaceClass,
isExpanded ? "pr-3.5" : "pr-2",
splitView && "!hidden"
)}
>
<button
type="button"
title={!isExpanded ? "Nouveau message" : undefined}
aria-label={!isExpanded ? "Nouveau message" : undefined}
onClick={openCompose}
className={cn(
"inline-flex h-[52px] min-w-0 shrink-0 cursor-pointer items-center rounded-2xl border border-border bg-mail-surface text-sm font-medium text-foreground shadow-sm outline-none transition-[box-shadow,background-color,border-color,color] duration-200 hover:bg-accent hover:shadow-md focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 [&_svg]:pointer-events-none [&_svg]:size-5 [&_svg]:shrink-0",
isExpanded
? "w-auto max-w-full justify-start gap-3 self-start pl-4 pr-8"
: "w-[52px] justify-center px-0 py-0"
)}
>
<Pencil className="size-5 shrink-0" />
{isExpanded && (
<span className="min-w-0 truncate text-sm font-medium">
Nouveau message
</span>
)}
</button>
</div>
</>
)
}

View File

@ -0,0 +1,694 @@
"use client"
import { useState, useRef, useEffect, type DragEvent } from "react"
import { MoreVertical } from "lucide-react"
import { cn } from "@/lib/utils"
import { useEmailDropTarget } from "@/lib/drag-context"
import {
MAIL_SIDEBAR_COLOR_PICKER_CLASS,
MAIL_SIDEBAR_COLOR_SWATCH_RING_CLASS,
MAIL_SIDEBAR_MENU_PLAIN_ITEM_CLASS,
MAIL_SIDEBAR_MENU_SEPARATOR_CLASS,
MAIL_SIDEBAR_MENU_SUB_TRIGGER_CLASS,
MAIL_SIDEBAR_MENU_SURFACE_CLASS,
} from "@/lib/mail-chrome-classes"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { isSystemNavLabelId } from "@/lib/sidebar-nav-data"
import type {
LabelInMessageListVisibility,
LabelListSidebarVisibility,
NavItemPrefs,
} from "@/lib/sidebar-nav-context"
import {
readSidebarNavDragData,
resolveNavDropPlacement,
setSidebarNavDragData,
} from "@/lib/sidebar-nav-dnd"
import { LABEL_MENU_COLOR_SWATCHES } from "@/components/gmail/sidebar/sidebar-nav-constants"
import {
SidebarNavOptionsSheet,
SidebarNavSheetAction,
SidebarNavSheetCheckOption,
SidebarNavSheetColorPicker,
SidebarNavSheetDivider,
SidebarNavSheetSectionLabel,
} from "@/components/gmail/sidebar/sidebar-nav-options-sheet"
import { useSidebarTouchOptionsMenu } from "@/components/gmail/sidebar/use-sidebar-touch-options"
import {
LabelMenuOptionWithCheck,
ContextLabelMenuOptionWithCheck,
navRowRoundedWhenActive,
SidebarNavDragHandle,
SidebarNavIconSlot,
SidebarOverflowColumn,
sidebarOverflowMenuButtonClass,
navRowActivate,
} from "@/components/gmail/sidebar/sidebar-nav-primitives"
import type { SidebarNavDragBindings } from "@/components/gmail/sidebar/sidebar-nav-drag-bindings"
export type SidebarLabelItemRowProps = SidebarNavDragBindings & {
item: { id: string; label: string; color: string; count?: number }
unreadCount: number
isExpanded: boolean
selectedFolder: string
touchNav: boolean
onSelectFolder: (id: string) => void
getNavItemPrefs: (id: string) => Required<Pick<NavItemPrefs, "sidebar" | "messages">>
setNavItemSidebarVisibility: (id: string, v: LabelListSidebarVisibility) => void
setNavItemMessageVisibility: (id: string, v: LabelInMessageListVisibility) => void
updateFolderOrLabelColor: (id: string, color: string) => void
renameFolderOrLabel: (id: string, name: string) => void
removeFolderOrLabelRow: (id: string) => void
addChildLabelRow: (parentId: string, name: string) => void
}
export function SidebarLabelItemRow({
item,
unreadCount,
isExpanded: labelRowExpanded,
selectedFolder,
touchNav,
onSelectFolder,
getNavItemPrefs,
setNavItemSidebarVisibility,
setNavItemMessageVisibility,
updateFolderOrLabelColor,
renameFolderOrLabel,
removeFolderOrLabelRow,
addChildLabelRow,
navDragRef,
navDropPlacementRef,
beginNavDrag,
clearNavDrag,
updateNavDropTarget,
clearNavDropTarget,
commitNavDrop,
}: SidebarLabelItemRowProps) {
const { isOver, dropHandlers } = useEmailDropTarget(item.id, item.label)
const isSelected = selectedFolder === item.id
const hasUnread = unreadCount > 0
const [menuOpen, setMenuOpen] = useState(false)
const [contextMenuOpen, setContextMenuOpen] = useState(false)
const menuTriggerRef = useRef<HTMLButtonElement>(null)
const [renameOpen, setRenameOpen] = useState(false)
const [renameDraft, setRenameDraft] = useState(item.label)
const [sublabelOpen, setSublabelOpen] = useState(false)
const [sublabelName, setSublabelName] = useState("")
const labelRenameInputRef = useRef<HTMLInputElement>(null)
const sublabelNameInputRef = useRef<HTMLInputElement>(null)
const canDragLabel = labelRowExpanded && !isSystemNavLabelId(item.id)
const { sheetOpen, setSheetOpen, touchRowProps, touchRowClassName, closeSheet } =
useSidebarTouchOptionsMenu(touchNav && labelRowExpanded)
useEffect(() => {
setRenameDraft(item.label)
}, [item.label])
const handleMenuOpenChange = (open: boolean) => {
setMenuOpen(open)
if (!open) {
queueMicrotask(() => menuTriggerRef.current?.blur())
}
}
const rowHoverHeld =
!isSelected && !isOver && (contextMenuOpen || menuOpen || sheetOpen)
const prefs = getNavItemPrefs(item.id)
const labelDotClass = item.color ?? "bg-gray-400"
const labelMenuSurface =
MAIL_SIDEBAR_MENU_SURFACE_CLASS
const colorSub = (subKind: "dropdown" | "context") => {
const Sub = subKind === "dropdown" ? DropdownMenuSub : ContextMenuSub
const SubTr =
subKind === "dropdown" ? DropdownMenuSubTrigger : ContextMenuSubTrigger
const SubCo =
subKind === "dropdown" ? DropdownMenuSubContent : ContextMenuSubContent
return (
<Sub>
<SubTr
className={cn(
MAIL_SIDEBAR_MENU_SUB_TRIGGER_CLASS,
subKind === "context" && "flex items-center gap-2"
)}
>
<span className="flex size-5 shrink-0 items-center justify-center rounded-full border border-border bg-mail-surface">
<span
className={cn(
"block size-3 rounded-sm border border-black/10",
labelDotClass
)}
aria-hidden
/>
</span>
<span className="flex-1 text-left text-sm">Couleur du libellé</span>
</SubTr>
<SubCo className={MAIL_SIDEBAR_COLOR_PICKER_CLASS}>
<div className="grid grid-cols-6 gap-1.5">
{LABEL_MENU_COLOR_SWATCHES.map((sw) => (
<button
key={sw}
type="button"
title={sw}
onClick={() => {
updateFolderOrLabelColor(item.id, sw)
setMenuOpen(false)
}}
className={cn(
cn(
"size-6 rounded-full border border-black/10 outline-none ring-offset-1 hover:ring-2",
MAIL_SIDEBAR_COLOR_SWATCH_RING_CLASS
),
sw
)}
/>
))}
</div>
</SubCo>
</Sub>
)
}
const rowClass = cn(
"group/labelrow relative flex h-8 w-full min-w-0 shrink-0 cursor-default items-center pl-6 pr-2 transition-colors",
navRowRoundedWhenActive(isSelected || isOver || rowHoverHeld),
isSelected
? "bg-mail-nav-selected text-mail-nav-selected font-medium"
: isOver
? "bg-mail-nav-drop text-foreground"
: rowHoverHeld
? "bg-mail-nav-hover text-foreground"
: hasUnread
? "text-gray-900 hover:bg-mail-nav-hover"
: "text-gray-700 hover:bg-mail-nav-hover",
touchRowClassName
)
const onLabelRowDragEnter = (e: DragEvent) => {
const active = navDragRef.current
if (active?.kind === "label" && active.id !== item.id) {
e.preventDefault()
return
}
dropHandlers.onDragEnter(e)
}
const onLabelRowDragOver = (e: DragEvent) => {
const active = navDragRef.current
if (active?.kind === "label") {
e.preventDefault()
e.stopPropagation()
if (active.id === item.id) return
e.dataTransfer.dropEffect = "move"
updateNavDropTarget(
e.currentTarget as HTMLElement,
resolveNavDropPlacement(e, false)
)
return
}
dropHandlers.onDragOver(e)
}
const onLabelRowDragLeave = (e: DragEvent) => {
if (navDragRef.current?.kind === "label") {
const rt = e.relatedTarget as Node | null
if (rt && e.currentTarget instanceof Node && e.currentTarget.contains(rt)) return
clearNavDropTarget(e.currentTarget as HTMLElement)
return
}
dropHandlers.onDragLeave(e)
}
const onLabelRowDrop = (e: DragEvent) => {
const payload = readSidebarNavDragData(e, navDragRef.current)
if (payload?.kind === "label") {
e.preventDefault()
e.stopPropagation()
const placement = navDropPlacementRef.current ?? resolveNavDropPlacement(e, false)
if (placement !== "inside") {
commitNavDrop(payload, item.id, placement, "label")
} else {
clearNavDrag()
}
return
}
dropHandlers.onDrop(e)
}
const onLabelDragHandleStart = (e: DragEvent<HTMLSpanElement>) => {
const payload = { kind: "label" as const, id: item.id }
setSidebarNavDragData(e, payload)
const rowEl = (e.currentTarget as HTMLElement).closest("[data-nav-row]") as HTMLElement | null
beginNavDrag(payload, rowEl)
}
const overflowMenu = labelRowExpanded ? (
<SidebarOverflowColumn
unread={unreadCount}
menuOpen={menuOpen || sheetOpen}
hoverGroup="labelrow"
isSelected={isSelected}
hasUnread={hasUnread}
className="mr-[-7px]"
showMenuButton={!touchNav}
>
{!touchNav && (
<DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}>
<DropdownMenuTrigger asChild>
<button
ref={menuTriggerRef}
type="button"
draggable={false}
className={cn(sidebarOverflowMenuButtonClass, isSelected && "text-gray-900")}
aria-label={`Options pour ${item.label}`}
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className={labelMenuSurface}>
{colorSub("dropdown")}
<DropdownMenuSeparator className={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<DropdownMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-foreground">
Dans la liste des libellés
</DropdownMenuLabel>
<LabelMenuOptionWithCheck
checked={prefs.sidebar === "show"}
onPick={() => setNavItemSidebarVisibility(item.id, "show")}
>
Afficher
</LabelMenuOptionWithCheck>
<LabelMenuOptionWithCheck
checked={prefs.sidebar === "showUnread"}
onPick={() => setNavItemSidebarVisibility(item.id, "showUnread")}
>
Afficher si messages non lus
</LabelMenuOptionWithCheck>
<LabelMenuOptionWithCheck
checked={prefs.sidebar === "hide"}
onPick={() => setNavItemSidebarVisibility(item.id, "hide")}
>
Masquer
</LabelMenuOptionWithCheck>
<DropdownMenuSeparator className={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<DropdownMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-foreground">
Dans la liste des messages
</DropdownMenuLabel>
<LabelMenuOptionWithCheck
checked={prefs.messages === "show"}
onPick={() => setNavItemMessageVisibility(item.id, "show")}
>
Afficher
</LabelMenuOptionWithCheck>
<LabelMenuOptionWithCheck
checked={prefs.messages === "hide"}
onPick={() => setNavItemMessageVisibility(item.id, "hide")}
>
Masquer
</LabelMenuOptionWithCheck>
<DropdownMenuSeparator className={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<DropdownMenuItem
className={MAIL_SIDEBAR_MENU_PLAIN_ITEM_CLASS}
onClick={() => {
setRenameDraft(item.label)
setRenameOpen(true)
setMenuOpen(false)
}}
>
Renommer
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
className="mx-1 cursor-pointer px-3 py-2 text-sm focus:bg-destructive/15"
onClick={() => {
removeFolderOrLabelRow(item.id)
setMenuOpen(false)
}}
>
Supprimer le libellé
</DropdownMenuItem>
<DropdownMenuItem
className={MAIL_SIDEBAR_MENU_PLAIN_ITEM_CLASS}
onClick={() => {
setSublabelName("")
setSublabelOpen(true)
setMenuOpen(false)
}}
>
Ajouter un sous-libellé
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</SidebarOverflowColumn>
) : null
const labelOptionsSheet = touchNav && labelRowExpanded && (
<SidebarNavOptionsSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
title={item.label}
colorDotClass={labelDotClass}
>
<SidebarNavSheetColorPicker
title="Couleur du libellé"
dotClass={labelDotClass}
swatches={LABEL_MENU_COLOR_SWATCHES}
onPick={(sw) => {
updateFolderOrLabelColor(item.id, sw)
closeSheet()
}}
/>
<SidebarNavSheetDivider />
<SidebarNavSheetSectionLabel>Dans la liste des libellés</SidebarNavSheetSectionLabel>
<SidebarNavSheetCheckOption
checked={prefs.sidebar === "show"}
onPick={() => {
setNavItemSidebarVisibility(item.id, "show")
closeSheet()
}}
>
Afficher
</SidebarNavSheetCheckOption>
<SidebarNavSheetCheckOption
checked={prefs.sidebar === "showUnread"}
onPick={() => {
setNavItemSidebarVisibility(item.id, "showUnread")
closeSheet()
}}
>
Afficher si messages non lus
</SidebarNavSheetCheckOption>
<SidebarNavSheetCheckOption
checked={prefs.sidebar === "hide"}
onPick={() => {
setNavItemSidebarVisibility(item.id, "hide")
closeSheet()
}}
>
Masquer
</SidebarNavSheetCheckOption>
<SidebarNavSheetDivider />
<SidebarNavSheetSectionLabel>Dans la liste des messages</SidebarNavSheetSectionLabel>
<SidebarNavSheetCheckOption
checked={prefs.messages === "show"}
onPick={() => {
setNavItemMessageVisibility(item.id, "show")
closeSheet()
}}
>
Afficher
</SidebarNavSheetCheckOption>
<SidebarNavSheetCheckOption
checked={prefs.messages === "hide"}
onPick={() => {
setNavItemMessageVisibility(item.id, "hide")
closeSheet()
}}
>
Masquer
</SidebarNavSheetCheckOption>
<SidebarNavSheetDivider />
<SidebarNavSheetAction
onClick={() => {
setRenameDraft(item.label)
setRenameOpen(true)
closeSheet()
}}
>
Renommer
</SidebarNavSheetAction>
<SidebarNavSheetAction
destructive
onClick={() => {
removeFolderOrLabelRow(item.id)
closeSheet()
}}
>
Supprimer le libellé
</SidebarNavSheetAction>
<SidebarNavSheetAction
onClick={() => {
setSublabelName("")
setSublabelOpen(true)
closeSheet()
}}
>
Ajouter un sous-libellé
</SidebarNavSheetAction>
</SidebarNavOptionsSheet>
)
const labelRowEl = (
<div
data-nav-row
{...touchRowProps}
onDragEnter={onLabelRowDragEnter}
onDragOver={onLabelRowDragOver}
onDragLeave={onLabelRowDragLeave}
onDrop={onLabelRowDrop}
className={rowClass}
>
{canDragLabel ? (
<SidebarNavDragHandle
label={item.label}
onDragStart={onLabelDragHandleStart}
onDragEnd={clearNavDrag}
/>
) : null}
<div
role="button"
tabIndex={0}
title={!labelRowExpanded ? item.label : undefined}
onClick={() => onSelectFolder(item.id)}
onKeyDown={(e) => navRowActivate(e, () => onSelectFolder(item.id))}
className={cn(
"flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-4 py-0 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
labelRowExpanded ? "pr-1" : "pr-3"
)}
>
<SidebarNavIconSlot showUnreadDot={hasUnread}>
<span
className={cn("block h-3 w-3 rounded-sm", item.color ?? "bg-gray-400")}
/>
</SidebarNavIconSlot>
{labelRowExpanded && (
<span
className={cn(
"min-w-0 flex-1 truncate text-sm leading-5",
hasUnread && !isSelected && "font-semibold text-gray-900"
)}
>
{item.label}
</span>
)}
</div>
{overflowMenu}
</div>
)
return (
<>
{touchNav ? (
labelRowEl
) : (
<ContextMenu onOpenChange={setContextMenuOpen}>
<ContextMenuTrigger asChild>{labelRowEl}</ContextMenuTrigger>
<ContextMenuContent className={labelMenuSurface}>
{colorSub("context")}
<ContextMenuSeparator className={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<ContextMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-foreground">
Dans la liste des libellés
</ContextMenuLabel>
<ContextLabelMenuOptionWithCheck
checked={prefs.sidebar === "show"}
onPick={() => setNavItemSidebarVisibility(item.id, "show")}
>
Afficher
</ContextLabelMenuOptionWithCheck>
<ContextLabelMenuOptionWithCheck
checked={prefs.sidebar === "showUnread"}
onPick={() => setNavItemSidebarVisibility(item.id, "showUnread")}
>
Afficher si non lus
</ContextLabelMenuOptionWithCheck>
<ContextLabelMenuOptionWithCheck
checked={prefs.sidebar === "hide"}
onPick={() => setNavItemSidebarVisibility(item.id, "hide")}
>
Masquer
</ContextLabelMenuOptionWithCheck>
<ContextMenuSeparator className={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<ContextMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-foreground">
Dans la liste des messages
</ContextMenuLabel>
<ContextLabelMenuOptionWithCheck
checked={prefs.messages === "show"}
onPick={() => setNavItemMessageVisibility(item.id, "show")}
>
Afficher
</ContextLabelMenuOptionWithCheck>
<ContextLabelMenuOptionWithCheck
checked={prefs.messages === "hide"}
onPick={() => setNavItemMessageVisibility(item.id, "hide")}
>
Masquer
</ContextLabelMenuOptionWithCheck>
<ContextMenuSeparator className="my-1.5 bg-gray-200" />
<ContextMenuItem
className="mx-1 cursor-pointer px-3 py-2 text-sm"
onClick={() => {
setRenameDraft(item.label)
setRenameOpen(true)
}}
>
Renommer
</ContextMenuItem>
<ContextMenuItem
variant="destructive"
className="mx-1 cursor-pointer px-3 py-2 text-sm"
onClick={() => removeFolderOrLabelRow(item.id)}
>
Supprimer le libellé
</ContextMenuItem>
<ContextMenuItem
className="mx-1 cursor-pointer px-3 py-2 text-sm"
onClick={() => {
setSublabelName("")
setSublabelOpen(true)
}}
>
Ajouter un sous-libellé
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)}
{labelOptionsSheet}
<Dialog open={renameOpen} onOpenChange={setRenameOpen}>
<DialogContent
className="sm:max-w-md"
showCloseButton
onOpenAutoFocus={(e) => {
e.preventDefault()
window.requestAnimationFrame(() =>
labelRenameInputRef.current?.focus()
)
}}
>
<DialogHeader>
<DialogTitle>Renommer le libellé</DialogTitle>
<DialogDescription>Nouveau nom pour « {item.label} ».</DialogDescription>
</DialogHeader>
<Input
ref={labelRenameInputRef}
value={renameDraft}
onChange={(e) => setRenameDraft(e.target.value)}
autoComplete="off"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
renameFolderOrLabel(item.id, renameDraft)
setRenameOpen(false)
}
}}
/>
<DialogFooter>
<Button variant="outline" type="button" onClick={() => setRenameOpen(false)}>
Annuler
</Button>
<Button
type="button"
onClick={() => {
renameFolderOrLabel(item.id, renameDraft)
setRenameOpen(false)
}}
>
Enregistrer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={sublabelOpen} onOpenChange={setSublabelOpen}>
<DialogContent
className="sm:max-w-md"
showCloseButton
onOpenAutoFocus={(e) => {
e.preventDefault()
window.requestAnimationFrame(() =>
sublabelNameInputRef.current?.focus()
)
}}
>
<DialogHeader>
<DialogTitle>Sous-libellé</DialogTitle>
<DialogDescription>
Sera créé sous « {item.label} » (chemin type Parent/Enfant).
</DialogDescription>
</DialogHeader>
<Input
ref={sublabelNameInputRef}
value={sublabelName}
onChange={(e) => setSublabelName(e.target.value)}
placeholder="Nom du sous-libellé"
autoComplete="off"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
addChildLabelRow(item.id, sublabelName)
setSublabelOpen(false)
}
}}
/>
<DialogFooter>
<Button variant="outline" type="button" onClick={() => setSublabelOpen(false)}>
Annuler
</Button>
<Button
type="button"
onClick={() => {
addChildLabelRow(item.id, sublabelName)
setSublabelOpen(false)
}}
>
Créer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@ -76,3 +76,21 @@ export type CategoryNavSourceItem = {
label: string label: string
icon?: string icon?: string
} }
/** Sticky row height (px) — section headers and open folder branches. */
export const MAIL_SIDEBAR_STICKY_ROW_PX = 32
/**
* Sticky z-index stack in the Dossiers section (higher = covers lower when scrolling):
* Dossiers header > root folder > subfolder > nested
*/
export const MAIL_SIDEBAR_DOSSIERS_SECTION_STICKY_Z = 40
export const MAIL_SIDEBAR_FOLDER_BRANCH_STICKY_Z_BASE = 35
export function mailSidebarFolderBranchStickyZ(depth: number) {
return Math.max(1, MAIL_SIDEBAR_FOLDER_BRANCH_STICKY_Z_BASE - depth)
}
export function mailSidebarFolderBranchStickyTopPx(depth: number) {
return MAIL_SIDEBAR_STICKY_ROW_PX + depth * MAIL_SIDEBAR_STICKY_ROW_PX
}

View File

@ -0,0 +1,26 @@
import type { MutableRefObject } from "react"
import type {
SidebarNavDragPayload,
SidebarNavDropPlacement,
} from "@/lib/sidebar-nav-dnd"
export type SidebarNavDragBindings = {
navDragRef: MutableRefObject<SidebarNavDragPayload | null>
navDropPlacementRef: MutableRefObject<SidebarNavDropPlacement | null>
beginNavDrag: (
payload: SidebarNavDragPayload,
sourceEl: HTMLElement | null
) => void
clearNavDrag: () => void
updateNavDropTarget: (
el: HTMLElement,
placement: SidebarNavDropPlacement
) => void
clearNavDropTarget: (el: HTMLElement) => void
commitNavDrop: (
payload: SidebarNavDragPayload,
targetId: string,
placement: SidebarNavDropPlacement,
targetKind: "label" | "folder"
) => void
}

View File

@ -0,0 +1,75 @@
"use client"
import type { ElementType } from "react"
import { Icon } from "@iconify/react"
import { cn, formatCount } from "@/lib/utils"
import { useEmailDropTarget } from "@/lib/drag-context"
import { navRowRoundedWhenActive } from "@/components/gmail/sidebar/sidebar-nav-primitives"
export function SidebarNavItem({
item,
isSelected,
unreadCount,
isExpanded,
onSelectFolder,
}: {
item: { id: string; label: string; icon: ElementType | string }
isSelected: boolean
unreadCount: number
isExpanded: boolean
onSelectFolder: (id: string) => void
}) {
const { isOver, dropHandlers } = useEmailDropTarget(item.id, item.label)
const hasUnread = unreadCount > 0
const iconClassName = cn(
"h-5 w-5 shrink-0",
hasUnread && !isSelected && "text-gray-900"
)
return (
<button
onClick={() => onSelectFolder(item.id)}
title={!isExpanded ? item.label : undefined}
{...dropHandlers}
className={cn(
"flex h-8 w-full min-w-0 shrink-0 cursor-pointer items-center gap-4 pl-6 pr-3 transition-colors",
navRowRoundedWhenActive(isSelected || isOver),
isSelected
? "bg-mail-nav-selected text-mail-nav-selected font-medium"
: isOver
? "bg-mail-nav-drop text-foreground"
: hasUnread
? "text-gray-900 hover:bg-mail-nav-hover"
: "text-gray-700 hover:bg-mail-nav-hover"
)}
>
{typeof item.icon === "string" ? (
<Icon icon={item.icon} className={iconClassName} aria-hidden />
) : (
<item.icon className={iconClassName} />
)}
{isExpanded && (
<div className="flex min-w-0 flex-1 items-baseline gap-4">
<span
className={cn(
"min-w-0 flex-1 truncate text-left text-sm leading-5",
hasUnread && !isSelected && "font-semibold text-gray-900"
)}
>
{item.label}
</span>
{unreadCount > 0 && (
<span
className={cn(
"shrink-0 text-xs tabular-nums leading-none",
isSelected && "font-medium",
hasUnread && !isSelected && "font-semibold"
)}
>
{formatCount(unreadCount)}
</span>
)}
</div>
)}
</button>
)
}

View File

@ -0,0 +1,303 @@
"use client"
import { ChevronDown, Plus, Bot } from "lucide-react"
import { cn } from "@/lib/utils"
import { Icon } from "@iconify/react"
import { FOLDER_SECTION_ICON } from "@/lib/folder-nav-icons"
import {
CATEGORY_IDS_IN_PLUS_ONLY,
MAIL_SIDEBAR_DOSSIERS_SECTION_STICKY_Z,
hasPlusOnlyExtras,
sidebarSecondaryActions,
} from "@/components/gmail/sidebar/sidebar-nav-constants"
import { navRowRoundedWhenActive } from "@/components/gmail/sidebar/sidebar-nav-primitives"
import { CategoryNavRow } from "@/components/gmail/sidebar/category-nav-row"
import { SidebarNavItem } from "@/components/gmail/sidebar/sidebar-nav-item"
import { SidebarLabelItemRow } from "@/components/gmail/sidebar/sidebar-label-item-row"
import {
renderCollapsedFolderList,
renderExpandedFolderSubtree,
} from "@/components/gmail/sidebar/sidebar-folder-tree"
import { MAIL_SIDEBAR_BLUR_SURFACE_CLASS } from "@/lib/mail-chrome-classes"
import type { useSidebarState } from "@/components/gmail/sidebar/use-sidebar-state"
type SidebarState = ReturnType<typeof useSidebarState>
export function SidebarNavPanel({
selectedFolder,
onSelectFolder,
folderUnreadCounts,
splitView = false,
state,
}: {
selectedFolder: string
onSelectFolder: (folder: string) => void
folderUnreadCounts: Record<string, number>
splitView?: boolean
state: SidebarState
}) {
const {
isExpanded,
navRailInset,
touchNav,
visibleMainItems,
primaryVisibleCategories,
plusOnlyVisibleCategories,
disabledSystemNavItems,
navMoreOpen,
setNavMoreOpen,
setLabelRowEnabled,
folderTree,
folderRowProps,
collapsedFolderOpts,
visibleNavLabelRows,
labelRowProps,
setNewFolderParent,
setNewFolderName,
setFolderDialogOpen,
setNewLabelName,
setLabelDialogOpen,
} = state
return (
<div
className={cn(
"min-h-0 flex-1 overflow-y-auto overflow-x-hidden",
"[scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
)}
>
<nav
className={cn(
"flex min-h-full flex-col",
navRailInset,
!splitView && "sm:pt-3"
)}
>
{visibleMainItems.map((item) => (
<SidebarNavItem
key={item.id}
item={item}
isSelected={selectedFolder === item.id}
unreadCount={folderUnreadCounts[item.id] ?? 0}
isExpanded={isExpanded}
onSelectFolder={onSelectFolder}
/>
))}
{primaryVisibleCategories.map((item) => (
<CategoryNavRow
key={item.id}
item={item}
isSelected={selectedFolder === item.id}
isExpanded={isExpanded}
unreadCount={folderUnreadCounts[item.id] ?? 0}
onSelectFolder={onSelectFolder}
touchNav={touchNav}
onDisableNavLabel={(id) => setLabelRowEnabled(id, false)}
onEnableNavLabel={(id) => setLabelRowEnabled(id, true)}
/>
))}
{hasPlusOnlyExtras && (
<button
type="button"
title={!isExpanded ? (navMoreOpen ? "Moins" : "Plus") : undefined}
aria-expanded={navMoreOpen}
aria-label={
!isExpanded
? navMoreOpen
? "Moins dentrées"
: "Plus dentrées"
: undefined
}
onClick={() =>
setNavMoreOpen((wasOpen) => {
if (!wasOpen) return true
if (CATEGORY_IDS_IN_PLUS_ONLY.has(selectedFolder)) {
onSelectFolder("inbox")
return false
}
return false
})
}
className={cn(
"flex h-8 w-full shrink-0 cursor-pointer items-center gap-4 pl-6 pr-3 text-gray-700 transition-colors hover:bg-mail-nav-hover",
navRowRoundedWhenActive(false)
)}
>
<ChevronDown
className={cn(
"h-5 w-5 shrink-0 transition-transform duration-200",
navMoreOpen && "rotate-180"
)}
/>
{isExpanded && (
<span className="text-sm">{navMoreOpen ? "Moins" : "Plus"}</span>
)}
</button>
)}
{navMoreOpen && (
<>
{plusOnlyVisibleCategories.map((item) => (
<CategoryNavRow
key={item.id}
item={item}
isSelected={selectedFolder === item.id}
isExpanded={isExpanded}
unreadCount={folderUnreadCounts[item.id] ?? 0}
onSelectFolder={onSelectFolder}
touchNav={touchNav}
onDisableNavLabel={(id) => setLabelRowEnabled(id, false)}
onEnableNavLabel={(id) => setLabelRowEnabled(id, true)}
/>
))}
{isExpanded && (
<div className="mt-1 flex flex-col gap-px">
{sidebarSecondaryActions.map((a) => {
const ActionIcon = a.icon
return (
<button
key={a.id}
type="button"
className="flex min-h-8 w-full cursor-pointer items-center gap-2 rounded-md py-1.5 pl-6 pr-3 text-left text-xs text-gray-600 transition-colors hover:bg-gray-50 hover:text-gray-800"
>
<ActionIcon className="h-3.5 w-3.5 shrink-0 opacity-70" aria-hidden />
<span className="min-w-0 leading-snug">{a.label}</span>
</button>
)
})}
</div>
)}
{isExpanded && disabledSystemNavItems.length > 0 && (
<div className="mt-2 pt-2">
<div className="mb-1 pl-6 pr-3 text-[11px] font-medium uppercase tracking-wide text-gray-500">
Désactivées
</div>
{disabledSystemNavItems.map((item) => (
<CategoryNavRow
key={item.id}
item={item}
isSelected={false}
isExpanded={isExpanded}
unreadCount={folderUnreadCounts[item.id] ?? 0}
onSelectFolder={onSelectFolder}
touchNav={touchNav}
onDisableNavLabel={(id) => setLabelRowEnabled(id, false)}
onEnableNavLabel={(id) => setLabelRowEnabled(id, true)}
variant="hidden"
/>
))}
</div>
)}
</>
)}
<div className="mt-3 pt-1">
<div
className={cn(
"sticky top-0 flex h-8 w-full min-w-0 shrink-0 items-center gap-2 pl-6 pr-3",
MAIL_SIDEBAR_BLUR_SURFACE_CLASS
)}
style={{ zIndex: MAIL_SIDEBAR_DOSSIERS_SECTION_STICKY_Z }}
title={!isExpanded ? "Dossiers" : undefined}
>
<Icon
icon={FOLDER_SECTION_ICON}
className="h-5 w-5 shrink-0 text-gray-600"
aria-hidden
/>
{isExpanded && (
<span className="min-w-0 flex-1 truncate text-left text-sm font-medium text-gray-700">
Dossiers
</span>
)}
{isExpanded && (
<button
type="button"
className="flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-full text-gray-500 hover:bg-mail-nav-hover hover:text-gray-700"
aria-label="Ajouter un dossier"
title="Ajouter un dossier"
onClick={() => {
setNewFolderParent("__root__")
setNewFolderName("")
setFolderDialogOpen(true)
}}
>
<Plus className="h-5 w-5 shrink-0" />
</button>
)}
</div>
{isExpanded
? renderExpandedFolderSubtree(folderTree, 0, folderRowProps)
: renderCollapsedFolderList(folderTree, collapsedFolderOpts)}
</div>
<div className="mt-3 pt-1">
<div
className={cn(
"sticky top-0 z-30 flex h-8 w-full min-w-0 shrink-0 items-center gap-2 pl-6 pr-3",
MAIL_SIDEBAR_BLUR_SURFACE_CLASS
)}
title={!isExpanded ? "Libellés" : undefined}
>
<Icon
icon="mdi:label-outline"
className="h-5 w-5 shrink-0 text-gray-600"
aria-hidden
/>
{isExpanded && (
<span className="min-w-0 flex-1 truncate text-left text-sm font-medium text-gray-700">
Libellés
</span>
)}
{isExpanded && (
<button
type="button"
className="flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-full text-gray-500 hover:bg-mail-nav-hover hover:text-gray-700"
aria-label="Ajouter un libellé"
title="Ajouter un libellé"
onClick={() => {
setNewLabelName("")
setLabelDialogOpen(true)
}}
>
<Plus className="h-5 w-5 shrink-0" />
</button>
)}
</div>
{visibleNavLabelRows.map((item) => (
<SidebarLabelItemRow
key={item.id}
item={item}
unreadCount={folderUnreadCounts[item.id] ?? 0}
isExpanded={isExpanded}
{...labelRowProps}
/>
))}
</div>
<div
className={cn(
"relative z-[41] mt-auto pt-2",
MAIL_SIDEBAR_BLUR_SURFACE_CLASS,
"max-sm:pb-16 sm:-mr-3.5 sm:w-[calc(100%+0.875rem)] sm:sticky sm:bottom-0 sm:border-t sm:border-gray-200 sm:pb-3"
)}
>
<button
type="button"
title={!isExpanded ? "Sortbot" : undefined}
className={cn(
"flex h-8 w-full shrink-0 cursor-pointer items-center gap-4 pl-6 pr-3 text-sm text-gray-700 transition-colors hover:bg-mail-nav-hover",
navRowRoundedWhenActive(false)
)}
>
<Bot className="h-5 w-5 shrink-0 text-gray-600" aria-hidden />
{isExpanded && <span>Sortbot</span>}
</button>
</div>
</nav>
</div>
)
}

View File

@ -0,0 +1,78 @@
"use client"
import { cn } from "@/lib/utils"
import { useIsXs } from "@/hooks/use-xs"
import { Icon, addCollection } from "@iconify/react"
import { icons as mdiIcons } from "@iconify-json/mdi"
import { SidebarCreateDialogs } from "@/components/gmail/sidebar/sidebar-create-dialogs"
import { SidebarHeader } from "@/components/gmail/sidebar/sidebar-header"
import { SidebarNavPanel } from "@/components/gmail/sidebar/sidebar-nav-panel"
import {
useSidebarState,
type SidebarProps,
} from "@/components/gmail/sidebar/use-sidebar-state"
addCollection(mdiIcons)
export function Sidebar(props: SidebarProps) {
const { splitView = false, folderUnreadCounts = {}, selectedFolder, onSelectFolder } =
props
const isXs = useIsXs()
const state = useSidebarState(props)
return (
<aside
ref={state.sidebarRef}
data-sidebar
data-sidebar-overlay={state.isOverlayOpen ? "" : undefined}
onMouseEnter={state.handleMouseEnter}
onMouseLeave={state.handleMouseLeave}
className={cn(
"absolute left-0 top-0 bottom-0 z-40 flex flex-col overflow-x-hidden transition-[width,transform] duration-200 ease-linear select-none",
state.panelSurfaceClass,
state.isExpanded ? "w-60" : "w-[68px]",
splitView && "border-r border-gray-200",
!state.touchNav && state.hoverExpanded && "shadow-xl border-r border-gray-200 mail-sidebar-hover-frosted",
state.isOverlayOpen && "z-50 shadow-xl border-r border-gray-200",
props.collapsed && isXs && "-translate-x-full pointer-events-none"
)}
>
<SidebarHeader
splitView={splitView}
isExpanded={state.isExpanded}
panelSurfaceClass={state.panelSurfaceClass}
splitViewLogoHeaderClass={state.splitViewLogoHeaderClass}
splitViewLogoIconClass={state.splitViewLogoIconClass}
touchNav={state.touchNav}
/>
<SidebarNavPanel
selectedFolder={selectedFolder}
onSelectFolder={onSelectFolder}
folderUnreadCounts={folderUnreadCounts}
splitView={splitView}
state={state}
/>
<SidebarCreateDialogs
folderDialogOpen={state.folderDialogOpen}
setFolderDialogOpen={state.setFolderDialogOpen}
labelDialogOpen={state.labelDialogOpen}
setLabelDialogOpen={state.setLabelDialogOpen}
newFolderName={state.newFolderName}
setNewFolderName={state.setNewFolderName}
newFolderParent={state.newFolderParent}
setNewFolderParent={state.setNewFolderParent}
newLabelName={state.newLabelName}
setNewLabelName={state.setNewLabelName}
newFolderNameInputRef={state.newFolderNameInputRef}
newLabelNameInputRef={state.newLabelNameInputRef}
folderParentOptions={state.folderParentOptions}
onSubmitNewFolder={state.handleSubmitNewFolder}
onSubmitNewLabel={state.handleSubmitNewLabel}
/>
</aside>
)
}
export type { SidebarProps }

View File

@ -0,0 +1,381 @@
"use client"
import { useState, useRef, useEffect, useMemo } from "react"
import { useIsXs } from "@/hooks/use-xs"
import { useTouchNav } from "@/hooks/use-touch-nav"
import { readTouchNavMatches } from "@/hooks/use-touch-nav"
import { isSystemNavLabelId } from "@/lib/sidebar-nav-data"
import { useSidebarNav } from "@/lib/sidebar-nav-context"
import { ancestorFolderIdsForTarget } from "@/lib/sidebar-folder-tree-utils"
import {
mainItems,
CATEGORY_IDS_IN_PLUS_ONLY,
sortSystemLabelRows,
} from "@/components/gmail/sidebar/sidebar-nav-constants"
import { folderParentSelectOptions } from "@/components/gmail/sidebar/sidebar-nav-primitives"
import { useSidebarNavDrag } from "@/hooks/use-sidebar-nav-drag"
import {
MAIL_SIDEBAR_PANEL_SURFACE_CLASS,
MAIL_SIDEBAR_PANEL_SURFACE_MOBILE_CLASS,
} from "@/lib/mail-chrome-classes"
export interface SidebarProps {
selectedFolder: string
onSelectFolder: (folder: string) => void
collapsed: boolean
folderUnreadCounts?: Record<string, number>
splitView?: boolean
}
export function useSidebarState({
selectedFolder,
onSelectFolder,
collapsed,
folderUnreadCounts = {},
}: SidebarProps) {
const [hoverExpanded, setHoverExpanded] = useState(false)
const [navMoreOpen, setNavMoreOpen] = useState(false)
const [expandedFolderIds, setExpandedFolderIds] = useState<Set<string>>(() => new Set())
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const sidebarRef = useRef<HTMLElement>(null)
const touchNav = useTouchNav()
const isXs = useIsXs()
const isExpanded = !collapsed || (!touchNav && hoverExpanded)
const isOverlayOpen = touchNav && !collapsed
const nav = useSidebarNav()
const {
folderTree,
labelRows,
folderIdToLabel,
addFolder,
addLabelRowFromSidebar,
getNavItemPrefs,
setNavItemSidebarVisibility,
setNavItemMessageVisibility,
updateFolderOrLabelColor,
renameFolderOrLabel,
removeFolderOrLabelRow,
moveFolder,
reorderLabelRows,
moveFolderRelative,
addSubfolder,
addChildLabelRow,
setLabelRowEnabled,
} = nav
const drag = useSidebarNavDrag({
reorderLabelRows,
moveFolderRelative,
setExpandedFolderIds,
})
const visibleNavLabelRows = useMemo(() => {
return labelRows.filter((row) => {
if (row.enabled === false) return false
if (isSystemNavLabelId(row.id)) return false
const p = getNavItemPrefs(row.id)
if (p.sidebar === "hide") return false
if (
p.sidebar === "showUnread" &&
(folderUnreadCounts[row.id] ?? 0) === 0
) {
return false
}
return true
})
}, [labelRows, getNavItemPrefs, folderUnreadCounts])
const validNavFolderIds = useMemo(() => {
const s = new Set<string>()
for (const i of mainItems) s.add(i.id)
for (const k of Object.keys(folderIdToLabel)) s.add(k)
return s
}, [folderIdToLabel])
useEffect(() => {
if (selectedFolder !== "search" && !validNavFolderIds.has(selectedFolder)) {
onSelectFolder("inbox")
}
}, [validNavFolderIds, selectedFolder, onSelectFolder])
const [folderDialogOpen, setFolderDialogOpen] = useState(false)
const [labelDialogOpen, setLabelDialogOpen] = useState(false)
const [newFolderName, setNewFolderName] = useState("")
const [newFolderParent, setNewFolderParent] = useState("__root__")
const [newLabelName, setNewLabelName] = useState("")
const newFolderNameInputRef = useRef<HTMLInputElement>(null)
const newLabelNameInputRef = useRef<HTMLInputElement>(null)
const folderParentOptions = useMemo(
() => folderParentSelectOptions(folderTree),
[folderTree]
)
const { primaryVisibleCategories, plusOnlyVisibleCategories } = useMemo(() => {
const systemEnabled = sortSystemLabelRows(
labelRows.filter((r) => r.enabled !== false && isSystemNavLabelId(r.id))
).map((r) => ({ id: r.id, label: r.label, icon: r.icon }))
return {
primaryVisibleCategories: systemEnabled.filter(
(c) => !CATEGORY_IDS_IN_PLUS_ONLY.has(c.id)
),
plusOnlyVisibleCategories: systemEnabled.filter((c) =>
CATEGORY_IDS_IN_PLUS_ONLY.has(c.id)
),
}
}, [labelRows])
const disabledSystemNavItems = useMemo(() => {
return sortSystemLabelRows(
labelRows.filter((r) => r.enabled === false && isSystemNavLabelId(r.id))
).map((r) => ({ id: r.id, label: r.label, icon: r.icon }))
}, [labelRows])
const visibleMainItems = useMemo(() => {
const scheduledTotal = folderUnreadCounts.scheduled ?? 0
if (scheduledTotal > 0) return mainItems
return mainItems.filter((item) => item.id !== "scheduled")
}, [folderUnreadCounts.scheduled])
const toggleFolderExpanded = (id: string) => {
setExpandedFolderIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const handleSubmitNewFolder = () => {
const name = newFolderName.trim()
if (!name) return
const parentId = newFolderParent === "__root__" ? null : newFolderParent
addFolder(parentId, name)
setNewFolderName("")
setFolderDialogOpen(false)
}
const handleSubmitNewLabel = () => {
const name = newLabelName.trim()
if (!name) return
addLabelRowFromSidebar(name)
setNewLabelName("")
setLabelDialogOpen(false)
}
useEffect(() => {
const row = labelRows.find((r) => r.id === selectedFolder)
if (row && row.enabled === false) {
onSelectFolder("inbox")
}
}, [labelRows, selectedFolder, onSelectFolder])
useEffect(() => {
if (selectedFolder !== "scheduled") return
if ((folderUnreadCounts.scheduled ?? 0) > 0) return
onSelectFolder("inbox")
}, [folderUnreadCounts.scheduled, selectedFolder, onSelectFolder])
useEffect(() => {
if (CATEGORY_IDS_IN_PLUS_ONLY.has(selectedFolder) && !navMoreOpen) {
setNavMoreOpen(true)
}
}, [selectedFolder, navMoreOpen])
useEffect(() => {
const ancestors = ancestorFolderIdsForTarget(folderTree, selectedFolder)
if (ancestors?.length) {
setExpandedFolderIds((prev) => {
const next = new Set(prev)
ancestors.forEach((id) => next.add(id))
return next
})
}
}, [selectedFolder, folderTree])
const handleMouseEnter = () => {
if (readTouchNavMatches()) return
if (collapsed) {
hoverTimeoutRef.current = setTimeout(() => {
setHoverExpanded(true)
}, 300)
}
}
const handleMouseLeave = () => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current)
hoverTimeoutRef.current = null
}
if (readTouchNavMatches()) return
setHoverExpanded(false)
}
useEffect(() => {
if (touchNav) setHoverExpanded(false)
}, [touchNav, collapsed])
useEffect(() => {
return () => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current)
}
}
}, [])
const navRailInset = "pr-3.5"
const splitViewLogoIconClass = "size-9 shrink-0"
const splitViewLogoHeaderClass =
"box-border min-h-[80px] pt-3 pl-4 pr-3.5 pb-4"
const navDragBindings = useMemo(
() => ({
navDragRef: drag.navDragRef,
navDropPlacementRef: drag.navDropPlacementRef,
beginNavDrag: drag.beginNavDrag,
clearNavDrag: drag.clearNavDrag,
updateNavDropTarget: drag.updateNavDropTarget,
clearNavDropTarget: drag.clearNavDropTarget,
commitNavDrop: drag.commitNavDrop,
}),
[drag]
)
const folderRowProps = useMemo(
() => ({
...navDragBindings,
selectedFolder,
folderUnreadCounts,
expandedFolderIds,
isExpanded,
isOverlayOpen,
touchNav,
folderTree,
onSelectFolder,
toggleFolderExpanded,
getNavItemPrefs,
setNavItemSidebarVisibility,
setNavItemMessageVisibility,
updateFolderOrLabelColor,
renameFolderOrLabel,
removeFolderOrLabelRow,
moveFolder,
addSubfolder,
}),
[
navDragBindings,
selectedFolder,
folderUnreadCounts,
expandedFolderIds,
isExpanded,
isOverlayOpen,
touchNav,
folderTree,
onSelectFolder,
getNavItemPrefs,
setNavItemSidebarVisibility,
setNavItemMessageVisibility,
updateFolderOrLabelColor,
renameFolderOrLabel,
removeFolderOrLabelRow,
moveFolder,
addSubfolder,
]
)
const collapsedFolderOpts = useMemo(
() => ({
getNavItemPrefs,
folderUnreadCounts,
expandedFolderIds,
isExpanded,
selectedFolder,
onSelectFolder,
}),
[
getNavItemPrefs,
folderUnreadCounts,
expandedFolderIds,
isExpanded,
selectedFolder,
onSelectFolder,
]
)
const labelRowProps = useMemo(
() => ({
...navDragBindings,
selectedFolder,
touchNav,
onSelectFolder,
getNavItemPrefs,
setNavItemSidebarVisibility,
setNavItemMessageVisibility,
updateFolderOrLabelColor,
renameFolderOrLabel,
removeFolderOrLabelRow,
addChildLabelRow,
}),
[
navDragBindings,
selectedFolder,
touchNav,
onSelectFolder,
getNavItemPrefs,
setNavItemSidebarVisibility,
setNavItemMessageVisibility,
updateFolderOrLabelColor,
renameFolderOrLabel,
removeFolderOrLabelRow,
addChildLabelRow,
]
)
const panelSurfaceClass =
isOverlayOpen && isXs
? MAIL_SIDEBAR_PANEL_SURFACE_MOBILE_CLASS
: MAIL_SIDEBAR_PANEL_SURFACE_CLASS
return {
sidebarRef,
touchNav,
hoverExpanded,
isExpanded,
isOverlayOpen,
panelSurfaceClass,
navRailInset,
splitViewLogoIconClass,
splitViewLogoHeaderClass,
handleMouseEnter,
handleMouseLeave,
navMoreOpen,
setNavMoreOpen,
visibleMainItems,
primaryVisibleCategories,
plusOnlyVisibleCategories,
disabledSystemNavItems,
folderTree,
folderRowProps,
collapsedFolderOpts,
visibleNavLabelRows,
labelRowProps,
setLabelRowEnabled,
folderDialogOpen,
setFolderDialogOpen,
labelDialogOpen,
setLabelDialogOpen,
newFolderName,
setNewFolderName,
newFolderParent,
setNewFolderParent,
newLabelName,
setNewLabelName,
newFolderNameInputRef,
newLabelNameInputRef,
folderParentOptions,
handleSubmitNewFolder,
handleSubmitNewLabel,
}
}

View File

@ -260,11 +260,16 @@ export const MAIL_MOBILE_SEARCH_SHEET_CLASS = cn(
"data-mail-mobile-search" "data-mail-mobile-search"
) )
/** Sidebar — panneau desktop (flou sur le canvas). */ /** Sidebar xs only (flou sur le canvas). sm+ stays transparent with NO backdrop-filter
* so descendant sticky rows can use their own backdrop-filter freely. */
export const MAIL_SIDEBAR_PANEL_SURFACE_CLASS = cn( export const MAIL_SIDEBAR_PANEL_SURFACE_CLASS = cn(
"bg-app-canvas/80 backdrop-blur-xl backdrop-saturate-150 supports-[backdrop-filter]:bg-app-canvas/65" "max-sm:bg-app-canvas/80 max-sm:backdrop-blur-xl max-sm:backdrop-saturate-150 max-sm:supports-[backdrop-filter]:bg-app-canvas/65",
"sm:bg-transparent"
) )
/** Sidebar — frosted strips (Sortbot, section headers, open folder branches). */
export const MAIL_SIDEBAR_BLUR_SURFACE_CLASS = "mail-sidebar-blur-surface"
/** Sidebar — overlay mobile/touch : classe CSS dédiée (pas bg-* Tailwind). */ /** Sidebar — overlay mobile/touch : classe CSS dédiée (pas bg-* Tailwind). */
export const MAIL_SIDEBAR_PANEL_SURFACE_MOBILE_CLASS = "mail-sidebar-overlay-panel" export const MAIL_SIDEBAR_PANEL_SURFACE_MOBILE_CLASS = "mail-sidebar-overlay-panel"

View File

@ -0,0 +1,78 @@
"use client"
import { useState } from "react"
import {
EMPTY_SEARCH_PARAMS,
type SearchParams,
} from "@/lib/mail-search/search-params"
export function useAdvancedSearchForm(
initialQuery: string,
currentParams: SearchParams | null
) {
const [from, setFrom] = useState(currentParams?.from ?? "")
const [to, setTo] = useState(currentParams?.to ?? "")
const [subject, setSubject] = useState(currentParams?.subject ?? "")
const [hasWords, setHasWords] = useState(
currentParams?.hasWords || currentParams?.q || initialQuery
)
const [doesNotHave, setDoesNotHave] = useState(currentParams?.doesNotHave ?? "")
const [sizeVal, setSizeVal] = useState(currentParams?.size ?? "")
const [sizeOp, setSizeOp] = useState<"gt" | "lt">(currentParams?.sizeOp ?? "gt")
const [sizeUnit, setSizeUnit] = useState<"Mo" | "Ko">(currentParams?.sizeUnit ?? "Mo")
const [within, setWithin] = useState(currentParams?.within ?? "")
const [dateAfter, setDateAfter] = useState(currentParams?.after ?? "")
const [searchIn, setSearchIn] = useState(currentParams?.in ?? "all")
const [hasAttachment, setHasAttachment] = useState(
currentParams?.has?.includes("attachment") ?? false
)
const [excludeChats, setExcludeChats] = useState(currentParams?.excludeChats ?? false)
const buildParams = (): Partial<SearchParams> => ({
...EMPTY_SEARCH_PARAMS,
q: "",
from,
to,
subject,
hasWords,
doesNotHave,
size: sizeVal,
sizeOp,
sizeUnit,
within,
after: dateAfter,
in: searchIn,
has: hasAttachment ? ["attachment"] : [],
excludeChats,
})
return {
from,
setFrom,
to,
setTo,
subject,
setSubject,
hasWords,
setHasWords,
doesNotHave,
setDoesNotHave,
sizeVal,
setSizeVal,
sizeOp,
setSizeOp,
sizeUnit,
setSizeUnit,
within,
setWithin,
dateAfter,
setDateAfter,
searchIn,
setSearchIn,
hasAttachment,
setHasAttachment,
excludeChats,
setExcludeChats,
buildParams,
}
}

File diff suppressed because one or more lines are too long