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