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 && (