896 lines
27 KiB
TypeScript
896 lines
27 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 } 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 { 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 { 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 { 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 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 {
|
|
q: searchParams.q || undefined,
|
|
from: searchParams.from || undefined,
|
|
label: searchParams.in !== "all" ? searchParams.in : undefined,
|
|
account_id: accountId,
|
|
date_from: searchParams.after || undefined,
|
|
date_to: searchParams.before || undefined,
|
|
has_attachment: searchParams.has.includes("attachment") ? true : undefined,
|
|
}
|
|
}, [isSearchMode, searchParams, accountId])
|
|
|
|
const messagesQuery = useMessages(
|
|
effectiveApiFolder === "__search__" || effectiveApiFolder === "__local__"
|
|
? "inbox"
|
|
: effectiveApiFolder,
|
|
accountId,
|
|
listPage,
|
|
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 isXs = useIsXs()
|
|
const touchNav = useTouchNav()
|
|
|
|
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 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 (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<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,
|
|
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,
|
|
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>
|