major perf improvements
This commit is contained in:
parent
22e7b8e1d2
commit
6af6e62774
@ -67,7 +67,8 @@ import {
|
||||
DEFAULT_IDENTITIES,
|
||||
MOCK_CONTACTS,
|
||||
SIGNATURES,
|
||||
useCompose,
|
||||
useComposeActions,
|
||||
useComposeWindows,
|
||||
} from "@/lib/compose-context"
|
||||
import { useScheduledMail } from "@/lib/scheduled-mail-context"
|
||||
import type { ScheduleSendPayload } from "@/lib/api/scheduled-mail"
|
||||
@ -987,7 +988,7 @@ function SignatureButton({
|
||||
editor: Editor | null
|
||||
compose: ComposeState
|
||||
}) {
|
||||
const { updateCompose } = useCompose()
|
||||
const { updateCompose } = useComposeActions()
|
||||
|
||||
const replaceSignature = useCallback(
|
||||
(sigId: string | null) => {
|
||||
@ -1233,7 +1234,7 @@ export function ComposeWindow({
|
||||
toggleMinimize,
|
||||
toggleMaximize,
|
||||
restoreComposeFromSnapshot,
|
||||
} = useCompose()
|
||||
} = useComposeActions()
|
||||
const { scheduleSend, requestUpdateScheduledSend, requestSendScheduledNow } =
|
||||
useScheduledMail()
|
||||
const isInline = compose.placement === "inline"
|
||||
@ -2226,7 +2227,7 @@ export function ComposeWindow({
|
||||
}
|
||||
|
||||
export function ComposeModalManager() {
|
||||
const { composeWindows } = useCompose()
|
||||
const { composeWindows } = useComposeWindows()
|
||||
const isXs = useIsXs()
|
||||
|
||||
const nonMaximized = composeWindows.filter(
|
||||
|
||||
@ -23,7 +23,7 @@ import {
|
||||
UserPlus,
|
||||
Video,
|
||||
} from "lucide-react"
|
||||
import { useCompose } from "@/lib/compose-context"
|
||||
import { useComposeActions } from "@/lib/compose-context"
|
||||
|
||||
export interface ContactHoverCardProps {
|
||||
/** Champ expéditeur brut (liste, conversation, etc.) */
|
||||
@ -45,7 +45,7 @@ export function ContactHoverCard({
|
||||
align = "start",
|
||||
side = "bottom",
|
||||
}: ContactHoverCardProps) {
|
||||
const { openComposeWithInitial } = useCompose()
|
||||
const { openComposeWithInitial } = useComposeActions()
|
||||
const [open, setOpen] = useState(false)
|
||||
const name = cleanSenderName(displayName)
|
||||
const email = resolveSenderEmail(displayName, emailOverride)
|
||||
|
||||
49
components/gmail/email-list-row.tsx
Normal file
49
components/gmail/email-list-row.tsx
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -121,7 +121,12 @@ import {
|
||||
type MoveTarget,
|
||||
} from "@/components/gmail/move-to-menu-items"
|
||||
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 {
|
||||
effectiveLabels,
|
||||
@ -595,12 +600,12 @@ export function EmailList({
|
||||
}: EmailListProps) {
|
||||
const isViewMode = openMailId !== null
|
||||
|
||||
const { savedThreadReplyDrafts } = useComposeDrafts()
|
||||
const {
|
||||
openComposeWithInitial,
|
||||
closeAllInlineComposes,
|
||||
pruneInlineComposesToOpenThread,
|
||||
savedThreadReplyDrafts,
|
||||
} = useCompose()
|
||||
} = useComposeActions()
|
||||
|
||||
const {
|
||||
scheduledEmails,
|
||||
@ -884,13 +889,26 @@ export function EmailList({
|
||||
}, [isRefreshing, isViewMode, isXs, applyPullVisual])
|
||||
|
||||
const filteredEmails = useMemo(() => {
|
||||
const visible = allEmails
|
||||
.filter((email) => !hiddenEmailIds.includes(email.id))
|
||||
.map((e) =>
|
||||
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)
|
||||
)
|
||||
}
|
||||
let rows = visible.filter((email) =>
|
||||
emailMatchesFolder(email, selectedFolder, folderFilterCtx, navMaps)
|
||||
emailMatchesFolder(
|
||||
email,
|
||||
selectedFolder,
|
||||
folderFilterCtx,
|
||||
navMaps,
|
||||
subtreeIdsCache
|
||||
)
|
||||
)
|
||||
if (selectedFolder === "inbox") {
|
||||
rows = rows.filter((email) => email.category === inboxTab)
|
||||
@ -947,6 +965,21 @@ export function EmailList({
|
||||
return 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(() => {
|
||||
if (isXs) return
|
||||
if (listPage > totalPages) {
|
||||
@ -2658,7 +2691,7 @@ export function EmailList({
|
||||
|
||||
<div
|
||||
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 && (
|
||||
<div
|
||||
@ -2770,8 +2803,10 @@ export function EmailList({
|
||||
const senderForSearch = email.sender.replace(/\s+/g, " ").trim()
|
||||
const isSelected = selectedEmails.includes(email.id)
|
||||
const hasInvitation = email.hasInvitation === true
|
||||
const parsedInvitation = resolveParsedCalendarInvitation(email)
|
||||
const attachmentList = attachmentsForEmailList(email)
|
||||
const parsedInvitation =
|
||||
listRowExtras.invitationById.get(email.id) ?? null
|
||||
const attachmentList =
|
||||
listRowExtras.attachmentsById.get(email.id) ?? []
|
||||
const isScheduled = email.labels?.includes("scheduled") === true
|
||||
const contextTargetIds = contextMenuTargetIdsForRow(
|
||||
email.id,
|
||||
@ -2782,14 +2817,12 @@ export function EmailList({
|
||||
const allContextTargetsScheduled =
|
||||
contextTargetIds.length > 0 &&
|
||||
contextTargetIds.every((id) =>
|
||||
allEmails.some(
|
||||
(e) => e.id === id && e.labels?.includes("scheduled")
|
||||
)
|
||||
listMailIndex.scheduledIds.has(id)
|
||||
)
|
||||
const scheduledCtxAnyUnread =
|
||||
allContextTargetsScheduled &&
|
||||
contextTargetIds.some((id) => {
|
||||
const em = allEmails.find((e) => e.id === id)
|
||||
const em = listMailIndex.emailById.get(id)
|
||||
if (!em) return false
|
||||
return !(readOverrides[id] ?? em.read)
|
||||
})
|
||||
|
||||
@ -61,7 +61,9 @@ import {
|
||||
shouldUseAttachmentPillsInPreview,
|
||||
} from "@/lib/attachment-display"
|
||||
import {
|
||||
useCompose,
|
||||
useComposeActions,
|
||||
useComposeDrafts,
|
||||
useComposeWindows,
|
||||
DEFAULT_IDENTITIES,
|
||||
type ThreadComposeKind,
|
||||
savedThreadDraftToComposePreset,
|
||||
@ -761,8 +763,9 @@ export function EmailView({
|
||||
const mainSenderName = cleanSenderName(email.sender)
|
||||
const mainSenderAddr = email.senderEmail || `${mainSenderName.toLowerCase().replace(/\s+/g, ".")}@example.com`
|
||||
|
||||
const { composeWindows, openComposeWithInitial, savedThreadReplyDrafts } =
|
||||
useCompose()
|
||||
const { composeWindows } = useComposeWindows()
|
||||
const { savedThreadReplyDrafts } = useComposeDrafts()
|
||||
const { openComposeWithInitial } = useComposeActions()
|
||||
const inlineCompose = useMemo(
|
||||
() =>
|
||||
composeWindows.find(
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react"
|
||||
import { Menu, Search, X, ChevronLeft, Pencil } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useCompose } from "@/lib/compose-context"
|
||||
import { useComposeActions } from "@/lib/compose-context"
|
||||
|
||||
interface MobileBottomBarProps {
|
||||
sidebarOpen: boolean
|
||||
@ -16,7 +16,7 @@ export function MobileBottomBar({
|
||||
}: MobileBottomBarProps) {
|
||||
const [searchValue, setSearchValue] = useState("")
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const { openCompose } = useCompose()
|
||||
const { openCompose } = useComposeActions()
|
||||
|
||||
const hasSearch = searchValue.length > 0
|
||||
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react"
|
||||
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { Mail } from "lucide-react"
|
||||
import { EMAIL_DRAG_RETURN_MS, useEmailDrag } from "@/lib/drag-context"
|
||||
import {
|
||||
getDragPointerSnapshot,
|
||||
subscribeDragPointer,
|
||||
} from "@/lib/drag-pointer-store"
|
||||
import { useIsXs } from "@/hooks/use-xs"
|
||||
|
||||
export function MoveDragIndicator() {
|
||||
@ -12,24 +16,16 @@ export function MoveDragIndicator() {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const elRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const livePointer = useSyncExternalStore(
|
||||
subscribeDragPointer,
|
||||
getDragPointerSnapshot,
|
||||
() => ({ x: 0, y: 0 })
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
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(() => {
|
||||
if (!state) return
|
||||
if (state.phase !== "returning") return
|
||||
@ -59,14 +55,17 @@ export function MoveDragIndicator() {
|
||||
const label =
|
||||
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(
|
||||
<div
|
||||
ref={elRef}
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed z-100 select-none"
|
||||
style={{
|
||||
left: state.pointerX,
|
||||
top: state.pointerY,
|
||||
left: x,
|
||||
top: y,
|
||||
transform: "translate(-50%, -50%)",
|
||||
willChange: "translate, opacity",
|
||||
}}
|
||||
|
||||
@ -34,7 +34,7 @@ import { cn, formatCount } from "@/lib/utils"
|
||||
import { readXsMatches } from "@/hooks/use-xs"
|
||||
import { useState, useRef, useEffect, useMemo, type ReactNode, type CSSProperties } from "react"
|
||||
import { useEmailDropTarget } from "@/lib/drag-context"
|
||||
import { useCompose } from "@/lib/compose-context"
|
||||
import { useComposeActions } from "@/lib/compose-context"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@ -504,7 +504,7 @@ export function Sidebar({
|
||||
collapsed,
|
||||
folderUnreadCounts = {},
|
||||
}: SidebarProps) {
|
||||
const { openCompose } = useCompose()
|
||||
const { openCompose } = useComposeActions()
|
||||
const [hoverExpanded, setHoverExpanded] = useState(false)
|
||||
const [navMoreOpen, setNavMoreOpen] = useState(false)
|
||||
const [expandedFolderIds, setExpandedFolderIds] = useState<Set<string>>(() => new Set())
|
||||
|
||||
@ -393,10 +393,7 @@ function applySavedDraftsAfterClosingInline(
|
||||
return next
|
||||
}
|
||||
|
||||
type ComposeContextValue = {
|
||||
composeWindows: ComposeState[]
|
||||
/** Brouillons de réponse/transfert par id de conversation */
|
||||
savedThreadReplyDrafts: Record<string, SavedThreadReplyDraft>
|
||||
export type ComposeActionsContextValue = {
|
||||
openCompose: () => void
|
||||
openComposeWithInitial: (preset: ComposeOpenPreset) => void
|
||||
/** Réouvre une fenêtre après annulation d’un envoi en cours (nouvel id, baseline recalculée). */
|
||||
@ -413,7 +410,22 @@ type ComposeContextValue = {
|
||||
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 }) {
|
||||
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,
|
||||
openComposeWithInitial,
|
||||
restoreComposeFromSnapshot,
|
||||
@ -677,8 +687,6 @@ export function ComposeProvider({ children }: { children: React.ReactNode }) {
|
||||
saveDraft,
|
||||
}),
|
||||
[
|
||||
composeWindows,
|
||||
savedThreadReplyDrafts,
|
||||
openCompose,
|
||||
openComposeWithInitial,
|
||||
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 (
|
||||
<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() {
|
||||
const ctx = useContext(ComposeContext)
|
||||
if (!ctx) throw new Error("useCompose must be used inside <ComposeProvider>")
|
||||
export function useComposeActions(): ComposeActionsContextValue {
|
||||
const ctx = useContext(ComposeActionsContext)
|
||||
if (!ctx) throw new Error("useComposeActions must be used inside <ComposeProvider>")
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,8 +9,12 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { flushSync } from "react-dom"
|
||||
import { useIsXs } from "@/hooks/use-xs"
|
||||
import {
|
||||
getDragPointerSnapshot,
|
||||
notifyDragPointerMove,
|
||||
resetDragPointer,
|
||||
} from "@/lib/drag-pointer-store"
|
||||
|
||||
export type EmailDragPhase = "dragging" | "returning"
|
||||
|
||||
@ -19,6 +23,7 @@ export type EmailDragState = {
|
||||
sourceFolderId: string
|
||||
hoveredTargetId: string | null
|
||||
hoveredTargetLabel: string | null
|
||||
/** Last committed pointer (begin drag + snapshot when entering `returning`). */
|
||||
pointerX: number
|
||||
pointerY: number
|
||||
originX: number
|
||||
@ -57,6 +62,7 @@ export function EmailDragProvider({ children }: { children: React.ReactNode }) {
|
||||
if (!isXs) return
|
||||
stateRef.current = null
|
||||
setState(null)
|
||||
resetDragPointer(0, 0)
|
||||
}, [isXs])
|
||||
|
||||
const beginDrag = useCallback(
|
||||
@ -74,6 +80,7 @@ export function EmailDragProvider({ children }: { children: React.ReactNode }) {
|
||||
phase: "dragging",
|
||||
}
|
||||
stateRef.current = next
|
||||
resetDragPointer(pointerX, pointerY)
|
||||
setState(next)
|
||||
},
|
||||
[isXs]
|
||||
@ -99,11 +106,17 @@ export function EmailDragProvider({ children }: { children: React.ReactNode }) {
|
||||
const cur = stateRef.current
|
||||
if (!cur) 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
|
||||
flushSync(() => {
|
||||
setState(next)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const completeDrop = useCallback((targetId: string, targetLabel: string) => {
|
||||
@ -111,9 +124,8 @@ export function EmailDragProvider({ children }: { children: React.ReactNode }) {
|
||||
if (!cur) return
|
||||
stateRef.current = null
|
||||
setState(null)
|
||||
resetDragPointer(0, 0)
|
||||
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 ids = cur.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(() => {
|
||||
if (!state) return
|
||||
if (!state || state.phase !== "dragging") return
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
setState((prev) =>
|
||||
prev && prev.phase === "dragging"
|
||||
? { ...prev, pointerX: e.clientX, pointerY: e.clientY }
|
||||
: prev
|
||||
)
|
||||
notifyDragPointerMove(e.clientX, e.clientY)
|
||||
}
|
||||
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
|
||||
if (!cur) return
|
||||
if (cur.phase === "returning") return
|
||||
const p = getDragPointerSnapshot()
|
||||
const next: EmailDragState = {
|
||||
...cur,
|
||||
phase: "returning",
|
||||
hoveredTargetId: null,
|
||||
hoveredTargetLabel: null,
|
||||
pointerX: p.x,
|
||||
pointerY: p.y,
|
||||
}
|
||||
stateRef.current = next
|
||||
flushSync(() => {
|
||||
setState(next)
|
||||
})
|
||||
}
|
||||
window.addEventListener("dragover", onDragOver)
|
||||
window.addEventListener("dragend", onDragEnd)
|
||||
@ -165,7 +167,7 @@ export function EmailDragProvider({ children }: { children: React.ReactNode }) {
|
||||
window.removeEventListener("dragover", onDragOver)
|
||||
window.removeEventListener("dragend", onDragEnd)
|
||||
}
|
||||
}, [state !== null])
|
||||
}, [state])
|
||||
|
||||
// Auto-clear after the return animation finishes.
|
||||
useEffect(() => {
|
||||
@ -173,6 +175,7 @@ export function EmailDragProvider({ children }: { children: React.ReactNode }) {
|
||||
const id = window.setTimeout(() => {
|
||||
stateRef.current = null
|
||||
setState(null)
|
||||
resetDragPointer(0, 0)
|
||||
}, RETURN_ANIMATION_MS + 20)
|
||||
return () => window.clearTimeout(id)
|
||||
}, [state?.phase])
|
||||
@ -211,12 +214,6 @@ export function useEmailDrag() {
|
||||
|
||||
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 = {
|
||||
onDragEnter: () => {},
|
||||
onDragOver: () => {},
|
||||
|
||||
39
lib/drag-pointer-store.ts
Normal file
39
lib/drag-pointer-store.ts
Normal 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()
|
||||
}
|
||||
@ -75,9 +75,21 @@ function matchesFolderLabelRow(
|
||||
function matchesLabelNav(
|
||||
email: Email,
|
||||
folderId: string,
|
||||
maps: MailNavFolderMaps
|
||||
maps: MailNavFolderMaps,
|
||||
subtreeIdsCache?: Map<string, string[] | null>
|
||||
): 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) {
|
||||
return subtreeIds.some((id) => matchesFolderLabelRow(email, id, maps))
|
||||
}
|
||||
@ -88,7 +100,9 @@ export function emailMatchesFolder(
|
||||
email: Email,
|
||||
folderId: string,
|
||||
ctx: MailFolderFilterCtx,
|
||||
maps?: MailNavFolderMaps | null
|
||||
maps?: MailNavFolderMaps | null,
|
||||
/** Réutiliser entre appels (ex. `computeFolderUnreadCounts`) pour éviter de rescanner l’arbre à chaque mail. */
|
||||
subtreeIdsCache?: Map<string, string[] | null>
|
||||
): boolean {
|
||||
const nav = resolveNavMaps(maps)
|
||||
|
||||
@ -127,7 +141,7 @@ export function emailMatchesFolder(
|
||||
}
|
||||
|
||||
if (nav.folderIdToLabel[folderId]) {
|
||||
return matchesLabelNav(email, folderId, nav)
|
||||
return matchesLabelNav(email, folderId, nav, subtreeIdsCache)
|
||||
}
|
||||
|
||||
if (email.labels?.includes(folderId)) return true
|
||||
|
||||
@ -76,13 +76,14 @@ export function countUnreadInFolder(
|
||||
ctx: MailFolderFilterCtx,
|
||||
hiddenIds: Set<string>,
|
||||
readOverrides: Record<string, boolean>,
|
||||
maps?: MailNavFolderMaps | null
|
||||
maps?: MailNavFolderMaps | null,
|
||||
subtreeIdsCache?: Map<string, string[] | null>
|
||||
): number {
|
||||
if (folderId === "scheduled" || folderId === "snoozed") {
|
||||
let n = 0
|
||||
for (const e of allEmails) {
|
||||
if (hiddenIds.has(e.id)) continue
|
||||
if (!emailMatchesFolder(e, folderId, ctx, maps)) continue
|
||||
if (!emailMatchesFolder(e, folderId, ctx, maps, subtreeIdsCache)) continue
|
||||
n++
|
||||
}
|
||||
return n
|
||||
@ -91,7 +92,7 @@ export function countUnreadInFolder(
|
||||
let n = 0
|
||||
for (const e of allEmails) {
|
||||
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++
|
||||
}
|
||||
return n
|
||||
@ -106,17 +107,28 @@ export function computeFolderUnreadCounts(
|
||||
labelEdits?: LabelEditState,
|
||||
notSpamEmailIds?: readonly string[]
|
||||
): Record<string, number> {
|
||||
let pool =
|
||||
let pool: Email[] =
|
||||
labelEdits &&
|
||||
(Object.keys(labelEdits.additions).length > 0 ||
|
||||
Object.keys(labelEdits.removals).length > 0)
|
||||
? applyLabelEditsToEmails(allEmails, labelEdits)
|
||||
: 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 subtreeIdsCache = new Map<string, string[] | null>()
|
||||
const out: Record<string, number> = {}
|
||||
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
|
||||
}
|
||||
|
||||
100
lib/stores/debounced-json-storage.ts
Normal file
100
lib/stores/debounced-json-storage.ts
Normal 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()
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { create } from "zustand"
|
||||
import { persist } from "zustand/middleware"
|
||||
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
|
||||
|
||||
/**
|
||||
* Persistent mail store — survives across navigations and page reloads.
|
||||
@ -186,6 +187,7 @@ export const useMailStore = create<MailStoreState & MailStoreActions>()(
|
||||
}),
|
||||
{
|
||||
name: "ultimail-mail-state",
|
||||
storage: debouncedPersistJSONStorage,
|
||||
version: 3,
|
||||
migrate: (persisted, version) => {
|
||||
const state = persisted as MailStoreState & { notSpamEmailIds?: string[] }
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { create } from "zustand"
|
||||
import { persist } from "zustand/middleware"
|
||||
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
|
||||
import {
|
||||
cloneDefaultFolderTree,
|
||||
cloneDefaultLabelRows,
|
||||
@ -386,6 +387,7 @@ export const useNavStore = create<NavStoreState & NavStoreActions>()(
|
||||
}),
|
||||
{
|
||||
name: "ultimail-nav-state",
|
||||
storage: debouncedPersistJSONStorage,
|
||||
version: 1,
|
||||
}
|
||||
)
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { create } from "zustand"
|
||||
import { persist } from "zustand/middleware"
|
||||
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
|
||||
import type { Email } from "@/lib/email-data"
|
||||
|
||||
export type ScheduleSendPayload = {
|
||||
@ -242,6 +243,7 @@ export const useScheduledStore = create<ScheduledStoreState & ScheduledStoreActi
|
||||
}),
|
||||
{
|
||||
name: "ultimail-scheduled-state",
|
||||
storage: debouncedPersistJSONStorage,
|
||||
version: 1,
|
||||
}
|
||||
)
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user