From ac8e3fb2e9a7ce973363a901b3788238fa0d22f0 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Thu, 24 Sep 2026 19:34:34 +0200
Subject: [PATCH] fix: paint the message header from the list row while the
body loads
The viewer showed a full skeleton until the message's detail had come
back, although the list row it was opened from already holds the
subject, sender, date and avatar. The header appeared about 0.4 s after
the tap.
A page whose list row is known now renders the real subject and header
from that row straight away, with placeholder lines where the body
goes; the body (and the star, reply and quick reply that need the full
message) follows when the detail arrives. Pages handed over by id only
(notification, deep link) keep the skeleton.
---
src/components/email/MessageContent.tsx | 28 +++++++++++++++++++---
src/screens/EmailThreadScreen.tsx | 32 +++++++++++++++++--------
2 files changed, 47 insertions(+), 13 deletions(-)
diff --git a/src/components/email/MessageContent.tsx b/src/components/email/MessageContent.tsx
index cfec898a..24a7250f 100644
--- a/src/components/email/MessageContent.tsx
+++ b/src/components/email/MessageContent.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import { View, StyleSheet, ActivityIndicator } from 'react-native';
import type { Email, EmailAddress, Identity } from '../../api/types';
-import { spacing, type ThemePalette } from '../../theme/tokens';
+import { spacing, radius, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore } from '../../stores/settings-store';
import EmailBodyView from '../EmailBodyView';
@@ -30,6 +30,11 @@ export interface MessageContentProps {
onEmailPatched: (email: Email) => void;
/** Compact header (thread cards). */
compact?: boolean;
+ /**
+ * Show a placeholder where the body goes: `email` is the list row, painted
+ * while the message itself loads.
+ */
+ deferBody?: boolean;
}
/**
@@ -40,7 +45,7 @@ export interface MessageContentProps {
*/
export function MessageContent({
email, jmapAccountId, identities, currentMailboxRole, active, themeOverride, onSwipe, onZoomChange,
- onToggleStar, onAddressPress, onEmailPatched, compact,
+ onToggleStar, onAddressPress, onEmailPatched, compact, deferBody,
}: MessageContentProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -98,7 +103,9 @@ export function MessageContent({
- {unwrap.loading ? (
+ {deferBody ? (
+
+ ) : unwrap.loading ? (
) : (
}) {
+ return (
+
+ {PLACEHOLDER_LINE_WIDTHS.map((w, i) => (
+
+ ))}
+
+ );
+}
+
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
headerBlock: {
@@ -129,5 +149,7 @@ function makeStyles(c: ThemePalette) {
},
body: { backgroundColor: c.background },
loading: { padding: spacing.xl, alignItems: 'center' },
+ placeholder: { paddingHorizontal: spacing.lg, paddingVertical: spacing.lg, gap: spacing.sm },
+ placeholderLine: { height: 12, borderRadius: radius.xs, backgroundColor: c.surfaceHover },
});
}
diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx
index 9ce581be..d5c5b13d 100644
--- a/src/screens/EmailThreadScreen.tsx
+++ b/src/screens/EmailThreadScreen.tsx
@@ -700,6 +700,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
active={item.id === activeEmailId}
threadIdHint={item.threadId}
email={detailOf(item.id) ?? null}
+ row={item}
threadIds={
!disableThreading
? threadIdsOf(detailOf(item.id)?.threadId ?? item.threadId)
@@ -845,6 +846,8 @@ interface EmailPaneProps {
active: boolean;
threadIdHint?: string;
email: Email | null;
+ /** The page as the pager lists it: a list row, or just the id for one handed over by id. */
+ row: Email;
/** Ids of the whole conversation (oldest first) once fetched; null = not (yet) loaded. */
threadIds: string[] | null;
/** A conversation member: its full copy when held, else its header. */
@@ -874,7 +877,7 @@ interface EmailPaneProps {
// swipe slides ready content into view. A conversation is listed from its
// members' headers; bodies are only downloaded for the cards that are open.
function EmailPane({
- id, active, threadIdHint, email, threadIds, memberOf, threading, jmapAccountId, currentMailboxRole,
+ id, active, threadIdHint, email, row, threadIds, memberOf, threading, jmapAccountId, currentMailboxRole,
identities, themeOverrides, ensureDetail, ensureDetails, ensureThread, scheduleMarkRead, styles,
bottomBarHeight, onToggleStar, onAddressPress, onEmailPatched, onReply, onSwipe, onZoomChange,
}: EmailPaneProps) {
@@ -939,17 +942,21 @@ function EmailPane({
});
};
- if (!email) {
+ // Until the message itself is here, its list row paints the subject and
+ // header (sender, date, avatar); only a page handed over by id alone
+ // (notification, deep link) starts as a skeleton.
+ const shown = email ?? (row.receivedAt ? row : null);
+ if (!shown) {
return ;
}
- const subject = singleLine(email.subject) || t('email_viewer.no_subject', '(No Subject)');
+ const subject = singleLine(shown.subject) || t('email_viewer.no_subject', '(No Subject)');
const conversation = threading && threadId && threadIds && threadIds.length > 1
? threadIds.map((mid) => memberOf(threadId, mid)).filter((m): m is Email => !!m)
: null;
const newest = conversation ? conversation[conversation.length - 1] : email;
// The quick reply quotes the message, so it waits for the newest one's body.
- const newestLoaded = !conversation || !!peekDetail(newest.id, jmapAccountId);
+ const newestLoaded = !!newest && !!peekDetail(newest.id, jmapAccountId);
return (
{conversation.length}
) : (
- onToggleStar(email)} hitSlop={8} style={styles.subjectStar}>
+ onToggleStar(email) : undefined}
+ hitSlop={8}
+ style={styles.subjectStar}
+ >
)}
@@ -1012,12 +1023,13 @@ function EmailPane({
})
) : (
{ setPinching(z.pinching); onZoomChange(z); }}
onAddressPress={onAddressPress}
@@ -1025,7 +1037,7 @@ function EmailPane({
/>
)}
- {newestLoaded && (
+ {newest && newestLoaded && (