From 5237d9004d19a01e6ea76e28cfa7688bffd72bd8 Mon Sep 17 00:00:00 2001 From: arnauda-gh <59512940+arnauda-gh@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:00:51 +0200 Subject: [PATCH] fix(viewer): adapt screen and auto-scroll quick reply box when keyboard appears --- src/components/email/QuickReplyBox.tsx | 6 +- src/screens/EmailThreadScreen.tsx | 132 +++++++++++++++++++------ 2 files changed, 107 insertions(+), 31 deletions(-) diff --git a/src/components/email/QuickReplyBox.tsx b/src/components/email/QuickReplyBox.tsx index 920a2a99..55fa029a 100644 --- a/src/components/email/QuickReplyBox.tsx +++ b/src/components/email/QuickReplyBox.tsx @@ -29,6 +29,8 @@ interface Props { onMoreOptions: (draft: string) => void; /** Reflect `$answered` in the caller's cache. */ onSent?: (email: Email) => void; + onFocus?: () => void; + onBlur?: () => void; } /** @@ -36,7 +38,7 @@ interface Props { * to the sender with the original quoted, sent through the identity that * received the message. "More options" hands the text to the full composer. */ -export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: Props) { +export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onFocus, onBlur }: Props) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); @@ -147,6 +149,8 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: P { + const recompute = (endY?: number, height?: number) => { + const screenH = Dimensions.get('screen').height; + if (typeof endY === 'number' && screenH - endY > 0) { + setKbObstruction(screenH - endY); + } else if (typeof height === 'number' && height > 0) { + setKbObstruction(height); + } else { + setKbObstruction(0); + } + }; + const subs = + Platform.OS === 'ios' + ? [ + Keyboard.addListener('keyboardWillChangeFrame', (e) => { + recompute(e.endCoordinates?.screenY, e.endCoordinates?.height); + }), + Keyboard.addListener('keyboardWillHide', () => setKbObstruction(0)), + ] + : [ + Keyboard.addListener('keyboardDidShow', (e) => { + recompute(e.endCoordinates?.screenY, e.endCoordinates?.height); + }), + Keyboard.addListener('keyboardDidHide', () => setKbObstruction(0)), + ]; + return () => { + for (const s of subs) s.remove(); + }; + }, []); + // Drop optional toolbar buttons on narrow screens. const showMarkUnread = windowWidth >= 340; const showArchive = windowWidth >= 400 && canArchive; @@ -738,7 +775,7 @@ function EmailViewer({ route, navigation }: Props) { initialNumToRender={1} maxToRenderPerBatch={2} removeClippedSubviews - scrollEnabled={!pagerLocked} + scrollEnabled={!pagerLocked && kbObstruction === 0} onScrollBeginDrag={releaseNeighbours} onMomentumScrollEnd={onMomentumEnd} renderItem={({ item, index }) => ( @@ -769,6 +806,7 @@ function EmailViewer({ route, navigation }: Props) { scheduleMarkRead={scheduleMarkRead} styles={styles} bottomBarHeight={bottomBarHeight} + kbObstruction={item.id === activeEmailId ? kbObstruction : 0} onToggleStar={toggleStarFor} onAddressPress={setAddressSheet} onEmailPatched={onEmailPatched} @@ -780,33 +818,35 @@ function EmailViewer({ route, navigation }: Props) { )} /> - {/* Bottom action bar */} - - } - label={t('email_viewer.previous', 'Prev')} - onPress={prevEmail ? () => goToIndex(currentIndex - 1) : undefined} - disabled={!prevEmail} - /> - {bottomActions.map((id) => { - const def = quickActionRegistry[id]; - return ( - - ); - })} - } - label={t('email_viewer.next', 'Next')} - onPress={nextEmail ? () => goToIndex(currentIndex + 1) : undefined} - disabled={!nextEmail} - /> - + {/* Bottom action bar (hidden while keyboard is up so quick reply stays visible) */} + {kbObstruction === 0 && ( + + } + label={t('email_viewer.previous', 'Prev')} + onPress={prevEmail ? () => goToIndex(currentIndex - 1) : undefined} + disabled={!prevEmail} + /> + {bottomActions.map((id) => { + const def = quickActionRegistry[id]; + return ( + + ); + })} + } + label={t('email_viewer.next', 'Next')} + onPress={nextEmail ? () => goToIndex(currentIndex + 1) : undefined} + disabled={!nextEmail} + /> + + )} )} @@ -917,6 +957,7 @@ interface EmailPaneProps { scheduleMarkRead: (email: Email) => () => void; styles: ReturnType; bottomBarHeight: number; + kbObstruction: number; onToggleStar: (email: Email) => void; onAddressPress: (address: EmailAddress) => void; onEmailPatched: (email: Email) => void; @@ -933,7 +974,7 @@ interface EmailPaneProps { function EmailPane({ id, active, bodyEnabled, onBodySettled, threadIdHint, email, row, threadIds, memberOf, threadSizeHint, threading, jmapAccountId, currentMailboxRole, identities, themeOverrides, ensureDetail, ensureDetails, - ensureThread, scheduleMarkRead, styles, bottomBarHeight, onToggleStar, onAddressPress, + ensureThread, scheduleMarkRead, styles, bottomBarHeight, kbObstruction, onToggleStar, onAddressPress, onEmailPatched, onReply, onSwipe, onZoomChange, }: EmailPaneProps) { const c = useColors(); @@ -1028,13 +1069,42 @@ function EmailPane({ onZoomChange(z); }; + const scrollRef = React.useRef(null); + const isInputFocused = React.useRef(false); + + const bottomPadding = kbObstruction > 0 + ? kbObstruction + spacing.md + : bottomBarHeight + spacing.lg; + + const handleQuickReplyFocus = () => { + isInputFocused.current = true; + setTimeout(() => { + scrollRef.current?.scrollToEnd({ animated: true }); + }, 100); + }; + + const handleQuickReplyBlur = () => { + isInputFocused.current = false; + }; + + React.useEffect(() => { + if (kbObstruction > 0 && isInputFocused.current) { + setTimeout(() => { + scrollRef.current?.scrollToEnd({ animated: true }); + }, 50); + } + }, [kbObstruction]); + return ( {/* Subject block */} @@ -1112,6 +1182,8 @@ function EmailPane({ jmapAccountId={jmapAccountId} onMoreOptions={() => onReply('reply', newest)} onSent={onEmailPatched} + onFocus={handleQuickReplyFocus} + onBlur={handleQuickReplyBlur} /> )}