fix: prevent iframe flash on Load images or Trust sender
This commit is contained in:
+11
-4
@@ -654,8 +654,10 @@ export default function Home() {
|
|||||||
// Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive)
|
// Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedEmail || !client) return;
|
if (!selectedEmail || !client) return;
|
||||||
// If the email lacks bodyValues, it was auto-selected from the list and needs full content
|
// If the email lacks bodyValues, it was auto-selected from the list and needs full content.
|
||||||
if (!selectedEmail.bodyValues) {
|
// Skip when handleEmailSelect already started a fetch (it sets isLoadingEmail before
|
||||||
|
// calling selectEmail on the stub), to avoid a duplicate request.
|
||||||
|
if (!selectedEmail.bodyValues && !isLoadingEmail) {
|
||||||
const perAccountClient = isUnifiedView && selectedEmail.accountId
|
const perAccountClient = isUnifiedView && selectedEmail.accountId
|
||||||
? useAuthStore.getState().getClientForAccount(selectedEmail.accountId)
|
? useAuthStore.getState().getClientForAccount(selectedEmail.accountId)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -1502,7 +1504,13 @@ export default function Home() {
|
|||||||
setShowComposer(false);
|
setShowComposer(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set loading state immediately (keep current email visible)
|
// Show the list stub immediately so subject/sender render without
|
||||||
|
// waiting for the body fetch — avoids the loading flicker.
|
||||||
|
const listEmail = emails.find(e => e.id === email.id);
|
||||||
|
if (listEmail) {
|
||||||
|
selectEmail(listEmail);
|
||||||
|
}
|
||||||
|
|
||||||
setLoadingEmail(true);
|
setLoadingEmail(true);
|
||||||
|
|
||||||
// On mobile, switch to viewer
|
// On mobile, switch to viewer
|
||||||
@@ -1519,7 +1527,6 @@ export default function Home() {
|
|||||||
try {
|
try {
|
||||||
// In unified view each email carries its own accountId. Use that
|
// In unified view each email carries its own accountId. Use that
|
||||||
// account's client so we fetch from the server that actually owns it.
|
// account's client so we fetch from the server that actually owns it.
|
||||||
const listEmail = emails.find(e => e.id === email.id);
|
|
||||||
const emailAccountId = isUnifiedView ? listEmail?.accountId : undefined;
|
const emailAccountId = isUnifiedView ? listEmail?.accountId : undefined;
|
||||||
const perAccountClient = emailAccountId
|
const perAccountClient = emailAccountId
|
||||||
? useAuthStore.getState().getClientForAccount(emailAccountId)
|
? useAuthStore.getState().getClientForAccount(emailAccountId)
|
||||||
|
|||||||
@@ -2338,8 +2338,7 @@ export function EmailViewer({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (shouldBlockExternal) {
|
if (shouldBlockExternal) {
|
||||||
sanitizeConfig.FORBID_TAGS.push('link');
|
sanitizeConfig.FORBID_TAGS = [...sanitizeConfig.FORBID_TAGS, 'link'];
|
||||||
sanitizeConfig.FORBID_ATTR.push('background');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||||
@@ -2357,11 +2356,19 @@ export function EmailViewer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bgAttr = node.getAttribute?.('background');
|
||||||
|
if (bgAttr && (bgAttr.startsWith('http://') || bgAttr.startsWith('https://') || bgAttr.startsWith('//'))) {
|
||||||
|
node.setAttribute('data-blocked-background', bgAttr);
|
||||||
|
node.removeAttribute('background');
|
||||||
|
blockedExternalContent = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (htmlNode.style) {
|
if (htmlNode.style) {
|
||||||
const style = htmlNode.style.cssText;
|
const style = htmlNode.style.cssText;
|
||||||
if (style && style.includes('url(')) {
|
if (style && style.includes('url(')) {
|
||||||
const urlMatch = style.match(/url\(['"]?(https?:\/\/[^'")\s]+)['"]?\)/gi);
|
const urlMatch = style.match(/url\(['"]?(https?:\/\/[^'")\s]+)['"]?\)/gi);
|
||||||
if (urlMatch) {
|
if (urlMatch) {
|
||||||
|
node.setAttribute('data-blocked-style', style);
|
||||||
htmlNode.style.cssText = style.replace(/url\(['"]?https?:\/\/[^'")\s]+['"]?\)/gi, 'url()');
|
htmlNode.style.cssText = style.replace(/url\(['"]?https?:\/\/[^'")\s]+['"]?\)/gi, 'url()');
|
||||||
blockedExternalContent = true;
|
blockedExternalContent = true;
|
||||||
}
|
}
|
||||||
@@ -2427,7 +2434,11 @@ export function EmailViewer({
|
|||||||
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
|
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
|
||||||
isHtml: false
|
isHtml: false
|
||||||
};
|
};
|
||||||
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, isTrustedAddressBookSender, trustedSendersAddressBook, cidBlobUrls]);
|
// Intentionally omit allowExternalContent and trust state from deps:
|
||||||
|
// toggling permission imperatively unblocks content via restoreBlockedContent
|
||||||
|
// in an effect below, so the iframe srcDoc stays stable and doesn't reload/flash.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [email, externalContentPolicy, cidBlobUrls]);
|
||||||
|
|
||||||
// Override email content with S/MIME decrypted content when available
|
// Override email content with S/MIME decrypted content when available
|
||||||
const effectiveEmailContent = useMemo(() => {
|
const effectiveEmailContent = useMemo(() => {
|
||||||
@@ -2613,6 +2624,63 @@ export function EmailViewer({
|
|||||||
</style></head><body>${effectiveEmailContent.html}</body></html>`;
|
</style></head><body>${effectiveEmailContent.html}</body></html>`;
|
||||||
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, isDark, emailHasNativeDarkMode]);
|
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, isDark, emailHasNativeDarkMode]);
|
||||||
|
|
||||||
|
// Imperatively restore blocked external content inside the iframe document.
|
||||||
|
// Avoids re-rendering the iframe srcDoc (which would reload and flash) when
|
||||||
|
// the user clicks "Load images" or "Trust sender".
|
||||||
|
const restoreBlockedContent = useCallback(() => {
|
||||||
|
const doc = iframeRef.current?.contentDocument;
|
||||||
|
if (!doc) return;
|
||||||
|
|
||||||
|
doc.querySelectorAll('img[data-blocked-src]').forEach((node) => {
|
||||||
|
const el = node as HTMLImageElement;
|
||||||
|
const src = el.getAttribute('data-blocked-src');
|
||||||
|
if (src) {
|
||||||
|
el.setAttribute('src', src);
|
||||||
|
el.style.display = '';
|
||||||
|
el.removeAttribute('data-blocked-src');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.querySelectorAll('[data-blocked-style]').forEach((node) => {
|
||||||
|
const el = node as HTMLElement;
|
||||||
|
const style = el.getAttribute('data-blocked-style');
|
||||||
|
if (style !== null) {
|
||||||
|
el.style.cssText = style;
|
||||||
|
el.removeAttribute('data-blocked-style');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.querySelectorAll('[data-blocked-background]').forEach((node) => {
|
||||||
|
const el = node as HTMLElement;
|
||||||
|
const bg = el.getAttribute('data-blocked-background');
|
||||||
|
if (bg) {
|
||||||
|
el.setAttribute('background', bg);
|
||||||
|
el.removeAttribute('data-blocked-background');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.querySelectorAll('[data-blocked-collapsed-style]').forEach((node) => {
|
||||||
|
const el = node as HTMLElement;
|
||||||
|
const style = el.getAttribute('data-blocked-collapsed-style');
|
||||||
|
if (style !== null) {
|
||||||
|
el.style.cssText = style;
|
||||||
|
el.removeAttribute('data-blocked-collapsed-style');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Whenever permission is granted (allow toggled, or sender becomes trusted),
|
||||||
|
// restore blocked content in the existing iframe — no srcDoc rebuild.
|
||||||
|
const senderEmailLower = email?.from?.[0]?.email?.toLowerCase();
|
||||||
|
const senderIsTrustedNow = senderEmailLower
|
||||||
|
? isSenderTrusted(senderEmailLower) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmailLower))
|
||||||
|
: false;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasBlockedContent) return;
|
||||||
|
if (!allowExternalContent && !senderIsTrustedNow) return;
|
||||||
|
restoreBlockedContent();
|
||||||
|
}, [allowExternalContent, senderIsTrustedNow, hasBlockedContent, restoreBlockedContent]);
|
||||||
|
|
||||||
const handleIframeLoad = useCallback(() => {
|
const handleIframeLoad = useCallback(() => {
|
||||||
const iframe = iframeRef.current;
|
const iframe = iframeRef.current;
|
||||||
if (!iframe) return;
|
if (!iframe) return;
|
||||||
@@ -2946,11 +3014,6 @@ export function EmailViewer({
|
|||||||
|
|
||||||
{/* Right: Organize actions - order: archive, delete, move, star, tag, spam, read state, print, view source */}
|
{/* Right: Organize actions - order: archive, delete, move, star, tag, spam, read state, print, view source */}
|
||||||
<div className="flex items-center gap-0 sm:gap-0.5">
|
<div className="flex items-center gap-0 sm:gap-0.5">
|
||||||
{isLoading && (
|
|
||||||
<div className="mr-2 flex items-center gap-1.5 text-muted-foreground">
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Archive */}
|
{/* Archive */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -3629,15 +3692,6 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
{/* Main email content */}
|
{/* Main email content */}
|
||||||
<div className="flex-1 flex flex-col h-full overflow-hidden min-w-0">
|
<div className="flex-1 flex flex-col h-full overflow-hidden min-w-0">
|
||||||
{/* Loading overlay when fetching new email */}
|
|
||||||
{isLoading && (
|
|
||||||
<div className="absolute inset-0 bg-background/60 backdrop-blur-[2px] z-50 flex items-center justify-center animate-in fade-in duration-200">
|
|
||||||
<div className="bg-background rounded-lg shadow-lg border border-border p-4 flex items-center gap-3">
|
|
||||||
<Loader2 className="w-5 h-5 animate-spin text-primary" />
|
|
||||||
<span className="text-sm font-medium text-foreground">{t('loading_email')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* === TOOLBAR (top position) === */}
|
{/* === TOOLBAR (top position) === */}
|
||||||
{toolbarPosition === 'top' && (
|
{toolbarPosition === 'top' && (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
@@ -4662,8 +4716,8 @@ export function EmailViewer({
|
|||||||
|
|
||||||
<PluginSlot name="email-footer" />
|
<PluginSlot name="email-footer" />
|
||||||
|
|
||||||
{/* Quick Reply Section - hidden for drafts */}
|
{/* Quick Reply Section - hidden for drafts and while loading a new email */}
|
||||||
{!isDraft && (<div className={cn(
|
{!isDraft && !isLoading && (<div className={cn(
|
||||||
"mt-6 mx-6 mb-6 bg-background rounded-lg shadow-sm border transition-all",
|
"mt-6 mx-6 mb-6 bg-background rounded-lg shadow-sm border transition-all",
|
||||||
isQuickReplyFocused || quickReplyText ? "border-primary" : "border-border"
|
isQuickReplyFocused || quickReplyText ? "border-primary" : "border-border"
|
||||||
)}>
|
)}>
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ export function collapseBlockedImageContainers(html: string): string {
|
|||||||
const hasVisibleMedia = el.querySelector('img:not([data-blocked-src]), video, canvas');
|
const hasVisibleMedia = el.querySelector('img:not([data-blocked-src]), video, canvas');
|
||||||
const hasLinks = el.querySelector('a[href]');
|
const hasLinks = el.querySelector('a[href]');
|
||||||
if (!hasVisibleText && !hasVisibleMedia && !hasLinks) {
|
if (!hasVisibleText && !hasVisibleMedia && !hasLinks) {
|
||||||
|
el.setAttribute('data-blocked-collapsed-style', el.style.cssText);
|
||||||
el.style.display = 'none';
|
el.style.display = 'none';
|
||||||
el.style.height = '0';
|
el.style.height = '0';
|
||||||
el.style.padding = '0';
|
el.style.padding = '0';
|
||||||
|
|||||||
Reference in New Issue
Block a user