refactoed and sidebar better
This commit is contained in:
parent
8551150ffe
commit
c36793e440
@ -429,6 +429,58 @@ html[data-mail-background]:not([data-mail-background='none'])
|
||||
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.
|
||||
* Nom hors préfixe bg-* pour éviter qu’un utility Tailwind écrase la règle.
|
||||
|
||||
@ -119,14 +119,13 @@ function MailAppInner() {
|
||||
onClick={() => setSidebarCollapsed(true)}
|
||||
/>
|
||||
)}
|
||||
{/* xs: overlay (w-0). sm+: spacer matches rail; hover-expand can grow over main without shifting layout */}
|
||||
<div
|
||||
className={
|
||||
touchNav && isXs
|
||||
? "w-0 shrink-0"
|
||||
: touchNav || sidebarCollapsed
|
||||
? "w-0 shrink-0 sm:w-[68px]"
|
||||
: "w-0 shrink-0 sm:w-60"
|
||||
}
|
||||
className={cn(
|
||||
"shrink-0 transition-[width] duration-200 ease-linear",
|
||||
isXs ? "w-0" : sidebarCollapsed ? "w-[68px]" : "w-60"
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<Sidebar
|
||||
selectedFolder={route.folderId}
|
||||
|
||||
55
components/gmail/README.md
Normal file
55
components/gmail/README.md
Normal file
@ -0,0 +1,55 @@
|
||||
# Composants Gmail — arborescence
|
||||
|
||||
Point d’entré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 d’un message
|
||||
├── mail-search/ # Recherche avancée (desktop + mobile)
|
||||
├── sidebar/ # Navigation latérale
|
||||
├── contacts/ # Carnet d’adresses
|
||||
├── 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 d’outils
|
||||
- `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`
|
||||
@ -1,815 +1 @@
|
||||
"use client"
|
||||
|
||||
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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
export { ComposeWindow, ComposeModalManager } from "./compose"
|
||||
|
||||
533
components/gmail/compose/compose-bottom-toolbar.tsx
Normal file
533
components/gmail/compose/compose-bottom-toolbar.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
79
components/gmail/compose/compose-emoji-picker.tsx
Normal file
79
components/gmail/compose/compose-emoji-picker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
277
components/gmail/compose/compose-formatting-dropdowns.tsx
Normal file
277
components/gmail/compose/compose-formatting-dropdowns.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
151
components/gmail/compose/compose-formatting-toolbar.tsx
Normal file
151
components/gmail/compose/compose-formatting-toolbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
115
components/gmail/compose/compose-modal-manager.tsx
Normal file
115
components/gmail/compose/compose-modal-manager.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,994 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
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'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>
|
||||
)
|
||||
}
|
||||
export { FormattingToolbar } from "./compose-formatting-toolbar"
|
||||
export { ComposeBottomToolbar } from "./compose-bottom-toolbar"
|
||||
export type { ComposeBottomToolbarProps } from "./compose-bottom-toolbar"
|
||||
|
||||
249
components/gmail/compose/compose-window.tsx
Normal file
249
components/gmail/compose/compose-window.tsx
Normal 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
|
||||
}
|
||||
2
components/gmail/compose/index.ts
Normal file
2
components/gmail/compose/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { ComposeWindow } from "./compose-window"
|
||||
export { ComposeModalManager } from "./compose-modal-manager"
|
||||
549
components/gmail/compose/use-compose-window.ts
Normal file
549
components/gmail/compose/use-compose-window.ts
Normal 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
274
components/gmail/email-list/email-list-body.tsx
Normal file
274
components/gmail/email-list/email-list-body.tsx
Normal 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 }
|
||||
70
components/gmail/email-list/email-list-email-view-pane.tsx
Normal file
70
components/gmail/email-list/email-list-email-view-pane.tsx
Normal 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 }
|
||||
135
components/gmail/email-list/email-list-empty.tsx
Normal file
135
components/gmail/email-list/email-list-empty.tsx
Normal 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'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 « Envois programmés » seront envoyés à l'heure prévue pour chacun d'eux.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
212
components/gmail/email-list/email-list-layout.tsx
Normal file
212
components/gmail/email-list/email-list-layout.tsx
Normal 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 }
|
||||
1622
components/gmail/email-list/email-list-row.tsx
Normal file
1622
components/gmail/email-list/email-list-row.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1344
components/gmail/email-list/email-list-toolbar.tsx
Normal file
1344
components/gmail/email-list/email-list-toolbar.tsx
Normal file
File diff suppressed because it is too large
Load Diff
27
components/gmail/email-list/email-list.tsx
Normal file
27
components/gmail/email-list/email-list.tsx
Normal 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"
|
||||
785
components/gmail/email-list/hooks/use-email-list-data.ts
Normal file
785
components/gmail/email-list/hooks/use-email-list-data.ts
Normal 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>
|
||||
290
components/gmail/email-list/hooks/use-email-list-labels.ts
Normal file
290
components/gmail/email-list/hooks/use-email-list-labels.ts
Normal 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>
|
||||
564
components/gmail/email-list/hooks/use-email-list-reading.ts
Normal file
564
components/gmail/email-list/hooks/use-email-list-reading.ts
Normal 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>
|
||||
337
components/gmail/email-list/hooks/use-email-list-selection.ts
Normal file
337
components/gmail/email-list/hooks/use-email-list-selection.ts
Normal 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>
|
||||
@ -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"
|
||||
|
||||
@ -4,7 +4,7 @@ import { useCallback } from "react"
|
||||
import type { Email } from "@/lib/email-data"
|
||||
import { useMailStore } from "@/lib/stores/mail-store"
|
||||
|
||||
type ListMailIndex = {
|
||||
export type ListMailIndex = {
|
||||
emailById: Map<string, Email>
|
||||
scheduledIds: Set<string>
|
||||
}
|
||||
@ -8,17 +8,7 @@ import {
|
||||
useState,
|
||||
type CSSProperties,
|
||||
} from "react"
|
||||
import {
|
||||
Star,
|
||||
Reply,
|
||||
ReplyAll,
|
||||
Forward,
|
||||
Info,
|
||||
HardDrive,
|
||||
File,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
} from "lucide-react"
|
||||
import { Star, Reply, ReplyAll, Forward } from "lucide-react"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@ -31,19 +21,8 @@ import {
|
||||
cleanSenderName,
|
||||
senderInitial,
|
||||
} from "@/lib/sender-display"
|
||||
import { MailDateText } from "@/components/gmail/mail-date-text"
|
||||
import type {
|
||||
Email,
|
||||
ConversationMessage,
|
||||
EmailAttachment,
|
||||
EmailAttachmentKind,
|
||||
} from "@/lib/email-data"
|
||||
import type { Email, EmailAttachment } from "@/lib/email-data"
|
||||
import type { FolderTreeNode, LabelRowItem } from "@/lib/sidebar-nav-data"
|
||||
import {
|
||||
attachmentPreviewTooltip,
|
||||
resolveAttachmentKind,
|
||||
shouldUseAttachmentPillsInPreview,
|
||||
} from "@/lib/attachment-display"
|
||||
import {
|
||||
useComposeActions,
|
||||
useComposeDrafts,
|
||||
@ -61,23 +40,17 @@ import { openConversationPrint } from "@/lib/print-conversation"
|
||||
import { resolveParsedCalendarInvitation } from "@/lib/resolve-email-calendar-invitation"
|
||||
import { ComposeWindow } from "@/components/gmail/compose-modal"
|
||||
import { CalendarInvitationPreview } from "@/components/gmail/calendar-invitation-preview"
|
||||
import { ContactHoverCard } from "./contact-hover-card"
|
||||
import { EmailViewSubjectHeader } from "./email-view/email-view-header"
|
||||
import { EmailViewMessageToolbar } from "./email-view/email-view-toolbar"
|
||||
import {
|
||||
MAIL_MESSAGE_HOVER_CLASS,
|
||||
MAIL_PREVIEW_SCROLL_CLASS,
|
||||
MAIL_REPLY_BAR_CLASS,
|
||||
MAIL_REPLY_BUTTON_CLASS,
|
||||
MAIL_TOOLTIP_CONTENT_CLASS,
|
||||
} from "@/lib/mail-chrome-classes"
|
||||
import { useTheme } from "next-themes"
|
||||
import {
|
||||
emailPreviewBaseCss,
|
||||
emailPreviewDarkOverrideCss,
|
||||
emailPreviewLightOverrideCss,
|
||||
preprocessEmailHtmlForTheme,
|
||||
} from "@/lib/email-preview-dark-styles"
|
||||
CollapsedMessage,
|
||||
ExpandedMessage,
|
||||
SpamWhyBanner,
|
||||
} from "@/components/gmail/email-view/email-view-messages"
|
||||
|
||||
interface EmailViewProps {
|
||||
email: Email
|
||||
@ -101,400 +74,6 @@ interface EmailViewProps {
|
||||
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'améliorer.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Main EmailView component ── */
|
||||
|
||||
export function EmailView({
|
||||
|
||||
172
components/gmail/email-view/email-view-messages.tsx
Normal file
172
components/gmail/email-view/email-view-messages.tsx
Normal 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'améliorer.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
173
components/gmail/email-view/message-attachments.tsx
Normal file
173
components/gmail/email-view/message-attachments.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
99
components/gmail/email-view/sandboxed-content.tsx
Normal file
99
components/gmail/email-view/sandboxed-content.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -18,16 +18,6 @@ import {
|
||||
User,
|
||||
} from "lucide-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 { emails } from "@/lib/email-data"
|
||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||
@ -40,24 +30,13 @@ import {
|
||||
type SearchSuggestion,
|
||||
} from "@/lib/mail-search/search-engine"
|
||||
import {
|
||||
buildSearchUrl,
|
||||
parseSearchParams,
|
||||
EMPTY_SEARCH_PARAMS,
|
||||
DATE_RANGE_OPTIONS,
|
||||
SEARCH_IN_OPTIONS,
|
||||
type SearchParams,
|
||||
} from "@/lib/mail-search/search-params"
|
||||
import {
|
||||
buildQuickSearchParams,
|
||||
submitMailSearch,
|
||||
} from "@/lib/mail-search/navigate"
|
||||
import {
|
||||
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 { MAIL_SEARCH_SUGGESTIONS_DROPDOWN_CLASS } from "@/lib/mail-chrome-classes"
|
||||
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
||||
|
||||
interface MailSearchBarProps {
|
||||
@ -65,220 +44,7 @@ interface MailSearchBarProps {
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
// ─── 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>
|
||||
)
|
||||
}
|
||||
import { AdvancedSearchPanel } from "@/components/gmail/mail-search/advanced-search-panel"
|
||||
|
||||
// ─── Main Search Bar ─────────────────────────────────────────────────────────
|
||||
|
||||
@ -454,10 +220,10 @@ export function MailSearchBar({
|
||||
className={cn("relative flex w-full min-w-0 flex-col overflow-visible", className)}
|
||||
>
|
||||
{/* 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
|
||||
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"
|
||||
)}
|
||||
>
|
||||
@ -491,7 +257,7 @@ export function MailSearchBar({
|
||||
onBlur={() => setFocused(false)}
|
||||
onKeyDown={handleKeyDown}
|
||||
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
|
||||
? "bg-white shadow-md ring-1 ring-gray-300 dark:bg-gray-900 dark:ring-gray-600"
|
||||
: "",
|
||||
|
||||
321
components/gmail/mail-search/advanced-search-fields.tsx
Normal file
321
components/gmail/mail-search/advanced-search-fields.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
28
components/gmail/mail-search/advanced-search-panel.tsx
Normal file
28
components/gmail/mail-search/advanced-search-panel.tsx
Normal 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} />
|
||||
)
|
||||
}
|
||||
21
components/gmail/mail-search/mobile-advanced-search.tsx
Normal file
21
components/gmail/mail-search/mobile-advanced-search.tsx
Normal 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} />
|
||||
}
|
||||
@ -20,16 +20,6 @@ import {
|
||||
} from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
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 { emails } from "@/lib/email-data"
|
||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||
@ -40,13 +30,6 @@ import {
|
||||
bestCompletion,
|
||||
type SearchSuggestion,
|
||||
} 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 {
|
||||
buildQuickSearchParams,
|
||||
submitMailSearch,
|
||||
@ -55,12 +38,11 @@ import { useMailSearchStore } from "@/lib/stores/mail-search-store"
|
||||
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
||||
import {
|
||||
MAIL_MOBILE_SEARCH_SHEET_CLASS,
|
||||
MAIL_SEARCH_CHECKBOX_CLASS,
|
||||
MAIL_SEARCH_CHIP_INACTIVE_CLASS,
|
||||
MAIL_SEARCH_FIELD_CLASS,
|
||||
MAIL_SEARCH_SECTION_DIVIDER_CLASS,
|
||||
MAIL_SEARCH_SUGGESTIONS_SURFACE_CLASS,
|
||||
} from "@/lib/mail-chrome-classes"
|
||||
import { MobileAdvancedSearch } from "@/components/gmail/mail-search/mobile-advanced-search"
|
||||
|
||||
interface MobileSearchOverlayProps {
|
||||
open: boolean
|
||||
@ -394,176 +376,3 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
|
||||
</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
@ -15,8 +15,8 @@ import {
|
||||
import {
|
||||
SidebarNavOptionsSheet,
|
||||
SidebarNavSheetAction,
|
||||
} from "@/components/gmail/sidebar-nav-options-sheet"
|
||||
import { useSidebarTouchOptionsMenu } from "@/components/gmail/use-sidebar-touch-options"
|
||||
} from "@/components/gmail/sidebar/sidebar-nav-options-sheet"
|
||||
import { useSidebarTouchOptionsMenu } from "@/components/gmail/sidebar/use-sidebar-touch-options"
|
||||
import type { CategoryNavSourceItem } from "@/components/gmail/sidebar/sidebar-nav-constants"
|
||||
import {
|
||||
navRowRoundedWhenActive,
|
||||
|
||||
2
components/gmail/sidebar/index.ts
Normal file
2
components/gmail/sidebar/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { Sidebar } from "@/components/gmail/sidebar/sidebar"
|
||||
export type { SidebarProps } from "@/components/gmail/sidebar/use-sidebar-state"
|
||||
176
components/gmail/sidebar/sidebar-create-dialogs.tsx
Normal file
176
components/gmail/sidebar/sidebar-create-dialogs.tsx
Normal 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 l’emplacement (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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
65
components/gmail/sidebar/sidebar-folder-button-collapsed.tsx
Normal file
65
components/gmail/sidebar/sidebar-folder-button-collapsed.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
842
components/gmail/sidebar/sidebar-folder-row-expanded.tsx
Normal file
842
components/gmail/sidebar/sidebar-folder-row-expanded.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
94
components/gmail/sidebar/sidebar-folder-tree.tsx
Normal file
94
components/gmail/sidebar/sidebar-folder-tree.tsx
Normal 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)
|
||||
}
|
||||
101
components/gmail/sidebar/sidebar-header.tsx
Normal file
101
components/gmail/sidebar/sidebar-header.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
694
components/gmail/sidebar/sidebar-label-item-row.tsx
Normal file
694
components/gmail/sidebar/sidebar-label-item-row.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -76,3 +76,21 @@ export type CategoryNavSourceItem = {
|
||||
label: 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
|
||||
}
|
||||
|
||||
26
components/gmail/sidebar/sidebar-nav-drag-bindings.ts
Normal file
26
components/gmail/sidebar/sidebar-nav-drag-bindings.ts
Normal 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
|
||||
}
|
||||
75
components/gmail/sidebar/sidebar-nav-item.tsx
Normal file
75
components/gmail/sidebar/sidebar-nav-item.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
303
components/gmail/sidebar/sidebar-nav-panel.tsx
Normal file
303
components/gmail/sidebar/sidebar-nav-panel.tsx
Normal 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 d’entrées"
|
||||
: "Plus d’entré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>
|
||||
)
|
||||
}
|
||||
78
components/gmail/sidebar/sidebar.tsx
Normal file
78
components/gmail/sidebar/sidebar.tsx
Normal 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 }
|
||||
381
components/gmail/sidebar/use-sidebar-state.ts
Normal file
381
components/gmail/sidebar/use-sidebar-state.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -260,11 +260,16 @@ export const MAIL_MOBILE_SEARCH_SHEET_CLASS = cn(
|
||||
"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(
|
||||
"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). */
|
||||
export const MAIL_SIDEBAR_PANEL_SURFACE_MOBILE_CLASS = "mail-sidebar-overlay-panel"
|
||||
|
||||
|
||||
78
lib/mail-search/use-advanced-search-form.ts
Normal file
78
lib/mail-search/use-advanced-search-form.ts
Normal 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
Loading…
Reference in New Issue
Block a user