Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/components/email/QuickReplyBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,16 @@ interface Props {
onMoreOptions: (draft: string) => void;
/** Reflect `$answered` in the caller's cache. */
onSent?: (email: Email) => void;
onFocus?: () => void;
onBlur?: () => void;
}

/**
* Inline reply box under the message (webmail quick reply): plain-text reply
* 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);
Expand Down Expand Up @@ -147,6 +149,8 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: P
<TextInput
value={text}
onChangeText={setText}
onFocus={onFocus}
onBlur={onBlur}
placeholder={t('email_viewer.quick_reply_placeholder', 'Write a quick reply...')}
placeholderTextColor={c.textMuted}
style={styles.input}
Expand Down
132 changes: 102 additions & 30 deletions src/screens/EmailThreadScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, ActivityIndicator,
Modal, useWindowDimensions, Animated, Easing, Alert, FlatList,
Keyboard, Dimensions, Platform,
} from 'react-native';
import type { NativeSyntheticEvent, NativeScrollEvent } from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
Expand Down Expand Up @@ -639,6 +640,42 @@ function EmailViewer({ route, navigation }: Props) {
};

const bottomBarHeight = 60 + Math.max(insets.bottom, 4);

// Track the visible keyboard obstruction so quick reply stays visible and
// the bottom bar hides while typing. On Android edge-to-edge, the IME inset
// is measured from the top of the gesture bar rather than screen bottom,
// so we derive obstruction height from Dimensions.screen.
const [kbObstruction, setKbObstruction] = React.useState(0);
React.useEffect(() => {
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;
Expand Down Expand Up @@ -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 }) => (
Expand Down Expand Up @@ -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}
Expand All @@ -780,33 +818,35 @@ function EmailViewer({ route, navigation }: Props) {
)}
/>

{/* Bottom action bar */}
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 4) }]}>
<BottomBarButton
icon={<ChevronLeft size={20} color={c.textMuted} />}
label={t('email_viewer.previous', 'Prev')}
onPress={prevEmail ? () => goToIndex(currentIndex - 1) : undefined}
disabled={!prevEmail}
/>
{bottomActions.map((id) => {
const def = quickActionRegistry[id];
return (
<BottomBarButton
key={id}
icon={def.icon(20, c.textSecondary)}
label={def.label}
onPress={def.available ? def.onPress : undefined}
disabled={!def.available}
/>
);
})}
<BottomBarButton
icon={<ChevronRight size={20} color={c.textMuted} />}
label={t('email_viewer.next', 'Next')}
onPress={nextEmail ? () => goToIndex(currentIndex + 1) : undefined}
disabled={!nextEmail}
/>
</View>
{/* Bottom action bar (hidden while keyboard is up so quick reply stays visible) */}
{kbObstruction === 0 && (
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 4) }]}>
<BottomBarButton
icon={<ChevronLeft size={20} color={c.textMuted} />}
label={t('email_viewer.previous', 'Prev')}
onPress={prevEmail ? () => goToIndex(currentIndex - 1) : undefined}
disabled={!prevEmail}
/>
{bottomActions.map((id) => {
const def = quickActionRegistry[id];
return (
<BottomBarButton
key={id}
icon={def.icon(20, c.textSecondary)}
label={def.label}
onPress={def.available ? def.onPress : undefined}
disabled={!def.available}
/>
);
})}
<BottomBarButton
icon={<ChevronRight size={20} color={c.textMuted} />}
label={t('email_viewer.next', 'Next')}
onPress={nextEmail ? () => goToIndex(currentIndex + 1) : undefined}
disabled={!nextEmail}
/>
</View>
)}
</>
)}

Expand Down Expand Up @@ -917,6 +957,7 @@ interface EmailPaneProps {
scheduleMarkRead: (email: Email) => () => void;
styles: ReturnType<typeof makeStyles>;
bottomBarHeight: number;
kbObstruction: number;
onToggleStar: (email: Email) => void;
onAddressPress: (address: EmailAddress) => void;
onEmailPatched: (email: Email) => void;
Expand All @@ -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();
Expand Down Expand Up @@ -1028,13 +1069,42 @@ function EmailPane({
onZoomChange(z);
};

const scrollRef = React.useRef<ScrollView>(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 (
<ScrollView
ref={scrollRef}
style={styles.scroll}
// 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 }]}
contentContainerStyle={[styles.paneContent, { paddingBottom: bottomPadding }]}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
scrollEnabled={!pinching}
>
{/* Subject block */}
Expand Down Expand Up @@ -1112,6 +1182,8 @@ function EmailPane({
jmapAccountId={jmapAccountId}
onMoreOptions={() => onReply('reply', newest)}
onSent={onEmailPatched}
onFocus={handleQuickReplyFocus}
onBlur={handleQuickReplyBlur}
/>
)}
</View>
Expand Down
Loading