diff --git a/src/components/EmailBodyView.tsx b/src/components/EmailBodyView.tsx index add6ede5..37710bf4 100644 --- a/src/components/EmailBodyView.tsx +++ b/src/components/EmailBodyView.tsx @@ -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>({}); const webviewRef = React.useRef(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 ( - + {showBanner && ( @@ -750,7 +777,7 @@ export default function EmailBodyView({ )} - + 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 ( - + - + {deferBody ? ( - + ) : unwrap.loading ? ( ) : ( @@ -120,6 +125,7 @@ export function MessageContent({ themeOverride={themeOverride} bodyOverride={unwrap.override} onSettled={onBodySettled} + fill={fill} /> )} @@ -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 }) { +function BodyPlaceholder({ styles, fill }: { styles: ReturnType; fill?: boolean }) { return ( - + {PLACEHOLDER_LINE_WIDTHS.map((w, i) => ( ))} @@ -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 }, diff --git a/src/components/email/ThreadMessageCard.tsx b/src/components/email/ThreadMessageCard.tsx index f6801225..1a221bb4 100644 --- a/src/components/email/ThreadMessageCard.tsx +++ b/src/components/email/ThreadMessageCard.tsx @@ -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 {singleLine(email.preview)} - {loading && } {starred && } ); } + // 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 ( - - - - - onReply('reply', email)} hitSlop={4}> - - {t('email_viewer.reply', 'Reply')} - - onReply('replyAll', email)} hitSlop={4}> - - {t('email_viewer.reply_all', 'Reply All')} - - onReply('forward', email)} hitSlop={4}> - - {t('email_viewer.forward', 'Forward')} - + + {!bare && ( + + )} + + {!bare && ( + + + + {t('email_viewer.reply', 'Reply')} + + + + {t('email_viewer.reply_all', 'Reply All')} + + + + {t('email_viewer.forward', 'Forward')} + + + )} + + ); +} + +/** + * 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 ( + + + + + ); @@ -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, diff --git a/src/lib/__tests__/body-heights.test.ts b/src/lib/__tests__/body-heights.test.ts new file mode 100644 index 00000000..fc9e18fd --- /dev/null +++ b/src/lib/__tests__/body-heights.test.ts @@ -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); + }); +}); diff --git a/src/lib/body-heights.ts b/src/lib/body-heights.ts new file mode 100644 index 00000000..4dd1df01 --- /dev/null +++ b/src/lib/body-heights.ts @@ -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(); + +// 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)); +} diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index 3ec65501..bf9731eb 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -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 ( {/* Subject block */} {subject} - {conversation ? ( + {/* In the row, so the page does not shift when the conversation arrives. */} + {threadLoading && ( + + )} + {asConversation ? ( - {conversation.length} + {conversation ? conversation.length : threadSizeHint} ) : ( - {threading && threadId && !threadIds && ( - - - {t('threads.loading', 'Loading conversation...')} - - )} - - {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; + + {Array.from({ length: pendingCards }, (_, 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 ( 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} /> ); - }) - ) : ( - { setPinching(z.pinching); onZoomChange(z); }} - onAddressPress={onAddressPress} - onEmailPatched={onEmailPatched} - /> - )} + })} + - {newest && newestLoaded && ( - onReply('reply', newest)} - onSent={onEmailPatched} - /> - )} + + {newest && newestLoaded && ( + onReply('reply', newest)} + onSent={onEmailPatched} + /> + )} + ); } @@ -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: {