diff --git a/src/components/EmailBodyView.tsx b/src/components/EmailBodyView.tsx index c83946e9..59d5fd7b 100644 --- a/src/components/EmailBodyView.tsx +++ b/src/components/EmailBodyView.tsx @@ -27,6 +27,10 @@ function arrayBufferToBase64(buffer: ArrayBuffer): string { interface EmailBodyViewProps { email: Email; senderEmail?: string; + // The body is a native WebView, which on Android swallows horizontal touches + // before the surrounding pager can see them. We detect a clear horizontal + // swipe inside the page and report it here so the pager can change messages. + onSwipe?: (direction: 'prev' | 'next') => void; } function extractHtmlBody(email: Email): string | null { @@ -274,7 +278,37 @@ const HEIGHT_REPORTER = ` })(); `; -export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps) { +// Detect a clear horizontal flick inside the WebView and report it. Uses +// capture-phase, passive listeners so it never blocks vertical scrolling, text +// selection, or link taps — it only reacts to a quick, horizontal-dominant +// gesture on release. Guarded so re-injection (onLoadEnd) is a no-op. +const SWIPE_DETECTOR = ` +(function () { + if (window.__rnSwipeInit) return; + window.__rnSwipeInit = true; + var x0 = 0, y0 = 0, t0 = 0, tracking = false; + document.addEventListener('touchstart', function (e) { + if (e.touches.length !== 1) { tracking = false; return; } + var t = e.touches[0]; + x0 = t.clientX; y0 = t.clientY; t0 = Date.now(); tracking = true; + }, { passive: true, capture: true }); + document.addEventListener('touchend', function (e) { + if (!tracking) return; + tracking = false; + var t = e.changedTouches[0]; + if (!t) return; + var dx = t.clientX - x0, dy = t.clientY - y0, dt = Date.now() - t0; + if (dt < 700 && Math.abs(dx) > 55 && Math.abs(dx) > Math.abs(dy) * 1.8) { + if (window.ReactNativeWebView) { + window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'swipe', dir: dx < 0 ? 'next' : 'prev' })); + } + } + }, { passive: true, capture: true }); + true; +})(); +`; + +export default function EmailBodyView({ email, senderEmail, onSwipe }: EmailBodyViewProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy); @@ -423,12 +457,25 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps const applyInversion = !!rawHtml && renderAsDark && !hasNativeDarkMode(rawHtml); const injectedJs = React.useMemo( - () => (applyInversion ? DARK_REINVERT_SCRIPT + HEIGHT_REPORTER : HEIGHT_REPORTER), + () => + (applyInversion ? DARK_REINVERT_SCRIPT + HEIGHT_REPORTER : HEIGHT_REPORTER) + + SWIPE_DETECTOR, [applyInversion], ); const onMessage = (e: WebViewMessageEvent) => { - const parsed = parseInt(e.nativeEvent.data, 10); + const data = e.nativeEvent.data; + // Swipe messages arrive as JSON; the height reporter posts a bare number. + if (data.charCodeAt(0) === 123 /* '{' */) { + try { + const msg = JSON.parse(data); + if (msg.type === 'swipe' && (msg.dir === 'prev' || msg.dir === 'next')) { + onSwipe?.(msg.dir); + } + } catch { /* ignore malformed */ } + return; + } + const parsed = parseInt(data, 10); if (Number.isNaN(parsed) || parsed <= 0) return; setHeight((prev) => (Math.abs(parsed - prev) < 2 ? prev : parsed)); }; diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index 7b5cac8b..c17e32a3 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -1,8 +1,9 @@ import React from 'react'; import { View, Text, StyleSheet, ScrollView, Pressable, ActivityIndicator, - Modal, useWindowDimensions, Animated, Easing, Alert, PanResponder, + Modal, useWindowDimensions, Animated, Easing, Alert, FlatList, } from 'react-native'; +import type { NativeSyntheticEvent, NativeScrollEvent } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import { @@ -99,103 +100,57 @@ export default function EmailThreadScreen({ route, navigation }: Props) { [activeEmailId, cacheVersion, detailCache], ); - // --- Swipe / slide navigation between adjacent emails ------------------- - // A three-pane pager: [prev | current | next] are laid out in a single row of - // width 3×screen and kept mounted side by side. `drag` translates the whole - // row so the neighbour's real content follows the finger — no blank gap and - // no late content-swap. At rest the row is shifted left by one screen so the - // middle (current) pane sits on screen. After a settled swipe we re-point - // `activeEmailId` at the neighbour and reset `drag` to 0 in the same commit; - // because the landed pane already shows that exact message, the recentre is - // seamless. Refs keep the (memoised once) PanResponder reading the latest - // neighbours / dimensions without being recreated every render. - const drag = React.useRef(new Animated.Value(0)).current; - const animatingRef = React.useRef(false); - const pendingRecentreRef = React.useRef(false); - const prevEmailRef = React.useRef(prevEmail); - const nextEmailRef = React.useRef(nextEmail); - const windowWidthRef = React.useRef(windowWidth); - prevEmailRef.current = prevEmail; - nextEmailRef.current = nextEmail; - windowWidthRef.current = windowWidth; + // --- Pager ------------------------------------------------------------- + // The pager is a horizontal, page-snapping FlatList over the mailbox's + // `emails`. Native scroll provides the swipe + snap (and plays nicely with + // each pane's vertical body scroll); Prev/Next scroll programmatically. When + // scrolling settles, the centred page's id becomes `activeEmailId`, which is + // what the toolbar and action handlers operate on. + const listRef = React.useRef>(null); + // The page to open on mount — the entry point arrives as a route param, so + // capture its index once. + const initialIndexRef = React.useRef( + Math.max(0, emails.findIndex((e) => e.id === route.params.emailId)), + ); - const switchEmail = React.useCallback((direction: 'prev' | 'next') => { - const target = direction === 'next' ? nextEmailRef.current : prevEmailRef.current; - if (!target || animatingRef.current) return; - animatingRef.current = true; - const w = windowWidthRef.current; - // Bring the neighbour pane to centre: next sits to the right (slide row - // left → -w), prev sits to the left (slide row right → +w). - const out = direction === 'next' ? -w : w; + // Coalesced detail fetch: stores the result in the shared cache and bumps the + // version so every mounted pane (and the toolbar) re-reads it. Concurrent + // calls for the same id share a single in-flight request. + const inFlight = React.useRef(new Map>()).current; + const ensureDetail = React.useCallback((id: string): Promise => { + const pending = inFlight.get(id); + if (pending) return pending; + const p = getEmailDetail(id, jmapAccountId) + .then((fetched) => { detailCache.set(id, fetched); bumpCache(); return fetched; }) + .catch(() => detailCache.get(id) ?? null) + .finally(() => { inFlight.delete(id); }); + inFlight.set(id, p); + return p; + }, [getEmailDetail, jmapAccountId, detailCache, bumpCache, inFlight]); - // Best-effort: ensure the target detail is cached so the recentred pane has - // content. Neighbours are normally prefetched, so this is usually a no-op. - if (!detailCache.has(target.id)) { - getEmailDetail(target.id, jmapAccountId) - .then((e) => { detailCache.set(target.id, e); bumpCache(); }) - .catch(() => { /* the load effect will retry once it becomes active */ }); - } + // Slide to a page by index and reflect it in the toolbar immediately; a swipe + // that settles on the same page confirms the same id via onMomentumEnd. + const goToIndex = React.useCallback((index: number) => { + if (index < 0 || index >= emails.length) return; + listRef.current?.scrollToOffset({ offset: index * windowWidth, animated: true }); + const target = emails[index]; + if (target) setActiveEmailId(target.id); + }, [emails, windowWidth]); - Animated.timing(drag, { - toValue: out, duration: 230, easing: Easing.out(Easing.cubic), useNativeDriver: true, - }).start(({ finished }) => { - if (!finished) { animatingRef.current = false; return; } - // Re-point at the neighbour; the drag reset happens in the layout effect - // below, in the same commit that re-keys the panes. Resetting here would - // recentre the row while the *old* arrangement is still on screen, - // flashing the previous email for a frame until React catches up. - pendingRecentreRef.current = true; - setActiveEmailId(target.id); - }); - }, [drag, detailCache, getEmailDetail, jmapAccountId, bumpCache]); - const switchEmailRef = React.useRef(switchEmail); - switchEmailRef.current = switchEmail; + // Adopt the centred page as the active message once a swipe settles. + const onMomentumEnd = React.useCallback((e: NativeSyntheticEvent) => { + const index = Math.round(e.nativeEvent.contentOffset.x / windowWidth); + const target = emails[index]; + if (target && target.id !== activeEmailId) setActiveEmailId(target.id); + }, [emails, windowWidth, activeEmailId]); - // Recentre the row in the same commit that re-pointed the panes: the pane - // that slid to centre is now the middle pane (same key, same mounted - // WebView), so snapping the translation back to 0 is invisible. + // Page offsets are in window-width units, so a rotation / resize invalidates + // the scroll position — re-centre the active page after the width changes. React.useLayoutEffect(() => { - if (!pendingRecentreRef.current) return; - pendingRecentreRef.current = false; - drag.setValue(0); - animatingRef.current = false; - }, [activeEmailId, drag]); - - const panResponder = React.useRef( - PanResponder.create({ - onMoveShouldSetPanResponder: (_, g) => { - if (animatingRef.current) return false; - if (Math.abs(g.dx) < 10) return false; - // Only claim clearly-horizontal drags so the body's vertical scroll - // (and link taps) keep working. - return Math.abs(g.dx) > Math.abs(g.dy) * 1.5; - }, - onPanResponderMove: (_, g) => { - let dx = g.dx; - // Rubber-band when there's no neighbour to reach in that direction. - if ((dx > 0 && !prevEmailRef.current) || (dx < 0 && !nextEmailRef.current)) { - dx *= 0.25; - } - drag.setValue(dx); - }, - onPanResponderRelease: (_, g) => { - const w = windowWidthRef.current; - const threshold = Math.min(w * 0.28, 120); - const fast = Math.abs(g.vx) > 0.5; - if ((g.dx <= -threshold || (fast && g.vx < 0)) && nextEmailRef.current) { - switchEmailRef.current('next'); - } else if ((g.dx >= threshold || (fast && g.vx > 0)) && prevEmailRef.current) { - switchEmailRef.current('prev'); - } else { - Animated.spring(drag, { toValue: 0, useNativeDriver: true, speed: 20, bounciness: 6 }).start(); - } - }, - onPanResponderTerminate: () => { - Animated.spring(drag, { toValue: 0, useNativeDriver: true, speed: 20, bounciness: 6 }).start(); - }, - onPanResponderTerminationRequest: () => true, - }), - ).current; + const index = emails.findIndex((e) => e.id === activeEmailId); + if (index >= 0) listRef.current?.scrollToOffset({ offset: index * windowWidth, animated: false }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [windowWidth]); const mailAttachmentAction = useSettingsStore((s) => s.mailAttachmentAction); const attachmentPosition = useSettingsStore((s) => s.attachmentPosition); @@ -239,30 +194,26 @@ export default function EmailThreadScreen({ route, navigation }: Props) { React.useEffect(() => { let cancelled = false; let readTimer: ReturnType | null = null; - // If we already have this message cached (e.g. it was prefetched as a - // neighbour) it renders immediately; otherwise the pane shows a skeleton - // until the fetch below lands. Either way, refresh in the background. + // Refresh the active message — a cached copy (the pane may have fetched it, + // or it was visited before) renders immediately while this lands. Then mark + // it read per the user's delay. The pane itself fetches its own detail, so + // ensureDetail here usually rides the same in-flight request. setError(null); void (async () => { - try { - const fetched = await getEmailDetail(activeEmailId, jmapAccountId); - if (cancelled) return; - detailCache.set(activeEmailId, fetched); - bumpCache(); - if (fetched && !fetched.keywords?.$seen) { - if (markAsReadDelay > 0) { - readTimer = setTimeout(() => { - if (!cancelled) void markRead(activeEmailId, jmapAccountId); - }, markAsReadDelay); - } else { - void markRead(activeEmailId, jmapAccountId); - } - } - } catch (e) { - // Only surface the error if we have nothing to show; a cached copy is - // better than an error screen when the refresh fails. - if (!cancelled && !detailCache.has(activeEmailId)) { - setError(e instanceof Error ? e.message : 'Failed to load email'); + const fetched = await ensureDetail(activeEmailId); + if (cancelled) return; + if (!fetched) { + // Only surface an error if there's nothing cached to show. + if (!detailCache.has(activeEmailId)) setError('Failed to load email'); + return; + } + if (!fetched.keywords?.$seen) { + if (markAsReadDelay > 0) { + readTimer = setTimeout(() => { + if (!cancelled) void markRead(activeEmailId, jmapAccountId); + }, markAsReadDelay); + } else { + void markRead(activeEmailId, jmapAccountId); } } })(); @@ -270,20 +221,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) { cancelled = true; if (readTimer) clearTimeout(readTimer); }; - }, [activeEmailId, jmapAccountId, getEmailDetail, markRead, markAsReadDelay, detailCache, bumpCache]); - - // Prefetch the adjacent messages so their pane is already populated when the - // pager slides it into view. Best-effort and silent; bump the cache version - // on arrival so the off-screen neighbour pane renders its content. - React.useEffect(() => { - for (const neighbour of [prevEmail, nextEmail]) { - if (neighbour && !detailCache.has(neighbour.id)) { - getEmailDetail(neighbour.id, jmapAccountId) - .then((e) => { detailCache.set(neighbour.id, e); bumpCache(); }) - .catch(() => { /* ignore — prefetch is best-effort */ }); - } - } - }, [prevEmail, nextEmail, getEmailDetail, jmapAccountId, detailCache, bumpCache]); + }, [activeEmailId, ensureDetail, markRead, markAsReadDelay, jmapAccountId, detailCache]); const starred = !!email?.keywords?.$flagged; const unread = !!email && !email.keywords?.$seen; @@ -549,43 +487,51 @@ export default function EmailThreadScreen({ route, navigation }: Props) { ) : ( <> - {/* Three-pane pager: [prev | current | next] live side by side in a - row shifted left by one screen, so a horizontal drag slides the - neighbour's already-rendered content into view with no gap. */} - - - {[prevEmail?.id ?? null, activeEmailId, nextEmail?.id ?? null].map((id, i) => ( - - {id ? ( - - ) : null} - - ))} - - + {/* Pager: a horizontal page-snapping list over the mailbox. Each + page is the full-width pane for one message; native scroll slides + between them and onMomentumEnd reports the settled page. */} + item.id} + horizontal + pagingEnabled + showsHorizontalScrollIndicator={false} + initialScrollIndex={initialIndexRef.current} + getItemLayout={(_, index) => ({ length: windowWidth, offset: windowWidth * index, index })} + windowSize={3} + initialNumToRender={1} + maxToRenderPerBatch={2} + removeClippedSubviews + onMomentumScrollEnd={onMomentumEnd} + renderItem={({ item, index }) => ( + + goToIndex(dir === 'next' ? index + 1 : index - 1)} + /> + + )} + /> {/* Bottom action bar */} } label="Prev" - onPress={prevEmail ? () => switchEmail('prev') : undefined} + onPress={prevEmail ? () => goToIndex(currentIndex - 1) : undefined} disabled={!prevEmail} /> {bottomActions.map((id) => { @@ -603,7 +549,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) { } label="Next" - onPress={nextEmail ? () => switchEmail('next') : undefined} + onPress={nextEmail ? () => goToIndex(currentIndex + 1) : undefined} disabled={!nextEmail} /> @@ -668,7 +614,9 @@ export default function EmailThreadScreen({ route, navigation }: Props) { } interface EmailPaneProps { + id: string; email: Email | null; + ensureDetail: (id: string) => Promise; c: ThemePalette; styles: ReturnType; bottomBarHeight: number; @@ -677,6 +625,7 @@ interface EmailPaneProps { downloadingBlobId: string | null; onToggleStar: (email: Email) => void; onPressAttachment: (email: Email, blobId: string, name: string | undefined, type: string | undefined) => void; + onSwipe: (direction: 'prev' | 'next') => void; } // One swipeable page: the scrollable subject / sender / attachments / body for a @@ -685,14 +634,20 @@ interface EmailPaneProps { // scroll position and "show all attachments" toggle, and renders directly from // the email passed to it — neighbours show real content, not a placeholder. function EmailPane({ - email, c, styles, bottomBarHeight, attachmentPosition, - hideInlineImageAttachments, downloadingBlobId, onToggleStar, onPressAttachment, + id, email, ensureDetail, c, styles, bottomBarHeight, attachmentPosition, + hideInlineImageAttachments, downloadingBlobId, onToggleStar, onPressAttachment, onSwipe, }: EmailPaneProps) { const [attachmentsExpanded, setAttachmentsExpanded] = React.useState(false); - // Detail not fetched yet (initial open, or a neighbour the prefetch hasn't - // caught up with). Show a skeleton of the pane layout instead of a spinner - // so the transition into real content doesn't jump. + // Each pane owns loading its own message: when the list mounts this page and + // its detail isn't cached yet, fetch it (coalesced upstream). The shared + // cache then re-renders the pane with real content. + React.useEffect(() => { + if (!email) void ensureDetail(id); + }, [id, email, ensureDetail]); + + // Detail not fetched yet. Show a skeleton of the pane layout instead of a + // spinner so the transition into real content doesn't jump. if (!email) { return ; } @@ -818,7 +773,7 @@ function EmailPane({ {/* Body */} - + ); @@ -1188,9 +1143,8 @@ function makeStyles(c: ThemePalette) { padding: spacing.lg, }, errorText: { ...typography.body, color: c.error, textAlign: 'center' }, - // Pager: a clipping viewport with a 3-screen-wide row of panes inside it. - pagerViewport: { flex: 1, overflow: 'hidden', backgroundColor: c.background }, - pagerRow: { position: 'absolute', top: 0, bottom: 0, flexDirection: 'row' }, + // Pager: the horizontal page-snapping list fills the area between the bars. + pagerViewport: { flex: 1, backgroundColor: c.background }, scroll: { flex: 1, backgroundColor: c.background }, // Subject block