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.
This commit is contained in:
Linus Rath
2026-09-24 20:12:17 +02:00
parent 33e303e248
commit ac8e3fb2e9
2 changed files with 47 additions and 13 deletions
+25 -3
View File
@@ -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({
<CalendarInvitationBanner email={email} jmapAccountId={jmapAccountId} />
<View style={styles.body}>
{unwrap.loading ? (
{deferBody ? (
<BodyPlaceholder styles={styles} />
) : unwrap.loading ? (
<View style={styles.loading}><ActivityIndicator color={c.primary} /></View>
) : (
<EmailBodyView
@@ -116,6 +123,19 @@ export function MessageContent({
);
}
const PLACEHOLDER_LINE_WIDTHS = ['92%', '100%', '85%', '96%', '60%', '88%', '74%', '40%'] as const;
// Static bones where the body will be: cheap enough for off-screen pages.
function BodyPlaceholder({ styles }: { styles: ReturnType<typeof makeStyles> }) {
return (
<View style={styles.placeholder}>
{PLACEHOLDER_LINE_WIDTHS.map((w, i) => (
<View key={i} style={[styles.placeholderLine, { width: w }]} />
))}
</View>
);
}
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 },
});
}
+22 -10
View File
@@ -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 <EmailPaneSkeleton styles={styles} />;
}
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 (
<ScrollView
@@ -966,11 +973,15 @@ function EmailPane({
<Text style={styles.threadCountText}>{conversation.length}</Text>
</View>
) : (
<Pressable onPress={() => onToggleStar(email)} hitSlop={8} style={styles.subjectStar}>
<Pressable
onPress={email ? () => onToggleStar(email) : undefined}
hitSlop={8}
style={styles.subjectStar}
>
<Star
size={18}
color={email.keywords?.$flagged ? c.starred : c.textMuted}
fill={email.keywords?.$flagged ? c.starred : 'transparent'}
color={shown.keywords?.$flagged ? c.starred : c.textMuted}
fill={shown.keywords?.$flagged ? c.starred : 'transparent'}
/>
</Pressable>
)}
@@ -1012,12 +1023,13 @@ function EmailPane({
})
) : (
<MessageContent
email={email}
email={shown}
deferBody={!email}
jmapAccountId={jmapAccountId}
identities={identities}
currentMailboxRole={currentMailboxRole}
active={active}
themeOverride={themeOverrides[email.id] ?? null}
themeOverride={themeOverrides[shown.id] ?? null}
onSwipe={onSwipe}
onZoomChange={(z) => { setPinching(z.pinching); onZoomChange(z); }}
onAddressPress={onAddressPress}
@@ -1025,7 +1037,7 @@ function EmailPane({
/>
)}
{newestLoaded && (
{newest && newestLoaded && (
<QuickReplyBox
email={newest}
jmapAccountId={jmapAccountId}