major perf improvements

This commit is contained in:
R3D347HR4Y 2026-05-15 23:51:57 +02:00
parent 22e7b8e1d2
commit 6af6e62774
18 changed files with 399 additions and 101 deletions

View File

@ -67,7 +67,8 @@ import {
DEFAULT_IDENTITIES, DEFAULT_IDENTITIES,
MOCK_CONTACTS, MOCK_CONTACTS,
SIGNATURES, SIGNATURES,
useCompose, useComposeActions,
useComposeWindows,
} from "@/lib/compose-context" } from "@/lib/compose-context"
import { useScheduledMail } from "@/lib/scheduled-mail-context" import { useScheduledMail } from "@/lib/scheduled-mail-context"
import type { ScheduleSendPayload } from "@/lib/api/scheduled-mail" import type { ScheduleSendPayload } from "@/lib/api/scheduled-mail"
@ -987,7 +988,7 @@ function SignatureButton({
editor: Editor | null editor: Editor | null
compose: ComposeState compose: ComposeState
}) { }) {
const { updateCompose } = useCompose() const { updateCompose } = useComposeActions()
const replaceSignature = useCallback( const replaceSignature = useCallback(
(sigId: string | null) => { (sigId: string | null) => {
@ -1233,7 +1234,7 @@ export function ComposeWindow({
toggleMinimize, toggleMinimize,
toggleMaximize, toggleMaximize,
restoreComposeFromSnapshot, restoreComposeFromSnapshot,
} = useCompose() } = useComposeActions()
const { scheduleSend, requestUpdateScheduledSend, requestSendScheduledNow } = const { scheduleSend, requestUpdateScheduledSend, requestSendScheduledNow } =
useScheduledMail() useScheduledMail()
const isInline = compose.placement === "inline" const isInline = compose.placement === "inline"
@ -2226,7 +2227,7 @@ export function ComposeWindow({
} }
export function ComposeModalManager() { export function ComposeModalManager() {
const { composeWindows } = useCompose() const { composeWindows } = useComposeWindows()
const isXs = useIsXs() const isXs = useIsXs()
const nonMaximized = composeWindows.filter( const nonMaximized = composeWindows.filter(

View File

@ -23,7 +23,7 @@ import {
UserPlus, UserPlus,
Video, Video,
} from "lucide-react" } from "lucide-react"
import { useCompose } from "@/lib/compose-context" import { useComposeActions } from "@/lib/compose-context"
export interface ContactHoverCardProps { export interface ContactHoverCardProps {
/** Champ expéditeur brut (liste, conversation, etc.) */ /** Champ expéditeur brut (liste, conversation, etc.) */
@ -45,7 +45,7 @@ export function ContactHoverCard({
align = "start", align = "start",
side = "bottom", side = "bottom",
}: ContactHoverCardProps) { }: ContactHoverCardProps) {
const { openComposeWithInitial } = useCompose() const { openComposeWithInitial } = useComposeActions()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const name = cleanSenderName(displayName) const name = cleanSenderName(displayName)
const email = resolveSenderEmail(displayName, emailOverride) const email = resolveSenderEmail(displayName, emailOverride)

View File

@ -0,0 +1,49 @@
"use client"
import { useCallback } from "react"
import type { Email } from "@/lib/email-data"
import { useMailStore } from "@/lib/stores/mail-store"
type ListMailIndex = {
emailById: Map<string, Email>
scheduledIds: Set<string>
}
/** O(n) index for list row logic — avoids repeated `allEmails.some` / `find` per row. */
export function buildListMailIndex(emails: Email[]): ListMailIndex {
const emailById = new Map<string, Email>()
const scheduledIds = new Set<string>()
for (const e of emails) {
emailById.set(e.id, e)
if (e.labels?.includes("scheduled")) scheduledIds.add(e.id)
}
return { emailById, scheduledIds }
}
export type MailRowFlags = {
isRead: boolean
isStarred: boolean
isImportant: boolean
}
/**
* Per-row mail UI flags from the persisted mail store.
* Use inside a keyed `memo` row component (not a plain `.map` callback).
*/
export function useMailRowFlags(email: Email): MailRowFlags {
const id = email.id
const readOverride = useMailStore(
useCallback((s) => s.readOverrides[id], [id])
)
const starred = useMailStore(
useCallback((s) => s.starredIds.includes(id), [id])
)
const important = useMailStore(
useCallback((s) => s.importantIds.includes(id), [id])
)
return {
isRead: readOverride !== undefined ? readOverride : email.read,
isStarred: starred || email.starred,
isImportant: important || email.important,
}
}

View File

@ -121,7 +121,12 @@ import {
type MoveTarget, type MoveTarget,
} from "@/components/gmail/move-to-menu-items" } from "@/components/gmail/move-to-menu-items"
import { EmailView } from "./email-view" import { EmailView } from "./email-view"
import { useCompose, type Contact } from "@/lib/compose-context" import { buildListMailIndex } from "./email-list-row"
import {
useComposeActions,
useComposeDrafts,
type Contact,
} from "@/lib/compose-context"
import { computeFolderUnreadCounts } from "@/lib/mail-nav-metrics" import { computeFolderUnreadCounts } from "@/lib/mail-nav-metrics"
import { import {
effectiveLabels, effectiveLabels,
@ -595,12 +600,12 @@ export function EmailList({
}: EmailListProps) { }: EmailListProps) {
const isViewMode = openMailId !== null const isViewMode = openMailId !== null
const { savedThreadReplyDrafts } = useComposeDrafts()
const { const {
openComposeWithInitial, openComposeWithInitial,
closeAllInlineComposes, closeAllInlineComposes,
pruneInlineComposesToOpenThread, pruneInlineComposesToOpenThread,
savedThreadReplyDrafts, } = useComposeActions()
} = useCompose()
const { const {
scheduledEmails, scheduledEmails,
@ -884,13 +889,26 @@ export function EmailList({
}, [isRefreshing, isViewMode, isXs, applyPullVisual]) }, [isRefreshing, isViewMode, isXs, applyPullVisual])
const filteredEmails = useMemo(() => { const filteredEmails = useMemo(() => {
const visible = allEmails const hiddenSet = new Set(hiddenEmailIds)
.filter((email) => !hiddenEmailIds.includes(email.id)) const subtreeIdsCache = new Map<string, string[] | null>()
.map((e) => 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) mergeEmailNotSpam(mergeEmailLabelEdits(e, labelEdits), notSpamEmailIds)
) )
}
let rows = visible.filter((email) => let rows = visible.filter((email) =>
emailMatchesFolder(email, selectedFolder, folderFilterCtx, navMaps) emailMatchesFolder(
email,
selectedFolder,
folderFilterCtx,
navMaps,
subtreeIdsCache
)
) )
if (selectedFolder === "inbox") { if (selectedFolder === "inbox") {
rows = rows.filter((email) => email.category === inboxTab) rows = rows.filter((email) => email.category === inboxTab)
@ -947,6 +965,21 @@ export function EmailList({
return pagedEmails return pagedEmails
}, [isXs, isViewMode, filteredEmails, mobileVisibleCount, pagedEmails]) }, [isXs, isViewMode, filteredEmails, 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[]>()
for (const e of listEmails) {
invitationById.set(e.id, resolveParsedCalendarInvitation(e))
attachmentsById.set(e.id, attachmentsForEmailList(e))
}
return { invitationById, attachmentsById }
}, [listEmails])
useEffect(() => { useEffect(() => {
if (isXs) return if (isXs) return
if (listPage > totalPages) { if (listPage > totalPages) {
@ -2658,7 +2691,7 @@ export function EmailList({
<div <div
ref={listViewportRef} ref={listViewportRef}
className={cn(mainScrollClass, "relative overscroll-y-none", !isViewMode && "max-sm:pb-16")} className={cn(mainScrollClass, "relative overscroll-y-none max-sm:pb-16")}
> >
{!isViewMode && ( {!isViewMode && (
<div <div
@ -2770,8 +2803,10 @@ export function EmailList({
const senderForSearch = email.sender.replace(/\s+/g, " ").trim() const senderForSearch = email.sender.replace(/\s+/g, " ").trim()
const isSelected = selectedEmails.includes(email.id) const isSelected = selectedEmails.includes(email.id)
const hasInvitation = email.hasInvitation === true const hasInvitation = email.hasInvitation === true
const parsedInvitation = resolveParsedCalendarInvitation(email) const parsedInvitation =
const attachmentList = attachmentsForEmailList(email) listRowExtras.invitationById.get(email.id) ?? null
const attachmentList =
listRowExtras.attachmentsById.get(email.id) ?? []
const isScheduled = email.labels?.includes("scheduled") === true const isScheduled = email.labels?.includes("scheduled") === true
const contextTargetIds = contextMenuTargetIdsForRow( const contextTargetIds = contextMenuTargetIdsForRow(
email.id, email.id,
@ -2782,14 +2817,12 @@ export function EmailList({
const allContextTargetsScheduled = const allContextTargetsScheduled =
contextTargetIds.length > 0 && contextTargetIds.length > 0 &&
contextTargetIds.every((id) => contextTargetIds.every((id) =>
allEmails.some( listMailIndex.scheduledIds.has(id)
(e) => e.id === id && e.labels?.includes("scheduled")
)
) )
const scheduledCtxAnyUnread = const scheduledCtxAnyUnread =
allContextTargetsScheduled && allContextTargetsScheduled &&
contextTargetIds.some((id) => { contextTargetIds.some((id) => {
const em = allEmails.find((e) => e.id === id) const em = listMailIndex.emailById.get(id)
if (!em) return false if (!em) return false
return !(readOverrides[id] ?? em.read) return !(readOverrides[id] ?? em.read)
}) })

View File

@ -61,7 +61,9 @@ import {
shouldUseAttachmentPillsInPreview, shouldUseAttachmentPillsInPreview,
} from "@/lib/attachment-display" } from "@/lib/attachment-display"
import { import {
useCompose, useComposeActions,
useComposeDrafts,
useComposeWindows,
DEFAULT_IDENTITIES, DEFAULT_IDENTITIES,
type ThreadComposeKind, type ThreadComposeKind,
savedThreadDraftToComposePreset, savedThreadDraftToComposePreset,
@ -761,8 +763,9 @@ export function EmailView({
const mainSenderName = cleanSenderName(email.sender) const mainSenderName = cleanSenderName(email.sender)
const mainSenderAddr = email.senderEmail || `${mainSenderName.toLowerCase().replace(/\s+/g, ".")}@example.com` const mainSenderAddr = email.senderEmail || `${mainSenderName.toLowerCase().replace(/\s+/g, ".")}@example.com`
const { composeWindows, openComposeWithInitial, savedThreadReplyDrafts } = const { composeWindows } = useComposeWindows()
useCompose() const { savedThreadReplyDrafts } = useComposeDrafts()
const { openComposeWithInitial } = useComposeActions()
const inlineCompose = useMemo( const inlineCompose = useMemo(
() => () =>
composeWindows.find( composeWindows.find(

View File

@ -3,7 +3,7 @@
import { useState, useRef, useEffect, useCallback } from "react" import { useState, useRef, useEffect, useCallback } from "react"
import { Menu, Search, X, ChevronLeft, Pencil } from "lucide-react" import { Menu, Search, X, ChevronLeft, Pencil } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { useCompose } from "@/lib/compose-context" import { useComposeActions } from "@/lib/compose-context"
interface MobileBottomBarProps { interface MobileBottomBarProps {
sidebarOpen: boolean sidebarOpen: boolean
@ -16,7 +16,7 @@ export function MobileBottomBar({
}: MobileBottomBarProps) { }: MobileBottomBarProps) {
const [searchValue, setSearchValue] = useState("") const [searchValue, setSearchValue] = useState("")
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const { openCompose } = useCompose() const { openCompose } = useComposeActions()
const hasSearch = searchValue.length > 0 const hasSearch = searchValue.length > 0

View File

@ -1,9 +1,13 @@
"use client" "use client"
import { useEffect, useLayoutEffect, useRef, useState } from "react" import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react"
import { createPortal } from "react-dom" import { createPortal } from "react-dom"
import { Mail } from "lucide-react" import { Mail } from "lucide-react"
import { EMAIL_DRAG_RETURN_MS, useEmailDrag } from "@/lib/drag-context" import { EMAIL_DRAG_RETURN_MS, useEmailDrag } from "@/lib/drag-context"
import {
getDragPointerSnapshot,
subscribeDragPointer,
} from "@/lib/drag-pointer-store"
import { useIsXs } from "@/hooks/use-xs" import { useIsXs } from "@/hooks/use-xs"
export function MoveDragIndicator() { export function MoveDragIndicator() {
@ -12,24 +16,16 @@ export function MoveDragIndicator() {
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
const elRef = useRef<HTMLDivElement>(null) const elRef = useRef<HTMLDivElement>(null)
const livePointer = useSyncExternalStore(
subscribeDragPointer,
getDragPointerSnapshot,
() => ({ x: 0, y: 0 })
)
useEffect(() => { useEffect(() => {
setMounted(true) setMounted(true)
}, []) }, [])
/**
* When the user releases the drag outside any valid target the provider
* flips `phase` to "returning". We then drive a Web-Animations API tween
* from the current cursor position back to the origin (where the drag
* started) while fading out, to clearly signal "no move happened".
*
* `useLayoutEffect` (not `useEffect`) so the animation is queued in the
* same paint as the phase flip otherwise the indicator visibly pauses
* for one frame at the release point before animating.
*
* We animate the `translate` CSS property (NOT `transform`) so the
* inline `transform: translate(-50%, -50%)` used for centering stays
* untouched and composes additively with the animated translation.
*/
useLayoutEffect(() => { useLayoutEffect(() => {
if (!state) return if (!state) return
if (state.phase !== "returning") return if (state.phase !== "returning") return
@ -59,14 +55,17 @@ export function MoveDragIndicator() {
const label = const label =
count > 1 ? `Déplacer ${count} conversations` : "Déplacer 1 conversation" count > 1 ? `Déplacer ${count} conversations` : "Déplacer 1 conversation"
const x = state.phase === "returning" ? state.pointerX : livePointer.x
const y = state.phase === "returning" ? state.pointerY : livePointer.y
return createPortal( return createPortal(
<div <div
ref={elRef} ref={elRef}
aria-hidden aria-hidden
className="pointer-events-none fixed z-100 select-none" className="pointer-events-none fixed z-100 select-none"
style={{ style={{
left: state.pointerX, left: x,
top: state.pointerY, top: y,
transform: "translate(-50%, -50%)", transform: "translate(-50%, -50%)",
willChange: "translate, opacity", willChange: "translate, opacity",
}} }}

View File

@ -34,7 +34,7 @@ import { cn, formatCount } from "@/lib/utils"
import { readXsMatches } from "@/hooks/use-xs" import { readXsMatches } from "@/hooks/use-xs"
import { useState, useRef, useEffect, useMemo, type ReactNode, type CSSProperties } from "react" import { useState, useRef, useEffect, useMemo, type ReactNode, type CSSProperties } from "react"
import { useEmailDropTarget } from "@/lib/drag-context" import { useEmailDropTarget } from "@/lib/drag-context"
import { useCompose } from "@/lib/compose-context" import { useComposeActions } from "@/lib/compose-context"
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -504,7 +504,7 @@ export function Sidebar({
collapsed, collapsed,
folderUnreadCounts = {}, folderUnreadCounts = {},
}: SidebarProps) { }: SidebarProps) {
const { openCompose } = useCompose() const { openCompose } = useComposeActions()
const [hoverExpanded, setHoverExpanded] = useState(false) const [hoverExpanded, setHoverExpanded] = useState(false)
const [navMoreOpen, setNavMoreOpen] = useState(false) const [navMoreOpen, setNavMoreOpen] = useState(false)
const [expandedFolderIds, setExpandedFolderIds] = useState<Set<string>>(() => new Set()) const [expandedFolderIds, setExpandedFolderIds] = useState<Set<string>>(() => new Set())

View File

@ -393,10 +393,7 @@ function applySavedDraftsAfterClosingInline(
return next return next
} }
type ComposeContextValue = { export type ComposeActionsContextValue = {
composeWindows: ComposeState[]
/** Brouillons de réponse/transfert par id de conversation */
savedThreadReplyDrafts: Record<string, SavedThreadReplyDraft>
openCompose: () => void openCompose: () => void
openComposeWithInitial: (preset: ComposeOpenPreset) => void openComposeWithInitial: (preset: ComposeOpenPreset) => void
/** Réouvre une fenêtre après annulation dun envoi en cours (nouvel id, baseline recalculée). */ /** Réouvre une fenêtre après annulation dun envoi en cours (nouvel id, baseline recalculée). */
@ -413,7 +410,22 @@ type ComposeContextValue = {
saveDraft: (id: string) => void saveDraft: (id: string) => void
} }
const ComposeContext = createContext<ComposeContextValue | null>(null) export type ComposeDraftsContextValue = {
/** Brouillons de réponse/transfert par id de conversation */
savedThreadReplyDrafts: Record<string, SavedThreadReplyDraft>
}
export type ComposeWindowsContextValue = {
composeWindows: ComposeState[]
}
export type ComposeContextValue = ComposeActionsContextValue &
ComposeDraftsContextValue &
ComposeWindowsContextValue
const ComposeActionsContext = createContext<ComposeActionsContextValue | null>(null)
const ComposeDraftsContext = createContext<ComposeDraftsContextValue | null>(null)
const ComposeWindowsContext = createContext<ComposeWindowsContextValue | null>(null)
export function ComposeProvider({ children }: { children: React.ReactNode }) { export function ComposeProvider({ children }: { children: React.ReactNode }) {
const [composeWindows, setComposeWindows] = useState<ComposeState[]>([]) const [composeWindows, setComposeWindows] = useState<ComposeState[]>([])
@ -660,10 +672,8 @@ export function ComposeProvider({ children }: { children: React.ReactNode }) {
) )
}, []) }, [])
const value = useMemo<ComposeContextValue>( const actionsValue = useMemo<ComposeActionsContextValue>(
() => ({ () => ({
composeWindows,
savedThreadReplyDrafts,
openCompose, openCompose,
openComposeWithInitial, openComposeWithInitial,
restoreComposeFromSnapshot, restoreComposeFromSnapshot,
@ -677,8 +687,6 @@ export function ComposeProvider({ children }: { children: React.ReactNode }) {
saveDraft, saveDraft,
}), }),
[ [
composeWindows,
savedThreadReplyDrafts,
openCompose, openCompose,
openComposeWithInitial, openComposeWithInitial,
restoreComposeFromSnapshot, restoreComposeFromSnapshot,
@ -693,13 +701,50 @@ export function ComposeProvider({ children }: { children: React.ReactNode }) {
] ]
) )
const draftsValue = useMemo<ComposeDraftsContextValue>(
() => ({ savedThreadReplyDrafts }),
[savedThreadReplyDrafts]
)
const windowsValue = useMemo<ComposeWindowsContextValue>(
() => ({ composeWindows }),
[composeWindows]
)
return ( return (
<ComposeContext.Provider value={value}>{children}</ComposeContext.Provider> <ComposeActionsContext.Provider value={actionsValue}>
<ComposeDraftsContext.Provider value={draftsValue}>
<ComposeWindowsContext.Provider value={windowsValue}>
{children}
</ComposeWindowsContext.Provider>
</ComposeDraftsContext.Provider>
</ComposeActionsContext.Provider>
) )
} }
export function useCompose() { export function useComposeActions(): ComposeActionsContextValue {
const ctx = useContext(ComposeContext) const ctx = useContext(ComposeActionsContext)
if (!ctx) throw new Error("useCompose must be used inside <ComposeProvider>") if (!ctx) throw new Error("useComposeActions must be used inside <ComposeProvider>")
return ctx return ctx
} }
export function useComposeDrafts(): ComposeDraftsContextValue {
const ctx = useContext(ComposeDraftsContext)
if (!ctx) throw new Error("useComposeDrafts must be used inside <ComposeProvider>")
return ctx
}
export function useComposeWindows(): ComposeWindowsContextValue {
const ctx = useContext(ComposeWindowsContext)
if (!ctx) throw new Error("useComposeWindows must be used inside <ComposeProvider>")
return ctx
}
/** Merged read — rerenders when windows, drafts, or any action identity changes. Prefer split hooks for perf. */
export function useCompose(): ComposeContextValue {
return {
...useComposeActions(),
...useComposeDrafts(),
...useComposeWindows(),
}
}

View File

@ -9,8 +9,12 @@ import {
useRef, useRef,
useState, useState,
} from "react" } from "react"
import { flushSync } from "react-dom"
import { useIsXs } from "@/hooks/use-xs" import { useIsXs } from "@/hooks/use-xs"
import {
getDragPointerSnapshot,
notifyDragPointerMove,
resetDragPointer,
} from "@/lib/drag-pointer-store"
export type EmailDragPhase = "dragging" | "returning" export type EmailDragPhase = "dragging" | "returning"
@ -19,6 +23,7 @@ export type EmailDragState = {
sourceFolderId: string sourceFolderId: string
hoveredTargetId: string | null hoveredTargetId: string | null
hoveredTargetLabel: string | null hoveredTargetLabel: string | null
/** Last committed pointer (begin drag + snapshot when entering `returning`). */
pointerX: number pointerX: number
pointerY: number pointerY: number
originX: number originX: number
@ -57,6 +62,7 @@ export function EmailDragProvider({ children }: { children: React.ReactNode }) {
if (!isXs) return if (!isXs) return
stateRef.current = null stateRef.current = null
setState(null) setState(null)
resetDragPointer(0, 0)
}, [isXs]) }, [isXs])
const beginDrag = useCallback( const beginDrag = useCallback(
@ -74,6 +80,7 @@ export function EmailDragProvider({ children }: { children: React.ReactNode }) {
phase: "dragging", phase: "dragging",
} }
stateRef.current = next stateRef.current = next
resetDragPointer(pointerX, pointerY)
setState(next) setState(next)
}, },
[isXs] [isXs]
@ -99,11 +106,17 @@ export function EmailDragProvider({ children }: { children: React.ReactNode }) {
const cur = stateRef.current const cur = stateRef.current
if (!cur) return if (!cur) return
if (cur.phase === "returning") return if (cur.phase === "returning") return
const next: EmailDragState = { ...cur, phase: "returning", hoveredTargetId: null } const p = getDragPointerSnapshot()
const next: EmailDragState = {
...cur,
phase: "returning",
hoveredTargetId: null,
hoveredTargetLabel: null,
pointerX: p.x,
pointerY: p.y,
}
stateRef.current = next stateRef.current = next
flushSync(() => {
setState(next) setState(next)
})
}, []) }, [])
const completeDrop = useCallback((targetId: string, targetLabel: string) => { const completeDrop = useCallback((targetId: string, targetLabel: string) => {
@ -111,9 +124,8 @@ export function EmailDragProvider({ children }: { children: React.ReactNode }) {
if (!cur) return if (!cur) return
stateRef.current = null stateRef.current = null
setState(null) setState(null)
resetDragPointer(0, 0)
if (targetId !== cur.sourceFolderId && onDropRef.current) { if (targetId !== cur.sourceFolderId && onDropRef.current) {
// Defer to a microtask so we never call setState in another
// component synchronously from within this render/commit phase.
const cb = onDropRef.current const cb = onDropRef.current
const ids = cur.ids const ids = cur.ids
queueMicrotask(() => cb(targetId, targetLabel, ids)) queueMicrotask(() => cb(targetId, targetLabel, ids))
@ -127,37 +139,27 @@ export function EmailDragProvider({ children }: { children: React.ReactNode }) {
} }
}, []) }, [])
// Track cursor + handle native end-of-drag. // Live pointer during native drag — updates external store only (no React state).
useEffect(() => { useEffect(() => {
if (!state) return if (!state || state.phase !== "dragging") return
const onDragOver = (e: DragEvent) => { const onDragOver = (e: DragEvent) => {
setState((prev) => notifyDragPointerMove(e.clientX, e.clientY)
prev && prev.phase === "dragging"
? { ...prev, pointerX: e.clientX, pointerY: e.clientY }
: prev
)
} }
const onDragEnd = () => { const onDragEnd = () => {
// If state still active and no drop was accepted, animate return.
// We deliberately keep `pointerX/Y` from the last `dragover` so the
// tween starts from the exact spot where the indicator was last
// painted. Snapping to `e.clientX/Y` would visibly shift the badge
// one frame before the animation begins.
// `flushSync` forces React to commit this state change synchronously
// so the indicator's `useLayoutEffect` (which starts the animation)
// fires in the same paint as the phase flip — no perceptible delay.
const cur = stateRef.current const cur = stateRef.current
if (!cur) return if (!cur) return
if (cur.phase === "returning") return if (cur.phase === "returning") return
const p = getDragPointerSnapshot()
const next: EmailDragState = { const next: EmailDragState = {
...cur, ...cur,
phase: "returning", phase: "returning",
hoveredTargetId: null, hoveredTargetId: null,
hoveredTargetLabel: null,
pointerX: p.x,
pointerY: p.y,
} }
stateRef.current = next stateRef.current = next
flushSync(() => {
setState(next) setState(next)
})
} }
window.addEventListener("dragover", onDragOver) window.addEventListener("dragover", onDragOver)
window.addEventListener("dragend", onDragEnd) window.addEventListener("dragend", onDragEnd)
@ -165,7 +167,7 @@ export function EmailDragProvider({ children }: { children: React.ReactNode }) {
window.removeEventListener("dragover", onDragOver) window.removeEventListener("dragover", onDragOver)
window.removeEventListener("dragend", onDragEnd) window.removeEventListener("dragend", onDragEnd)
} }
}, [state !== null]) }, [state])
// Auto-clear after the return animation finishes. // Auto-clear after the return animation finishes.
useEffect(() => { useEffect(() => {
@ -173,6 +175,7 @@ export function EmailDragProvider({ children }: { children: React.ReactNode }) {
const id = window.setTimeout(() => { const id = window.setTimeout(() => {
stateRef.current = null stateRef.current = null
setState(null) setState(null)
resetDragPointer(0, 0)
}, RETURN_ANIMATION_MS + 20) }, RETURN_ANIMATION_MS + 20)
return () => window.clearTimeout(id) return () => window.clearTimeout(id)
}, [state?.phase]) }, [state?.phase])
@ -211,12 +214,6 @@ export function useEmailDrag() {
export const EMAIL_DRAG_RETURN_MS = RETURN_ANIMATION_MS export const EMAIL_DRAG_RETURN_MS = RETURN_ANIMATION_MS
/**
* Helper for sidebar/folder/label rows. Returns drop-event handlers + a flag
* indicating whether the current drag is hovering this target. If the target
* is the drag source (same id as `sourceFolderId`), the drop is rejected so
* the user just sees a "cancel" and no move happens.
*/
const noopDropHandlers = { const noopDropHandlers = {
onDragEnter: () => {}, onDragEnter: () => {},
onDragOver: () => {}, onDragOver: () => {},

39
lib/drag-pointer-store.ts Normal file
View File

@ -0,0 +1,39 @@
"use client"
type Point = { x: number; y: number }
const listeners = new Set<() => void>()
let latest: Point = { x: 0, y: 0 }
let rafId: number | null = null
function emit() {
rafId = null
for (const cb of listeners) cb()
}
/** Throttle pointer notifications to at most once per animation frame. */
export function notifyDragPointerMove(x: number, y: number) {
latest = { x, y }
if (rafId !== null) return
rafId = globalThis.requestAnimationFrame(emit)
}
export function getDragPointerSnapshot(): Point {
return latest
}
export function subscribeDragPointer(cb: () => void) {
listeners.add(cb)
return () => {
listeners.delete(cb)
}
}
export function resetDragPointer(x: number, y: number) {
if (rafId !== null) {
globalThis.cancelAnimationFrame(rafId)
rafId = null
}
latest = { x, y }
for (const cb of listeners) cb()
}

View File

@ -75,9 +75,21 @@ function matchesFolderLabelRow(
function matchesLabelNav( function matchesLabelNav(
email: Email, email: Email,
folderId: string, folderId: string,
maps: MailNavFolderMaps maps: MailNavFolderMaps,
subtreeIdsCache?: Map<string, string[] | null>
): boolean { ): boolean {
const subtreeIds = collectSubtreeFolderIds(maps.folderTree, folderId) let subtreeIds: string[] | null
if (subtreeIdsCache) {
if (!subtreeIdsCache.has(folderId)) {
subtreeIdsCache.set(
folderId,
collectSubtreeFolderIds(maps.folderTree, folderId)
)
}
subtreeIds = subtreeIdsCache.get(folderId) ?? null
} else {
subtreeIds = collectSubtreeFolderIds(maps.folderTree, folderId)
}
if (subtreeIds) { if (subtreeIds) {
return subtreeIds.some((id) => matchesFolderLabelRow(email, id, maps)) return subtreeIds.some((id) => matchesFolderLabelRow(email, id, maps))
} }
@ -88,7 +100,9 @@ export function emailMatchesFolder(
email: Email, email: Email,
folderId: string, folderId: string,
ctx: MailFolderFilterCtx, ctx: MailFolderFilterCtx,
maps?: MailNavFolderMaps | null maps?: MailNavFolderMaps | null,
/** Réutiliser entre appels (ex. `computeFolderUnreadCounts`) pour éviter de rescanner larbre à chaque mail. */
subtreeIdsCache?: Map<string, string[] | null>
): boolean { ): boolean {
const nav = resolveNavMaps(maps) const nav = resolveNavMaps(maps)
@ -127,7 +141,7 @@ export function emailMatchesFolder(
} }
if (nav.folderIdToLabel[folderId]) { if (nav.folderIdToLabel[folderId]) {
return matchesLabelNav(email, folderId, nav) return matchesLabelNav(email, folderId, nav, subtreeIdsCache)
} }
if (email.labels?.includes(folderId)) return true if (email.labels?.includes(folderId)) return true

View File

@ -76,13 +76,14 @@ export function countUnreadInFolder(
ctx: MailFolderFilterCtx, ctx: MailFolderFilterCtx,
hiddenIds: Set<string>, hiddenIds: Set<string>,
readOverrides: Record<string, boolean>, readOverrides: Record<string, boolean>,
maps?: MailNavFolderMaps | null maps?: MailNavFolderMaps | null,
subtreeIdsCache?: Map<string, string[] | null>
): number { ): number {
if (folderId === "scheduled" || folderId === "snoozed") { if (folderId === "scheduled" || folderId === "snoozed") {
let n = 0 let n = 0
for (const e of allEmails) { for (const e of allEmails) {
if (hiddenIds.has(e.id)) continue if (hiddenIds.has(e.id)) continue
if (!emailMatchesFolder(e, folderId, ctx, maps)) continue if (!emailMatchesFolder(e, folderId, ctx, maps, subtreeIdsCache)) continue
n++ n++
} }
return n return n
@ -91,7 +92,7 @@ export function countUnreadInFolder(
let n = 0 let n = 0
for (const e of allEmails) { for (const e of allEmails) {
if (hiddenIds.has(e.id)) continue if (hiddenIds.has(e.id)) continue
if (!emailMatchesFolder(e, folderId, ctx, maps)) continue if (!emailMatchesFolder(e, folderId, ctx, maps, subtreeIdsCache)) continue
if (!effectiveRead(e, readOverrides)) n++ if (!effectiveRead(e, readOverrides)) n++
} }
return n return n
@ -106,17 +107,28 @@ export function computeFolderUnreadCounts(
labelEdits?: LabelEditState, labelEdits?: LabelEditState,
notSpamEmailIds?: readonly string[] notSpamEmailIds?: readonly string[]
): Record<string, number> { ): Record<string, number> {
let pool = let pool: Email[] =
labelEdits && labelEdits &&
(Object.keys(labelEdits.additions).length > 0 || (Object.keys(labelEdits.additions).length > 0 ||
Object.keys(labelEdits.removals).length > 0) Object.keys(labelEdits.removals).length > 0)
? applyLabelEditsToEmails(allEmails, labelEdits) ? applyLabelEditsToEmails(allEmails, labelEdits)
: allEmails : allEmails
pool = pool.map((e) => mergeEmailNotSpam(e, notSpamEmailIds ?? [])) if (notSpamEmailIds && notSpamEmailIds.length > 0) {
pool = pool.map((e) => mergeEmailNotSpam(e, notSpamEmailIds))
}
const hidden = new Set(hiddenEmailIds) const hidden = new Set(hiddenEmailIds)
const subtreeIdsCache = new Map<string, string[] | null>()
const out: Record<string, number> = {} const out: Record<string, number> = {}
for (const id of allSidebarNavFolderIds(maps)) { for (const id of allSidebarNavFolderIds(maps)) {
out[id] = countUnreadInFolder(pool, id, ctx, hidden, readOverrides, maps) out[id] = countUnreadInFolder(
pool,
id,
ctx,
hidden,
readOverrides,
maps,
subtreeIdsCache
)
} }
return out return out
} }

View File

@ -0,0 +1,100 @@
"use client"
import {
createJSONStorage,
type PersistStorage,
type StateStorage,
type StorageValue,
} from "zustand/middleware"
const DEFAULT_DEBOUNCE_MS = 220
/** In-memory fallback when `localStorage` is missing (SSR) or throws (private mode, etc.). */
function createMemoryStateStorage(): StateStorage {
const data = new Map<string, string>()
return {
getItem: (name) => data.get(name) ?? null,
setItem: (name, value) => {
data.set(name, value)
},
removeItem: (name) => {
data.delete(name)
},
}
}
function getPersistBackingStorage(): StateStorage {
if (typeof window === "undefined") {
return createMemoryStateStorage()
}
try {
return window.localStorage
} catch {
return createMemoryStateStorage()
}
}
/**
* JSON persist storage that debounces writes to `localStorage` so rapid
* store updates do not block the main thread on every mutation.
* Flushes pending keys on a timer, `beforeunload`, and `pagehide`.
* Uses in-memory storage during SSR or when `localStorage` is unavailable.
*/
function buildDebouncedJsonStorage(): PersistStorage<unknown> {
const base =
createJSONStorage(getPersistBackingStorage) ??
createJSONStorage(() => createMemoryStateStorage())
if (!base) {
throw new Error("[debounced-json-storage] failed to create JSON storage")
}
const pending = new Map<string, StorageValue<unknown>>()
const timers = new Map<string, ReturnType<typeof globalThis.setTimeout>>()
const flushKey = (name: string) => {
const t = timers.get(name)
if (t !== undefined) {
globalThis.clearTimeout(t)
timers.delete(name)
}
const value = pending.get(name)
if (value === undefined) return
pending.delete(name)
base.setItem(name, value)
}
const flushAll = () => {
for (const name of [...pending.keys()]) flushKey(name)
}
if (typeof window !== "undefined") {
window.addEventListener("beforeunload", flushAll)
window.addEventListener("pagehide", flushAll)
}
return {
getItem: (name) => base.getItem(name),
setItem: (name, value) => {
pending.set(name, value)
const existing = timers.get(name)
if (existing !== undefined) globalThis.clearTimeout(existing)
const id = globalThis.setTimeout(() => {
timers.delete(name)
flushKey(name)
}, DEFAULT_DEBOUNCE_MS)
timers.set(name, id)
},
removeItem: (name) => {
const existing = timers.get(name)
if (existing !== undefined) {
globalThis.clearTimeout(existing)
timers.delete(name)
}
pending.delete(name)
return base.removeItem(name)
},
}
}
/** Shared instance for all zustand `persist` stores in this app. */
export const debouncedPersistJSONStorage = buildDebouncedJsonStorage()

View File

@ -2,6 +2,7 @@
import { create } from "zustand" import { create } from "zustand"
import { persist } from "zustand/middleware" import { persist } from "zustand/middleware"
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
/** /**
* Persistent mail store survives across navigations and page reloads. * Persistent mail store survives across navigations and page reloads.
@ -186,6 +187,7 @@ export const useMailStore = create<MailStoreState & MailStoreActions>()(
}), }),
{ {
name: "ultimail-mail-state", name: "ultimail-mail-state",
storage: debouncedPersistJSONStorage,
version: 3, version: 3,
migrate: (persisted, version) => { migrate: (persisted, version) => {
const state = persisted as MailStoreState & { notSpamEmailIds?: string[] } const state = persisted as MailStoreState & { notSpamEmailIds?: string[] }

View File

@ -2,6 +2,7 @@
import { create } from "zustand" import { create } from "zustand"
import { persist } from "zustand/middleware" import { persist } from "zustand/middleware"
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
import { import {
cloneDefaultFolderTree, cloneDefaultFolderTree,
cloneDefaultLabelRows, cloneDefaultLabelRows,
@ -386,6 +387,7 @@ export const useNavStore = create<NavStoreState & NavStoreActions>()(
}), }),
{ {
name: "ultimail-nav-state", name: "ultimail-nav-state",
storage: debouncedPersistJSONStorage,
version: 1, version: 1,
} }
) )

View File

@ -2,6 +2,7 @@
import { create } from "zustand" import { create } from "zustand"
import { persist } from "zustand/middleware" import { persist } from "zustand/middleware"
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
import type { Email } from "@/lib/email-data" import type { Email } from "@/lib/email-data"
export type ScheduleSendPayload = { export type ScheduleSendPayload = {
@ -242,6 +243,7 @@ export const useScheduledStore = create<ScheduledStoreState & ScheduledStoreActi
}), }),
{ {
name: "ultimail-scheduled-state", name: "ultimail-scheduled-state",
storage: debouncedPersistJSONStorage,
version: 1, version: 1,
} }
) )

File diff suppressed because one or more lines are too long