ultisuite-client/components/gmail/email-list/hooks/use-email-list-reading.ts
R3D347HR4Y efaaf16f60
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat: update metadata and layout for new product pages
- Refactored metadata for contacts, administration, and Ulticards pages to utilize dynamic app names and descriptions.
- Introduced new product pages for Ultiai, Ultical, Ulticards, Ultidrive, Ultimail, and Ultimeet with appropriate metadata.
- Enhanced layout components to ensure consistent styling and functionality across new product sections.
- Updated various components to replace hardcoded labels with dynamic references to improve maintainability and consistency.
2026-06-19 22:11:42 +02:00

613 lines
17 KiB
TypeScript

"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<string | null>(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
? `<p style="color:#5f6368">${escapeHtml(email.preview)}</p>`
: "<p></p>")
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<HTMLElement>(
`[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<HTMLElement>("[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<typeof useEmailListReading>