ultisuite-client/components/gmail/email-list/hooks/use-email-list-data.ts
2026-05-20 18:22:36 +02:00

786 lines
23 KiB
TypeScript

"use client"
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import { useSearchParams, useRouter } from "next/navigation"
import { buildLabelTextToNavColorClass } from "@/components/gmail/mail-label-pills"
import { emails } from "@/lib/email-data"
import {
isListRowRead,
isThreadHeadMessage,
readStateTargets,
} from "@/lib/mail-thread"
import { useScheduledMail } from "@/lib/scheduled-mail-context"
import { useMailStore } from "@/lib/stores/mail-store"
import { useScheduledStore } from "@/lib/stores/scheduled-store"
import { usePersistHydrated } from "@/hooks/use-persist-hydrated"
import { useIsMd } from "@/hooks/use-md-breakpoint"
import { sortEmailsForInbox } from "@/lib/mail-settings/sort-emails"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import { useActiveAccount } from "@/lib/stores/account-store"
import { useMailSearchStore } from "@/lib/stores/mail-search-store"
import {
emailMatchesFolder,
emailMatchesInboxPrimaryTab,
type MailNavFolderMaps,
} from "@/lib/mail-folder-filter"
import {
getMailNavFolderLabel,
inboxTabDisplayLabel,
} from "@/lib/sidebar-nav-data"
import { buildInboxCategoryTabIcons } from "@/lib/inbox-category-tabs"
import {
INBOX_ALL_TAB,
SEARCH_FOLDER_ID,
inboxTabShowsInactiveMeta,
normalizeInboxTabSegment,
} from "@/lib/mail-url"
import {
parseSearchParams,
buildSearchUrl,
type SearchParams,
} from "@/lib/mail-search/search-params"
import { filterEmailsBySearchParams } from "@/lib/mail-search/search-engine"
import { useSidebarNav, registerNavEmailSync } from "@/lib/sidebar-nav-context"
import { useMoveTargets } from "@/components/gmail/move-to-menu-items"
import { buildListMailIndex } from "@/components/gmail/email-list/list-mail-index"
import {
useComposeActions,
useComposeDrafts,
} from "@/lib/compose-context"
import { computeFolderUnreadCounts } from "@/lib/mail-nav-metrics"
import {
mergeEmailLabelEdits,
mergeEmailNotSpam,
} from "@/lib/label-edits"
import type { LabelEditState } from "@/lib/stores/mail-store"
import { useIsXs } from "@/hooks/use-xs"
import { useTouchNav } from "@/hooks/use-touch-nav"
import {
applyNavRenameToEdits,
applyNavRemoveLabelToEdits,
} from "@/lib/mail-list/label-actions"
import {
LIST_PAGE_SIZE,
type EmailListProps,
buildInboxTabBarItems,
} from "@/components/gmail/email-list/email-list-helpers"
import { useMailListPullRefresh } from "@/hooks/use-mail-list-pull-refresh"
import { ensureVcLogosCollection } from "@/lib/register-vc-logos"
import { attachmentsForEmailList } from "@/lib/attachment-display"
import { resolveParsedCalendarInvitation } from "@/lib/resolve-email-calendar-invitation"
import { resolveEmailInboxCategoryTabs } from "@/lib/inbox-category-tabs"
import type { Email, EmailAttachment } from "@/lib/email-data"
import { cleanSenderName } from "@/lib/sender-display"
import { threadStoreId } from "@/lib/mail-settings/list-row-id"
export function useEmailListData({
selectedFolder,
inboxTab,
listPage,
openMailId,
splitView = false,
onMailRouteNavigate,
onFolderUnreadCountsChange,
}: EmailListProps) {
const isViewMode = openMailId !== null && !splitView
const showSplitReadingPane = splitView && openMailId !== null
const isSearchMode = selectedFolder === SEARCH_FOLDER_ID
const searchRouter = useRouter()
const searchAccount = useActiveAccount()
const setAdvancedOpen = useMailSearchStore((s) => s.setAdvancedOpen)
const urlSearchParams = useSearchParams()
const searchParams = useMemo(
() => (isSearchMode ? parseSearchParams(urlSearchParams) : null),
[isSearchMode, urlSearchParams]
)
const setSearchFilter = useCallback(
(patch: Partial<SearchParams>) => {
if (!searchParams) return
searchRouter.push(buildSearchUrl({ ...searchParams, ...patch }))
},
[searchParams, searchRouter]
)
const toggleSearchFilter = useCallback(
(key: keyof SearchParams, value: string) => {
if (!searchParams) return
const next = { ...searchParams }
if (key === "has") {
const arr = [...next.has]
if (arr.includes(value)) next.has = arr.filter((v) => v !== value)
else next.has = [...arr, value]
} else if (key === "excludeChats") {
next.excludeChats = !next.excludeChats
} else {
const cur = (next as Record<string, unknown>)[key]
;(next as Record<string, unknown>)[key] = cur === value ? "" : value
}
searchRouter.push(buildSearchUrl(next))
},
[searchParams, searchRouter]
)
const { savedThreadReplyDrafts } = useComposeDrafts()
const {
openCompose,
openComposeWithInitial,
closeAllInlineComposes,
pruneInlineComposesToOpenThread,
} = useComposeActions()
const {
scheduledEmails,
snoozedEmails,
sentPlaceholderEmails,
requestDeleteScheduled,
requestArchiveScheduled,
requestSnoozeScheduled,
requestToggleReadScheduled,
requestRescheduleScheduled,
requestGetScheduledEditPayload,
requestSendScheduledNow,
requestSnoozeMailboxEmail,
requestRestoreSnoozedToInbox,
} = useScheduledMail()
const scheduledPersistHydrated = usePersistHydrated(useScheduledStore)
const allEmails = useMemo(
() =>
scheduledPersistHydrated
? [...emails, ...scheduledEmails, ...snoozedEmails, ...sentPlaceholderEmails]
: emails,
[scheduledPersistHydrated, scheduledEmails, snoozedEmails, sentPlaceholderEmails]
)
const emailById = useMemo(
() => new Map(allEmails.map((e) => [e.id, e])),
[allEmails]
)
const sidebarNav = useSidebarNav()
const navMaps = useMemo<MailNavFolderMaps>(
() => ({
folderIdToLabel: sidebarNav.folderIdToLabel,
folderTree: sidebarNav.folderTree,
labelRows: sidebarNav.labelRows,
}),
[sidebarNav.folderIdToLabel, sidebarNav.folderTree, sidebarNav.labelRows]
)
const inboxCategoryTabIconsCatalog = useMemo(
() => buildInboxCategoryTabIcons(sidebarNav.labelRows),
[sidebarNav.labelRows]
)
const inboxTabBarItems = useMemo(
() => buildInboxTabBarItems(sidebarNav.labelRows),
[sidebarNav.labelRows]
)
const listRowLabelBgByTextLower = useMemo(
() => buildLabelTextToNavColorClass(sidebarNav.folderTree, sidebarNav.labelRows),
[sidebarNav.folderTree, sidebarNav.labelRows]
)
const [rescheduleTarget, setRescheduleTarget] = useState<{
id: string
value: string
panelOpen: boolean
} | null>(null)
const rescheduleDismissTimeoutsRef = useRef<
Map<string, ReturnType<typeof setTimeout>>
>(new Map())
const scheduleReschedulePopoverDismiss = useCallback((rowId: string) => {
const existing = rescheduleDismissTimeoutsRef.current.get(rowId)
if (existing) clearTimeout(existing)
const t = setTimeout(() => {
rescheduleDismissTimeoutsRef.current.delete(rowId)
setRescheduleTarget((p) => (p?.id === rowId ? null : p))
}, 280)
rescheduleDismissTimeoutsRef.current.set(rowId, t)
}, [])
useEffect(() => {
const m = rescheduleDismissTimeoutsRef.current
return () => {
for (const t of m.values()) clearTimeout(t)
m.clear()
}
}, [])
useEffect(() => {
ensureVcLogosCollection()
}, [])
const [cmScheduledRescheduleValue, setCmScheduledRescheduleValue] =
useState("")
const handleEditScheduledMail = useCallback(
async (id: string) => {
const payload = await requestGetScheduledEditPayload(id)
if (!payload) return
openComposeWithInitial({
to: payload.to,
subject: payload.subject,
bodyHtml: payload.bodyHtml,
editingScheduledId: id,
scheduledSendAtIso: payload.sendAtIso,
focusToOnMount: false,
focusBodyOnMount: true,
})
},
[requestGetScheduledEditPayload, openComposeWithInitial]
)
useEffect(() => {
if (!openMailId) {
closeAllInlineComposes()
} else {
const msg = emailById.get(openMailId)
pruneInlineComposesToOpenThread(msg ? threadStoreId(msg) : openMailId)
}
}, [
openMailId,
emailById,
closeAllInlineComposes,
pruneInlineComposesToOpenThread,
])
const starredEmails = useMailStore((s) => s.starredIds)
const importantEmails = useMailStore((s) => s.importantIds)
const readOverrides = useMailStore((s) => s.readOverrides)
const conversationMode = useMailSettingsStore((s) => s.conversationMode)
const inboxSort = useMailSettingsStore((s) => s.inboxSort)
const density = useMailSettingsStore((s) => s.density)
const isMd = useIsMd()
const labelEdits = useMailStore((s) => s.labelEdits)
const mailActions = useRef(useMailStore.getState()).current
const setReadOverrides = useCallback(
(updater: (prev: Record<string, boolean>) => Record<string, boolean>) => {
const current = useMailStore.getState().readOverrides
const next = updater(current)
if (next !== current) mailActions.setReadOverrides(next)
},
[mailActions]
)
const setLabelEdits = useCallback(
(updater: (prev: LabelEditState) => LabelEditState) => {
mailActions.setLabelEdits(updater)
},
[mailActions]
)
useEffect(() => {
registerNavEmailSync({
renameLabel: (from, to) => {
setLabelEdits((prev) => applyNavRenameToEdits(allEmails, prev, from, to))
},
removeLabel: (label) => {
setLabelEdits((prev) => applyNavRemoveLabelToEdits(allEmails, prev, label))
},
})
return () => registerNavEmailSync(null)
}, [allEmails, setLabelEdits])
const [labelPickerQuery, setLabelPickerQuery] = useState("")
const hiddenEmailIds = useMailStore((s) => s.hiddenEmailIds)
const notSpamEmailIds = useMailStore((s) => s.notSpamEmailIds)
const recentMoveTargets = useMailStore((s) => s.recentMoveTargets)
const [mobileVisibleCount, setMobileVisibleCount] = useState(LIST_PAGE_SIZE)
const isXs = useIsXs()
const touchNav = useTouchNav()
const seenEmailIdsRaw = useMailStore((s) => s.seenEmailIds)
const seenEmailIds = useMemo(() => new Set(seenEmailIdsRaw), [seenEmailIdsRaw])
const handleRefreshMessages = useCallback(async () => {
await new Promise((resolve) => setTimeout(resolve, 900))
}, [])
const {
isRefreshing,
setIsRefreshing,
listViewportRef,
pullContentRef,
pullIconRef,
} = useMailListPullRefresh({
enabled: isXs && !isViewMode,
isViewMode,
onRefresh: handleRefreshMessages,
})
const handleManualRefresh = useCallback(async () => {
if (isRefreshing) return
setIsRefreshing(true)
try {
await handleRefreshMessages()
} finally {
setIsRefreshing(false)
}
}, [isRefreshing, handleRefreshMessages, setIsRefreshing])
const markEmailSeen = useCallback((id: string) => {
mailActions.markSeen(id)
}, [mailActions])
const folderFilterCtx = useMemo(
() => ({
starredEmailIds: starredEmails,
importantEmailIds: importantEmails,
}),
[starredEmails, importantEmails]
)
const filteredEmails = useMemo(() => {
const hiddenSet = new Set(hiddenEmailIds)
const subtreeIdsCache = new Map<string, string[] | null>()
let visible = allEmails.filter((email) => !hiddenSet.has(email.id))
const hasLabelEdits =
labelEdits &&
(Object.keys(labelEdits.additions).length > 0 ||
Object.keys(labelEdits.removals).length > 0)
if (hasLabelEdits || notSpamEmailIds.length > 0) {
visible = visible.map((e) =>
mergeEmailNotSpam(mergeEmailLabelEdits(e, labelEdits), notSpamEmailIds)
)
}
if (isSearchMode && searchParams) {
return filterEmailsBySearchParams(visible, searchParams, {
starredIds: starredEmails,
importantIds: importantEmails,
})
}
let rows = visible.filter((email) =>
emailMatchesFolder(
email,
selectedFolder,
folderFilterCtx,
navMaps,
subtreeIdsCache
)
)
if (selectedFolder === "inbox") {
const tab = normalizeInboxTabSegment(inboxTab)
if (tab === "primary") {
rows = rows.filter((email) =>
emailMatchesInboxPrimaryTab(
email,
folderFilterCtx,
navMaps,
subtreeIdsCache
)
)
} else if (tab !== INBOX_ALL_TAB) {
rows = rows.filter(
(email) =>
emailMatchesFolder(
email,
"inbox",
folderFilterCtx,
navMaps,
subtreeIdsCache
) &&
emailMatchesFolder(
email,
tab,
folderFilterCtx,
navMaps,
subtreeIdsCache
)
)
}
}
return rows
}, [
selectedFolder,
inboxTab,
hiddenEmailIds,
folderFilterCtx,
labelEdits,
notSpamEmailIds,
allEmails,
navMaps,
isSearchMode,
searchParams,
starredEmails,
importantEmails,
])
const displayListEmails = useMemo(() => {
let rows = filteredEmails
if (conversationMode) {
rows = rows.filter(isThreadHeadMessage)
}
return sortEmailsForInbox(
rows,
inboxSort,
{
readOverrides,
starredIds: starredEmails,
importantIds: importantEmails,
},
{ conversationMode, byId: emailById }
)
}, [
filteredEmails,
conversationMode,
inboxSort,
readOverrides,
starredEmails,
importantEmails,
emailById,
])
const inboxCategoryTabLabel = useMemo(
() =>
inboxTabDisplayLabel(
inboxTab,
sidebarNav.labelRows,
sidebarNav.folderIdToLabel
),
[inboxTab, sidebarNav.labelRows, sidebarNav.folderIdToLabel]
)
const mobileUnreadCount = useMemo(
() =>
displayListEmails.filter(
(e) => !isListRowRead(e, readOverrides, emailById, conversationMode)
).length,
[displayListEmails, readOverrides, emailById, conversationMode]
)
const mobileFolderLabel = useMemo(() => {
if (isSearchMode) return "Résultats de recherche"
const inboxTabNorm = normalizeInboxTabSegment(inboxTab)
return selectedFolder === "inbox" && inboxTabNorm !== "primary"
? inboxCategoryTabLabel
: getMailNavFolderLabel(selectedFolder, sidebarNav.folderIdToLabel)
}, [
selectedFolder,
inboxTab,
inboxCategoryTabLabel,
sidebarNav.folderIdToLabel,
isSearchMode,
])
const totalPages = useMemo(
() => Math.max(1, Math.ceil(displayListEmails.length / LIST_PAGE_SIZE)),
[displayListEmails.length]
)
const pagedEmails = useMemo(() => {
const start = (listPage - 1) * LIST_PAGE_SIZE
return displayListEmails.slice(start, start + LIST_PAGE_SIZE)
}, [displayListEmails, listPage])
const listEmails = useMemo(() => {
if (isXs && !isViewMode) {
return displayListEmails.slice(0, mobileVisibleCount)
}
return pagedEmails
}, [isXs, isViewMode, displayListEmails, mobileVisibleCount, pagedEmails])
const listMailIndex = useMemo(() => buildListMailIndex(allEmails), [allEmails])
const listRowExtras = useMemo(() => {
const invitationById = new Map<
string,
ReturnType<typeof resolveParsedCalendarInvitation>
>()
const attachmentsById = new Map<string, EmailAttachment[]>()
const categoryTabsById = new Map<
string,
ReturnType<typeof resolveEmailInboxCategoryTabs>
>()
const subtreeIdsCache = new Map<string, string[] | null>()
const showCategoryTabIcons =
selectedFolder === "inbox" &&
normalizeInboxTabSegment(inboxTab) === INBOX_ALL_TAB
for (const e of listEmails) {
invitationById.set(e.id, resolveParsedCalendarInvitation(e))
attachmentsById.set(e.id, attachmentsForEmailList(e))
if (showCategoryTabIcons) {
const tabs = resolveEmailInboxCategoryTabs(
e,
folderFilterCtx,
navMaps,
inboxCategoryTabIconsCatalog,
subtreeIdsCache
)
if (tabs.length > 0) categoryTabsById.set(e.id, tabs)
}
}
return { invitationById, attachmentsById, categoryTabsById }
}, [
listEmails,
selectedFolder,
inboxTab,
folderFilterCtx,
navMaps,
inboxCategoryTabIconsCatalog,
])
useEffect(() => {
if (isXs) return
if (listPage > totalPages) {
onMailRouteNavigate({ page: totalPages })
}
}, [isXs, listPage, totalPages, onMailRouteNavigate])
useEffect(() => {
if (isXs && !isViewMode) return
listViewportRef.current?.scrollTo(0, 0)
}, [listPage, selectedFolder, inboxTab, isXs, isViewMode, listViewportRef])
useEffect(() => {
if (!isXs) return
setMobileVisibleCount(LIST_PAGE_SIZE)
listViewportRef.current?.scrollTo(0, 0)
}, [selectedFolder, inboxTab, isXs, listViewportRef])
useEffect(() => {
const root = listViewportRef.current
if (!root || !isXs || isViewMode) return
const onScroll = () => {
if (mobileVisibleCount >= displayListEmails.length) return
const nearBottom =
root.scrollTop + root.clientHeight >= root.scrollHeight - 120
if (nearBottom) {
setMobileVisibleCount((prev) =>
Math.min(prev + LIST_PAGE_SIZE, displayListEmails.length)
)
}
}
root.addEventListener("scroll", onScroll, { passive: true })
return () => root.removeEventListener("scroll", onScroll)
}, [isXs, isViewMode, mobileVisibleCount, displayListEmails.length, listViewportRef])
const moveTargets = useMoveTargets({
folderTree: sidebarNav.folderTree,
recentMoveTargets,
currentFolderId: selectedFolder,
})
const folderUnreadCounts = useMemo(
() =>
computeFolderUnreadCounts(
allEmails,
folderFilterCtx,
hiddenEmailIds,
readOverrides,
navMaps,
labelEdits,
notSpamEmailIds
),
[
folderFilterCtx,
hiddenEmailIds,
readOverrides,
allEmails,
navMaps,
labelEdits,
notSpamEmailIds,
]
)
const seenSerialized = useMemo(
() => [...seenEmailIds].sort().join(","),
[seenEmailIds]
)
const { unseenInTabById, tabUnseenSenderLineById } = useMemo(() => {
const seen = new Set(
seenSerialized.length > 0 ? seenSerialized.split(",") : []
)
const hidden = new Set(hiddenEmailIds)
const visible = allEmails
.filter((email) => !hidden.has(email.id))
.map((e) =>
mergeEmailNotSpam(mergeEmailLabelEdits(e, labelEdits), notSpamEmailIds)
)
const inboxPool = visible.filter((e) =>
emailMatchesFolder(e, "inbox", folderFilterCtx, navMaps)
)
const counts: Record<string, number> = {}
const preview: Record<string, string> = {}
const tabCache = new Map<string, string[] | null>()
for (const tab of inboxTabBarItems) {
const rows = inboxPool.filter((e) => {
if (tab.id === "primary") {
return (
emailMatchesInboxPrimaryTab(e, folderFilterCtx, navMaps, tabCache) &&
!seen.has(e.id)
)
}
if (tab.id === INBOX_ALL_TAB) {
return !seen.has(e.id)
}
return (
emailMatchesFolder(e, "inbox", folderFilterCtx, navMaps, tabCache) &&
emailMatchesFolder(e, tab.id, folderFilterCtx, navMaps, tabCache) &&
!seen.has(e.id)
)
})
counts[tab.id] = rows.length
if (inboxTabShowsInactiveMeta(tab.id)) {
const chain: string[] = []
const used = new Set<string>()
for (const e of rows) {
const n = cleanSenderName(e.sender).trim()
if (!n || used.has(n)) continue
used.add(n)
chain.push(n)
if (chain.length >= 6) break
}
preview[tab.id] = chain.join(", ")
}
}
return { unseenInTabById: counts, tabUnseenSenderLineById: preview }
}, [folderFilterCtx, hiddenEmailIds, labelEdits, seenSerialized, allEmails, navMaps, notSpamEmailIds, inboxTabBarItems])
useEffect(() => {
onFolderUnreadCountsChange?.(folderUnreadCounts)
}, [folderUnreadCounts, onFolderUnreadCountsChange])
const listToolbarMode = splitView || !isViewMode
const compactInboxTabs = isXs || splitView
const activeInboxTabId = useMemo(
() => normalizeInboxTabSegment(inboxTab),
[inboxTab]
)
const pageIds = useMemo(() => listEmails.map((e) => e.id), [listEmails])
const listRowsDep = listEmails.map((e) => e.id).join(",")
const effectiveRead = useCallback(
(email: Email) =>
readOverrides[email.id] !== undefined ? readOverrides[email.id]! : email.read,
[readOverrides]
)
const effectiveStarred = useCallback(
(email: Email) =>
starredEmails.includes(email.id) || email.starred,
[starredEmails]
)
const markAllInViewAsRead = useCallback(() => {
setReadOverrides((prev) => {
const next = { ...prev }
for (const e of displayListEmails) {
for (const id of readStateTargets(e, conversationMode)) {
next[id] = true
}
}
return next
})
}, [displayListEmails, conversationMode, setReadOverrides])
return {
selectedFolder,
inboxTab,
listPage,
openMailId,
splitView,
isViewMode,
showSplitReadingPane,
isSearchMode,
searchRouter,
searchAccount,
setAdvancedOpen,
searchParams,
setSearchFilter,
toggleSearchFilter,
savedThreadReplyDrafts,
openCompose,
openComposeWithInitial,
allEmails,
emailById,
sidebarNav,
navMaps,
inboxCategoryTabIconsCatalog,
inboxTabBarItems,
listRowLabelBgByTextLower,
rescheduleTarget,
setRescheduleTarget,
rescheduleDismissTimeoutsRef,
scheduleReschedulePopoverDismiss,
cmScheduledRescheduleValue,
setCmScheduledRescheduleValue,
handleEditScheduledMail,
starredEmails,
importantEmails,
readOverrides,
conversationMode,
inboxSort,
density,
isMd,
labelEdits,
mailActions,
setReadOverrides,
setLabelEdits,
labelPickerQuery,
setLabelPickerQuery,
hiddenEmailIds,
notSpamEmailIds,
recentMoveTargets,
mobileVisibleCount,
isXs,
touchNav,
seenEmailIds,
isRefreshing,
listViewportRef,
pullContentRef,
pullIconRef,
handleManualRefresh,
markEmailSeen,
folderFilterCtx,
filteredEmails,
displayListEmails,
inboxCategoryTabLabel,
mobileUnreadCount,
mobileFolderLabel,
totalPages,
pagedEmails,
listEmails,
listMailIndex,
listRowExtras,
moveTargets,
folderUnreadCounts,
unseenInTabById,
tabUnseenSenderLineById,
listToolbarMode,
compactInboxTabs,
activeInboxTabId,
pageIds,
listRowsDep,
effectiveRead,
effectiveStarred,
markAllInViewAsRead,
requestDeleteScheduled,
requestArchiveScheduled,
requestSnoozeScheduled,
requestToggleReadScheduled,
requestRescheduleScheduled,
requestSendScheduledNow,
requestSnoozeMailboxEmail,
requestRestoreSnoozedToInbox,
}
}
export type EmailListData = ReturnType<typeof useEmailListData>