"use client" import { startTransition, useCallback, useEffect, useLayoutEffect, useMemo, useRef, } from "react" import type { Email } from "@/lib/email-data" import { readStateTargets } from "@/lib/mail-thread" import { threadStoreId } from "@/lib/mail-settings/list-row-id" import { resolveOpenEmailView } from "@/lib/mail-settings/resolve-open-email" import { DEFAULT_INBOX_TAB, } from "@/lib/mail-url" import { mailNavVisitKey, parseMailNavVisitKey, } from "@/lib/mail-folder-display" import { escapeHtml, } from "@/components/gmail/email-list/email-list-helpers" import type { Contact } from "@/lib/compose-context" import { buildThreadComposePreset, withTouchFullscreenComposePreset, } from "@/lib/thread-compose-preset" import type { EmailListProps } from "@/components/gmail/email-list/email-list-helpers" import type { EmailListData } from "@/components/gmail/email-list/hooks/use-email-list-data" import type { EmailListLabels } from "@/components/gmail/email-list/hooks/use-email-list-labels" import { useMailUiStore } from "@/lib/stores/mail-ui-store" export function useEmailListReading( props: EmailListProps, data: EmailListData, labels: EmailListLabels ) { const { onMailRouteNavigate, onSelectFolder, onXsViewChromeChange, } = props const { openMailId, splitView, isViewMode, showSplitReadingPane, isXs, allEmails, emailById, displayListEmails, listPage, listPageSize, listRowsDep, listViewportRef, conversationMode, setReadOverrides, markEmailSeen, mailActions, moveTargets, selectedFolder, inboxTab, openComposeWithInitial, } = data const { moveEmailsToTarget } = labels const openEmailView = useMemo(() => { if (!openMailId) return null const resolved = resolveOpenEmailView( openMailId, allEmails, conversationMode ) if (!resolved) return null if (resolved.email.labels?.includes("scheduled")) return null return { email: resolved.email, threadRoot: resolved.threadRoot, isSingleMessageView: resolved.isSingleMessageView, } }, [openMailId, allEmails, conversationMode]) const openEmail = openEmailView?.email ?? null const openEmailThreadRoot = openEmailView?.threadRoot ?? null const isSingleMessageView = openEmailView?.isSingleMessageView ?? false const openMailIndex = useMemo( () => openMailId ? displayListEmails.findIndex((e) => e.id === openMailId) : -1, [openMailId, displayListEmails] ) // Guard: emailById/setReadOverrides get new refs after each messages refetch — without // this guard, mark-read → invalidate → refetch → effect re-runs in a loop. const readAppliedForMailRef = useRef(null) const splitViewScrollTargetRef = useRef({ openMailId: null as string | null, listPage: 1, hadOpenRow: false, }) useEffect(() => { if (!openMailId) { readAppliedForMailRef.current = null return } if (readAppliedForMailRef.current === openMailId) return const message = emailById.get(openMailId) if (!message) return readAppliedForMailRef.current = openMailId const targets = readStateTargets(message, conversationMode) for (const id of targets) { markEmailSeen(id) } setReadOverrides((prev) => { const next = { ...prev } for (const id of targets) { next[id] = true } return next }) }, [openMailId, markEmailSeen, emailById, conversationMode, setReadOverrides]) const navigateToMail = useCallback( (id: string | null) => { if (id === null) { useMailUiStore.getState().requestSuppressSplitAutoOpen() } startTransition(() => { if (id && splitView) { const idx = displayListEmails.findIndex((e) => e.id === id) if (idx >= 0) { const page = Math.floor(idx / listPageSize) + 1 onMailRouteNavigate({ mailId: id, page }) return } } onMailRouteNavigate({ mailId: id }) }) }, [splitView, displayListEmails, onMailRouteNavigate, listPageSize] ) useEffect(() => { if (!openMailId) return const raw = allEmails.find((e) => e.id === openMailId) if (raw?.labels?.includes("scheduled")) { navigateToMail(null) } }, [openMailId, allEmails, navigateToMail]) const pickAdjacentMailId = useCallback( (currentId: string) => { const idx = displayListEmails.findIndex((e) => e.id === currentId) if (idx < 0) return displayListEmails[0]?.id ?? null if (idx < displayListEmails.length - 1) return displayListEmails[idx + 1]!.id if (idx > 0) return displayListEmails[idx - 1]!.id return null }, [displayListEmails] ) const leaveReadingPane = useCallback(() => { if (!splitView) { navigateToMail(null) return } if (!openMailId) return navigateToMail(pickAdjacentMailId(openMailId)) }, [splitView, openMailId, navigateToMail, pickAdjacentMailId]) const goBack = useCallback(() => { if (splitView) { navigateToMail(null) return } navigateToMail(null) }, [splitView, navigateToMail]) const closeViewIfShowingEmail = useCallback( (emailId: string) => { if (openMailId === emailId) goBack() }, [openMailId, goBack] ) const archiveListRow = useCallback( (email: Email) => { if (email.labels?.includes("scheduled")) { void data.requestArchiveScheduled(email.id) } else { mailActions.hideEmail(email.id) closeViewIfShowingEmail(email.id) } }, [closeViewIfShowingEmail, mailActions, data] ) const deleteListRow = useCallback( (email: Email) => { if (email.labels?.includes("scheduled")) { void data.requestDeleteScheduled(email.id) } else { mailActions.hideEmail(email.id) closeViewIfShowingEmail(email.id) } }, [closeViewIfShowingEmail, mailActions, data] ) const restoreSnoozedRowToMailbox = useCallback( (emailRow: Email) => { void data.requestRestoreSnoozedToInbox(emailRow) if (emailRow.id.startsWith("snz-")) { onSelectFolder?.("inbox") } else { onSelectFolder?.("scheduled") } closeViewIfShowingEmail(emailRow.id) }, [ data, closeViewIfShowingEmail, onSelectFolder, ] ) const handleCategoryInboxTabClick = useCallback( (tabId: string) => { useMailUiStore.getState().requestSuppressSplitAutoOpen() startTransition(() => { onMailRouteNavigate({ inboxTab: tabId, page: 1, mailId: null, }) }) }, [onMailRouteNavigate] ) const handleBreadcrumbNavigate = useCallback( (visitKey: string) => { if (visitKey === mailNavVisitKey(selectedFolder, inboxTab)) return const { folderId, inboxTab: tab } = parseMailNavVisitKey(visitKey) startTransition(() => { if (folderId === "inbox" && tab && tab !== DEFAULT_INBOX_TAB) { onMailRouteNavigate({ folderId: "inbox", inboxTab: tab, page: 1, mailId: null, }) return } if (onSelectFolder) { onSelectFolder(folderId) return } onMailRouteNavigate({ folderId, inboxTab: DEFAULT_INBOX_TAB, page: 1, mailId: null, }) }) }, [ selectedFolder, inboxTab, onMailRouteNavigate, onSelectFolder, ] ) const goListPrevPage = useCallback(() => { if (listPage <= 1) return onMailRouteNavigate({ page: listPage - 1 }) }, [listPage, onMailRouteNavigate]) const goListNextPage = useCallback(() => { if (listPage >= data.totalPages) return onMailRouteNavigate({ page: listPage + 1 }) }, [listPage, data.totalPages, onMailRouteNavigate]) const goToPrev = useCallback(() => { if (openMailIndex > 0) { const id = displayListEmails[openMailIndex - 1]!.id markEmailSeen(id) setReadOverrides(() => ({ [id]: true })) navigateToMail(id) } }, [openMailIndex, displayListEmails, navigateToMail, markEmailSeen, setReadOverrides]) const goToNext = useCallback(() => { if (openMailIndex >= 0 && openMailIndex < displayListEmails.length - 1) { const id = displayListEmails[openMailIndex + 1]!.id markEmailSeen(id) setReadOverrides(() => ({ [id]: true })) navigateToMail(id) } }, [openMailIndex, displayListEmails, navigateToMail, markEmailSeen, setReadOverrides]) const handleOpenEmail = useCallback( (id: string) => { const em = allEmails.find((e) => e.id === id) if (em?.labels?.includes("scheduled")) return markEmailSeen(id) setReadOverrides(() => ({ [id]: true })) navigateToMail(id) }, [navigateToMail, markEmailSeen, allEmails, setReadOverrides] ) const openDraftInCompose = useCallback( (email: Email) => { markEmailSeen(email.id) setReadOverrides(() => ({ [email.id]: true })) const to: Contact[] = email.senderEmail ? [{ name: email.sender.trim(), email: email.senderEmail }] : [] const body = email.body ?? (email.preview ? `

${escapeHtml(email.preview)}

` : "

") openComposeWithInitial({ to, subject: email.subject, bodyHtml: body, focusToOnMount: false, focusBodyOnMount: true, }) }, [markEmailSeen, openComposeWithInitial, setReadOverrides] ) const handleRowActivate = useCallback( (email: Email) => { if (email.labels?.includes("scheduled")) return if (email.labels?.includes("drafts")) { openDraftInCompose(email) return } handleOpenEmail(email.id) }, [handleOpenEmail, openDraftInCompose] ) const viewModeIsRead = useMemo(() => { if (!openEmail) return true return openEmail.read }, [openEmail]) const afterSingleMessageRemoved = useCallback( (removedId: string) => { if (splitView) navigateToMail(pickAdjacentMailId(removedId)) else navigateToMail(null) }, [splitView, navigateToMail, pickAdjacentMailId] ) const singleArchive = useCallback(() => { if (!openMailId) return const id = openMailId mailActions.hideEmail(id) afterSingleMessageRemoved(id) }, [openMailId, afterSingleMessageRemoved, mailActions]) const singleDelete = useCallback(() => { if (!openMailId) return const id = openMailId mailActions.hideEmail(id) afterSingleMessageRemoved(id) }, [openMailId, afterSingleMessageRemoved, mailActions]) const singleSpam = useCallback(() => { if (!openMailId) return const id = openMailId mailActions.hideEmail(id) afterSingleMessageRemoved(id) }, [openMailId, afterSingleMessageRemoved, mailActions]) const singleNotSpam = useCallback(() => { if (!openMailId) return const id = openMailId mailActions.markNotSpam(id) onSelectFolder?.("inbox") afterSingleMessageRemoved(id) }, [openMailId, afterSingleMessageRemoved, onSelectFolder, mailActions]) const singleToggleRead = useCallback(() => { if (!openMailId) return const next = !viewModeIsRead setReadOverrides(() => ({ [openMailId]: next })) }, [openMailId, viewModeIsRead, setReadOverrides]) const singleMoveTo = useCallback( (targetId: string) => { if (!openMailId) return moveEmailsToTarget([openMailId], targetId) const isSystemHide = ["sent", "drafts", "spam", "trash"].includes(targetId) if (isSystemHide || targetId !== "inbox") { afterSingleMessageRemoved(openMailId) } }, [openMailId, afterSingleMessageRemoved, moveEmailsToTarget] ) const singleReply = useCallback(() => { if (!openEmail) return openComposeWithInitial( withTouchFullscreenComposePreset(buildThreadComposePreset(openEmail, "reply")) ) }, [openEmail, openComposeWithInitial]) const xsViewChromeCallbacksRef = useRef({ onArchive: singleArchive, onReply: singleReply, moveTargets, onMoveTo: singleMoveTo, }) xsViewChromeCallbacksRef.current = { onArchive: singleArchive, onReply: singleReply, moveTargets, onMoveTo: singleMoveTo, } useEffect(() => { if (!onXsViewChromeChange) return if (!isXs || !isViewMode || !openMailId) { onXsViewChromeChange(null) return } onXsViewChromeChange({ onArchive: () => xsViewChromeCallbacksRef.current.onArchive(), onReply: () => xsViewChromeCallbacksRef.current.onReply(), moveTargets: xsViewChromeCallbacksRef.current.moveTargets, onMoveTo: (targetId) => xsViewChromeCallbacksRef.current.onMoveTo(targetId), }) return () => onXsViewChromeChange(null) }, [onXsViewChromeChange, isXs, isViewMode, openMailId, moveTargets]) useEffect(() => { if (!splitView) return if (useMailUiStore.getState().consumeSuppressSplitAutoOpen()) { return } const firstId = displayListEmails[0]?.id ?? null if (!openMailId) { if (firstId) navigateToMail(firstId) return } const raw = allEmails.find((e) => e.id === openMailId) if (raw?.labels?.includes("scheduled")) { navigateToMail(firstId) return } if (!displayListEmails.some((e) => e.id === openMailId)) { navigateToMail(firstId) } }, [ splitView, selectedFolder, inboxTab, listPage, displayListEmails, openMailId, navigateToMail, allEmails, ]) const handleNavigateToLabel = useCallback( (label: string) => { const folderId = data.sidebarNav.emailLabelToSidebarFolderId[label] ?? label onSelectFolder?.(folderId) }, [onSelectFolder, data.sidebarNav.emailLabelToSidebarFolderId] ) useLayoutEffect(() => { if (!splitView || !openMailId) { splitViewScrollTargetRef.current = { openMailId: null, listPage, hadOpenRow: false, } return } const root = listViewportRef.current const row = root?.querySelector( `[data-email-row-id="${openMailId}"]` ) const rowInList = row != null const prev = splitViewScrollTargetRef.current const openMailChanged = prev.openMailId !== openMailId const pageChanged = prev.listPage !== listPage const rowJustAppeared = rowInList && !prev.hadOpenRow splitViewScrollTargetRef.current = { openMailId, listPage, hadOpenRow: rowInList, } // Infinite scroll appends rows → listRowsDep changes; skip scroll unless the // open mail changed, the page changed, or its row just entered the list. if (!openMailChanged && !pageChanged && !rowJustAppeared) return if (!row) return const scrollActiveRowIntoView = () => { row.scrollIntoView({ block: "nearest", behavior: "smooth" }) } scrollActiveRowIntoView() const frame = requestAnimationFrame(scrollActiveRowIntoView) return () => cancelAnimationFrame(frame) }, [splitView, openMailId, listPage, listRowsDep, listViewportRef]) useEffect(() => { const root = listViewportRef.current if (!root) return const obs = new IntersectionObserver( (entries) => { for (const en of entries) { if (!en.isIntersecting) continue const id = (en.target as HTMLElement).dataset.emailRowId if (id) markEmailSeen(id) } }, { root, threshold: 0.12, rootMargin: "0px" } ) const observeNewRows = () => { root.querySelectorAll("[data-email-row-id]").forEach((el) => { if (el.dataset.seenObserved === "1") return el.dataset.seenObserved = "1" obs.observe(el) }) } observeNewRows() const mutationObserver = new MutationObserver(observeNewRows) mutationObserver.observe(root, { childList: true, subtree: true }) return () => { mutationObserver.disconnect() obs.disconnect() } }, [markEmailSeen, listViewportRef]) useEffect(() => { if (!isViewMode && !showSplitReadingPane) return const handler = (e: KeyboardEvent) => { if (e.key === "Escape") { if (!splitView) goBack() return } if (e.key === "ArrowLeft" || e.key === "k") { goToPrev() return } if (e.key === "ArrowRight" || e.key === "j") { goToNext() return } } window.addEventListener("keydown", handler) return () => window.removeEventListener("keydown", handler) }, [isViewMode, showSplitReadingPane, splitView, goBack, goToPrev, goToNext]) return { openEmail, openEmailThreadRoot, isSingleMessageView, openMailIndex, navigateToMail, goBack, closeViewIfShowingEmail, archiveListRow, deleteListRow, restoreSnoozedRowToMailbox, handleCategoryInboxTabClick, handleBreadcrumbNavigate, goListPrevPage, goListNextPage, goToPrev, goToNext, handleOpenEmail, handleRowActivate, viewModeIsRead, singleArchive, singleDelete, singleSpam, singleNotSpam, singleToggleRead, singleMoveTo, singleReply, handleNavigateToLabel, } } export type EmailListReading = ReturnType