Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- 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.
613 lines
17 KiB
TypeScript
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>
|