mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
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:
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user