major perf improvements
This commit is contained in:
parent
22e7b8e1d2
commit
6af6e62774
@ -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(
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
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,
|
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)
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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 d’un envoi en cours (nouvel id, baseline recalculée). */
|
/** 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
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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
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(
|
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 l’arbre à 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
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
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 { 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[] }
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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
Loading…
Reference in New Issue
Block a user