Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- Introduced turbopack alias for canvas in next.config.mjs. - Updated package.json scripts for development and branding tasks. - Added new dependencies for Tiptap extensions. - Implemented new demo layouts for agenda, contacts, drive, and mail applications. - Enhanced globals.css for improved theming and splash screen animations. - Added OAuth callback handling for drive mounts. - Updated layout components to integrate new demo shells and improve structure.
319 lines
9.0 KiB
TypeScript
319 lines
9.0 KiB
TypeScript
"use client"
|
|
|
|
import {
|
|
useCallback,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type CSSProperties,
|
|
} from "react"
|
|
import { useTheme } from "next-themes"
|
|
import {
|
|
emailPreviewBaseCss,
|
|
emailPreviewDarkOverrideCss,
|
|
emailPreviewDarkRemoteBodyTailCss,
|
|
emailPreviewDarkTailOverrideCss,
|
|
emailPreviewLightOverrideCss,
|
|
emailPreviewWrapperCss,
|
|
} from "@/lib/email-preview-dark-styles"
|
|
import {
|
|
EMAIL_PREVIEW_MIN_IFRAME_HEIGHT,
|
|
measureEmailPreviewIframeHeight,
|
|
resolveEmailPreviewMeasureRoot,
|
|
} from "@/lib/email-preview-iframe-height"
|
|
import {
|
|
buildEmailPreviewSrcdoc,
|
|
prepareEmailHtmlForIframe,
|
|
} from "@/lib/mail-html-iframe"
|
|
import { buildEmailPreviewCsp } from "@/lib/mail-remote-content"
|
|
import {
|
|
isEmailPreviewContrastDebugEnabled,
|
|
logEmailPreviewContrastIssues,
|
|
repairEmailPreviewContrast,
|
|
} from "@/lib/email-preview-contrast"
|
|
|
|
const EMAIL_PREVIEW_IFRAME_STYLE: CSSProperties = {
|
|
display: "block",
|
|
background: "transparent",
|
|
overflow: "hidden",
|
|
}
|
|
|
|
function documentIsDark(): boolean {
|
|
return document.documentElement.classList.contains("dark")
|
|
}
|
|
|
|
function bindEmailPreviewImageLoads(
|
|
root: HTMLElement,
|
|
onChange: () => void
|
|
): () => void {
|
|
const images = root.querySelectorAll("img")
|
|
if (images.length === 0) return () => {}
|
|
|
|
let pending = 0
|
|
const onDone = () => {
|
|
pending -= 1
|
|
if (pending <= 0) onChange()
|
|
}
|
|
|
|
for (const img of images) {
|
|
if (img.complete) continue
|
|
pending += 1
|
|
img.addEventListener("load", onDone, { once: true })
|
|
img.addEventListener("error", onDone, { once: true })
|
|
}
|
|
|
|
if (pending === 0) return () => {}
|
|
|
|
return () => {
|
|
for (const img of images) {
|
|
img.removeEventListener("load", onDone)
|
|
img.removeEventListener("error", onDone)
|
|
}
|
|
}
|
|
}
|
|
|
|
export function SandboxedContent({
|
|
html,
|
|
blockRemoteContent,
|
|
restrictPopups = false,
|
|
senderEmail,
|
|
cidUrlMap,
|
|
plainTextFallback,
|
|
messageId,
|
|
previewPart = "body",
|
|
}: {
|
|
html: string
|
|
blockRemoteContent: boolean
|
|
restrictPopups?: boolean
|
|
senderEmail?: string
|
|
cidUrlMap?: Record<string, string>
|
|
/** Plain body when HTML is image-only or empty with remote content blocked. */
|
|
plainTextFallback?: string
|
|
/** Pour les logs dev [email-preview:low-contrast]. */
|
|
messageId?: string
|
|
previewPart?: "body" | "quoted"
|
|
}) {
|
|
const iframeRef = useRef<HTMLIFrameElement>(null)
|
|
const contrastLoggedKeyRef = useRef<string | null>(null)
|
|
const contrastDelayTimerRef = useRef<number | null>(null)
|
|
const resizeObserverRef = useRef<ResizeObserver | null>(null)
|
|
const unbindImageLoadsRef = useRef<(() => void) | null>(null)
|
|
const contentGenerationRef = useRef(0)
|
|
const appliedHeightRef = useRef(EMAIL_PREVIEW_MIN_IFRAME_HEIGHT)
|
|
const syncRafRef = useRef<number | null>(null)
|
|
const [height, setHeight] = useState(EMAIL_PREVIEW_MIN_IFRAME_HEIGHT)
|
|
|
|
const sandboxValue = restrictPopups
|
|
? "allow-same-origin"
|
|
: "allow-same-origin allow-popups"
|
|
|
|
const { resolvedTheme } = useTheme()
|
|
const isDark =
|
|
resolvedTheme === "dark" ||
|
|
((resolvedTheme === "system" || resolvedTheme === undefined) &&
|
|
typeof document !== "undefined" &&
|
|
documentIsDark())
|
|
|
|
const parsedEmail = useMemo(
|
|
() =>
|
|
prepareEmailHtmlForIframe(html, {
|
|
blockRemoteContent,
|
|
isDark,
|
|
senderEmail,
|
|
cidUrlMap,
|
|
plainTextFallback,
|
|
}),
|
|
[html, blockRemoteContent, isDark, senderEmail, cidUrlMap, plainTextFallback]
|
|
)
|
|
|
|
const themeCss = useMemo(() => {
|
|
if (!blockRemoteContent) {
|
|
const wrapper = emailPreviewWrapperCss(isDark)
|
|
return isDark ? `${wrapper}${emailPreviewDarkOverrideCss()}` : wrapper
|
|
}
|
|
return `${emailPreviewBaseCss(isDark)}${
|
|
isDark ? emailPreviewDarkOverrideCss() : emailPreviewLightOverrideCss()
|
|
}`
|
|
}, [blockRemoteContent, isDark])
|
|
|
|
const cspContent = useMemo(
|
|
() => buildEmailPreviewCsp(blockRemoteContent),
|
|
[blockRemoteContent]
|
|
)
|
|
|
|
const srcdoc = useMemo(
|
|
() =>
|
|
buildEmailPreviewSrcdoc(parsedEmail, {
|
|
csp: cspContent,
|
|
wrapperCss: themeCss,
|
|
plainTextFallback,
|
|
loadAppFont: !blockRemoteContent,
|
|
bodyTailCss: isDark
|
|
? blockRemoteContent
|
|
? emailPreviewDarkTailOverrideCss()
|
|
: emailPreviewDarkRemoteBodyTailCss()
|
|
: undefined,
|
|
}),
|
|
[
|
|
parsedEmail,
|
|
cspContent,
|
|
themeCss,
|
|
plainTextFallback,
|
|
isDark,
|
|
blockRemoteContent,
|
|
]
|
|
)
|
|
|
|
const iframeKey = `${messageId ?? "no-id"}:${previewPart}:${blockRemoteContent ? "remote-blocked" : "remote-allowed"}:${isDark ? "dark" : "light"}`
|
|
|
|
const applyMeasuredHeight = useCallback((generation: number) => {
|
|
if (generation !== contentGenerationRef.current) return
|
|
const doc = iframeRef.current?.contentDocument
|
|
if (!doc) return
|
|
|
|
const next = measureEmailPreviewIframeHeight(doc)
|
|
if (appliedHeightRef.current === next) return
|
|
appliedHeightRef.current = next
|
|
setHeight(next)
|
|
}, [])
|
|
|
|
const scheduleHeightSync = useCallback(
|
|
(generation: number) => {
|
|
if (syncRafRef.current !== null) {
|
|
cancelAnimationFrame(syncRafRef.current)
|
|
}
|
|
syncRafRef.current = requestAnimationFrame(() => {
|
|
syncRafRef.current = requestAnimationFrame(() => {
|
|
syncRafRef.current = null
|
|
applyMeasuredHeight(generation)
|
|
})
|
|
})
|
|
},
|
|
[applyMeasuredHeight]
|
|
)
|
|
|
|
const runContrastPipeline = useCallback(
|
|
(doc: Document, pass: "initial" | "delayed", generation: number) => {
|
|
if (!isDark || generation !== contentGenerationRef.current) return
|
|
|
|
const repair = repairEmailPreviewContrast(doc, {
|
|
isDark: true,
|
|
repairMode: blockRemoteContent ? "dark-only" : "all",
|
|
newsletterLightCanvas: false,
|
|
assumedCanvasRgb: [32, 33, 36],
|
|
})
|
|
|
|
if (repair && (repair.lightSurfaces > 0 || repair.darkSurfaces > 0)) {
|
|
scheduleHeightSync(generation)
|
|
if (isEmailPreviewContrastDebugEnabled()) {
|
|
console.info("[email-preview:contrast-repaired]", {
|
|
messageId,
|
|
part: previewPart,
|
|
pass,
|
|
blockRemoteContent,
|
|
...repair,
|
|
})
|
|
}
|
|
}
|
|
|
|
if (!isEmailPreviewContrastDebugEnabled()) return
|
|
|
|
const logKey = `${messageId ?? "no-id"}:${previewPart}:${blockRemoteContent}:${isDark}:${srcdoc.length}:${pass}`
|
|
if (contrastLoggedKeyRef.current === logKey) return
|
|
contrastLoggedKeyRef.current = logKey
|
|
logEmailPreviewContrastIssues(
|
|
{
|
|
messageId,
|
|
part: previewPart,
|
|
blockRemoteContent,
|
|
isDark,
|
|
senderEmail,
|
|
},
|
|
doc,
|
|
{ newsletterLightCanvas: false, assumedCanvasRgb: [32, 33, 36] }
|
|
)
|
|
},
|
|
[
|
|
messageId,
|
|
previewPart,
|
|
blockRemoteContent,
|
|
isDark,
|
|
senderEmail,
|
|
srcdoc,
|
|
scheduleHeightSync,
|
|
]
|
|
)
|
|
|
|
const handleIframeLoad = useCallback(() => {
|
|
const generation = contentGenerationRef.current
|
|
const doc = iframeRef.current?.contentDocument
|
|
const measureRoot = doc ? resolveEmailPreviewMeasureRoot(doc) : null
|
|
|
|
resizeObserverRef.current?.disconnect()
|
|
unbindImageLoadsRef.current?.()
|
|
unbindImageLoadsRef.current = null
|
|
|
|
if (measureRoot) {
|
|
resizeObserverRef.current = new ResizeObserver(() => {
|
|
scheduleHeightSync(generation)
|
|
})
|
|
resizeObserverRef.current.observe(measureRoot)
|
|
|
|
unbindImageLoadsRef.current = bindEmailPreviewImageLoads(measureRoot, () => {
|
|
scheduleHeightSync(generation)
|
|
})
|
|
}
|
|
|
|
scheduleHeightSync(generation)
|
|
if (doc) runContrastPipeline(doc, "initial", generation)
|
|
|
|
if (contrastDelayTimerRef.current !== null) {
|
|
window.clearTimeout(contrastDelayTimerRef.current)
|
|
}
|
|
contrastDelayTimerRef.current = window.setTimeout(() => {
|
|
contrastDelayTimerRef.current = null
|
|
if (generation !== contentGenerationRef.current) return
|
|
const lateDoc = iframeRef.current?.contentDocument
|
|
if (lateDoc) {
|
|
scheduleHeightSync(generation)
|
|
runContrastPipeline(lateDoc, "delayed", generation)
|
|
}
|
|
}, 1000)
|
|
}, [scheduleHeightSync, runContrastPipeline])
|
|
|
|
useLayoutEffect(() => {
|
|
contentGenerationRef.current += 1
|
|
appliedHeightRef.current = EMAIL_PREVIEW_MIN_IFRAME_HEIGHT
|
|
setHeight(EMAIL_PREVIEW_MIN_IFRAME_HEIGHT)
|
|
contrastLoggedKeyRef.current = null
|
|
resizeObserverRef.current?.disconnect()
|
|
resizeObserverRef.current = null
|
|
unbindImageLoadsRef.current?.()
|
|
unbindImageLoadsRef.current = null
|
|
if (syncRafRef.current !== null) {
|
|
cancelAnimationFrame(syncRafRef.current)
|
|
syncRafRef.current = null
|
|
}
|
|
if (contrastDelayTimerRef.current !== null) {
|
|
window.clearTimeout(contrastDelayTimerRef.current)
|
|
contrastDelayTimerRef.current = null
|
|
}
|
|
}, [srcdoc, messageId])
|
|
|
|
return (
|
|
<iframe
|
|
key={iframeKey}
|
|
ref={iframeRef}
|
|
sandbox={sandboxValue}
|
|
scrolling="no"
|
|
title="Contenu du message"
|
|
className="w-full border-0 bg-transparent"
|
|
style={{ ...EMAIL_PREVIEW_IFRAME_STYLE, height: `${height}px` }}
|
|
srcDoc={srcdoc}
|
|
onLoad={handleIframeLoad}
|
|
tabIndex={-1}
|
|
/>
|
|
)
|
|
}
|