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);
|
background-color: var(--mail-invitation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sidebar frosted strips — backdrop blur hides children scrolling behind sticky parents (sm+).
|
||||||
|
* Background stays transparent so there is no tint — the blur alone conceals the text.
|
||||||
|
*/
|
||||||
|
.ultimail-app .mail-sidebar-blur-surface {
|
||||||
|
background-color: transparent;
|
||||||
|
-webkit-backdrop-filter: blur(24px) saturate(150%);
|
||||||
|
backdrop-filter: blur(24px) saturate(150%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hover-expanded sidebar frosted panel — uses ::before so it doesn't create
|
||||||
|
* a backdrop-filter stacking context that would clip children's backdrop-filters.
|
||||||
|
*/
|
||||||
|
.ultimail-app .mail-sidebar-hover-frosted::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
-webkit-backdrop-filter: blur(24px) saturate(150%);
|
||||||
|
backdrop-filter: blur(24px) saturate(150%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sticky sidebar strip — blur spans full rail width (not just indented label).
|
||||||
|
* Set --sidebar-sticky-pad-left on the element (px indent of row content).
|
||||||
|
*/
|
||||||
|
.ultimail-app .mail-sidebar-blur-sticky-strip {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ultimail-app .mail-sidebar-blur-sticky-strip::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: calc(-1 * var(--sidebar-sticky-pad-left, 0px));
|
||||||
|
right: calc(-1 * var(--sidebar-nav-rail-inset, 14px));
|
||||||
|
z-index: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
-webkit-backdrop-filter: blur(24px) saturate(150%);
|
||||||
|
backdrop-filter: blur(24px) saturate(150%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ultimail-app .mail-sidebar-blur-sticky-strip > * {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sidebar overlay (touch / xs) — fond opaque.
|
* Sidebar overlay (touch / xs) — fond opaque.
|
||||||
* Nom hors préfixe bg-* pour éviter qu’un utility Tailwind écrase la règle.
|
* Nom hors préfixe bg-* pour éviter qu’un utility Tailwind écrase la règle.
|
||||||
|
|||||||
@ -119,14 +119,13 @@ function MailAppInner() {
|
|||||||
onClick={() => setSidebarCollapsed(true)}
|
onClick={() => setSidebarCollapsed(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* xs: overlay (w-0). sm+: spacer matches rail; hover-expand can grow over main without shifting layout */}
|
||||||
<div
|
<div
|
||||||
className={
|
className={cn(
|
||||||
touchNav && isXs
|
"shrink-0 transition-[width] duration-200 ease-linear",
|
||||||
? "w-0 shrink-0"
|
isXs ? "w-0" : sidebarCollapsed ? "w-[68px]" : "w-60"
|
||||||
: touchNav || sidebarCollapsed
|
)}
|
||||||
? "w-0 shrink-0 sm:w-[68px]"
|
aria-hidden
|
||||||
: "w-0 shrink-0 sm:w-60"
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
selectedFolder={route.folderId}
|
selectedFolder={route.folderId}
|
||||||
|
|||||||
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"
|
export { ComposeWindow, ComposeModalManager } from "./compose"
|
||||||
|
|
||||||
import {
|
|
||||||
useState,
|
|
||||||
useRef,
|
|
||||||
useEffect,
|
|
||||||
useLayoutEffect,
|
|
||||||
useCallback,
|
|
||||||
useMemo,
|
|
||||||
} from "react"
|
|
||||||
import { useIsXs } from "@/hooks/use-xs"
|
|
||||||
import { readCoarsePointerMatches } from "@/hooks/use-touch-nav"
|
|
||||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
|
|
||||||
import { useEditor, EditorContent } from "@tiptap/react"
|
|
||||||
import { type Extensions } from "@tiptap/core"
|
|
||||||
import StarterKit from "@tiptap/starter-kit"
|
|
||||||
import Underline from "@tiptap/extension-underline"
|
|
||||||
import Link from "@tiptap/extension-link"
|
|
||||||
import TextAlign from "@tiptap/extension-text-align"
|
|
||||||
import { TextStyle, FontFamily, FontSize, BackgroundColor } from "@tiptap/extension-text-style"
|
|
||||||
import Color from "@tiptap/extension-color"
|
|
||||||
import {
|
|
||||||
Reply,
|
|
||||||
ReplyAll,
|
|
||||||
Forward,
|
|
||||||
Maximize2,
|
|
||||||
Minimize2,
|
|
||||||
X,
|
|
||||||
} from "lucide-react"
|
|
||||||
import {
|
|
||||||
type ComposeState,
|
|
||||||
cloneComposeForPendingSend,
|
|
||||||
DEFAULT_IDENTITIES,
|
|
||||||
useComposeActions,
|
|
||||||
useComposeWindows,
|
|
||||||
} from "@/lib/compose-context"
|
|
||||||
import { useScheduledMail } from "@/lib/scheduled-mail-context"
|
|
||||||
import type { ScheduleSendPayload } from "@/lib/api/scheduled-mail"
|
|
||||||
import type { Email } from "@/lib/email-data"
|
|
||||||
import {
|
|
||||||
buildThreadComposePreset,
|
|
||||||
collectThreadParticipants,
|
|
||||||
} from "@/lib/thread-compose-preset"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
import { showPendingSendToast } from "@/lib/pending-send-toast"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import {
|
|
||||||
MAIL_COMPOSE_TITLEBAR_CLASS,
|
|
||||||
MAIL_ICON_BTN,
|
|
||||||
} from "@/lib/mail-chrome-classes"
|
|
||||||
import { ComposeRecipientFields } from "@/components/gmail/compose/compose-recipients"
|
|
||||||
import {
|
|
||||||
ComposeBottomToolbar,
|
|
||||||
FormattingToolbar,
|
|
||||||
} from "@/components/gmail/compose/compose-toolbar"
|
|
||||||
import {
|
|
||||||
ComposeAttachmentsList,
|
|
||||||
ComposeDockTitleBar,
|
|
||||||
ComposeDropOverlay,
|
|
||||||
ComposeInlineRecipientHeader,
|
|
||||||
ComposeXsSheetHeader,
|
|
||||||
} from "@/components/gmail/compose/compose-editor-chrome"
|
|
||||||
import { SignatureBlock, stripSignature, insertSignatureHtml } from "@/components/gmail/compose/compose-shared"
|
|
||||||
|
|
||||||
export function ComposeWindow({
|
|
||||||
compose,
|
|
||||||
threadSourceEmail = null,
|
|
||||||
isXsSheet = false,
|
|
||||||
bindXsSheetClose,
|
|
||||||
}: {
|
|
||||||
compose: ComposeState
|
|
||||||
/** Fil courant : nécessaire pour le menu Répondre / Transférer en inline */
|
|
||||||
threadSourceEmail?: Email | null
|
|
||||||
/** Plein écran dans une bottom sheet (xs) — pas de file ni réduction */
|
|
||||||
isXsSheet?: boolean
|
|
||||||
bindXsSheetClose?: (fn: (() => void) | null) => void
|
|
||||||
}) {
|
|
||||||
const {
|
|
||||||
closeCompose,
|
|
||||||
updateCompose,
|
|
||||||
applyComposePreset,
|
|
||||||
toggleMinimize,
|
|
||||||
toggleMaximize,
|
|
||||||
restoreComposeFromSnapshot,
|
|
||||||
} = useComposeActions()
|
|
||||||
const { scheduleSend, requestUpdateScheduledSend, requestSendScheduledNow } =
|
|
||||||
useScheduledMail()
|
|
||||||
const isInline = compose.placement === "inline"
|
|
||||||
const isEditingScheduled = compose.editingScheduledId != null
|
|
||||||
const [showFormatting, setShowFormatting] = useState(false)
|
|
||||||
const [recipientsFocused, setRecipientsFocused] = useState(false)
|
|
||||||
const [sendMenuOpen, setSendMenuOpen] = useState(false)
|
|
||||||
const [isDragOver, setIsDragOver] = useState(false)
|
|
||||||
const fieldsRef = useRef<HTMLDivElement>(null)
|
|
||||||
const inlineRecipientShellRef = useRef<HTMLDivElement>(null)
|
|
||||||
const subjectInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
const imageInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
|
|
||||||
const snapBodyCaretToStartOnHeaderTab =
|
|
||||||
!isInline &&
|
|
||||||
!compose.threadEmailId &&
|
|
||||||
!compose.threadKind &&
|
|
||||||
!compose.editingScheduledId
|
|
||||||
|
|
||||||
const editor = useEditor({
|
|
||||||
immediatelyRender: false,
|
|
||||||
extensions: [
|
|
||||||
StarterKit,
|
|
||||||
Underline,
|
|
||||||
Link.configure({ openOnClick: false }),
|
|
||||||
TextStyle,
|
|
||||||
Color,
|
|
||||||
BackgroundColor,
|
|
||||||
FontFamily,
|
|
||||||
FontSize,
|
|
||||||
TextAlign.configure({ types: ["heading", "paragraph"], alignments: ["left", "center", "right", "justify"] }),
|
|
||||||
SignatureBlock,
|
|
||||||
] as Extensions,
|
|
||||||
content: compose.bodyHtml,
|
|
||||||
onUpdate: ({ editor: ed }) => {
|
|
||||||
updateCompose(compose.id, { bodyHtml: ed.getHTML() })
|
|
||||||
},
|
|
||||||
onFocus: ({ editor: ed, event }) => {
|
|
||||||
if (!snapBodyCaretToStartOnHeaderTab) return
|
|
||||||
const rt = event.relatedTarget as Node | null
|
|
||||||
if (!rt || !fieldsRef.current?.contains(rt)) return
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
if (!ed.view.hasFocus()) return
|
|
||||||
try {
|
|
||||||
ed.chain().setTextSelection(1).run()
|
|
||||||
} catch {
|
|
||||||
/* empty doc edge */
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
editorProps: {
|
|
||||||
attributes: {
|
|
||||||
class: cn(
|
|
||||||
"prose prose-sm max-w-none px-3 py-2 text-sm text-foreground outline-none focus:outline-none",
|
|
||||||
isInline
|
|
||||||
? "min-h-[200px]"
|
|
||||||
: isXsSheet
|
|
||||||
? "min-h-[min(36vh,280px)]"
|
|
||||||
: "min-h-[150px]"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const titleText = compose.subject || "Nouveau message"
|
|
||||||
const bodyWithoutSig = stripSignature(compose.bodyHtml)
|
|
||||||
.replace(/<p><\/p>/g, "")
|
|
||||||
.trim()
|
|
||||||
const hasContent =
|
|
||||||
compose.subject.trim() !== "" ||
|
|
||||||
compose.to.length > 0 ||
|
|
||||||
compose.cc.length > 0 ||
|
|
||||||
compose.bcc.length > 0 ||
|
|
||||||
compose.attachments.length > 0 ||
|
|
||||||
bodyWithoutSig !== ""
|
|
||||||
|
|
||||||
const handleIdentityChange = useCallback(
|
|
||||||
(identity: (typeof DEFAULT_IDENTITIES)[number]) => {
|
|
||||||
if (compose.autoInsertSignature && editor) {
|
|
||||||
const sigId = identity.defaultSignatureId
|
|
||||||
const newHtml = insertSignatureHtml(editor.getHTML(), sigId)
|
|
||||||
editor.commands.setContent(newHtml)
|
|
||||||
updateCompose(compose.id, { from: identity, bodyHtml: newHtml, signatureId: sigId })
|
|
||||||
} else {
|
|
||||||
updateCompose(compose.id, { from: identity })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[compose.id, compose.autoInsertSignature, editor, updateCompose]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
const threadInlineDiscard =
|
|
||||||
isInline && compose.threadEmailId
|
|
||||||
? ({ discardThreadReplyDraft: true } as const)
|
|
||||||
: undefined
|
|
||||||
if (!hasContent) {
|
|
||||||
closeCompose(compose.id, threadInlineDiscard)
|
|
||||||
} else {
|
|
||||||
updateCompose(compose.id, { savedAt: Date.now() })
|
|
||||||
closeCompose(compose.id, threadInlineDiscard)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCloseRef = useRef(handleClose)
|
|
||||||
handleCloseRef.current = handleClose
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!isXsSheet || !bindXsSheetClose) return
|
|
||||||
bindXsSheetClose(() => {
|
|
||||||
handleCloseRef.current()
|
|
||||||
})
|
|
||||||
return () => bindXsSheetClose(null)
|
|
||||||
}, [isXsSheet, bindXsSheetClose, compose.id])
|
|
||||||
|
|
||||||
const htmlToPreviewText = useCallback((html: string) => {
|
|
||||||
return html
|
|
||||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, " ")
|
|
||||||
.replace(/<[^>]+>/g, " ")
|
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.trim()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleSend = useCallback(() => {
|
|
||||||
if (compose.to.length === 0) return
|
|
||||||
const bodyHtml = editor?.getHTML() ?? compose.bodyHtml
|
|
||||||
const snapshot = cloneComposeForPendingSend({ ...compose, bodyHtml })
|
|
||||||
closeCompose(compose.id, { sent: true })
|
|
||||||
showPendingSendToast({
|
|
||||||
onCommit: async () => {},
|
|
||||||
onCancel: () => restoreComposeFromSnapshot(snapshot),
|
|
||||||
})
|
|
||||||
}, [
|
|
||||||
closeCompose,
|
|
||||||
compose,
|
|
||||||
editor,
|
|
||||||
restoreComposeFromSnapshot,
|
|
||||||
])
|
|
||||||
|
|
||||||
const submitScheduledSendAt = useCallback(
|
|
||||||
async (sendAt: Date) => {
|
|
||||||
if (isEditingScheduled) return
|
|
||||||
if (compose.to.length === 0) return
|
|
||||||
setSendMenuOpen(false)
|
|
||||||
const bodyHtml = editor?.getHTML() ?? compose.bodyHtml
|
|
||||||
await scheduleSend({
|
|
||||||
sendAtIso: sendAt.toISOString(),
|
|
||||||
to: compose.to.map((c) => ({ name: c.name, email: c.email })),
|
|
||||||
subject: compose.subject,
|
|
||||||
previewText: htmlToPreviewText(bodyHtml).slice(0, 500),
|
|
||||||
bodyHtml,
|
|
||||||
})
|
|
||||||
const whenLabel = sendAt.toLocaleString("fr-FR", {
|
|
||||||
dateStyle: "medium",
|
|
||||||
timeStyle: "short",
|
|
||||||
})
|
|
||||||
toast.message(`Ce mail sera envoyé le ${whenLabel}`)
|
|
||||||
closeCompose(compose.id, { sent: true })
|
|
||||||
},
|
|
||||||
[
|
|
||||||
isEditingScheduled,
|
|
||||||
compose.bodyHtml,
|
|
||||||
compose.id,
|
|
||||||
compose.subject,
|
|
||||||
compose.to,
|
|
||||||
closeCompose,
|
|
||||||
editor,
|
|
||||||
htmlToPreviewText,
|
|
||||||
scheduleSend,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
const buildSchedulePayload = useCallback(
|
|
||||||
(sendAtIso: string): ScheduleSendPayload | null => {
|
|
||||||
if (compose.to.length === 0) return null
|
|
||||||
const bodyHtml = editor?.getHTML() ?? compose.bodyHtml
|
|
||||||
return {
|
|
||||||
sendAtIso,
|
|
||||||
to: compose.to.map((c) => ({ name: c.name, email: c.email })),
|
|
||||||
subject: compose.subject,
|
|
||||||
previewText: htmlToPreviewText(bodyHtml).slice(0, 500),
|
|
||||||
bodyHtml,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[compose.to, compose.subject, compose.bodyHtml, editor, htmlToPreviewText]
|
|
||||||
)
|
|
||||||
|
|
||||||
const saveScheduledEdit = useCallback(async () => {
|
|
||||||
const id = compose.editingScheduledId
|
|
||||||
if (!id) return
|
|
||||||
const iso =
|
|
||||||
compose.scheduledSendAtIso ?? new Date().toISOString()
|
|
||||||
const payload = buildSchedulePayload(iso)
|
|
||||||
if (!payload) return
|
|
||||||
await requestUpdateScheduledSend(id, payload)
|
|
||||||
toast.message("Modifications enregistrées")
|
|
||||||
closeCompose(compose.id)
|
|
||||||
}, [
|
|
||||||
buildSchedulePayload,
|
|
||||||
closeCompose,
|
|
||||||
compose.editingScheduledId,
|
|
||||||
compose.id,
|
|
||||||
compose.scheduledSendAtIso,
|
|
||||||
requestUpdateScheduledSend,
|
|
||||||
])
|
|
||||||
|
|
||||||
const sendScheduledFromEditNow = useCallback(async () => {
|
|
||||||
const id = compose.editingScheduledId
|
|
||||||
if (!id) return
|
|
||||||
setSendMenuOpen(false)
|
|
||||||
const bodyHtml = editor?.getHTML() ?? compose.bodyHtml
|
|
||||||
const snapshot = cloneComposeForPendingSend({ ...compose, bodyHtml })
|
|
||||||
closeCompose(compose.id, { sent: true })
|
|
||||||
showPendingSendToast({
|
|
||||||
onCommit: async () => {
|
|
||||||
const schedId = snapshot.editingScheduledId
|
|
||||||
if (!schedId || snapshot.to.length === 0) return
|
|
||||||
const iso = snapshot.scheduledSendAtIso ?? new Date().toISOString()
|
|
||||||
const body = snapshot.bodyHtml
|
|
||||||
const payload = {
|
|
||||||
sendAtIso: iso,
|
|
||||||
to: snapshot.to.map((c) => ({ name: c.name, email: c.email })),
|
|
||||||
subject: snapshot.subject,
|
|
||||||
previewText: htmlToPreviewText(body).slice(0, 500),
|
|
||||||
bodyHtml: body,
|
|
||||||
}
|
|
||||||
await requestUpdateScheduledSend(schedId, payload)
|
|
||||||
await requestSendScheduledNow(schedId)
|
|
||||||
},
|
|
||||||
onCancel: () => restoreComposeFromSnapshot(snapshot),
|
|
||||||
})
|
|
||||||
}, [
|
|
||||||
closeCompose,
|
|
||||||
compose,
|
|
||||||
editor,
|
|
||||||
htmlToPreviewText,
|
|
||||||
requestSendScheduledNow,
|
|
||||||
requestUpdateScheduledSend,
|
|
||||||
restoreComposeFromSnapshot,
|
|
||||||
])
|
|
||||||
|
|
||||||
const applyScheduledPlanAt = useCallback(
|
|
||||||
async (sendAt: Date) => {
|
|
||||||
const id = compose.editingScheduledId
|
|
||||||
if (!id) return
|
|
||||||
setSendMenuOpen(false)
|
|
||||||
const iso = sendAt.toISOString()
|
|
||||||
const payload = buildSchedulePayload(iso)
|
|
||||||
if (!payload) return
|
|
||||||
await requestUpdateScheduledSend(id, payload)
|
|
||||||
updateCompose(compose.id, { scheduledSendAtIso: iso })
|
|
||||||
const whenLabel = sendAt.toLocaleString("fr-FR", {
|
|
||||||
dateStyle: "medium",
|
|
||||||
timeStyle: "short",
|
|
||||||
})
|
|
||||||
toast.message(`Envoi planifié le ${whenLabel}`)
|
|
||||||
},
|
|
||||||
[
|
|
||||||
buildSchedulePayload,
|
|
||||||
compose.editingScheduledId,
|
|
||||||
compose.id,
|
|
||||||
requestUpdateScheduledSend,
|
|
||||||
updateCompose,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
const addFiles = useCallback((files: FileList | File[]) => {
|
|
||||||
const newAttachments = Array.from(files).map((file) => ({
|
|
||||||
id: `att-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
||||||
file,
|
|
||||||
name: file.name,
|
|
||||||
size: file.size,
|
|
||||||
type: file.type,
|
|
||||||
}))
|
|
||||||
updateCompose(compose.id, {
|
|
||||||
attachments: [...compose.attachments, ...newAttachments],
|
|
||||||
})
|
|
||||||
}, [compose.id, compose.attachments, updateCompose])
|
|
||||||
|
|
||||||
const removeAttachment = useCallback((attId: string) => {
|
|
||||||
updateCompose(compose.id, {
|
|
||||||
attachments: compose.attachments.filter((a) => a.id !== attId),
|
|
||||||
})
|
|
||||||
}, [compose.id, compose.attachments, updateCompose])
|
|
||||||
|
|
||||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
setIsDragOver(false)
|
|
||||||
if (e.dataTransfer.files.length > 0) {
|
|
||||||
addFiles(e.dataTransfer.files)
|
|
||||||
}
|
|
||||||
}, [addFiles])
|
|
||||||
|
|
||||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
setIsDragOver(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) {
|
|
||||||
setIsDragOver(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const showFromField = recipientsFocused || isXsSheet
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!isInline || !compose.focusToOnMount) return
|
|
||||||
setRecipientsFocused(true)
|
|
||||||
}, [isInline, compose.focusToOnMount])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!recipientsFocused) return
|
|
||||||
const handleClickOutside = (e: Event) => {
|
|
||||||
const target = e.target as Node
|
|
||||||
const root = isInline ? inlineRecipientShellRef.current : fieldsRef.current
|
|
||||||
if (root && !root.contains(target)) {
|
|
||||||
const el = e.target as HTMLElement | null
|
|
||||||
const portal = el?.closest?.(
|
|
||||||
"[data-radix-popper-content-wrapper], [data-radix-dropdown-menu-content], [data-slot='dropdown-menu-content'], [data-slot='popover-content']"
|
|
||||||
)
|
|
||||||
if (portal) return
|
|
||||||
setRecipientsFocused(false)
|
|
||||||
if (compose.showCc && compose.cc.length === 0) {
|
|
||||||
updateCompose(compose.id, { showCc: false })
|
|
||||||
}
|
|
||||||
if (compose.showBcc && compose.bcc.length === 0) {
|
|
||||||
updateCompose(compose.id, { showBcc: false })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("pointerdown", handleClickOutside)
|
|
||||||
return () => document.removeEventListener("pointerdown", handleClickOutside)
|
|
||||||
}, [
|
|
||||||
recipientsFocused,
|
|
||||||
isInline,
|
|
||||||
compose.showCc,
|
|
||||||
compose.showBcc,
|
|
||||||
compose.cc.length,
|
|
||||||
compose.bcc.length,
|
|
||||||
compose.id,
|
|
||||||
updateCompose,
|
|
||||||
])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editor || editor.isDestroyed) return
|
|
||||||
const next = compose.bodyHtml
|
|
||||||
if (editor.getHTML() === next) return
|
|
||||||
editor.commands.setContent(next, { emitUpdate: false })
|
|
||||||
}, [compose.bodyHtml, compose.threadKind, editor])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!compose.focusSubjectOnMount || isInline) return
|
|
||||||
const id = window.requestAnimationFrame(() => {
|
|
||||||
subjectInputRef.current?.focus()
|
|
||||||
updateCompose(compose.id, { focusSubjectOnMount: false })
|
|
||||||
})
|
|
||||||
return () => window.cancelAnimationFrame(id)
|
|
||||||
}, [compose.focusSubjectOnMount, isInline, compose.id, updateCompose])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!compose.focusBodyOnMount || !editor || editor.isDestroyed) return
|
|
||||||
let cancelled = false
|
|
||||||
const outer = window.requestAnimationFrame(() => {
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
if (cancelled || !editor || editor.isDestroyed) return
|
|
||||||
try {
|
|
||||||
editor.chain().focus().setTextSelection(1).run()
|
|
||||||
} catch {
|
|
||||||
editor.chain().focus().run()
|
|
||||||
}
|
|
||||||
updateCompose(compose.id, { focusBodyOnMount: false })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
window.cancelAnimationFrame(outer)
|
|
||||||
}
|
|
||||||
}, [compose.focusBodyOnMount, compose.id, editor, updateCompose])
|
|
||||||
|
|
||||||
const clearFocusToMount = useCallback(() => {
|
|
||||||
updateCompose(compose.id, { focusToOnMount: false })
|
|
||||||
}, [compose.id, updateCompose])
|
|
||||||
|
|
||||||
const ThreadKindIcon =
|
|
||||||
compose.threadKind === "forward"
|
|
||||||
? Forward
|
|
||||||
: compose.threadKind === "replyAll"
|
|
||||||
? ReplyAll
|
|
||||||
: Reply
|
|
||||||
|
|
||||||
const recipientSummary =
|
|
||||||
compose.to.length === 0
|
|
||||||
? "Destinataires"
|
|
||||||
: compose.to.length === 1 && compose.to[0]
|
|
||||||
? compose.to[0].name === compose.to[0].email
|
|
||||||
? compose.to[0].email
|
|
||||||
: `${compose.to[0].name} <${compose.to[0].email}>`
|
|
||||||
: `${compose.to.length} destinataires`
|
|
||||||
|
|
||||||
const showReplyAllInMenu = useMemo(
|
|
||||||
() =>
|
|
||||||
Boolean(
|
|
||||||
threadSourceEmail &&
|
|
||||||
collectThreadParticipants(threadSourceEmail).length > 1
|
|
||||||
),
|
|
||||||
[threadSourceEmail]
|
|
||||||
)
|
|
||||||
|
|
||||||
const openInlinePreset = useCallback(
|
|
||||||
(kind: "reply" | "replyAll" | "forward") => {
|
|
||||||
if (!threadSourceEmail) return
|
|
||||||
applyComposePreset(
|
|
||||||
compose.id,
|
|
||||||
buildThreadComposePreset(threadSourceEmail, kind)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[threadSourceEmail, applyComposePreset, compose.id]
|
|
||||||
)
|
|
||||||
|
|
||||||
const openDockFromInline = useCallback(
|
|
||||||
(opts?: { focusSubject?: boolean }) => {
|
|
||||||
setRecipientsFocused(false)
|
|
||||||
updateCompose(compose.id, {
|
|
||||||
placement: "dock",
|
|
||||||
threadEmailId: null,
|
|
||||||
focusToOnMount: false,
|
|
||||||
focusBodyOnMount: false,
|
|
||||||
minimized: false,
|
|
||||||
maximized: false,
|
|
||||||
focusSubjectOnMount: Boolean(opts?.focusSubject),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[compose.id, updateCompose]
|
|
||||||
)
|
|
||||||
|
|
||||||
const recipientFieldsProps = {
|
|
||||||
compose,
|
|
||||||
isInline,
|
|
||||||
showFromField,
|
|
||||||
updateCompose,
|
|
||||||
handleIdentityChange,
|
|
||||||
clearFocusToMount,
|
|
||||||
subjectInputRef,
|
|
||||||
onRecipientsActivate: () => setRecipientsFocused(true),
|
|
||||||
}
|
|
||||||
|
|
||||||
const modalContent = (
|
|
||||||
<div
|
|
||||||
data-compose-window
|
|
||||||
className={cn(
|
|
||||||
"relative flex flex-col overflow-hidden bg-mail-surface text-foreground",
|
|
||||||
isInline
|
|
||||||
? "min-h-[360px] w-full rounded-xl border border-border shadow-none transition-shadow focus-within:shadow-[0_1px_4px_rgba(60,64,67,0.12)]"
|
|
||||||
: isXsSheet
|
|
||||||
? "h-full min-h-0 w-full max-w-none flex-1 rounded-none shadow-none"
|
|
||||||
: cn(
|
|
||||||
"rounded-t-lg shadow-[0_-2px_8px_rgba(0,0,0,0.08),_-4px_0_12px_rgba(0,0,0,0.12),_4px_0_12px_rgba(0,0,0,0.12)]",
|
|
||||||
compose.maximized
|
|
||||||
? readCoarsePointerMatches()
|
|
||||||
? "fixed inset-0 z-60 rounded-none"
|
|
||||||
: "fixed inset-12 z-60 rounded-lg"
|
|
||||||
: "h-[480px] w-[500px]"
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
>
|
|
||||||
{/* Hidden file inputs */}
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => {
|
|
||||||
if (e.target.files && e.target.files.length > 0) {
|
|
||||||
addFiles(e.target.files)
|
|
||||||
e.target.value = ""
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
ref={imageInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
accept="image/*"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => {
|
|
||||||
if (e.target.files && e.target.files.length > 0) {
|
|
||||||
addFiles(e.target.files)
|
|
||||||
e.target.value = ""
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Drop overlay */}
|
|
||||||
{isDragOver ? <ComposeDropOverlay /> : null}
|
|
||||||
{isInline ? (
|
|
||||||
<ComposeInlineRecipientHeader
|
|
||||||
compose={compose}
|
|
||||||
threadSourceEmail={threadSourceEmail}
|
|
||||||
recipientSummary={recipientSummary}
|
|
||||||
recipientsFocused={recipientsFocused}
|
|
||||||
showReplyAllInMenu={showReplyAllInMenu}
|
|
||||||
ThreadKindIcon={ThreadKindIcon}
|
|
||||||
onOpenInlinePreset={openInlinePreset}
|
|
||||||
onOpenDockFromInline={openDockFromInline}
|
|
||||||
onActivateRecipients={() => setRecipientsFocused(true)}
|
|
||||||
updateCompose={updateCompose}
|
|
||||||
recipientFieldsProps={recipientFieldsProps}
|
|
||||||
fieldsRef={fieldsRef}
|
|
||||||
inlineRecipientShellRef={inlineRecipientShellRef}
|
|
||||||
/>
|
|
||||||
) : isXsSheet ? (
|
|
||||||
<ComposeXsSheetHeader titleText={titleText} onClose={handleClose} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Title bar */}
|
|
||||||
<ComposeDockTitleBar
|
|
||||||
titleText={titleText}
|
|
||||||
maximized={compose.maximized}
|
|
||||||
onMinimize={() => toggleMinimize(compose.id)}
|
|
||||||
onMaximize={() => toggleMaximize(compose.id)}
|
|
||||||
onClose={handleClose}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isInline && (
|
|
||||||
<div ref={fieldsRef} className="flex shrink-0 flex-col">
|
|
||||||
<ComposeRecipientFields {...recipientFieldsProps} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Editor */}
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
||||||
<EditorContent editor={editor} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ComposeAttachmentsList
|
|
||||||
attachments={compose.attachments}
|
|
||||||
onRemove={removeAttachment}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{showFormatting ? <FormattingToolbar editor={editor} /> : null}
|
|
||||||
|
|
||||||
<ComposeBottomToolbar
|
|
||||||
compose={compose}
|
|
||||||
editor={editor}
|
|
||||||
isEditingScheduled={isEditingScheduled}
|
|
||||||
showFormatting={showFormatting}
|
|
||||||
sendMenuOpen={sendMenuOpen}
|
|
||||||
setShowFormatting={setShowFormatting}
|
|
||||||
setSendMenuOpen={setSendMenuOpen}
|
|
||||||
handleSend={handleSend}
|
|
||||||
saveScheduledEdit={saveScheduledEdit}
|
|
||||||
sendScheduledFromEditNow={sendScheduledFromEditNow}
|
|
||||||
applyScheduledPlanAt={applyScheduledPlanAt}
|
|
||||||
submitScheduledSendAt={submitScheduledSendAt}
|
|
||||||
handleClose={handleClose}
|
|
||||||
fileInputRef={fileInputRef}
|
|
||||||
imageInputRef={imageInputRef}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (compose.minimized && !isInline && !isXsSheet) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
MAIL_COMPOSE_TITLEBAR_CLASS,
|
|
||||||
"h-9 w-[280px] cursor-pointer shadow-lg transition-shadow hover:shadow-xl"
|
|
||||||
)}
|
|
||||||
onClick={() => toggleMinimize(compose.id)}
|
|
||||||
>
|
|
||||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
|
|
||||||
{titleText}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-0.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
toggleMaximize(compose.id)
|
|
||||||
}}
|
|
||||||
className={cn("flex h-6 w-6 items-center justify-center rounded-full", MAIL_ICON_BTN)}
|
|
||||||
>
|
|
||||||
<Maximize2 className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleClose()
|
|
||||||
}}
|
|
||||||
className={cn("flex h-6 w-6 items-center justify-center rounded-full", MAIL_ICON_BTN)}
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (compose.maximized && !isInline && !isXsSheet) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-55 bg-black/50"
|
|
||||||
onClick={() => toggleMaximize(compose.id)}
|
|
||||||
/>
|
|
||||||
{modalContent}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return modalContent
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ComposeModalManager() {
|
|
||||||
const { composeWindows } = useComposeWindows()
|
|
||||||
const isXs = useIsXs()
|
|
||||||
|
|
||||||
const nonMaximized = composeWindows.filter(
|
|
||||||
(w) => !w.maximized && w.placement !== "inline"
|
|
||||||
)
|
|
||||||
const maximized = composeWindows.filter((w) => w.maximized && w.placement !== "inline")
|
|
||||||
|
|
||||||
const xsSheetCloseRef = useRef<(() => void) | null>(null)
|
|
||||||
const bindXsSheetClose = useCallback((fn: (() => void) | null) => {
|
|
||||||
xsSheetCloseRef.current = fn
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/** Une seule fenêtre dock visible en xs : la plus récente (comportement type pile). */
|
|
||||||
const xsActiveDock =
|
|
||||||
isXs && nonMaximized.length > 0 ? nonMaximized[nonMaximized.length - 1] : null
|
|
||||||
|
|
||||||
const handleXsSheetOpenChange = useCallback((open: boolean) => {
|
|
||||||
if (!open) {
|
|
||||||
xsSheetCloseRef.current?.()
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const MODAL_WIDTH = 500
|
|
||||||
const MINIMIZED_WIDTH = 280
|
|
||||||
const GAP = 12
|
|
||||||
const RIGHT_OFFSET = 80
|
|
||||||
|
|
||||||
const positions = useMemo(() => {
|
|
||||||
const reversed = [...nonMaximized].reverse()
|
|
||||||
const result: { id: string; right: number; hidden: boolean }[] = []
|
|
||||||
let cursor = RIGHT_OFFSET
|
|
||||||
for (let i = 0; i < reversed.length; i++) {
|
|
||||||
const w = reversed[i]
|
|
||||||
const width = w.minimized ? MINIMIZED_WIDTH : MODAL_WIDTH
|
|
||||||
result.push({
|
|
||||||
id: w.id,
|
|
||||||
right: cursor,
|
|
||||||
hidden: i >= 2 && !w.minimized,
|
|
||||||
})
|
|
||||||
cursor += width + GAP
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}, [nonMaximized])
|
|
||||||
|
|
||||||
if (isXs) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Sheet open={xsActiveDock != null} onOpenChange={handleXsSheetOpenChange}>
|
|
||||||
<SheetContent
|
|
||||||
side="bottom"
|
|
||||||
hideClose
|
|
||||||
overlayClassName="z-[60]"
|
|
||||||
className="z-[61] h-[100dvh] max-h-[100dvh] w-full gap-0 rounded-none border-0 p-0 shadow-none duration-300 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:slide-in-from-bottom data-[state=closed]:slide-out-to-bottom overflow-hidden pb-[env(safe-area-inset-bottom)]"
|
|
||||||
>
|
|
||||||
<SheetTitle className="sr-only">
|
|
||||||
{(xsActiveDock?.subject ?? "").trim() || "Nouveau message"}
|
|
||||||
</SheetTitle>
|
|
||||||
{xsActiveDock ? (
|
|
||||||
<ComposeWindow
|
|
||||||
key={xsActiveDock.id}
|
|
||||||
compose={xsActiveDock}
|
|
||||||
isXsSheet
|
|
||||||
bindXsSheetClose={bindXsSheetClose}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
|
|
||||||
{maximized.map((compose) => (
|
|
||||||
<div key={compose.id} className="pointer-events-auto">
|
|
||||||
<ComposeWindow compose={compose} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{nonMaximized.map((compose) => {
|
|
||||||
const pos = positions.find((p) => p.id === compose.id)
|
|
||||||
if (!pos) return null
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={compose.id}
|
|
||||||
className={cn(
|
|
||||||
"pointer-events-auto fixed bottom-0 z-50 transition-all duration-300",
|
|
||||||
pos.hidden && "invisible"
|
|
||||||
)}
|
|
||||||
style={{ right: pos.right }}
|
|
||||||
>
|
|
||||||
<ComposeWindow compose={compose} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{maximized.map((compose) => (
|
|
||||||
<div key={compose.id} className="pointer-events-auto">
|
|
||||||
<ComposeWindow compose={compose} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
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"
|
export { FormattingToolbar } from "./compose-formatting-toolbar"
|
||||||
|
export { ComposeBottomToolbar } from "./compose-bottom-toolbar"
|
||||||
import {
|
export type { ComposeBottomToolbarProps } from "./compose-bottom-toolbar"
|
||||||
useState,
|
|
||||||
useCallback,
|
|
||||||
lazy,
|
|
||||||
Suspense,
|
|
||||||
} from "react"
|
|
||||||
import { useEditor, type Editor } from "@tiptap/react"
|
|
||||||
import {
|
|
||||||
ChevronDown,
|
|
||||||
Paperclip,
|
|
||||||
Link as LinkIcon,
|
|
||||||
Smile,
|
|
||||||
HardDrive,
|
|
||||||
Image as ImageIcon,
|
|
||||||
Lock,
|
|
||||||
PenTool,
|
|
||||||
MoreVertical,
|
|
||||||
Trash2,
|
|
||||||
Bold,
|
|
||||||
Italic,
|
|
||||||
Underline as UnderlineIcon,
|
|
||||||
AlignLeft,
|
|
||||||
AlignCenter,
|
|
||||||
AlignRight,
|
|
||||||
AlignJustify,
|
|
||||||
List,
|
|
||||||
ListOrdered,
|
|
||||||
Undo,
|
|
||||||
Redo,
|
|
||||||
Type,
|
|
||||||
Clock,
|
|
||||||
Indent,
|
|
||||||
Outdent,
|
|
||||||
RemoveFormatting,
|
|
||||||
Palette,
|
|
||||||
ALargeSmall,
|
|
||||||
CaseSensitive,
|
|
||||||
Send,
|
|
||||||
} from "lucide-react"
|
|
||||||
import {
|
|
||||||
type ComposeState,
|
|
||||||
SIGNATURES,
|
|
||||||
useComposeActions,
|
|
||||||
} from "@/lib/compose-context"
|
|
||||||
import { cn, getNextLocalWallClockDate } from "@/lib/utils"
|
|
||||||
import {
|
|
||||||
MAIL_COMPOSE_BOTTOM_ICON_BTN,
|
|
||||||
MAIL_COMPOSE_BOTTOM_ICON_BTN_ACTIVE,
|
|
||||||
MAIL_COMPOSE_MENU_SELECTED_CLASS,
|
|
||||||
MAIL_COMPOSE_POPOVER_CLASS,
|
|
||||||
MAIL_COMPOSE_PRIMARY_SEND_BTN,
|
|
||||||
MAIL_COMPOSE_TOOLBAR_BTN,
|
|
||||||
MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE,
|
|
||||||
MAIL_COMPOSE_TOOLBAR_SEP,
|
|
||||||
MAIL_MENU_SURFACE_CLASS,
|
|
||||||
} from "@/lib/mail-chrome-classes"
|
|
||||||
import { useTheme } from "next-themes"
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubContent,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu"
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover"
|
|
||||||
import data from "@emoji-mart/data"
|
|
||||||
import { COMPOSE_PORTAL_Z, insertSignatureHtml } from "./compose-shared"
|
|
||||||
|
|
||||||
const LazyPicker = lazy(() => import("@emoji-mart/react"))
|
|
||||||
|
|
||||||
function ComposeEmojiPicker({ onSelect }: { onSelect: (emoji: { native: string }) => void }) {
|
|
||||||
const { resolvedTheme } = useTheme()
|
|
||||||
return (
|
|
||||||
<Suspense fallback={<div className="flex h-[435px] w-[352px] items-center justify-center text-sm text-muted-foreground">Chargement…</div>}>
|
|
||||||
<LazyPicker
|
|
||||||
data={data}
|
|
||||||
onEmojiSelect={onSelect}
|
|
||||||
locale="fr"
|
|
||||||
theme={resolvedTheme === "dark" ? "dark" : "light"}
|
|
||||||
previewPosition="none"
|
|
||||||
skinTonePosition="search"
|
|
||||||
set="native"
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AlignmentDropdown({
|
|
||||||
editor,
|
|
||||||
btnClass,
|
|
||||||
activeClass,
|
|
||||||
}: {
|
|
||||||
editor: NonNullable<ReturnType<typeof useEditor>>
|
|
||||||
btnClass: string
|
|
||||||
activeClass: string
|
|
||||||
}) {
|
|
||||||
const currentIcon = editor.isActive({ textAlign: "center" })
|
|
||||||
? AlignCenter
|
|
||||||
: editor.isActive({ textAlign: "right" })
|
|
||||||
? AlignRight
|
|
||||||
: editor.isActive({ textAlign: "justify" })
|
|
||||||
? AlignJustify
|
|
||||||
: AlignLeft
|
|
||||||
const CurrentIcon = currentIcon
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(btnClass, "w-auto gap-0.5 px-1")}
|
|
||||||
title="Alignement"
|
|
||||||
>
|
|
||||||
<CurrentIcon className="h-4 w-4" />
|
|
||||||
<ChevronDown className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
align="start"
|
|
||||||
className={cn("min-w-[160px]", COMPOSE_PORTAL_Z)}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => editor.chain().focus().setTextAlign("left").run()}
|
|
||||||
className={cn(editor.isActive({ textAlign: "left" }) && MAIL_COMPOSE_MENU_SELECTED_CLASS)}
|
|
||||||
>
|
|
||||||
<AlignLeft className="h-4 w-4" /> Aligner à gauche
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => editor.chain().focus().setTextAlign("center").run()}
|
|
||||||
className={cn(editor.isActive({ textAlign: "center" }) && MAIL_COMPOSE_MENU_SELECTED_CLASS)}
|
|
||||||
>
|
|
||||||
<AlignCenter className="h-4 w-4" /> Centrer
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => editor.chain().focus().setTextAlign("right").run()}
|
|
||||||
className={cn(editor.isActive({ textAlign: "right" }) && MAIL_COMPOSE_MENU_SELECTED_CLASS)}
|
|
||||||
>
|
|
||||||
<AlignRight className="h-4 w-4" /> Aligner à droite
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => editor.chain().focus().setTextAlign("justify").run()}
|
|
||||||
className={cn(editor.isActive({ textAlign: "justify" }) && MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE)}
|
|
||||||
>
|
|
||||||
<AlignJustify className="h-4 w-4" /> Justifier
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const FONT_FAMILIES = [
|
|
||||||
{ label: "Sans Serif", value: "sans-serif" },
|
|
||||||
{ label: "Serif", value: "serif" },
|
|
||||||
{ label: "Monospace", value: "monospace" },
|
|
||||||
{ label: "Cursive", value: "cursive" },
|
|
||||||
{ label: "Comic Sans MS", value: "Comic Sans MS, cursive" },
|
|
||||||
{ label: "Garamond", value: "Garamond, serif" },
|
|
||||||
{ label: "Georgia", value: "Georgia, serif" },
|
|
||||||
{ label: "Impact", value: "Impact, sans-serif" },
|
|
||||||
{ label: "Tahoma", value: "Tahoma, sans-serif" },
|
|
||||||
{ label: "Trebuchet MS", value: "Trebuchet MS, sans-serif" },
|
|
||||||
{ label: "Verdana", value: "Verdana, sans-serif" },
|
|
||||||
]
|
|
||||||
|
|
||||||
const FONT_SIZES = [
|
|
||||||
{ label: "Très petit", value: "10px" },
|
|
||||||
{ label: "Petit", value: "13px" },
|
|
||||||
{ label: "Normal", value: "" },
|
|
||||||
{ label: "Grand", value: "18px" },
|
|
||||||
{ label: "Très grand", value: "24px" },
|
|
||||||
{ label: "Énorme", value: "32px" },
|
|
||||||
]
|
|
||||||
|
|
||||||
const TEXT_COLORS = [
|
|
||||||
"#000000", "#434343", "#666666", "#999999", "#cccccc", "#efefef", "#f3f3f3", "#ffffff",
|
|
||||||
"#fb4934", "#fe8019", "#fabd2f", "#b8bb26", "#8ec07c", "#83a598", "#d3869b", "#ebdbb2",
|
|
||||||
"#cc241d", "#d65d0e", "#d79921", "#98971a", "#689d6a", "#458588", "#b16286", "#a89984",
|
|
||||||
"#9d0006", "#af3a03", "#b57614", "#79740e", "#427b58", "#076678", "#8f3f71", "#7c6f64",
|
|
||||||
]
|
|
||||||
|
|
||||||
function FontDropdown({
|
|
||||||
editor,
|
|
||||||
btnClass,
|
|
||||||
}: {
|
|
||||||
editor: NonNullable<ReturnType<typeof useEditor>>
|
|
||||||
btnClass: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button type="button" className={cn(btnClass, "w-auto gap-0.5 px-1")} title="Police">
|
|
||||||
<CaseSensitive className="h-4 w-4" />
|
|
||||||
<ChevronDown className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
align="start"
|
|
||||||
className={cn("max-h-[280px] min-w-[180px] overflow-y-auto", COMPOSE_PORTAL_Z)}
|
|
||||||
>
|
|
||||||
{FONT_FAMILIES.map((f) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={f.value}
|
|
||||||
onSelect={() => editor.chain().focus().setMark("textStyle", { fontFamily: f.value }).run()}
|
|
||||||
style={{ fontFamily: f.value }}
|
|
||||||
className={cn(
|
|
||||||
editor.isActive("textStyle", { fontFamily: f.value }) && MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{f.label}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FontSizeDropdown({
|
|
||||||
editor,
|
|
||||||
btnClass,
|
|
||||||
}: {
|
|
||||||
editor: NonNullable<ReturnType<typeof useEditor>>
|
|
||||||
btnClass: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button type="button" className={cn(btnClass, "w-auto gap-0.5 px-1")} title="Taille du texte">
|
|
||||||
<ALargeSmall className="h-4 w-4" />
|
|
||||||
<ChevronDown className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
align="start"
|
|
||||||
className={cn("min-w-[140px]", COMPOSE_PORTAL_Z)}
|
|
||||||
>
|
|
||||||
{FONT_SIZES.map((s) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={s.label}
|
|
||||||
onSelect={() => {
|
|
||||||
if (s.value) {
|
|
||||||
editor.chain().focus().setMark("textStyle", { fontSize: s.value }).run()
|
|
||||||
} else {
|
|
||||||
editor.chain().focus().setMark("textStyle", { fontSize: null }).removeEmptyTextStyle().run()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={s.value ? { fontSize: s.value } : undefined}
|
|
||||||
className={cn(
|
|
||||||
s.value && editor.isActive("textStyle", { fontSize: s.value }) && MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{s.label}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ColorDropdown({
|
|
||||||
editor,
|
|
||||||
btnClass,
|
|
||||||
}: {
|
|
||||||
editor: NonNullable<ReturnType<typeof useEditor>>
|
|
||||||
btnClass: string
|
|
||||||
}) {
|
|
||||||
const [tab, setTab] = useState<"text" | "bg">("text")
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button type="button" className={cn(btnClass, "w-auto gap-0.5 px-1")} title="Couleur du texte">
|
|
||||||
<Palette className="h-4 w-4" />
|
|
||||||
<ChevronDown className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
align="start"
|
|
||||||
className={cn("w-[268px] p-2", COMPOSE_PORTAL_Z)}
|
|
||||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<div className="mb-2 flex gap-1 border-b border-border pb-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"flex-1 rounded px-2 py-1 text-xs font-medium transition-colors",
|
|
||||||
tab === "text" ? MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE : "text-[#5f6368] hover:bg-[#f1f3f4]"
|
|
||||||
)}
|
|
||||||
onClick={() => setTab("text")}
|
|
||||||
>
|
|
||||||
Couleur du texte
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"flex-1 rounded px-2 py-1 text-xs font-medium transition-colors",
|
|
||||||
tab === "bg" ? MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE : "text-[#5f6368] hover:bg-[#f1f3f4]"
|
|
||||||
)}
|
|
||||||
onClick={() => setTab("bg")}
|
|
||||||
>
|
|
||||||
Couleur de fond
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-8 gap-1">
|
|
||||||
{TEXT_COLORS.map((color) => (
|
|
||||||
<button
|
|
||||||
key={`${tab}-${color}`}
|
|
||||||
type="button"
|
|
||||||
className="h-6 w-6 rounded border border-border hover:scale-110 transition-transform"
|
|
||||||
style={{ backgroundColor: color }}
|
|
||||||
title={color}
|
|
||||||
onClick={() => {
|
|
||||||
if (tab === "text") {
|
|
||||||
editor.chain().focus().setColor(color).run()
|
|
||||||
} else {
|
|
||||||
editor.chain().focus().setMark("textStyle", { backgroundColor: color }).run()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="mt-2 w-full rounded px-2 py-1 text-xs text-muted-foreground hover:bg-accent transition-colors"
|
|
||||||
onClick={() => {
|
|
||||||
if (tab === "text") {
|
|
||||||
editor.chain().focus().unsetColor().run()
|
|
||||||
} else {
|
|
||||||
editor.chain().focus().setMark("textStyle", { backgroundColor: null }).removeEmptyTextStyle().run()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Réinitialiser
|
|
||||||
</button>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FormattingToolbar({
|
|
||||||
editor,
|
|
||||||
}: {
|
|
||||||
editor: Editor | null
|
|
||||||
}) {
|
|
||||||
if (!editor) return null
|
|
||||||
|
|
||||||
const btnClass = MAIL_COMPOSE_TOOLBAR_BTN
|
|
||||||
const activeClass = MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE
|
|
||||||
const sep = <span className={MAIL_COMPOSE_TOOLBAR_SEP} aria-hidden />
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="compose-toolbar flex flex-wrap items-center border-t border-border bg-muted px-1 py-1">
|
|
||||||
{/* Undo / Redo */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={btnClass}
|
|
||||||
onClick={() => editor.chain().focus().undo().run()}
|
|
||||||
disabled={!editor.can().undo()}
|
|
||||||
title="Annuler"
|
|
||||||
>
|
|
||||||
<Undo className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={btnClass}
|
|
||||||
onClick={() => editor.chain().focus().redo().run()}
|
|
||||||
disabled={!editor.can().redo()}
|
|
||||||
title="Rétablir"
|
|
||||||
>
|
|
||||||
<Redo className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{sep}
|
|
||||||
|
|
||||||
{/* Font */}
|
|
||||||
<FontDropdown editor={editor} btnClass={btnClass} />
|
|
||||||
|
|
||||||
{sep}
|
|
||||||
|
|
||||||
{/* Font size */}
|
|
||||||
<FontSizeDropdown editor={editor} btnClass={btnClass} />
|
|
||||||
|
|
||||||
{sep}
|
|
||||||
|
|
||||||
{/* Bold, Italic, Underline, Colors */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(btnClass, editor.isActive("bold") && activeClass)}
|
|
||||||
onClick={() => editor.chain().focus().toggleMark("bold").run()}
|
|
||||||
title="Gras"
|
|
||||||
>
|
|
||||||
<Bold className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(btnClass, editor.isActive("italic") && activeClass)}
|
|
||||||
onClick={() => editor.chain().focus().toggleMark("italic").run()}
|
|
||||||
title="Italique"
|
|
||||||
>
|
|
||||||
<Italic className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(btnClass, editor.isActive("underline") && activeClass)}
|
|
||||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
|
||||||
title="Souligné"
|
|
||||||
>
|
|
||||||
<UnderlineIcon className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<ColorDropdown editor={editor} btnClass={btnClass} />
|
|
||||||
|
|
||||||
{sep}
|
|
||||||
|
|
||||||
{/* Alignment dropdown, lists, indent/outdent, remove formatting */}
|
|
||||||
<AlignmentDropdown editor={editor} btnClass={btnClass} activeClass={activeClass} />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(btnClass, editor.isActive("orderedList") && activeClass)}
|
|
||||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
|
||||||
title="Liste numérotée"
|
|
||||||
>
|
|
||||||
<ListOrdered className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(btnClass, editor.isActive("bulletList") && activeClass)}
|
|
||||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
|
||||||
title="Liste à puces"
|
|
||||||
>
|
|
||||||
<List className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={btnClass}
|
|
||||||
onClick={() => {
|
|
||||||
try { editor.chain().focus().liftListItem("listItem").run() } catch { /* not in list */ }
|
|
||||||
}}
|
|
||||||
title="Désindenter"
|
|
||||||
>
|
|
||||||
<Outdent className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={btnClass}
|
|
||||||
onClick={() => {
|
|
||||||
try { editor.chain().focus().sinkListItem("listItem").run() } catch { /* not in list */ }
|
|
||||||
}}
|
|
||||||
title="Indenter"
|
|
||||||
>
|
|
||||||
<Indent className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={btnClass}
|
|
||||||
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
|
|
||||||
title="Supprimer la mise en forme"
|
|
||||||
>
|
|
||||||
<RemoveFormatting className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ComposeEmojiButton({
|
|
||||||
editor,
|
|
||||||
}: {
|
|
||||||
editor: Editor | null
|
|
||||||
}) {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
|
||||||
(emoji: { native: string }) => {
|
|
||||||
editor?.chain().focus().insertContent(emoji.native).run()
|
|
||||||
setOpen(false)
|
|
||||||
},
|
|
||||||
[editor]
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!editor) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
|
|
||||||
title="Insérer un emoji"
|
|
||||||
>
|
|
||||||
<Smile className="h-[18px] w-[18px]" />
|
|
||||||
</button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
align="start"
|
|
||||||
side="top"
|
|
||||||
className={cn("w-auto border-0 bg-popover p-0 shadow-xl", COMPOSE_PORTAL_Z)}
|
|
||||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<ComposeEmojiPicker onSelect={handleSelect} />
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ComposeLinkButton({
|
|
||||||
editor,
|
|
||||||
}: {
|
|
||||||
editor: Editor | null
|
|
||||||
}) {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [url, setUrl] = useState("")
|
|
||||||
const [text, setText] = useState("")
|
|
||||||
|
|
||||||
if (!editor) return null
|
|
||||||
|
|
||||||
const isLinkActive = editor.isActive("link")
|
|
||||||
|
|
||||||
const handleToggle = () => {
|
|
||||||
if (isLinkActive) {
|
|
||||||
editor.chain().focus().extendMarkRange("link").unsetLink().run()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleOpen = (isOpen: boolean) => {
|
|
||||||
if (isOpen) {
|
|
||||||
const { from, to, empty } = editor.state.selection
|
|
||||||
if (isLinkActive) {
|
|
||||||
const attrs = editor.getAttributes("link")
|
|
||||||
setUrl(attrs.href || "")
|
|
||||||
const selectedText = editor.state.doc.textBetween(from, to, " ")
|
|
||||||
setText(selectedText)
|
|
||||||
} else if (!empty) {
|
|
||||||
const selectedText = editor.state.doc.textBetween(from, to, " ")
|
|
||||||
setText(selectedText)
|
|
||||||
setUrl("")
|
|
||||||
} else {
|
|
||||||
setText("")
|
|
||||||
setUrl("")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setOpen(isOpen)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleInsert = () => {
|
|
||||||
if (!url.trim()) return
|
|
||||||
const href = url.match(/^https?:\/\//) ? url : `https://${url}`
|
|
||||||
|
|
||||||
const { empty } = editor.state.selection
|
|
||||||
|
|
||||||
if (empty && !isLinkActive) {
|
|
||||||
const displayText = text.trim() || href
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.insertContent(`<a href="${href}">${displayText}</a>`)
|
|
||||||
.run()
|
|
||||||
} else {
|
|
||||||
if (text.trim() && text.trim() !== editor.state.doc.textBetween(
|
|
||||||
editor.state.selection.from,
|
|
||||||
editor.state.selection.to,
|
|
||||||
" "
|
|
||||||
)) {
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.deleteSelection()
|
|
||||||
.insertContent(`<a href="${href}">${text.trim()}</a>`)
|
|
||||||
.run()
|
|
||||||
} else {
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.extendMarkRange("link")
|
|
||||||
.setLink({ href })
|
|
||||||
.run()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setOpen(false)
|
|
||||||
setUrl("")
|
|
||||||
setText("")
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRemoveLink = () => {
|
|
||||||
editor.chain().focus().extendMarkRange("link").unsetLink().run()
|
|
||||||
setOpen(false)
|
|
||||||
setUrl("")
|
|
||||||
setText("")
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover open={open} onOpenChange={handleOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
if (isLinkActive) {
|
|
||||||
e.preventDefault()
|
|
||||||
handleToggle()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
MAIL_COMPOSE_BOTTOM_ICON_BTN,
|
|
||||||
isLinkActive && MAIL_COMPOSE_BOTTOM_ICON_BTN_ACTIVE
|
|
||||||
)}
|
|
||||||
title={isLinkActive ? "Supprimer le lien" : "Insérer un lien"}
|
|
||||||
>
|
|
||||||
<LinkIcon className="h-[18px] w-[18px]" />
|
|
||||||
</button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
align="start"
|
|
||||||
side="top"
|
|
||||||
className={cn("w-[340px]", MAIL_COMPOSE_POPOVER_CLASS, COMPOSE_PORTAL_Z)}
|
|
||||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-2.5">
|
|
||||||
<div className="text-sm font-medium text-foreground">
|
|
||||||
{isLinkActive ? "Modifier le lien" : "Insérer un lien"}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label className="text-xs text-muted-foreground">Texte à afficher</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={text}
|
|
||||||
onChange={(e) => setText(e.target.value)}
|
|
||||||
placeholder="Texte du lien"
|
|
||||||
className="h-8 rounded border border-border bg-mail-surface px-2 text-sm text-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label className="text-xs text-muted-foreground">URL</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={url}
|
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
|
||||||
placeholder="https://example.com"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault()
|
|
||||||
handleInsert()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="h-8 rounded border border-border bg-mail-surface px-2 text-sm text-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between pt-1">
|
|
||||||
{isLinkActive ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleRemoveLink}
|
|
||||||
className="text-sm text-destructive hover:text-destructive/90 transition-colors"
|
|
||||||
>
|
|
||||||
Supprimer le lien
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span />
|
|
||||||
)}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
className="rounded px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent transition-colors"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleInsert}
|
|
||||||
disabled={!url.trim()}
|
|
||||||
className={cn("rounded px-3 py-1.5 text-sm font-medium disabled:opacity-50", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
|
|
||||||
>
|
|
||||||
{isLinkActive ? "Modifier" : "Insérer"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ComposeSignatureButton({
|
|
||||||
editor,
|
|
||||||
compose,
|
|
||||||
}: {
|
|
||||||
editor: Editor | null
|
|
||||||
compose: ComposeState
|
|
||||||
}) {
|
|
||||||
const { updateCompose } = useComposeActions()
|
|
||||||
|
|
||||||
const replaceSignature = useCallback(
|
|
||||||
(sigId: string | null) => {
|
|
||||||
if (!editor) return
|
|
||||||
const newHtml = insertSignatureHtml(editor.getHTML(), sigId)
|
|
||||||
editor.commands.setContent(newHtml)
|
|
||||||
updateCompose(compose.id, { bodyHtml: newHtml, signatureId: sigId })
|
|
||||||
},
|
|
||||||
[editor, compose.id, updateCompose]
|
|
||||||
)
|
|
||||||
|
|
||||||
const toggleAutoInsert = useCallback(() => {
|
|
||||||
const newVal = !compose.autoInsertSignature
|
|
||||||
updateCompose(compose.id, { autoInsertSignature: newVal })
|
|
||||||
if (!newVal) {
|
|
||||||
replaceSignature(null)
|
|
||||||
} else {
|
|
||||||
const sigId = compose.from.defaultSignatureId
|
|
||||||
if (sigId) replaceSignature(sigId)
|
|
||||||
}
|
|
||||||
}, [compose.autoInsertSignature, compose.from.defaultSignatureId, compose.id, updateCompose, replaceSignature])
|
|
||||||
|
|
||||||
if (!editor) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu modal={false}>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
|
|
||||||
title="Insérer une signature"
|
|
||||||
>
|
|
||||||
<PenTool className="h-[18px] w-[18px]" />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
align="start"
|
|
||||||
side="top"
|
|
||||||
className={cn(MAIL_MENU_SURFACE_CLASS, "min-w-[220px]", COMPOSE_PORTAL_Z)}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
toggleAutoInsert()
|
|
||||||
}}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<span className="flex h-4 w-4 items-center justify-center">
|
|
||||||
{compose.autoInsertSignature && <span className="text-xs">✓</span>}
|
|
||||||
</span>
|
|
||||||
Insérer automatiquement
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => replaceSignature(null)}
|
|
||||||
className={cn("gap-2", !compose.signatureId && MAIL_COMPOSE_MENU_SELECTED_CLASS)}
|
|
||||||
>
|
|
||||||
<span className="flex h-4 w-4 items-center justify-center">
|
|
||||||
{!compose.signatureId && <span className="text-xs">✓</span>}
|
|
||||||
</span>
|
|
||||||
Aucune signature
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{SIGNATURES.map((sig) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={sig.id}
|
|
||||||
onSelect={() => replaceSignature(sig.id)}
|
|
||||||
className={cn("gap-2", compose.signatureId === sig.id && MAIL_COMPOSE_MENU_SELECTED_CLASS)}
|
|
||||||
>
|
|
||||||
<span className="flex h-4 w-4 items-center justify-center">
|
|
||||||
{compose.signatureId === sig.id && <span className="text-xs">✓</span>}
|
|
||||||
</span>
|
|
||||||
{sig.name}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ComposeBottomToolbarProps {
|
|
||||||
compose: ComposeState
|
|
||||||
editor: Editor | null
|
|
||||||
isEditingScheduled: boolean
|
|
||||||
showFormatting: boolean
|
|
||||||
sendMenuOpen: boolean
|
|
||||||
setShowFormatting: (v: boolean | ((prev: boolean) => boolean)) => void
|
|
||||||
setSendMenuOpen: (v: boolean) => void
|
|
||||||
handleSend: () => void
|
|
||||||
saveScheduledEdit: () => void | Promise<void>
|
|
||||||
sendScheduledFromEditNow: () => void | Promise<void>
|
|
||||||
applyScheduledPlanAt: (sendAt: Date) => void | Promise<void>
|
|
||||||
submitScheduledSendAt: (sendAt: Date) => void | Promise<void>
|
|
||||||
handleClose: () => void
|
|
||||||
fileInputRef: React.RefObject<HTMLInputElement | null>
|
|
||||||
imageInputRef: React.RefObject<HTMLInputElement | null>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ComposeBottomToolbar(props: ComposeBottomToolbarProps) {
|
|
||||||
const {
|
|
||||||
compose,
|
|
||||||
editor,
|
|
||||||
isEditingScheduled,
|
|
||||||
showFormatting,
|
|
||||||
sendMenuOpen,
|
|
||||||
setShowFormatting,
|
|
||||||
setSendMenuOpen,
|
|
||||||
handleSend,
|
|
||||||
saveScheduledEdit,
|
|
||||||
sendScheduledFromEditNow,
|
|
||||||
applyScheduledPlanAt,
|
|
||||||
submitScheduledSendAt,
|
|
||||||
handleClose,
|
|
||||||
fileInputRef,
|
|
||||||
imageInputRef,
|
|
||||||
} = props
|
|
||||||
return (
|
|
||||||
<div className="flex shrink-0 items-center gap-1 border-t border-border px-2 py-1.5">
|
|
||||||
{/* Send / save + dropdown */}
|
|
||||||
<div className="flex items-center">
|
|
||||||
{isEditingScheduled ? (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void saveScheduledEdit()}
|
|
||||||
className={cn("rounded-l-full px-5 text-sm font-medium", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
|
|
||||||
>
|
|
||||||
Enregistrer
|
|
||||||
</button>
|
|
||||||
<DropdownMenu modal={false} open={sendMenuOpen} onOpenChange={setSendMenuOpen}>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn("rounded-r-full border-l border-primary-foreground/30 px-1.5", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
|
|
||||||
>
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start" className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => {
|
|
||||||
void sendScheduledFromEditNow()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Send className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
|
|
||||||
Envoyer maintenant
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSub>
|
|
||||||
<DropdownMenuSubTrigger className="[&>svg:last-child]:text-muted-foreground">
|
|
||||||
<Clock className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
|
|
||||||
Planifier
|
|
||||||
</DropdownMenuSubTrigger>
|
|
||||||
<DropdownMenuSubContent className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => {
|
|
||||||
void applyScheduledPlanAt(
|
|
||||||
new Date(Date.now() + 60 * 60 * 1000)
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Clock className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
|
|
||||||
Envoyer dans une heure
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => {
|
|
||||||
void applyScheduledPlanAt(
|
|
||||||
getNextLocalWallClockDate(9, 0)
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Clock className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
|
|
||||||
Envoyer à 9h
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuSubContent>
|
|
||||||
</DropdownMenuSub>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSend}
|
|
||||||
className={cn("rounded-l-full px-5 text-sm font-medium", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
|
|
||||||
>
|
|
||||||
Envoyer
|
|
||||||
</button>
|
|
||||||
<DropdownMenu modal={false} open={sendMenuOpen} onOpenChange={setSendMenuOpen}>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn("rounded-r-full border-l border-primary-foreground/30 px-1.5", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
|
|
||||||
>
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start" className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => {
|
|
||||||
void submitScheduledSendAt(
|
|
||||||
new Date(Date.now() + 60 * 60 * 1000)
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
||||||
Envoyer dans une heure
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => {
|
|
||||||
void submitScheduledSendAt(
|
|
||||||
getNextLocalWallClockDate(9, 0)
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
||||||
Envoyer à 9h
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onSelect={() => setSendMenuOpen(false)}>
|
|
||||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
||||||
Programmer l'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
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 type { Email } from "@/lib/email-data"
|
||||||
import { useMailStore } from "@/lib/stores/mail-store"
|
import { useMailStore } from "@/lib/stores/mail-store"
|
||||||
|
|
||||||
type ListMailIndex = {
|
export type ListMailIndex = {
|
||||||
emailById: Map<string, Email>
|
emailById: Map<string, Email>
|
||||||
scheduledIds: Set<string>
|
scheduledIds: Set<string>
|
||||||
}
|
}
|
||||||
@ -8,17 +8,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
} from "react"
|
} from "react"
|
||||||
import {
|
import { Star, Reply, ReplyAll, Forward } from "lucide-react"
|
||||||
Star,
|
|
||||||
Reply,
|
|
||||||
ReplyAll,
|
|
||||||
Forward,
|
|
||||||
Info,
|
|
||||||
HardDrive,
|
|
||||||
File,
|
|
||||||
FileText,
|
|
||||||
Image as ImageIcon,
|
|
||||||
} from "lucide-react"
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@ -31,19 +21,8 @@ import {
|
|||||||
cleanSenderName,
|
cleanSenderName,
|
||||||
senderInitial,
|
senderInitial,
|
||||||
} from "@/lib/sender-display"
|
} from "@/lib/sender-display"
|
||||||
import { MailDateText } from "@/components/gmail/mail-date-text"
|
import type { Email, EmailAttachment } from "@/lib/email-data"
|
||||||
import type {
|
|
||||||
Email,
|
|
||||||
ConversationMessage,
|
|
||||||
EmailAttachment,
|
|
||||||
EmailAttachmentKind,
|
|
||||||
} from "@/lib/email-data"
|
|
||||||
import type { FolderTreeNode, LabelRowItem } from "@/lib/sidebar-nav-data"
|
import type { FolderTreeNode, LabelRowItem } from "@/lib/sidebar-nav-data"
|
||||||
import {
|
|
||||||
attachmentPreviewTooltip,
|
|
||||||
resolveAttachmentKind,
|
|
||||||
shouldUseAttachmentPillsInPreview,
|
|
||||||
} from "@/lib/attachment-display"
|
|
||||||
import {
|
import {
|
||||||
useComposeActions,
|
useComposeActions,
|
||||||
useComposeDrafts,
|
useComposeDrafts,
|
||||||
@ -61,23 +40,17 @@ import { openConversationPrint } from "@/lib/print-conversation"
|
|||||||
import { resolveParsedCalendarInvitation } from "@/lib/resolve-email-calendar-invitation"
|
import { resolveParsedCalendarInvitation } from "@/lib/resolve-email-calendar-invitation"
|
||||||
import { ComposeWindow } from "@/components/gmail/compose-modal"
|
import { ComposeWindow } from "@/components/gmail/compose-modal"
|
||||||
import { CalendarInvitationPreview } from "@/components/gmail/calendar-invitation-preview"
|
import { CalendarInvitationPreview } from "@/components/gmail/calendar-invitation-preview"
|
||||||
import { ContactHoverCard } from "./contact-hover-card"
|
|
||||||
import { EmailViewSubjectHeader } from "./email-view/email-view-header"
|
import { EmailViewSubjectHeader } from "./email-view/email-view-header"
|
||||||
import { EmailViewMessageToolbar } from "./email-view/email-view-toolbar"
|
|
||||||
import {
|
import {
|
||||||
MAIL_MESSAGE_HOVER_CLASS,
|
|
||||||
MAIL_PREVIEW_SCROLL_CLASS,
|
MAIL_PREVIEW_SCROLL_CLASS,
|
||||||
MAIL_REPLY_BAR_CLASS,
|
MAIL_REPLY_BAR_CLASS,
|
||||||
MAIL_REPLY_BUTTON_CLASS,
|
MAIL_REPLY_BUTTON_CLASS,
|
||||||
MAIL_TOOLTIP_CONTENT_CLASS,
|
|
||||||
} from "@/lib/mail-chrome-classes"
|
} from "@/lib/mail-chrome-classes"
|
||||||
import { useTheme } from "next-themes"
|
|
||||||
import {
|
import {
|
||||||
emailPreviewBaseCss,
|
CollapsedMessage,
|
||||||
emailPreviewDarkOverrideCss,
|
ExpandedMessage,
|
||||||
emailPreviewLightOverrideCss,
|
SpamWhyBanner,
|
||||||
preprocessEmailHtmlForTheme,
|
} from "@/components/gmail/email-view/email-view-messages"
|
||||||
} from "@/lib/email-preview-dark-styles"
|
|
||||||
|
|
||||||
interface EmailViewProps {
|
interface EmailViewProps {
|
||||||
email: Email
|
email: Email
|
||||||
@ -101,400 +74,6 @@ interface EmailViewProps {
|
|||||||
isSingleMessageView?: boolean
|
isSingleMessageView?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMAIL_PREVIEW_IFRAME_STYLE: React.CSSProperties = {
|
|
||||||
display: "block",
|
|
||||||
background: "transparent",
|
|
||||||
}
|
|
||||||
|
|
||||||
function documentIsDark(): boolean {
|
|
||||||
return document.documentElement.classList.contains("dark")
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Sandboxed iframe for HTML body ── */
|
|
||||||
|
|
||||||
function SandboxedContent({
|
|
||||||
html,
|
|
||||||
isSpam,
|
|
||||||
}: {
|
|
||||||
html: string
|
|
||||||
isSpam: boolean
|
|
||||||
}) {
|
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
|
||||||
const [height, setHeight] = useState(120)
|
|
||||||
|
|
||||||
const sandboxValue = isSpam
|
|
||||||
? "allow-same-origin"
|
|
||||||
: "allow-same-origin allow-popups"
|
|
||||||
|
|
||||||
const { resolvedTheme } = useTheme()
|
|
||||||
|
|
||||||
const injectContent = useCallback(() => {
|
|
||||||
const iframe = iframeRef.current
|
|
||||||
if (!iframe) return
|
|
||||||
|
|
||||||
const doc = iframe.contentDocument
|
|
||||||
if (!doc) return
|
|
||||||
|
|
||||||
const cspMeta = isSpam
|
|
||||||
? `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:;">`
|
|
||||||
: `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src https: data:;">`
|
|
||||||
|
|
||||||
const isDark = documentIsDark()
|
|
||||||
const processedHtml = preprocessEmailHtmlForTheme(html, isDark)
|
|
||||||
const themeOverrides = isDark
|
|
||||||
? emailPreviewDarkOverrideCss()
|
|
||||||
: emailPreviewLightOverrideCss()
|
|
||||||
|
|
||||||
doc.open()
|
|
||||||
doc.write(`<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
${cspMeta}
|
|
||||||
<style>
|
|
||||||
${emailPreviewBaseCss(isDark)}
|
|
||||||
${themeOverrides}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>${processedHtml}</body>
|
|
||||||
</html>`)
|
|
||||||
doc.close()
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
|
||||||
const body = iframe.contentDocument?.body
|
|
||||||
if (body) {
|
|
||||||
setHeight(Math.max(60, body.scrollHeight + 2))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (doc.body) {
|
|
||||||
resizeObserver.observe(doc.body)
|
|
||||||
setHeight(Math.max(60, doc.body.scrollHeight + 2))
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => resizeObserver.disconnect()
|
|
||||||
}, [html, isSpam, resolvedTheme])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const cleanup = injectContent()
|
|
||||||
return () => cleanup?.()
|
|
||||||
}, [injectContent])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<iframe
|
|
||||||
ref={iframeRef}
|
|
||||||
sandbox={sandboxValue}
|
|
||||||
title="Contenu du message"
|
|
||||||
className="w-full border-0 bg-transparent"
|
|
||||||
style={{ ...EMAIL_PREVIEW_IFRAME_STYLE, height: `${height}px` }}
|
|
||||||
tabIndex={-1}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MessageAttachmentCard({ name, kind }: { name: string; kind: EmailAttachmentKind }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="relative flex h-[132px] shrink-0 flex-col items-center justify-center bg-linear-to-b from-muted to-muted/70 dark:from-[#3c4043] dark:to-[#303134]">
|
|
||||||
{kind === "image" ? (
|
|
||||||
<ImageIcon className="size-11 text-[#9aa0a6]" strokeWidth={1.25} aria-hidden />
|
|
||||||
) : kind === "pdf" ? (
|
|
||||||
<div
|
|
||||||
className="rounded border border-border bg-mail-surface px-4 py-5 shadow-sm"
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
<span className="text-[11px] font-bold leading-none text-[#d93025]">PDF</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<File className="size-11 text-[#9aa0a6]" strokeWidth={1.25} aria-hidden />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex min-h-[38px] items-center gap-2 border-t border-border bg-muted px-2 py-1.5">
|
|
||||||
{kind === "pdf" ? (
|
|
||||||
<FileText className="size-4 shrink-0 text-[#d93025]" strokeWidth={1.5} aria-hidden />
|
|
||||||
) : kind === "image" ? (
|
|
||||||
<ImageIcon className="size-4 shrink-0 text-[#1a73e8]" strokeWidth={1.5} aria-hidden />
|
|
||||||
) : (
|
|
||||||
<File className="size-4 shrink-0 text-[#5f6368]" strokeWidth={1.5} aria-hidden />
|
|
||||||
)}
|
|
||||||
<span className="min-w-0 flex-1 truncate text-xs leading-tight text-[#3c4043]">
|
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MessageAttachmentPill({
|
|
||||||
name,
|
|
||||||
kind,
|
|
||||||
sizeBytes,
|
|
||||||
}: {
|
|
||||||
name: string
|
|
||||||
kind: EmailAttachmentKind
|
|
||||||
sizeBytes?: number
|
|
||||||
}) {
|
|
||||||
const tip = attachmentPreviewTooltip(name, sizeBytes)
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex max-w-[min(100%,320px)] min-w-0 shrink-0 items-center gap-2 rounded-full border border-border bg-muted py-1.5 pl-2.5 pr-3 text-left text-sm text-foreground shadow-sm transition hover:border-border hover:bg-accent hover:shadow focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
|
||||||
>
|
|
||||||
{kind === "pdf" ? (
|
|
||||||
<FileText className="size-4 shrink-0 text-[#d93025]" strokeWidth={1.5} aria-hidden />
|
|
||||||
) : kind === "image" ? (
|
|
||||||
<ImageIcon className="size-4 shrink-0 text-[#1a73e8]" strokeWidth={1.5} aria-hidden />
|
|
||||||
) : (
|
|
||||||
<File className="size-4 shrink-0 text-[#5f6368]" strokeWidth={1.5} aria-hidden />
|
|
||||||
)}
|
|
||||||
<span className="min-w-0 truncate font-medium">{name}</span>
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs whitespace-pre-line text-xs")}>
|
|
||||||
{tip}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MessageAttachmentsSection({ attachments }: { attachments: EmailAttachment[] }) {
|
|
||||||
const n = attachments.length
|
|
||||||
if (n === 0) return null
|
|
||||||
|
|
||||||
const summary = n === 1 ? "Une pièce jointe" : `${n} pièces jointes`
|
|
||||||
const asPills = shouldUseAttachmentPillsInPreview(attachments)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-4 border-t border-border px-4 pb-4 pl-[68px] pt-4 max-sm:pl-4 max-sm:pr-4">
|
|
||||||
<div className="mb-3 flex min-w-0 flex-wrap items-center justify-between gap-x-3 gap-y-2">
|
|
||||||
<div className="flex min-w-0 max-w-[min(100%,28rem)] items-center gap-1 text-sm text-muted-foreground">
|
|
||||||
<span className="min-w-0 truncate">
|
|
||||||
{summary}
|
|
||||||
<span aria-hidden> · </span>
|
|
||||||
<span>Analysé par VirusTotal</span>
|
|
||||||
</span>
|
|
||||||
<Tooltip delayDuration={400}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-accent"
|
|
||||||
aria-label="Informations sur l'analyse VirusTotal des pièces jointes"
|
|
||||||
>
|
|
||||||
<Info className="size-4" strokeWidth={1.75} />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs text-xs")}>
|
|
||||||
VirusTotal analyse les pièces jointes et les compare à une base de signatures pour
|
|
||||||
repérer les virus et logiciels malveillants.
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex shrink-0 items-center gap-2 rounded-md py-1 pl-1 pr-2 text-sm font-medium text-primary hover:bg-accent"
|
|
||||||
aria-label="Ajouter à UltiDrive"
|
|
||||||
>
|
|
||||||
<HardDrive className="size-[18px] shrink-0" strokeWidth={1.5} aria-hidden />
|
|
||||||
Ajouter à UltiDrive
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
asPills
|
|
||||||
? "flex flex-wrap gap-2 pb-1"
|
|
||||||
: "flex flex-nowrap gap-3 overflow-x-auto overflow-y-hidden pb-1 [-webkit-overflow-scrolling:touch]"
|
|
||||||
}
|
|
||||||
role="list"
|
|
||||||
aria-label="Pièces jointes"
|
|
||||||
>
|
|
||||||
{attachments.map((att, index) => {
|
|
||||||
const kind = resolveAttachmentKind(att.name, att.kind)
|
|
||||||
const tip = attachmentPreviewTooltip(att.name, att.sizeBytes)
|
|
||||||
if (asPills) {
|
|
||||||
return (
|
|
||||||
<div key={`${att.name}-${index}`} className="shrink-0" role="listitem">
|
|
||||||
<MessageAttachmentPill name={att.name} kind={kind} sizeBytes={att.sizeBytes} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div key={`${att.name}-${index}`} className="shrink-0" role="listitem">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex w-[200px] flex-col overflow-hidden rounded border border-border bg-mail-surface text-left shadow-sm transition hover:border-border hover:shadow-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
|
||||||
>
|
|
||||||
<MessageAttachmentCard name={att.name} kind={kind} />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs whitespace-pre-line text-xs")}>
|
|
||||||
{tip}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Collapsed conversation message (accordion header) ── */
|
|
||||||
|
|
||||||
function CollapsedMessage({
|
|
||||||
message,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
message: ConversationMessage
|
|
||||||
onClick: () => void
|
|
||||||
}) {
|
|
||||||
const name = cleanSenderName(message.sender)
|
|
||||||
const color = avatarColor(name)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={onClick}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault()
|
|
||||||
onClick()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={cn("group flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left transition-colors", MAIL_MESSAGE_HOVER_CLASS)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"
|
|
||||||
style={{ backgroundColor: color }}
|
|
||||||
>
|
|
||||||
{senderInitial(name)}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1 flex flex-col gap-1" data-selectable-text>
|
|
||||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
|
||||||
<ContactHoverCard displayName={message.sender} email={message.senderEmail} className="min-w-0">
|
|
||||||
<span className="truncate text-sm font-semibold text-foreground">{name}</span>
|
|
||||||
</ContactHoverCard>
|
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
|
||||||
<MailDateText
|
|
||||||
iso={message.date}
|
|
||||||
variant="preview"
|
|
||||||
className="text-xs text-muted-foreground"
|
|
||||||
/>
|
|
||||||
<Star
|
|
||||||
strokeWidth={1.25}
|
|
||||||
className="ml-1 size-4 fill-transparent stroke-[#c2c2c2]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="min-w-0 truncate text-sm leading-snug text-muted-foreground">{message.preview}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Expanded message card (full body) ── */
|
|
||||||
|
|
||||||
function ExpandedMessage({
|
|
||||||
sender,
|
|
||||||
senderEmail,
|
|
||||||
dateIso,
|
|
||||||
body,
|
|
||||||
isSpam,
|
|
||||||
isLast,
|
|
||||||
starred,
|
|
||||||
attachments = [],
|
|
||||||
onToggleStar,
|
|
||||||
onCollapse,
|
|
||||||
onPrintConversation,
|
|
||||||
}: {
|
|
||||||
sender: string
|
|
||||||
senderEmail: string
|
|
||||||
dateIso: string
|
|
||||||
body: string
|
|
||||||
isSpam: boolean
|
|
||||||
isLast: boolean
|
|
||||||
starred: boolean
|
|
||||||
attachments?: EmailAttachment[]
|
|
||||||
onToggleStar?: () => void
|
|
||||||
onCollapse?: () => void
|
|
||||||
onPrintConversation?: () => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<EmailViewMessageToolbar
|
|
||||||
sender={sender}
|
|
||||||
senderEmail={senderEmail}
|
|
||||||
dateIso={dateIso}
|
|
||||||
isSpam={isSpam}
|
|
||||||
isLast={isLast}
|
|
||||||
starred={starred}
|
|
||||||
onToggleStar={onToggleStar}
|
|
||||||
onCollapse={onCollapse}
|
|
||||||
onPrintConversation={onPrintConversation}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"px-4 pl-[68px] max-sm:pl-4 max-sm:pr-4",
|
|
||||||
attachments.length > 0 ? "pb-0" : "pb-4"
|
|
||||||
)}
|
|
||||||
data-selectable-text
|
|
||||||
>
|
|
||||||
<SandboxedContent html={body} isSpam={isSpam} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{attachments.length > 0 && (
|
|
||||||
<MessageAttachmentsSection attachments={attachments} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Spam explainer (preview) ── */
|
|
||||||
|
|
||||||
function SpamWhyBanner({ onNotSpam }: { onNotSpam?: () => void }) {
|
|
||||||
return (
|
|
||||||
<div className="mx-6 mb-4 flex items-start gap-3 rounded-lg border border-border bg-muted px-4 py-3.5 max-sm:mx-4">
|
|
||||||
<div className="min-w-0 flex-1 space-y-3">
|
|
||||||
<p className="text-sm leading-snug text-foreground/80">
|
|
||||||
<span className="font-medium text-foreground">Pourquoi ce message est-il dans le spam ?</span>{" "}
|
|
||||||
Ce message est semblable à des messages identifiés comme spam par le passé.
|
|
||||||
</p>
|
|
||||||
{onNotSpam && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onNotSpam}
|
|
||||||
className="rounded-md border border-border bg-mail-surface px-4 py-2 text-sm font-medium text-primary shadow-sm transition-colors hover:bg-accent"
|
|
||||||
>
|
|
||||||
Signaler comme non-spam
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-accent"
|
|
||||||
aria-label="En savoir plus sur le filtre anti-spam"
|
|
||||||
>
|
|
||||||
<Info className="h-[18px] w-[18px]" strokeWidth={1.75} />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="left" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs text-xs")}>
|
|
||||||
Les filtres peuvent se tromper. Si le message est légitime, signalez-le comme non-spam pour
|
|
||||||
l'améliorer.
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Main EmailView component ── */
|
/* ── Main EmailView component ── */
|
||||||
|
|
||||||
export function EmailView({
|
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,
|
User,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { emails } from "@/lib/email-data"
|
import { emails } from "@/lib/email-data"
|
||||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||||
@ -40,24 +30,13 @@ import {
|
|||||||
type SearchSuggestion,
|
type SearchSuggestion,
|
||||||
} from "@/lib/mail-search/search-engine"
|
} from "@/lib/mail-search/search-engine"
|
||||||
import {
|
import {
|
||||||
buildSearchUrl,
|
|
||||||
parseSearchParams,
|
parseSearchParams,
|
||||||
EMPTY_SEARCH_PARAMS,
|
|
||||||
DATE_RANGE_OPTIONS,
|
|
||||||
SEARCH_IN_OPTIONS,
|
|
||||||
type SearchParams,
|
|
||||||
} from "@/lib/mail-search/search-params"
|
} from "@/lib/mail-search/search-params"
|
||||||
import {
|
import {
|
||||||
buildQuickSearchParams,
|
buildQuickSearchParams,
|
||||||
submitMailSearch,
|
submitMailSearch,
|
||||||
} from "@/lib/mail-search/navigate"
|
} from "@/lib/mail-search/navigate"
|
||||||
import {
|
import { MAIL_SEARCH_SUGGESTIONS_DROPDOWN_CLASS } from "@/lib/mail-chrome-classes"
|
||||||
MAIL_SEARCH_ADVANCED_PANEL_CLASS,
|
|
||||||
MAIL_SEARCH_CHECKBOX_CLASS,
|
|
||||||
MAIL_SEARCH_FIELD_CLASS,
|
|
||||||
MAIL_SEARCH_SECTION_DIVIDER_CLASS,
|
|
||||||
MAIL_SEARCH_SUGGESTIONS_DROPDOWN_CLASS,
|
|
||||||
} from "@/lib/mail-chrome-classes"
|
|
||||||
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
||||||
|
|
||||||
interface MailSearchBarProps {
|
interface MailSearchBarProps {
|
||||||
@ -65,220 +44,7 @@ interface MailSearchBarProps {
|
|||||||
compact?: boolean
|
compact?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Advanced Search Panel ───────────────────────────────────────────────────
|
import { AdvancedSearchPanel } from "@/components/gmail/mail-search/advanced-search-panel"
|
||||||
|
|
||||||
function AdvancedSearchPanel({
|
|
||||||
onClose,
|
|
||||||
initialQuery,
|
|
||||||
currentParams,
|
|
||||||
}: {
|
|
||||||
onClose: () => void
|
|
||||||
initialQuery: string
|
|
||||||
currentParams: SearchParams | null
|
|
||||||
}) {
|
|
||||||
const router = useRouter()
|
|
||||||
const [from, setFrom] = useState(currentParams?.from ?? "")
|
|
||||||
const [to, setTo] = useState(currentParams?.to ?? "")
|
|
||||||
const [subject, setSubject] = useState(currentParams?.subject ?? "")
|
|
||||||
const [hasWords, setHasWords] = useState(
|
|
||||||
currentParams?.hasWords || currentParams?.q || initialQuery
|
|
||||||
)
|
|
||||||
const [doesNotHave, setDoesNotHave] = useState(currentParams?.doesNotHave ?? "")
|
|
||||||
const [sizeVal, setSizeVal] = useState(currentParams?.size ?? "")
|
|
||||||
const [sizeOp, setSizeOp] = useState<"gt" | "lt">(currentParams?.sizeOp ?? "gt")
|
|
||||||
const [sizeUnit, setSizeUnit] = useState<"Mo" | "Ko">(currentParams?.sizeUnit ?? "Mo")
|
|
||||||
const [within, setWithin] = useState(currentParams?.within ?? "")
|
|
||||||
const [dateAfter, setDateAfter] = useState(currentParams?.after ?? "")
|
|
||||||
const [searchIn, setSearchIn] = useState(currentParams?.in ?? "all")
|
|
||||||
const [hasAttachment, setHasAttachment] = useState(
|
|
||||||
currentParams?.has?.includes("attachment") ?? false
|
|
||||||
)
|
|
||||||
const [excludeChats, setExcludeChats] = useState(currentParams?.excludeChats ?? false)
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
const params: Partial<SearchParams> = {
|
|
||||||
...EMPTY_SEARCH_PARAMS,
|
|
||||||
q: "",
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
subject,
|
|
||||||
hasWords,
|
|
||||||
doesNotHave,
|
|
||||||
size: sizeVal,
|
|
||||||
sizeOp,
|
|
||||||
sizeUnit,
|
|
||||||
within,
|
|
||||||
after: dateAfter,
|
|
||||||
in: searchIn,
|
|
||||||
has: hasAttachment ? ["attachment"] : [],
|
|
||||||
excludeChats,
|
|
||||||
}
|
|
||||||
router.push(buildSearchUrl(params))
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={MAIL_SEARCH_ADVANCED_PANEL_CLASS}>
|
|
||||||
<div className="space-y-3 p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Label className="w-36 shrink-0 text-sm text-muted-foreground">De</Label>
|
|
||||||
<Input
|
|
||||||
value={from}
|
|
||||||
onChange={(e) => setFrom(e.target.value)}
|
|
||||||
className={cn("h-8 flex-1 px-2 text-sm", MAIL_SEARCH_FIELD_CLASS)}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Label className="w-36 shrink-0 text-sm text-muted-foreground">À</Label>
|
|
||||||
<Input
|
|
||||||
value={to}
|
|
||||||
onChange={(e) => setTo(e.target.value)}
|
|
||||||
className={cn("h-8 flex-1 px-2 text-sm", MAIL_SEARCH_FIELD_CLASS)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Label className="w-36 shrink-0 text-sm text-muted-foreground">Objet</Label>
|
|
||||||
<Input
|
|
||||||
value={subject}
|
|
||||||
onChange={(e) => setSubject(e.target.value)}
|
|
||||||
className={cn("h-8 flex-1 px-2 text-sm", MAIL_SEARCH_FIELD_CLASS)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Label className="w-36 shrink-0 text-sm text-muted-foreground">
|
|
||||||
Contient les mots
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={hasWords}
|
|
||||||
onChange={(e) => setHasWords(e.target.value)}
|
|
||||||
className={cn("h-8 flex-1 px-2 text-sm", MAIL_SEARCH_FIELD_CLASS)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Label className="w-36 shrink-0 text-sm text-muted-foreground">
|
|
||||||
Ne contient pas
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={doesNotHave}
|
|
||||||
onChange={(e) => setDoesNotHave(e.target.value)}
|
|
||||||
className={cn("h-8 flex-1 px-2 text-sm", MAIL_SEARCH_FIELD_CLASS)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Label className="w-36 shrink-0 text-sm text-muted-foreground">Taille</Label>
|
|
||||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
|
||||||
<Select value={sizeOp} onValueChange={(v) => setSizeOp(v as "gt" | "lt")}>
|
|
||||||
<SelectTrigger className={cn("h-8 w-32 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="gt">supérieure à</SelectItem>
|
|
||||||
<SelectItem value="lt">inférieure à</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={sizeVal}
|
|
||||||
onChange={(e) => setSizeVal(e.target.value)}
|
|
||||||
className={cn("h-8 w-20 px-2 text-sm", MAIL_SEARCH_FIELD_CLASS)}
|
|
||||||
/>
|
|
||||||
<Select value={sizeUnit} onValueChange={(v) => setSizeUnit(v as "Mo" | "Ko")}>
|
|
||||||
<SelectTrigger className={cn("h-8 w-20 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="Mo">Mo</SelectItem>
|
|
||||||
<SelectItem value="Ko">Ko</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Label className="w-36 shrink-0 text-sm text-muted-foreground">
|
|
||||||
Plage de dates
|
|
||||||
</Label>
|
|
||||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
|
||||||
<Select value={within} onValueChange={setWithin}>
|
|
||||||
<SelectTrigger className={cn("h-8 w-32 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
|
|
||||||
<SelectValue placeholder="Sélectionner" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{DATE_RANGE_OPTIONS.map((opt) => (
|
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={dateAfter}
|
|
||||||
onChange={(e) => setDateAfter(e.target.value)}
|
|
||||||
className={cn("h-8 min-w-0 flex-1 px-2 text-sm", MAIL_SEARCH_FIELD_CLASS)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Label className="w-36 shrink-0 text-sm text-muted-foreground">
|
|
||||||
Rechercher
|
|
||||||
</Label>
|
|
||||||
<Select value={searchIn} onValueChange={setSearchIn}>
|
|
||||||
<SelectTrigger className={cn("h-8 flex-1 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{SEARCH_IN_OPTIONS.map((opt) => (
|
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-6 pt-1">
|
|
||||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
|
||||||
<Checkbox
|
|
||||||
className={MAIL_SEARCH_CHECKBOX_CLASS}
|
|
||||||
checked={hasAttachment}
|
|
||||||
onCheckedChange={(v) => setHasAttachment(v === true)}
|
|
||||||
/>
|
|
||||||
Contenant une pièce jointe
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
|
||||||
<Checkbox
|
|
||||||
className={MAIL_SEARCH_CHECKBOX_CLASS}
|
|
||||||
checked={excludeChats}
|
|
||||||
onCheckedChange={(v) => setExcludeChats(v === true)}
|
|
||||||
/>
|
|
||||||
Ne pas inclure les chats
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center justify-end gap-3 border-t pt-3",
|
|
||||||
MAIL_SEARCH_SECTION_DIVIDER_CLASS
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Button variant="ghost" className="text-sm text-blue-600" disabled>
|
|
||||||
Créer un filtre
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="bg-[#1a73e8] text-sm text-white hover:bg-[#1765cc]"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
>
|
|
||||||
Rechercher
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main Search Bar ─────────────────────────────────────────────────────────
|
// ─── Main Search Bar ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -454,10 +220,10 @@ export function MailSearchBar({
|
|||||||
className={cn("relative flex w-full min-w-0 flex-col overflow-visible", className)}
|
className={cn("relative flex w-full min-w-0 flex-col overflow-visible", className)}
|
||||||
>
|
>
|
||||||
{/* Input row */}
|
{/* Input row */}
|
||||||
<div className="relative flex w-full min-w-0 items-center">
|
<div className="relative flex w-full min-w-0 items-center text-[#5f6368] dark:text-[#9aa0a6]">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-none absolute flex items-center text-gray-500",
|
"pointer-events-none absolute flex items-center",
|
||||||
compact ? "left-4" : "left-3.5"
|
compact ? "left-4" : "left-3.5"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -491,7 +257,7 @@ export function MailSearchBar({
|
|||||||
onBlur={() => setFocused(false)}
|
onBlur={() => setFocused(false)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-12 w-full rounded-full border-0 bg-muted text-sm outline-none placeholder:text-gray-500",
|
"h-12 w-full rounded-full border-0 bg-muted text-sm text-foreground outline-none placeholder-shown:text-inherit placeholder:opacity-100",
|
||||||
focused || advancedOpen
|
focused || advancedOpen
|
||||||
? "bg-white shadow-md ring-1 ring-gray-300 dark:bg-gray-900 dark:ring-gray-600"
|
? "bg-white shadow-md ring-1 ring-gray-300 dark:bg-gray-900 dark:ring-gray-600"
|
||||||
: "",
|
: "",
|
||||||
|
|||||||
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"
|
} from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
|
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { emails } from "@/lib/email-data"
|
import { emails } from "@/lib/email-data"
|
||||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||||
@ -40,13 +30,6 @@ import {
|
|||||||
bestCompletion,
|
bestCompletion,
|
||||||
type SearchSuggestion,
|
type SearchSuggestion,
|
||||||
} from "@/lib/mail-search/search-engine"
|
} from "@/lib/mail-search/search-engine"
|
||||||
import {
|
|
||||||
buildSearchUrl,
|
|
||||||
EMPTY_SEARCH_PARAMS,
|
|
||||||
DATE_RANGE_OPTIONS,
|
|
||||||
SEARCH_IN_OPTIONS,
|
|
||||||
type SearchParams,
|
|
||||||
} from "@/lib/mail-search/search-params"
|
|
||||||
import {
|
import {
|
||||||
buildQuickSearchParams,
|
buildQuickSearchParams,
|
||||||
submitMailSearch,
|
submitMailSearch,
|
||||||
@ -55,12 +38,11 @@ import { useMailSearchStore } from "@/lib/stores/mail-search-store"
|
|||||||
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
||||||
import {
|
import {
|
||||||
MAIL_MOBILE_SEARCH_SHEET_CLASS,
|
MAIL_MOBILE_SEARCH_SHEET_CLASS,
|
||||||
MAIL_SEARCH_CHECKBOX_CLASS,
|
|
||||||
MAIL_SEARCH_CHIP_INACTIVE_CLASS,
|
MAIL_SEARCH_CHIP_INACTIVE_CLASS,
|
||||||
MAIL_SEARCH_FIELD_CLASS,
|
|
||||||
MAIL_SEARCH_SECTION_DIVIDER_CLASS,
|
MAIL_SEARCH_SECTION_DIVIDER_CLASS,
|
||||||
MAIL_SEARCH_SUGGESTIONS_SURFACE_CLASS,
|
MAIL_SEARCH_SUGGESTIONS_SURFACE_CLASS,
|
||||||
} from "@/lib/mail-chrome-classes"
|
} from "@/lib/mail-chrome-classes"
|
||||||
|
import { MobileAdvancedSearch } from "@/components/gmail/mail-search/mobile-advanced-search"
|
||||||
|
|
||||||
interface MobileSearchOverlayProps {
|
interface MobileSearchOverlayProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@ -394,176 +376,3 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
|
|||||||
</Sheet>
|
</Sheet>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Mobile Advanced Search ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function MobileAdvancedSearch({
|
|
||||||
initialQuery,
|
|
||||||
onSubmit,
|
|
||||||
}: {
|
|
||||||
initialQuery: string
|
|
||||||
onSubmit: (url: string) => void
|
|
||||||
}) {
|
|
||||||
const [from, setFrom] = useState("")
|
|
||||||
const [to, setTo] = useState("")
|
|
||||||
const [subject, setSubject] = useState("")
|
|
||||||
const [hasWords, setHasWords] = useState(initialQuery)
|
|
||||||
const [doesNotHave, setDoesNotHave] = useState("")
|
|
||||||
const [sizeVal, setSizeVal] = useState("")
|
|
||||||
const [sizeOp, setSizeOp] = useState<"gt" | "lt">("gt")
|
|
||||||
const [sizeUnit, setSizeUnit] = useState<"Mo" | "Ko">("Mo")
|
|
||||||
const [within, setWithin] = useState("")
|
|
||||||
const [searchIn, setSearchIn] = useState("all")
|
|
||||||
const [hasAttachment, setHasAttachment] = useState(false)
|
|
||||||
const [excludeChats, setExcludeChats] = useState(false)
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
const params: Partial<SearchParams> = {
|
|
||||||
...EMPTY_SEARCH_PARAMS,
|
|
||||||
q: "",
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
subject,
|
|
||||||
hasWords,
|
|
||||||
doesNotHave,
|
|
||||||
size: sizeVal,
|
|
||||||
sizeOp,
|
|
||||||
sizeUnit,
|
|
||||||
within,
|
|
||||||
in: searchIn,
|
|
||||||
has: hasAttachment ? ["attachment"] : [],
|
|
||||||
excludeChats,
|
|
||||||
}
|
|
||||||
onSubmit(buildSearchUrl(params))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs text-muted-foreground">De</Label>
|
|
||||||
<Input
|
|
||||||
value={from}
|
|
||||||
onChange={(e) => setFrom(e.target.value)}
|
|
||||||
className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs text-muted-foreground">À</Label>
|
|
||||||
<Input
|
|
||||||
value={to}
|
|
||||||
onChange={(e) => setTo(e.target.value)}
|
|
||||||
className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs text-muted-foreground">Objet</Label>
|
|
||||||
<Input
|
|
||||||
value={subject}
|
|
||||||
onChange={(e) => setSubject(e.target.value)}
|
|
||||||
className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs text-muted-foreground">Contient les mots</Label>
|
|
||||||
<Input
|
|
||||||
value={hasWords}
|
|
||||||
onChange={(e) => setHasWords(e.target.value)}
|
|
||||||
className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs text-muted-foreground">Ne contient pas</Label>
|
|
||||||
<Input
|
|
||||||
value={doesNotHave}
|
|
||||||
onChange={(e) => setDoesNotHave(e.target.value)}
|
|
||||||
className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs text-muted-foreground">Taille</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Select value={sizeOp} onValueChange={(v) => setSizeOp(v as "gt" | "lt")}>
|
|
||||||
<SelectTrigger className={cn("h-9 flex-1 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="gt">supérieure à</SelectItem>
|
|
||||||
<SelectItem value="lt">inférieure à</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={sizeVal}
|
|
||||||
onChange={(e) => setSizeVal(e.target.value)}
|
|
||||||
className={cn("h-9 w-20 text-sm", MAIL_SEARCH_FIELD_CLASS)}
|
|
||||||
/>
|
|
||||||
<Select value={sizeUnit} onValueChange={(v) => setSizeUnit(v as "Mo" | "Ko")}>
|
|
||||||
<SelectTrigger className={cn("h-9 w-20 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="Mo">Mo</SelectItem>
|
|
||||||
<SelectItem value="Ko">Ko</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs text-muted-foreground">Plage de dates</Label>
|
|
||||||
<Select value={within} onValueChange={setWithin}>
|
|
||||||
<SelectTrigger className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
|
|
||||||
<SelectValue placeholder="Sélectionner" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{DATE_RANGE_OPTIONS.map((opt) => (
|
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs text-muted-foreground">Rechercher dans</Label>
|
|
||||||
<Select value={searchIn} onValueChange={setSearchIn}>
|
|
||||||
<SelectTrigger className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{SEARCH_IN_OPTIONS.map((opt) => (
|
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3 pt-1">
|
|
||||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
|
||||||
<Checkbox
|
|
||||||
className={MAIL_SEARCH_CHECKBOX_CLASS}
|
|
||||||
checked={hasAttachment}
|
|
||||||
onCheckedChange={(v) => setHasAttachment(v === true)}
|
|
||||||
/>
|
|
||||||
Contenant une pièce jointe
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
|
||||||
<Checkbox
|
|
||||||
className={MAIL_SEARCH_CHECKBOX_CLASS}
|
|
||||||
checked={excludeChats}
|
|
||||||
onCheckedChange={(v) => setExcludeChats(v === true)}
|
|
||||||
/>
|
|
||||||
Ne pas inclure les chats
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
className="w-full bg-[#1a73e8] text-sm text-white hover:bg-[#1765cc]"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
>
|
|
||||||
Rechercher
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -15,8 +15,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
SidebarNavOptionsSheet,
|
SidebarNavOptionsSheet,
|
||||||
SidebarNavSheetAction,
|
SidebarNavSheetAction,
|
||||||
} from "@/components/gmail/sidebar-nav-options-sheet"
|
} from "@/components/gmail/sidebar/sidebar-nav-options-sheet"
|
||||||
import { useSidebarTouchOptionsMenu } from "@/components/gmail/use-sidebar-touch-options"
|
import { useSidebarTouchOptionsMenu } from "@/components/gmail/sidebar/use-sidebar-touch-options"
|
||||||
import type { CategoryNavSourceItem } from "@/components/gmail/sidebar/sidebar-nav-constants"
|
import type { CategoryNavSourceItem } from "@/components/gmail/sidebar/sidebar-nav-constants"
|
||||||
import {
|
import {
|
||||||
navRowRoundedWhenActive,
|
navRowRoundedWhenActive,
|
||||||
|
|||||||
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
|
label: string
|
||||||
icon?: string
|
icon?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sticky row height (px) — section headers and open folder branches. */
|
||||||
|
export const MAIL_SIDEBAR_STICKY_ROW_PX = 32
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sticky z-index stack in the Dossiers section (higher = covers lower when scrolling):
|
||||||
|
* Dossiers header > root folder > subfolder > nested…
|
||||||
|
*/
|
||||||
|
export const MAIL_SIDEBAR_DOSSIERS_SECTION_STICKY_Z = 40
|
||||||
|
export const MAIL_SIDEBAR_FOLDER_BRANCH_STICKY_Z_BASE = 35
|
||||||
|
|
||||||
|
export function mailSidebarFolderBranchStickyZ(depth: number) {
|
||||||
|
return Math.max(1, MAIL_SIDEBAR_FOLDER_BRANCH_STICKY_Z_BASE - depth)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mailSidebarFolderBranchStickyTopPx(depth: number) {
|
||||||
|
return MAIL_SIDEBAR_STICKY_ROW_PX + depth * MAIL_SIDEBAR_STICKY_ROW_PX
|
||||||
|
}
|
||||||
|
|||||||
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"
|
"data-mail-mobile-search"
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Sidebar — panneau desktop (flou sur le canvas). */
|
/** Sidebar — xs only (flou sur le canvas). sm+ stays transparent with NO backdrop-filter
|
||||||
|
* so descendant sticky rows can use their own backdrop-filter freely. */
|
||||||
export const MAIL_SIDEBAR_PANEL_SURFACE_CLASS = cn(
|
export const MAIL_SIDEBAR_PANEL_SURFACE_CLASS = cn(
|
||||||
"bg-app-canvas/80 backdrop-blur-xl backdrop-saturate-150 supports-[backdrop-filter]:bg-app-canvas/65"
|
"max-sm:bg-app-canvas/80 max-sm:backdrop-blur-xl max-sm:backdrop-saturate-150 max-sm:supports-[backdrop-filter]:bg-app-canvas/65",
|
||||||
|
"sm:bg-transparent"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Sidebar — frosted strips (Sortbot, section headers, open folder branches). */
|
||||||
|
export const MAIL_SIDEBAR_BLUR_SURFACE_CLASS = "mail-sidebar-blur-surface"
|
||||||
|
|
||||||
/** Sidebar — overlay mobile/touch : classe CSS dédiée (pas bg-* Tailwind). */
|
/** Sidebar — overlay mobile/touch : classe CSS dédiée (pas bg-* Tailwind). */
|
||||||
export const MAIL_SIDEBAR_PANEL_SURFACE_MOBILE_CLASS = "mail-sidebar-overlay-panel"
|
export const MAIL_SIDEBAR_PANEL_SURFACE_MOBILE_CLASS = "mail-sidebar-overlay-panel"
|
||||||
|
|
||||||
|
|||||||
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