ultisuite-client/components/gmail/email-list/hooks/use-email-list-data.ts
R3D347HR4Y 5304790ed5
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat(auth): enhance session management and identity provider settings
- Added SessionGuard component to manage session expiration and online status.
- Updated AuthProvider to streamline session fetching and handling.
- Introduced IdentityProvidersSection for managing OAuth, SAML, and LDAP identity providers.
- Implemented identity provider guides for easier configuration.
- Enhanced mail settings with infinite scroll option for improved user experience.
- Updated global styles and layout components for better consistency across the application.
2026-06-09 09:36:46 +02:00

1143 lines
34 KiB
TypeScript

"use client"
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import { useSearchParams, useRouter } from "next/navigation"
import { useQueryClient } from "@tanstack/react-query"
import { buildLabelTextToNavColorClass } from "@/components/gmail/mail-label-pills"
import { useMessages, useMailSearch, fetchMessagesPage, messagesQueryKey } from "@/lib/api/hooks/use-mail-queries"
import {
useUpdateFlags,
useUpdateLabels,
useDeleteMessage,
} from "@/lib/api/hooks/use-mail-mutations"
import type { ApiMessageSummary, PaginatedResponse } from "@/lib/api/types"
import type { Email, EmailAttachment } from "@/lib/email-data"
import {
isThreadHeadMessage,
} from "@/lib/mail-thread"
import { repairSnippet } from "@/lib/mail-mime-body"
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 {
emailMatchesInboxTab,
type MailFolderFilterCtx,
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 { searchParamsToMessageSearchFilter } from "@/lib/mail-search/search-filter"
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 {
type EmailListProps,
buildInboxTabBarItems,
} from "@/components/gmail/email-list/email-list-helpers"
import { useMailListPullRefresh } from "@/hooks/use-mail-list-pull-refresh"
import { useMailListInfiniteScroll } from "@/hooks/use-mail-list-infinite-scroll"
import { ensureVcLogosCollection } from "@/lib/register-vc-logos"
import { resolveListRowAttachments } from "@/lib/attachment-display"
import { useListMessageAttachments } from "@/lib/api/hooks/use-list-message-attachments"
import { resolveParsedCalendarInvitation } from "@/lib/resolve-email-calendar-invitation"
import { resolveEmailInboxCategoryTabs } from "@/lib/inbox-category-tabs"
import { cleanSenderName } from "@/lib/sender-display"
import { threadStoreId } from "@/lib/mail-settings/list-row-id"
import { useIsXs } from "@/hooks/use-xs"
import { useTouchNav } from "@/hooks/use-touch-nav"
import type { MessageSearchFilter } from "@/lib/api/types"
import {
mailFlagIsRead,
mailFlagIsStarred,
mailFlagIsImportant,
mailFlagsWithRead,
mailFlagsWithStarred,
mailFlagsWithImportant,
} from "@/lib/mail-flags"
import { LIST_PAGE_SIZE, type ListPageSize } from "@/lib/mail-list-page-size"
function apiMessageToEmail(msg: ApiMessageSummary): Email {
const sender = msg.from[0]?.name || msg.from[0]?.address || ""
const senderEmail = msg.from[0]?.address || ""
return {
id: msg.id,
sender,
senderEmail,
subject: msg.subject,
preview: repairSnippet(msg.snippet) ?? msg.snippet,
date: msg.date,
read: mailFlagIsRead(msg.flags),
starred: mailFlagIsStarred(msg.flags),
important: mailFlagIsImportant(msg.flags, msg.labels),
spam: msg.labels.includes("spam"),
hasAttachment: msg.has_attachments,
labels: msg.labels,
threadHeadId: msg.thread_id ?? msg.id,
threadMessageIds: [msg.id],
isThreadHead: true,
}
}
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,
requestDeleteScheduled,
requestArchiveScheduled,
requestSnoozeScheduled,
requestToggleReadScheduled,
requestRescheduleScheduled,
requestGetScheduledEditPayload,
requestSendScheduledNow,
requestSnoozeMailboxEmail,
requestRestoreSnoozedToInbox,
} = useScheduledMail()
const scheduledPersistHydrated = usePersistHydrated(useScheduledStore)
const accountId = searchAccount?.id
const queryClient = useQueryClient()
const listPageSize = useMailSettingsStore((s) => s.listPageSize)
const setListPageSize = useMailSettingsStore((s) => s.setListPageSize)
const infiniteScroll = useMailSettingsStore((s) => s.infiniteScroll)
const isXs = useIsXs()
const touchNav = useTouchNav()
const effectiveApiFolder = useMemo(() => {
if (isSearchMode) return "__search__"
if (selectedFolder === "scheduled" || selectedFolder === "snoozed") return "__local__"
if (selectedFolder === "inbox") return "inbox"
return selectedFolder
}, [selectedFolder, isSearchMode])
const searchFilter = useMemo<MessageSearchFilter | null>(() => {
if (!isSearchMode || !searchParams) return null
return searchParamsToMessageSearchFilter(searchParams, accountId)
}, [isSearchMode, searchParams, accountId])
const scrollInfiniteList = (isXs || infiniteScroll) && !isViewMode
const usesApiInfiniteScroll =
scrollInfiniteList &&
effectiveApiFolder !== "__local__" &&
!isSearchMode
const messagesApiFolder =
effectiveApiFolder === "__search__" || effectiveApiFolder === "__local__"
? "inbox"
: effectiveApiFolder
const messagesQueryPage = usesApiInfiniteScroll ? 1 : listPage
const messagesQuery = useMessages(
messagesApiFolder,
accountId,
messagesQueryPage,
listPageSize
)
const searchQuery = useMailSearch(searchFilter)
const updateFlags = useUpdateFlags()
const updateLabels = useUpdateLabels()
const deleteMessage = useDeleteMessage()
const apiMessages: ApiMessageSummary[] = useMemo(() => {
if (isSearchMode) return searchQuery.data?.data ?? []
if (effectiveApiFolder === "__local__") return []
return messagesQuery.data?.data ?? []
}, [isSearchMode, effectiveApiFolder, searchQuery.data, messagesQuery.data])
const apiEmails: Email[] = useMemo(
() => apiMessages.map(apiMessageToEmail),
[apiMessages]
)
const apiMessagesById = useMemo(
() => new Map(apiMessages.map((m) => [m.id, m])),
[apiMessages]
)
const allEmails = useMemo(() => {
if (selectedFolder === "scheduled" && scheduledPersistHydrated) {
return scheduledEmails.map<Email>((entry) => ({
id: entry.id,
sender: entry.to[0]?.name ?? "Destinataire",
senderEmail: entry.to[0]?.address,
subject: entry.subject || "(Sans objet)",
preview: "",
body: "",
date: entry.scheduled_at ?? entry.created_at,
read: true,
starred: false,
important: false,
labels: ["scheduled"],
scheduledSendAt: entry.scheduled_at,
scheduledToName: entry.to[0]?.name,
}))
}
if (selectedFolder === "snoozed" && scheduledPersistHydrated) {
return snoozedEmails
}
return apiEmails
}, [
selectedFolder,
scheduledPersistHydrated,
scheduledEmails,
snoozedEmails,
apiEmails,
])
const emailById = useMemo(
() => new Map(allEmails.map((e) => [e.id, e])),
[allEmails]
)
const isLoading = isSearchMode ? searchQuery.isLoading : messagesQuery.isLoading
const error = isSearchMode ? searchQuery.error : messagesQuery.error
const isFetching = isSearchMode ? searchQuery.isFetching : messagesQuery.isFetching
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 conversationMode = useMailSettingsStore((s) => s.conversationMode)
const inboxSort = useMailSettingsStore((s) => s.inboxSort)
const density = useMailSettingsStore((s) => s.density)
const isMd = useIsMd()
const readOverrides = useMemo<Record<string, boolean>>(() => ({}), [])
const starredEmails = useMemo<string[]>(() => [], [])
const importantEmails = useMemo<string[]>(() => [], [])
const labelEdits = useMemo(() => ({ additions: {} as Record<string, string[]>, removals: {} as Record<string, string[]> }), [])
const hiddenEmailIds = useMemo<string[]>(() => [], [])
const notSpamEmailIds = useMemo<string[]>(() => [], [])
const setReadOverrides = useCallback(
(updater: (prev: Record<string, boolean>) => Record<string, boolean>) => {
const changes = updater({})
for (const [id, isRead] of Object.entries(changes)) {
const msg = apiMessagesById.get(id)
if (!msg) continue
const alreadyRead = mailFlagIsRead(msg.flags)
if (isRead === alreadyRead) continue
const flags = mailFlagsWithRead(msg.flags, isRead)
updateFlags.mutate({ id, flags })
}
},
[apiMessagesById, updateFlags]
)
const setLabelEdits = useCallback(
(updater: (prev: { additions: Record<string, string[]>; removals: Record<string, string[]> }) => { additions: Record<string, string[]>; removals: Record<string, string[]> }) => {
const result = updater({ additions: {}, removals: {} })
for (const [id, additions] of Object.entries(result.additions)) {
const msg = apiMessagesById.get(id)
if (!msg) continue
const newLabels = [...new Set([...msg.labels, ...additions])]
const removals = result.removals[id] ?? []
const finalLabels = newLabels.filter(
(l) => !removals.some((r) => r.toLowerCase() === l.toLowerCase())
)
updateLabels.mutate({ id, labels: finalLabels })
}
for (const [id, removals] of Object.entries(result.removals)) {
if (result.additions[id]) continue
const msg = apiMessagesById.get(id)
if (!msg) continue
const finalLabels = msg.labels.filter(
(l) => !removals.some((r) => r.toLowerCase() === l.toLowerCase())
)
updateLabels.mutate({ id, labels: finalLabels })
}
},
[apiMessagesById, updateLabels]
)
const mailActions = useMemo(() => ({
markSeen: (id: string) => useMailStore.getState().markSeen(id),
pushRecentMoveTarget: (targetId: string) => useMailStore.getState().pushRecentMoveTarget(targetId),
hideEmail: (id: string) => deleteMessage.mutate({ id }),
hideEmails: (ids: string[]) => { for (const id of ids) deleteMessage.mutate({ id }) },
markNotSpam: (id: string) => {
const msg = apiMessagesById.get(id)
if (!msg) return
const newLabels = msg.labels.filter((l) => l !== "spam")
if (!newLabels.includes("inbox")) newLabels.push("inbox")
updateLabels.mutate({ id, labels: newLabels })
},
unhideEmail: (_id: string) => { /* no-op - API manages visibility */ },
toggleStar: (id: string) => {
const msg = apiMessagesById.get(id)
if (!msg) return
const starred = mailFlagIsStarred(msg.flags)
updateFlags.mutate({ id, flags: mailFlagsWithStarred(msg.flags, !starred) })
},
toggleImportant: (id: string) => {
const msg = apiMessagesById.get(id)
if (!msg) return
const important = mailFlagIsImportant(msg.flags, msg.labels)
updateFlags.mutate({ id, flags: mailFlagsWithImportant(msg.flags, !important) })
},
}), [deleteMessage, updateLabels, updateFlags, apiMessagesById])
useEffect(() => {
registerNavEmailSync({
renameLabel: (_from, _to) => {
queryClient.invalidateQueries({ queryKey: ["messages"] })
},
removeLabel: (_label) => {
queryClient.invalidateQueries({ queryKey: ["messages"] })
},
})
return () => registerNavEmailSync(null)
}, [queryClient])
const [labelPickerQuery, setLabelPickerQuery] = useState("")
const recentMoveTargets = useMailStore((s) => s.recentMoveTargets)
const [mobileVisibleCount, setMobileVisibleCount] = useState(LIST_PAGE_SIZE)
const [accumulatedApiEmails, setAccumulatedApiEmails] = useState<Email[]>([])
const [loadedApiPage, setLoadedApiPage] = useState(1)
const [isFetchingNextInfinitePage, setIsFetchingNextInfinitePage] =
useState(false)
const loadMoreSentinelRef = useRef<HTMLDivElement>(null)
const infiniteListContextRef = useRef("")
const seenEmailIdsRaw = useMailStore((s) => s.seenEmailIds)
const seenEmailIds = useMemo(() => new Set(seenEmailIdsRaw), [seenEmailIdsRaw])
const handleRefreshMessages = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey: ["messages"] })
}, [queryClient])
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) => {
useMailStore.getState().markSeen(id)
}, [])
const folderFilterCtx = useMemo<MailFolderFilterCtx>(
() => ({
starredEmailIds: starredEmails,
importantEmailIds: importantEmails,
}),
[starredEmails, importantEmails]
)
const filteredEmails = useMemo(() => {
if (selectedFolder !== "inbox") return allEmails
return allEmails.filter((e) =>
emailMatchesInboxTab(e, inboxTab, folderFilterCtx, navMaps)
)
}, [allEmails, selectedFolder, inboxTab, folderFilterCtx, navMaps])
const displayListEmails = useMemo(() => {
let rows = filteredEmails
if (conversationMode) {
rows = rows.filter(isThreadHeadMessage)
}
return sortEmailsForInbox(
rows,
inboxSort,
{
readOverrides: {},
starredIds: [],
importantIds: [],
},
{ conversationMode, byId: emailById }
)
}, [
filteredEmails,
conversationMode,
inboxSort,
emailById,
])
const inboxCategoryTabLabel = useMemo(
() =>
inboxTabDisplayLabel(
inboxTab,
sidebarNav.labelRows,
sidebarNav.folderIdToLabel
),
[inboxTab, sidebarNav.labelRows, sidebarNav.folderIdToLabel]
)
const mobileUnreadCount = useMemo(
() => displayListEmails.filter((e) => !e.read).length,
[displayListEmails]
)
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 paginationTotal = useMemo(() => {
if (isSearchMode) return searchQuery.data?.pagination?.total
if (effectiveApiFolder === "__local__") return allEmails.length
return messagesQuery.data?.pagination?.total
}, [isSearchMode, effectiveApiFolder, searchQuery.data, messagesQuery.data, allEmails.length])
const totalPages = useMemo(
() =>
Math.max(
1,
Math.ceil((paginationTotal ?? displayListEmails.length) / listPageSize)
),
[paginationTotal, displayListEmails.length, listPageSize]
)
const paginationRangeStart = useMemo(() => {
if (displayListEmails.length === 0) return 0
return (listPage - 1) * listPageSize + 1
}, [displayListEmails.length, listPage, listPageSize])
const paginationRangeEnd = useMemo(() => {
if (displayListEmails.length === 0) return 0
if (effectiveApiFolder !== "__local__" && !isSearchMode) {
const total = paginationTotal ?? displayListEmails.length
return Math.min(listPage * listPageSize, total)
}
return Math.min(listPage * listPageSize, displayListEmails.length)
}, [
displayListEmails.length,
listPage,
listPageSize,
paginationTotal,
effectiveApiFolder,
isSearchMode,
])
const handleListPageSizeChange = useCallback(
(size: ListPageSize) => {
if (size === listPageSize) return
setListPageSize(size)
onMailRouteNavigate({ page: 1 })
},
[listPageSize, setListPageSize, onMailRouteNavigate]
)
const processEmailsForDisplay = useCallback(
(emails: Email[]) => {
let rows =
selectedFolder !== "inbox"
? emails
: emails.filter((e) =>
emailMatchesInboxTab(e, inboxTab, folderFilterCtx, navMaps)
)
if (conversationMode) {
rows = rows.filter(isThreadHeadMessage)
}
const byId = new Map(emails.map((e) => [e.id, e]))
return sortEmailsForInbox(
rows,
inboxSort,
{
readOverrides: {},
starredIds: [],
importantIds: [],
},
{ conversationMode, byId }
)
},
[
selectedFolder,
inboxTab,
folderFilterCtx,
navMaps,
conversationMode,
inboxSort,
]
)
const infiniteListContextKey = `${selectedFolder}:${inboxTab}:${accountId ?? ""}:${messagesApiFolder}`
useEffect(() => {
if (!usesApiInfiniteScroll) {
setAccumulatedApiEmails([])
setLoadedApiPage(1)
infiniteListContextRef.current = ""
return
}
if (infiniteListContextRef.current !== infiniteListContextKey) {
infiniteListContextRef.current = infiniteListContextKey
setAccumulatedApiEmails(displayListEmails)
setLoadedApiPage(1)
setMobileVisibleCount(LIST_PAGE_SIZE)
listViewportRef.current?.scrollTo(0, 0)
return
}
if (loadedApiPage === 1) {
setAccumulatedApiEmails(displayListEmails)
}
}, [
usesApiInfiniteScroll,
infiniteListContextKey,
displayListEmails,
loadedApiPage,
listViewportRef,
])
const fetchNextApiPage = useCallback(async () => {
if (!usesApiInfiniteScroll || isFetchingNextInfinitePage) return
if (loadedApiPage >= totalPages) return
const nextPage = loadedApiPage + 1
setIsFetchingNextInfinitePage(true)
try {
const result = await queryClient.fetchQuery({
queryKey: messagesQueryKey(
messagesApiFolder,
accountId,
nextPage,
listPageSize
),
queryFn: () =>
fetchMessagesPage(
messagesApiFolder,
accountId,
nextPage,
listPageSize
),
staleTime: 60_000,
})
const processed = processEmailsForDisplay(
result.data.map(apiMessageToEmail)
)
setAccumulatedApiEmails((prev) => {
const ids = new Set(prev.map((e) => e.id))
const appended = processed.filter((e) => !ids.has(e.id))
if (appended.length === 0) return prev
return [...prev, ...appended]
})
setLoadedApiPage(nextPage)
if (nextPage < totalPages) {
void queryClient.prefetchQuery({
queryKey: messagesQueryKey(
messagesApiFolder,
accountId,
nextPage + 1,
listPageSize
),
queryFn: () =>
fetchMessagesPage(
messagesApiFolder,
accountId,
nextPage + 1,
listPageSize
),
staleTime: 60_000,
})
}
} finally {
setIsFetchingNextInfinitePage(false)
}
}, [
usesApiInfiniteScroll,
isFetchingNextInfinitePage,
loadedApiPage,
totalPages,
queryClient,
messagesApiFolder,
accountId,
listPageSize,
processEmailsForDisplay,
])
useEffect(() => {
if (!usesApiInfiniteScroll || loadedApiPage !== 1 || totalPages <= 1) return
void queryClient.prefetchQuery({
queryKey: messagesQueryKey(messagesApiFolder, accountId, 2, listPageSize),
queryFn: () =>
fetchMessagesPage(messagesApiFolder, accountId, 2, listPageSize),
staleTime: 60_000,
})
}, [
usesApiInfiniteScroll,
loadedApiPage,
totalPages,
queryClient,
messagesApiFolder,
accountId,
listPageSize,
])
const infiniteScrollSourceEmails = usesApiInfiniteScroll
? accumulatedApiEmails
: displayListEmails
const pagedEmails = useMemo(() => {
if (effectiveApiFolder !== "__local__" && !isSearchMode) {
return displayListEmails
}
const start = (listPage - 1) * listPageSize
return displayListEmails.slice(start, start + listPageSize)
}, [displayListEmails, listPage, effectiveApiFolder, isSearchMode, listPageSize])
const listEmails = useMemo(() => {
if (!scrollInfiniteList) return pagedEmails
if (usesApiInfiniteScroll) {
if (isXs) {
return infiniteScrollSourceEmails.slice(0, mobileVisibleCount)
}
return infiniteScrollSourceEmails
}
return displayListEmails.slice(0, mobileVisibleCount)
}, [
scrollInfiniteList,
usesApiInfiniteScroll,
isXs,
infiniteScrollSourceEmails,
mobileVisibleCount,
displayListEmails,
pagedEmails,
])
const hasMoreInfinite = scrollInfiniteList
? usesApiInfiniteScroll
? isXs
? mobileVisibleCount < infiniteScrollSourceEmails.length ||
loadedApiPage < totalPages
: loadedApiPage < totalPages
: mobileVisibleCount < displayListEmails.length
: false
const mobileVisibleCountRef = useRef(mobileVisibleCount)
mobileVisibleCountRef.current = mobileVisibleCount
const loadMoreInfinite = useCallback(() => {
if (!scrollInfiniteList) return
if (usesApiInfiniteScroll) {
const sourceLength = infiniteScrollSourceEmails.length
if (
isXs &&
mobileVisibleCountRef.current < sourceLength
) {
setMobileVisibleCount((prev) =>
Math.min(prev + LIST_PAGE_SIZE, sourceLength)
)
return
}
void fetchNextApiPage()
return
}
setMobileVisibleCount((prev) =>
Math.min(prev + LIST_PAGE_SIZE, displayListEmails.length)
)
}, [
scrollInfiniteList,
usesApiInfiniteScroll,
isXs,
infiniteScrollSourceEmails.length,
fetchNextApiPage,
displayListEmails.length,
])
useMailListInfiniteScroll({
enabled: scrollInfiniteList,
sentinelRef: loadMoreSentinelRef,
scrollRootRef: listViewportRef,
hasMore: hasMoreInfinite,
isLoadingMore: isFetchingNextInfinitePage,
onLoadMore: loadMoreInfinite,
})
const listMailIndex = useMemo(() => buildListMailIndex(allEmails), [allEmails])
const attachmentFetchIds = useMemo(
() =>
listEmails
.filter((e) => e.hasAttachment && !(e.attachments?.length))
.map((e) => e.id),
[listEmails]
)
const { byId: fetchedAttachmentsById, stateById: attachmentFetchStateById } =
useListMessageAttachments(attachmentFetchIds)
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))
const fetchState = attachmentFetchStateById.get(e.id) ?? "idle"
attachmentsById.set(
e.id,
resolveListRowAttachments(
e,
fetchedAttachmentsById.get(e.id),
fetchState
)
)
if (showCategoryTabIcons) {
const tabs = resolveEmailInboxCategoryTabs(
e,
folderFilterCtx,
navMaps,
inboxCategoryTabIconsCatalog,
subtreeIdsCache
)
if (tabs.length > 0) categoryTabsById.set(e.id, tabs)
}
}
return { invitationById, attachmentsById, categoryTabsById }
}, [
listEmails,
fetchedAttachmentsById,
attachmentFetchStateById,
selectedFolder,
inboxTab,
folderFilterCtx,
navMaps,
inboxCategoryTabIconsCatalog,
])
const prevInfiniteScrollRef = useRef(infiniteScroll)
useEffect(() => {
const turnedOn = infiniteScroll && !prevInfiniteScrollRef.current
prevInfiniteScrollRef.current = infiniteScroll
if (!turnedOn || isXs) return
setAccumulatedApiEmails([])
setLoadedApiPage(1)
setMobileVisibleCount(LIST_PAGE_SIZE)
if (listPage !== 1) onMailRouteNavigate({ page: 1 })
}, [infiniteScroll, isXs, listPage, onMailRouteNavigate])
useEffect(() => {
if (isXs || infiniteScroll) return
if (listPage > totalPages) {
onMailRouteNavigate({ page: totalPages })
}
}, [isXs, infiniteScroll, listPage, totalPages, onMailRouteNavigate])
useEffect(() => {
if (scrollInfiniteList) return
listViewportRef.current?.scrollTo(0, 0)
}, [listPage, selectedFolder, inboxTab, scrollInfiniteList, listViewportRef])
useEffect(() => {
if (!scrollInfiniteList || usesApiInfiniteScroll) return
setMobileVisibleCount(LIST_PAGE_SIZE)
listViewportRef.current?.scrollTo(0, 0)
}, [selectedFolder, inboxTab, scrollInfiniteList, usesApiInfiniteScroll, listViewportRef])
const moveTargets = useMoveTargets({
folderTree: sidebarNav.folderTree,
recentMoveTargets,
currentFolderId: selectedFolder,
})
const folderUnreadCounts = useMemo<Record<string, number>>(() => ({}), [])
const seenSerialized = useMemo(
() => [...seenEmailIds].sort().join(","),
[seenEmailIds]
)
const { unseenInTabById, tabUnseenSenderLineById } = useMemo(() => {
const seen = new Set(
seenSerialized.length > 0 ? seenSerialized.split(",") : []
)
const subtreeIdsCache = new Map<string, string[] | null>()
const counts: Record<string, number> = {}
const preview: Record<string, string> = {}
for (const tab of inboxTabBarItems) {
const rows: Email[] = []
for (const e of allEmails) {
if (seen.has(e.id)) continue
if (
!emailMatchesInboxTab(
e,
tab.id,
folderFilterCtx,
navMaps,
subtreeIdsCache
)
) {
continue
}
rows.push(e)
}
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 }
}, [
seenSerialized,
allEmails,
inboxTabBarItems,
folderFilterCtx,
navMaps,
])
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) => email.read,
[]
)
const effectiveStarred = useCallback(
(email: Email) => email.starred,
[]
)
const markAllInViewAsRead = useCallback(() => {
for (const e of displayListEmails) {
if (e.read) continue
const msg = apiMessagesById.get(e.id)
if (!msg) continue
if (!mailFlagIsRead(msg.flags)) {
updateFlags.mutate({ id: e.id, flags: mailFlagsWithRead(msg.flags, true) })
}
}
}, [displayListEmails, apiMessagesById, updateFlags])
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,
infiniteScroll,
scrollInfiniteList,
hasMoreInfinite,
loadMoreSentinelRef,
isFetchingNextInfinitePage,
isXs,
touchNav,
seenEmailIds,
isRefreshing,
listViewportRef,
pullContentRef,
pullIconRef,
handleManualRefresh,
markEmailSeen,
folderFilterCtx,
filteredEmails,
displayListEmails,
inboxCategoryTabLabel,
mobileUnreadCount,
mobileFolderLabel,
paginationTotal,
listPageSize,
paginationRangeStart,
paginationRangeEnd,
handleListPageSizeChange,
totalPages,
pagedEmails,
listEmails,
listMailIndex,
listRowExtras,
scrollInfiniteList,
hasMoreInfinite,
loadMoreSentinelRef,
isFetchingNextInfinitePage,
moveTargets,
folderUnreadCounts,
unseenInTabById,
tabUnseenSenderLineById,
listToolbarMode,
compactInboxTabs,
activeInboxTabId,
pageIds,
listRowsDep,
effectiveRead,
effectiveStarred,
markAllInViewAsRead,
requestDeleteScheduled,
requestArchiveScheduled,
requestSnoozeScheduled,
requestToggleReadScheduled,
requestRescheduleScheduled,
requestSendScheduledNow,
requestSnoozeMailboxEmail,
requestRestoreSnoozedToInbox,
isLoading,
error,
isFetching,
}
}
export type EmailListData = ReturnType<typeof useEmailListData>