From 1ba4a13353a6150632537ea5d74d7ea8fd706e82 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 17 May 2026 17:33:06 +0200 Subject: [PATCH] fix: show "no body content" instead of infinite skeleton for bodyless emails --- components/email/email-list-item.tsx | 2 +- components/email/email-viewer.tsx | 9 ++++++--- components/email/thread-conversation-view.tsx | 2 +- components/email/thread-email-item.tsx | 4 +++- components/email/thread-list-item.tsx | 6 ++++-- locales/en/common.json | 2 ++ 6 files changed, 17 insertions(+), 8 deletions(-) diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx index b625e383..6ea316b0 100644 --- a/components/email/email-list-item.tsx +++ b/components/email/email-list-item.tsx @@ -296,7 +296,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl ? "text-muted-foreground" : "text-muted-foreground/80" )}> - {trimmedPreview || "No preview available"} + {trimmedPreview || t('no_preview_available')}

)} diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index d3c40e1a..f738ff4c 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -2489,7 +2489,7 @@ export function EmailViewer({ } return { - html: '

No content available

', + html: `

${t('no_body_content')}

`, isHtml: false, hasStyleTag: false, }; @@ -2497,7 +2497,7 @@ export function EmailViewer({ // 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]); + }, [email, externalContentPolicy, cidBlobUrls, t]); // Override email content with S/MIME decrypted content when available const effectiveEmailContent = useMemo(() => { @@ -2862,7 +2862,10 @@ export function EmailViewer({ // True while the new email's body is still being fetched. Catches the // window between selectedEmail changing and isLoading flipping true, so the // quick reply / body don't flicker through a partial render. - const isBodyLoading = isLoading || !email?.bodyValues || Object.keys(email.bodyValues).length === 0; + // An empty bodyValues with no referenced parts means the email has no body + // (e.g. calendar-only invites) — not "still loading". + const hasBodyParts = (email?.textBody?.length ?? 0) > 0 || (email?.htmlBody?.length ?? 0) > 0; + const isBodyLoading = isLoading || (hasBodyParts && (!email?.bodyValues || Object.keys(email.bodyValues).length === 0)); // Gates the quick reply on the iframe having loaded the current srcDoc, so // it doesn't flash in below a still-resizing iframe. diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 9073eedb..1b82dfcb 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -526,7 +526,7 @@ function EmailCard({ {!isExpanded && density !== 'extra-compact' && (

- {email.preview || "No preview available"} + {email.preview || t('email_viewer.no_preview_available')}

)} diff --git a/components/email/thread-email-item.tsx b/components/email/thread-email-item.tsx index 05933a3d..bcc25b46 100644 --- a/components/email/thread-email-item.tsx +++ b/components/email/thread-email-item.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback } from "react"; +import { useTranslations } from "next-intl"; import { formatDate } from "@/lib/utils"; import { Email } from "@/lib/jmap/types"; import { cn } from "@/lib/utils"; @@ -27,6 +28,7 @@ export function ThreadEmailItem({ onClick, onContextMenu, }: ThreadEmailItemProps) { + const t = useTranslations('email_viewer'); const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; const isAnswered = email.keywords?.$answered; @@ -177,7 +179,7 @@ export function ThreadEmailItem({ ? "text-muted-foreground" : "text-muted-foreground/70" )}> - {email.preview || "No preview"} + {email.preview || t('no_preview_available')} {/* Date */} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 3cb26be5..34f67d5c 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -52,6 +52,7 @@ interface SingleEmailItemProps { const SingleEmailItem = React.forwardRef( function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }, ref) { + const t = useTranslations('email_viewer'); const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; const isAnswered = email.keywords?.$answered; @@ -317,7 +318,7 @@ const SingleEmailItem = React.forwardRef( ? "text-muted-foreground" : "text-muted-foreground/80" )}> - {trimmedPreview || "No preview available"} + {trimmedPreview || t('no_preview_available')}

)} @@ -360,6 +361,7 @@ export const ThreadListItem = React.forwardRef state.showPreview); const density = useSettingsStore((state) => state.density); const mailLayout = useSettingsStore((state) => state.mailLayout); @@ -724,7 +726,7 @@ export const ThreadListItem = React.forwardRef - {trimmedPreview || "No preview available"} + {trimmedPreview || tEmailViewer('no_preview_available')}

)} diff --git a/locales/en/common.json b/locales/en/common.json index 22d15d1d..1f5b7722 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -249,6 +249,8 @@ "compose": "Compose", "compose_hint": "Compose new message", "no_subject": "(No Subject)", + "no_body_content": "(No body content available)", + "no_preview_available": "No preview available", "loading_email": "Loading email...", "loading": "Loading...", "reply": "Reply",