fix: open a message at its full height and keep the quick reply still

Every body started as a 120 px strip that grew once the page had
measured itself, so an open showed a skeleton, then a clipped strip of
the body, then the full body, and the quick reply box below it jumped
down as the WebView grew. A conversation arriving after the message
swapped the single-message layout for cards, which reloaded the body
and pushed it down under a "Loading conversation" row.

- `lib/body-heights` remembers the last measured height of each body
  (per width); a body shown before starts at that height.
- A page showing one message stretches its body over the space below
  the header until it has measured itself, and the quick reply sits at
  the bottom of the page, so it does not move whether the body turns
  out shorter or longer. Conversation cards start from an estimate
  (most of a screen for HTML, the wrapped line count for plain text).
- A single message is rendered through the conversation's card list,
  so its body stays mounted when the conversation arrives; while it
  loads, the size the folder list knows for it holds the older cards'
  places, and the loading indicator sits in the subject row.
- An open card whose body has not arrived shows its header over
  placeholder lines, with its reply buttons disabled until it has.
This commit is contained in:
Linus Rath
2026-09-24 20:12:17 +02:00
parent 6ebadf738f
commit 54b169c6c7
6 changed files with 289 additions and 91 deletions
+37 -7
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { View, StyleSheet, Linking, Platform, Text, Pressable } from 'react-native';
import { View, StyleSheet, Linking, Platform, Text, Pressable, useWindowDimensions } from 'react-native';
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
@@ -9,6 +9,7 @@ import { extractCidRefs } from '../lib/email-html';
import { hasTruncatedDisplayedBody, pickEmailBody, selectRenderableHtml } from '../lib/email-body';
import { buildQuoteCollapseScript } from '../lib/quote-collapse';
import { bodyDocument } from '../lib/email-body-document';
import { estimateBodyHeight, lastBodyHeight, rememberBodyHeight } from '../lib/body-heights';
import { parseMailtoUrl } from '../lib/unsubscribe';
import { fetchInlineImageDataUri } from '../lib/email-export';
import { isSenderContentTrusted } from '../lib/trusted-senders';
@@ -45,6 +46,11 @@ interface EmailBodyViewProps {
bodyOverride?: { html?: string | null; text?: string | null } | null;
/** Called once the body has loaded and reported its height (it is on screen). */
onSettled?: () => void;
/**
* Until its height is known, take the space the parent leaves (the whole
* page below the header) rather than a guessed height.
*/
fill?: boolean;
}
// Dark-mode re-inversion, mirroring the webmail's handleIframeLoad pass. The
@@ -511,7 +517,7 @@ const PINCH_ZOOM = `
`;
export default function EmailBodyView({
email, senderEmail, jmapAccountId, onSwipe, onZoomChange, themeOverride, bodyOverride, onSettled,
email, senderEmail, jmapAccountId, onSwipe, onZoomChange, themeOverride, bodyOverride, onSettled, fill,
}: EmailBodyViewProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -575,13 +581,22 @@ export default function EmailBodyView({
!allowOnce &&
externalContentPolicy !== 'allow';
const [height, setHeight] = React.useState(120);
// The WebView's height: what the page reported, else the height this body
// had the last time it was shown, else (see `fill`) an estimate. Never a
// fixed strip that then grows in steps.
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const heightKey = `${jmapAccountId ?? ''}|${email.id}`;
const [measured, setMeasured] = React.useState<{ key: string; height: number } | null>(null);
const knownHeight = measured?.key === heightKey
? measured.height
: lastBodyHeight(heightKey, windowWidth);
// A pinch-zoomed page reports its scaled height; that is not one to keep.
const zoomedRef = React.useRef(false);
const [cidMap, setCidMap] = React.useState<Record<string, string>>({});
const webviewRef = React.useRef<WebView>(null);
const settledRef = React.useRef(false);
React.useEffect(() => {
setHeight(120);
// Keep the empty map it started with: a new object would rebuild the
// document (sanitise the body) a second time on every mount.
setCidMap((prev) => (Object.keys(prev).length === 0 ? prev : {}));
@@ -674,6 +689,7 @@ export default function EmailBodyView({
if (msg.type === 'swipe' && (msg.dir === 'prev' || msg.dir === 'next')) {
onSwipe?.(msg.dir);
} else if (msg.type === 'zoom') {
zoomedRef.current = !!msg.pinching || (typeof msg.scale === 'number' && msg.scale > 1.01);
onZoomChange?.({
pinching: !!msg.pinching,
zoomed: typeof msg.scale === 'number' && msg.scale > 1.01,
@@ -684,13 +700,24 @@ export default function EmailBodyView({
}
const parsed = parseInt(data, 10);
if (Number.isNaN(parsed) || parsed <= 0) return;
setHeight((prev) => (Math.abs(parsed - prev) < 2 ? prev : parsed));
setMeasured((prev) => (
prev?.key === heightKey && Math.abs(parsed - prev.height) < 2 ? prev : { key: heightKey, height: parsed }
));
if (!zoomedRef.current) rememberBodyHeight(heightKey, windowWidth, parsed);
if (!settledRef.current) {
settledRef.current = true;
onSettled?.();
}
};
const estimate = React.useMemo(
() => estimateBodyHeight({ isHtml: prepared.isHtml, text: text ?? email.preview, width: windowWidth, windowHeight }),
[prepared.isHtml, text, email.preview, windowWidth, windowHeight],
);
const containerSize = knownHeight !== undefined
? { height: knownHeight }
: fill ? styles.webContainerFill : { height: estimate };
const onLoadImages = () => setAllowOnce(true);
const onTrustSender = () => {
if (senderEmail) {
@@ -720,7 +747,7 @@ export default function EmailBodyView({
};
return (
<View style={styles.wrapper}>
<View style={[styles.wrapper, fill && styles.wrapperFill]}>
{showBanner && (
<View style={styles.banner}>
<Text style={styles.bannerText}>
@@ -750,7 +777,7 @@ export default function EmailBodyView({
</View>
)}
<View style={[styles.webContainer, { height }]}>
<View style={[styles.webContainer, containerSize]}>
<WebView
ref={webviewRef}
originWhitelist={['about:blank']}
@@ -762,6 +789,7 @@ export default function EmailBodyView({
// A (re)load resets the page's zoom state to 1 — drop any host-side
// scroll locks so they can't go stale (e.g. cid images arriving
// reload the source mid-zoom).
zoomedRef.current = false;
onZoomChange?.({ pinching: false, zoomed: false });
// Re-inject after load to cover Android cases where
// `injectedJavaScript` runs too early to see the final layout. Every
@@ -815,6 +843,7 @@ export default function EmailBodyView({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
wrapper: { width: '100%' },
wrapperFill: { flexGrow: 1 },
banner: {
gap: spacing.xs,
paddingHorizontal: spacing.lg,
@@ -848,6 +877,7 @@ function makeStyles(c: ThemePalette) {
backgroundColor: 'transparent',
overflow: 'hidden',
},
webContainerFill: { flexGrow: 1, minHeight: 120 },
webview: {
backgroundColor: 'transparent',
flex: 1,
+13 -6
View File
@@ -38,6 +38,11 @@ export interface MessageContentProps {
deferBody?: boolean;
/** The body loaded and reported its height. */
onBodySettled?: () => void;
/**
* Stretch to the space the parent leaves (a page showing one message), so
* the body starts at the full page height instead of growing into it.
*/
fill?: boolean;
}
/**
@@ -48,7 +53,7 @@ export interface MessageContentProps {
*/
export function MessageContent({
email, jmapAccountId, identities, currentMailboxRole, active, themeOverride, onSwipe, onZoomChange,
onToggleStar, onAddressPress, onEmailPatched, compact, deferBody, onBodySettled,
onToggleStar, onAddressPress, onEmailPatched, compact, deferBody, onBodySettled, fill,
}: MessageContentProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -70,7 +75,7 @@ export function MessageContent({
);
return (
<View>
<View style={fill ? styles.fill : undefined}>
<View style={styles.headerBlock}>
<MessageHeader
email={email}
@@ -105,9 +110,9 @@ export function MessageContent({
<CalendarInvitationBanner email={email} jmapAccountId={jmapAccountId} />
<View style={styles.body}>
<View style={[styles.body, fill && styles.fill]}>
{deferBody ? (
<BodyPlaceholder styles={styles} />
<BodyPlaceholder styles={styles} fill={fill} />
) : unwrap.loading ? (
<View style={styles.loading}><ActivityIndicator color={c.primary} /></View>
) : (
@@ -120,6 +125,7 @@ export function MessageContent({
themeOverride={themeOverride}
bodyOverride={unwrap.override}
onSettled={onBodySettled}
fill={fill}
/>
)}
</View>
@@ -130,9 +136,9 @@ 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> }) {
function BodyPlaceholder({ styles, fill }: { styles: ReturnType<typeof makeStyles>; fill?: boolean }) {
return (
<View style={styles.placeholder}>
<View style={[styles.placeholder, fill && styles.fill]}>
{PLACEHOLDER_LINE_WIDTHS.map((w, i) => (
<View key={i} style={[styles.placeholderLine, { width: w }]} />
))}
@@ -152,6 +158,7 @@ function makeStyles(c: ThemePalette) {
borderBottomColor: c.border,
},
body: { backgroundColor: c.background },
fill: { flexGrow: 1 },
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 },
+61 -21
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, ActivityIndicator } from 'react-native';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Paperclip, Reply, ReplyAll, Forward, Star } from 'lucide-react-native';
import type { Email } from '../../api/types';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
@@ -13,10 +13,16 @@ import { singleLine } from '../../lib/single-line';
interface Props extends MessageContentProps {
expanded: boolean;
/** Opened, with its body still on the way: the summary row with a spinner. */
loading?: boolean;
onToggleExpanded: () => void;
onReply: (mode: 'reply' | 'replyAll' | 'forward', email: Email) => void;
/**
* The message is not part of a conversation: just its content, without the
* card's collapse handle, reply buttons and header star. Rendered through
* the card so the content survives a conversation arriving around it.
*/
bare?: boolean;
/** The full message is here (not only its header), so it can be replied to. */
replyable?: boolean;
}
/**
@@ -24,7 +30,9 @@ interface Props extends MessageContentProps {
* preview) that expands into the full message with its own reply / forward
* actions - the webmail's thread-conversation-view cards.
*/
export function ThreadMessageCard({ expanded, loading, onToggleExpanded, onReply, ...content }: Props) {
export function ThreadMessageCard({
expanded, onToggleExpanded, onReply, bare, replyable = true, ...content
}: Props) {
const { email, onToggleStar } = content;
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -50,29 +58,53 @@ export function ThreadMessageCard({ expanded, loading, onToggleExpanded, onReply
</View>
<Text style={styles.collapsedPreview} numberOfLines={1}>{singleLine(email.preview)}</Text>
</View>
{loading && <ActivityIndicator size="small" color={c.textMuted} />}
{starred && <Star size={14} color={c.starred} fill={c.starred} />}
</Pressable>
);
}
// The content keeps its place among the children whether bare or not, so
// it (and its WebView) is not remounted when a conversation arrives.
const reply = (mode: 'reply' | 'replyAll' | 'forward') => (replyable ? () => onReply(mode, email) : undefined);
return (
<View style={styles.expanded}>
<Pressable onPress={onToggleExpanded} style={styles.collapseHandle} hitSlop={6} accessibilityLabel={t('threads.collapse', 'Collapse conversation')} />
<MessageContent {...content} compact={false} onToggleStar={onToggleStar} />
<View style={styles.actions}>
<Pressable style={styles.actionBtn} onPress={() => onReply('reply', email)} hitSlop={4}>
<Reply size={16} color={c.textSecondary} />
<Text style={styles.actionLabel}>{t('email_viewer.reply', 'Reply')}</Text>
</Pressable>
<Pressable style={styles.actionBtn} onPress={() => onReply('replyAll', email)} hitSlop={4}>
<ReplyAll size={16} color={c.textSecondary} />
<Text style={styles.actionLabel}>{t('email_viewer.reply_all', 'Reply All')}</Text>
</Pressable>
<Pressable style={styles.actionBtn} onPress={() => onReply('forward', email)} hitSlop={4}>
<Forward size={16} color={c.textSecondary} />
<Text style={styles.actionLabel}>{t('email_viewer.forward', 'Forward')}</Text>
</Pressable>
<View style={bare ? (content.fill ? styles.bareFill : undefined) : styles.expanded}>
{!bare && (
<Pressable onPress={onToggleExpanded} style={styles.collapseHandle} hitSlop={6} accessibilityLabel={t('threads.collapse', 'Collapse conversation')} />
)}
<MessageContent {...content} compact={false} onToggleStar={bare ? undefined : onToggleStar} />
{!bare && (
<View style={[styles.actions, !replyable && styles.actionsDisabled]}>
<Pressable style={styles.actionBtn} onPress={reply('reply')} hitSlop={4}>
<Reply size={16} color={c.textSecondary} />
<Text style={styles.actionLabel}>{t('email_viewer.reply', 'Reply')}</Text>
</Pressable>
<Pressable style={styles.actionBtn} onPress={reply('replyAll')} hitSlop={4}>
<ReplyAll size={16} color={c.textSecondary} />
<Text style={styles.actionLabel}>{t('email_viewer.reply_all', 'Reply All')}</Text>
</Pressable>
<Pressable style={styles.actionBtn} onPress={reply('forward')} hitSlop={4}>
<Forward size={16} color={c.textSecondary} />
<Text style={styles.actionLabel}>{t('email_viewer.forward', 'Forward')}</Text>
</Pressable>
</View>
)}
</View>
);
}
/**
* Stands in for a collapsed card while a conversation's headers load, at the
* same height, so the opened message does not move when they arrive.
*/
export function ThreadCardPlaceholder() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={styles.collapsed}>
<View style={styles.placeholderAvatar} />
<View style={styles.collapsedInfo}>
<Text style={styles.collapsedName}> </Text>
<Text style={styles.collapsedPreview}> </Text>
</View>
</View>
);
@@ -102,6 +134,14 @@ function makeStyles(c: ThemePalette) {
borderBottomWidth: 1,
borderBottomColor: c.border,
},
bareFill: { flexGrow: 1 },
placeholderAvatar: {
width: componentSizes.avatarSm,
height: componentSizes.avatarSm,
borderRadius: radius.full,
backgroundColor: c.surfaceHover,
},
actionsDisabled: { opacity: 0.4 },
collapseHandle: {
height: 6,
backgroundColor: c.surfaceHover,
+50
View File
@@ -0,0 +1,50 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { clearBodyHeights, estimateBodyHeight, lastBodyHeight, rememberBodyHeight } from '../body-heights';
beforeEach(() => {
clearBodyHeights();
});
describe('body heights', () => {
it('gives back the height a body had at the same width', () => {
rememberBodyHeight('|e1', 411.4, 1830);
expect(lastBodyHeight('|e1', 411.4)).toBe(1830);
expect(lastBodyHeight('|e1', 411)).toBe(1830);
// Rotated: the old height says nothing.
expect(lastBodyHeight('|e1', 891)).toBeUndefined();
expect(lastBodyHeight('group|e1', 411)).toBeUndefined();
});
it('ignores reports that are not a height', () => {
rememberBodyHeight('|e1', 400, 0);
rememberBodyHeight('|e1', 400, Number.NaN);
expect(lastBodyHeight('|e1', 400)).toBeUndefined();
});
it('forgets the oldest heights first', () => {
for (let i = 0; i <= 200; i++) rememberBodyHeight(`|m${i}`, 400, 100 + i);
expect(lastBodyHeight('|m0', 400)).toBeUndefined();
expect(lastBodyHeight('|m200', 400)).toBe(300);
});
});
describe('estimateBodyHeight', () => {
it('gives an HTML body most of a screen', () => {
expect(estimateBodyHeight({ isHtml: true, width: 400, windowHeight: 800 })).toBe(480);
});
it('counts wrapped lines of plain text', () => {
const short = estimateBodyHeight({ isHtml: false, text: 'Thanks!', width: 400, windowHeight: 800 });
const long = estimateBodyHeight({ isHtml: false, text: 'word '.repeat(400), width: 400, windowHeight: 800 });
const lines = estimateBodyHeight({ isHtml: false, text: 'a\n'.repeat(30), width: 400, windowHeight: 800 });
expect(short).toBe(120);
expect(long).toBeGreaterThan(short);
// 31 lines of 22.4 px plus padding.
expect(lines).toBe(Math.round(31 * 22.4 + 40));
});
it('stays within bounds', () => {
expect(estimateBodyHeight({ isHtml: false, text: 'x\n'.repeat(10_000), width: 400, windowHeight: 800 })).toBe(4000);
expect(estimateBodyHeight({ isHtml: true, width: 400, windowHeight: 100 })).toBe(120);
});
});
+56
View File
@@ -0,0 +1,56 @@
// How tall a message body turned out, so the next time it mounts (swiping
// back to it, opening it again, a card reopened) its WebView starts at that
// height instead of growing from a fixed strip.
const MAX_HEIGHTS = 200;
const heights = new Map<string, number>();
// The height only holds at the width it was measured at (rotation, split screen).
function keyOf(key: string, width: number): string {
return `${key}|${Math.round(width)}`;
}
/** Remember the body's measured height at this width. */
export function rememberBodyHeight(key: string, width: number, height: number): void {
if (!(height > 0)) return;
const k = keyOf(key, width);
heights.delete(k);
heights.set(k, height);
while (heights.size > MAX_HEIGHTS) heights.delete(heights.keys().next().value as string);
}
/** The body's height when it was last shown at this width, if known. */
export function lastBodyHeight(key: string, width: number): number | undefined {
return heights.get(keyOf(key, width));
}
/** Forget every height (tests). */
export function clearBodyHeights(): void {
heights.clear();
}
// The plain-text document: 14px text, line-height 1.6, 16px padding.
const TEXT_LINE_HEIGHT = 22.4;
const TEXT_CHAR_WIDTH = 7.5;
const TEXT_PADDING = 32 + 8;
const MIN_HEIGHT = 120;
const MAX_ESTIMATE = 4000;
/**
* A first guess at a body's height before it has been measured, for bodies
* that cannot simply take the space left on screen (conversation cards): an
* HTML body gets most of a screen, plain text its wrapped line count.
*/
export function estimateBodyHeight(opts: {
isHtml: boolean;
text?: string | null;
width: number;
windowHeight: number;
}): number {
if (opts.isHtml || !opts.text) return Math.max(MIN_HEIGHT, Math.round(opts.windowHeight * 0.6));
const perLine = Math.max(20, Math.floor((opts.width - 32) / TEXT_CHAR_WIDTH));
let lines = 0;
for (const line of opts.text.split('\n')) lines += Math.max(1, Math.ceil(line.length / perLine));
const estimate = Math.round(lines * TEXT_LINE_HEIGHT + TEXT_PADDING);
return Math.min(MAX_ESTIMATE, Math.max(MIN_HEIGHT, estimate));
}
+72 -57
View File
@@ -15,8 +15,7 @@ import {
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors, useResolvedTheme } from '../theme/colors';
import { MoveSheet } from '../components/MoveSheet';
import { MessageContent } from '../components/email/MessageContent';
import { ThreadMessageCard } from '../components/email/ThreadMessageCard';
import { ThreadMessageCard, ThreadCardPlaceholder } from '../components/email/ThreadMessageCard';
import { QuickReplyBox } from '../components/email/QuickReplyBox';
import { AddressActionSheet } from '../components/email/AddressActionSheet';
import { ToastHost } from '../components/ToastHost';
@@ -208,6 +207,14 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
if (view) return view.ids;
return failedThreads.has(threadId) ? [] : null;
}, [ownerAccountId, failedThreads]);
// The conversation's size as the open folder's list knows it (only when
// that list is the message's account: thread ids are per account).
const threadSizeOf = React.useCallback(
(threadId: string): number | undefined => (listAccountId === ownerAccountId
? useEmailStore.getState().threadCounts[threadId]
: undefined),
[listAccountId, ownerAccountId],
);
const memberOf = React.useCallback(
(threadId: string, id: string): Email | undefined =>
peekDetail(id, ownerAccountId) ?? peekThread(threadId, ownerAccountId)?.headers.get(id),
@@ -749,6 +756,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
: null
}
memberOf={memberOf}
threadSizeHint={!disableThreading ? threadSizeOf(item.threadId) : undefined}
threading={!disableThreading}
jmapAccountId={ownerAccountId}
currentMailboxRole={currentMailboxRole}
@@ -898,6 +906,8 @@ interface EmailPaneProps {
threadIds: string[] | null;
/** A conversation member: its full copy when held, else its header. */
memberOf: (threadId: string, id: string) => Email | undefined;
/** How many messages the list says the conversation has, when it knows. */
threadSizeHint?: number;
threading: boolean;
jmapAccountId?: string;
currentMailboxRole: string | null;
@@ -923,8 +933,8 @@ 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, bodyEnabled, onBodySettled, threadIdHint, email, row, threadIds, memberOf, threading,
jmapAccountId, currentMailboxRole, identities, themeOverrides, ensureDetail, ensureDetails,
id, active, bodyEnabled, onBodySettled, threadIdHint, email, row, threadIds, memberOf, threadSizeHint,
threading, jmapAccountId, currentMailboxRole, identities, themeOverrides, ensureDetail, ensureDetails,
ensureThread, scheduleMarkRead, styles, bottomBarHeight, onToggleStar, onAddressPress,
onEmailPatched, onReply, onSwipe, onZoomChange,
}: EmailPaneProps) {
@@ -1002,23 +1012,49 @@ function EmailPane({
const conversation = threading && threadId && threadIds && threadIds.length > 1
? threadIds.map((mid) => memberOf(threadId, mid)).filter((m): m is Email => !!m)
: null;
const threadLoading = threading && !!threadId && !threadIds;
// While the conversation loads, the size the list knows for it holds the
// older cards' places, so the opened message does not move when they come.
const pendingCards = !conversation && threadLoading && threadSizeHint && threadSizeHint > 1
? threadSizeHint - 1
: 0;
const asConversation = !!conversation || pendingCards > 0;
// A single message goes through the same card list (bare), so its content
// and WebView stay mounted when a conversation arrives around it.
const members = conversation ?? [shown];
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 = !!newest && !!peekDetail(newest.id, jmapAccountId);
const onPaneZoom = (z: { pinching: boolean; zoomed: boolean }) => {
setPinching(z.pinching);
onZoomChange(z);
};
return (
<ScrollView
style={styles.scroll}
contentContainerStyle={{ paddingBottom: bottomBarHeight + spacing.lg }}
// At least a page tall: a single message's body takes the space below
// its header, and the quick reply stays at the bottom however the body
// grows or shrinks once it has measured itself.
contentContainerStyle={[styles.paneContent, { paddingBottom: bottomBarHeight + spacing.lg }]}
scrollEnabled={!pinching}
>
{/* Subject block */}
<View style={styles.subjectBlock}>
<View style={styles.subjectRow}>
<Text style={styles.subjectText}>{subject}</Text>
{conversation ? (
{/* In the row, so the page does not shift when the conversation arrives. */}
{threadLoading && (
<ActivityIndicator
size="small"
color={c.textMuted}
style={styles.subjectSpinner}
accessibilityLabel={t('threads.loading', 'Loading conversation...')}
/>
)}
{asConversation ? (
<View style={styles.threadCount}>
<Text style={styles.threadCountText}>{conversation.length}</Text>
<Text style={styles.threadCountText}>{conversation ? conversation.length : threadSizeHint}</Text>
</View>
) : (
<Pressable
@@ -1036,24 +1072,22 @@ function EmailPane({
</View>
</View>
{threading && threadId && !threadIds && (
<View style={styles.threadLoading}>
<ActivityIndicator size="small" color={c.textMuted} />
<Text style={styles.threadLoadingText}>{t('threads.loading', 'Loading conversation...')}</Text>
</View>
)}
{conversation ? (
conversation.map((m) => {
const open = expanded?.has(m.id) ?? m.id === id;
// An open card shows its header until its body has arrived.
const full = open ? peekDetail(m.id, jmapAccountId) : undefined;
<View style={asConversation ? undefined : styles.paneFill}>
{Array.from({ length: pendingCards }, (_, i) => <ThreadCardPlaceholder key={`pending-${i}`} />)}
{members.map((m) => {
const open = !asConversation || (expanded?.has(m.id) ?? m.id === id);
// An open card without its body yet shows its header over placeholder lines.
const full = peekDetail(m.id, jmapAccountId);
return (
<ThreadMessageCard
key={m.id}
bare={!asConversation}
fill={!asConversation}
email={full ?? m}
expanded={!!full}
loading={open && !full}
expanded={open}
replyable={!!full}
deferBody={!full || !bodyEnabled}
onBodySettled={onBodySettled}
onToggleExpanded={() => toggleCard(m.id)}
onReply={onReply}
jmapAccountId={jmapAccountId}
@@ -1061,41 +1095,26 @@ function EmailPane({
currentMailboxRole={currentMailboxRole}
active={active}
themeOverride={themeOverrides[m.id] ?? null}
deferBody={!bodyEnabled}
onBodySettled={onBodySettled}
onSwipe={onSwipe}
onZoomChange={(z) => { setPinching(z.pinching); onZoomChange(z); }}
onZoomChange={onPaneZoom}
onToggleStar={onToggleStar}
onAddressPress={onAddressPress}
onEmailPatched={onEmailPatched}
/>
);
})
) : (
<MessageContent
email={shown}
deferBody={!email || !bodyEnabled}
onBodySettled={onBodySettled}
jmapAccountId={jmapAccountId}
identities={identities}
currentMailboxRole={currentMailboxRole}
active={active}
themeOverride={themeOverrides[shown.id] ?? null}
onSwipe={onSwipe}
onZoomChange={(z) => { setPinching(z.pinching); onZoomChange(z); }}
onAddressPress={onAddressPress}
onEmailPatched={onEmailPatched}
/>
)}
})}
</View>
{newest && newestLoaded && (
<QuickReplyBox
email={newest}
jmapAccountId={jmapAccountId}
onMoreOptions={() => onReply('reply', newest)}
onSent={onEmailPatched}
/>
)}
<View style={styles.quickReplySlot}>
{newest && newestLoaded && (
<QuickReplyBox
email={newest}
jmapAccountId={jmapAccountId}
onMoreOptions={() => onReply('reply', newest)}
onSent={onEmailPatched}
/>
)}
</View>
</ScrollView>
);
}
@@ -1529,14 +1548,10 @@ function makeStyles(c: ThemePalette) {
marginTop: 2,
},
threadCountText: { ...typography.small, color: c.textSecondary, fontWeight: '600' },
threadLoading: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
threadLoadingText: { ...typography.caption, color: c.textMuted },
subjectSpinner: { marginTop: 6 },
paneContent: { flexGrow: 1 },
paneFill: { flexGrow: 1 },
quickReplySlot: { marginTop: 'auto' },
// Loading skeleton
skeletonBone: {