diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index dd0af796..7b5cac8b 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -79,7 +79,6 @@ export default function EmailThreadScreen({ route, navigation }: Props) { const prevEmail = currentIndex > 0 ? emails[currentIndex - 1] : null; const nextEmail = currentIndex >= 0 && currentIndex < emails.length - 1 ? emails[currentIndex + 1] : null; - const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); const [moreMenuOpen, setMoreMenuOpen] = React.useState(false); const [moveMenuOpen, setMoveMenuOpen] = React.useState(false); @@ -112,6 +111,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) { // 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); @@ -140,17 +140,27 @@ export default function EmailThreadScreen({ route, navigation }: Props) { toValue: out, duration: 230, easing: Easing.out(Easing.cubic), useNativeDriver: true, }).start(({ finished }) => { if (!finished) { animatingRef.current = false; return; } - // Re-point at the neighbour and recentre in one go. The pane that just - // slid to centre keeps its mounted WebView (it becomes the new middle - // pane, keyed by id), so there is nothing to reload or flash. + // 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.setValue(0); - animatingRef.current = false; }); }, [drag, detailCache, getEmailDetail, jmapAccountId, bumpCache]); const switchEmailRef = React.useRef(switchEmail); switchEmailRef.current = switchEmail; + // 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. + 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) => { @@ -230,16 +240,9 @@ export default function EmailThreadScreen({ route, navigation }: Props) { let cancelled = false; let readTimer: ReturnType | null = null; // If we already have this message cached (e.g. it was prefetched as a - // neighbour), show it immediately — no spinner — and refresh in the - // background. Otherwise fall back to the loading state. - const cached = detailCache.get(activeEmailId); - if (cached) { - setLoading(false); - setError(null); - } else { - setLoading(true); - setError(null); - } + // neighbour) it renders immediately; otherwise the pane shows a skeleton + // until the fetch below lands. Either way, refresh in the background. + setError(null); void (async () => { try { const fetched = await getEmailDetail(activeEmailId, jmapAccountId); @@ -261,8 +264,6 @@ export default function EmailThreadScreen({ route, navigation }: Props) { if (!cancelled && !detailCache.has(activeEmailId)) { setError(e instanceof Error ? e.message : 'Failed to load email'); } - } finally { - if (!cancelled) setLoading(false); } })(); return () => { @@ -542,15 +543,11 @@ export default function EmailThreadScreen({ route, navigation }: Props) { - {loading && !email ? ( - - - - ) : error && !email ? ( + {error && !email ? ( {error} - ) : email ? ( + ) : ( <> {/* Three-pane pager: [prev | current | next] live side by side in a row shifted left by one screen, so a horizontal drag slides the @@ -611,7 +608,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) { /> - ) : null} + )} - - - ); + return ; } const from = email.from?.[0]; @@ -830,6 +824,49 @@ function EmailPane({ ); } +// Placeholder mimicking the pane layout (subject, sender, body lines) shown +// while a message's detail is loading. One pulsing opacity over static bones +// keeps it cheap enough to also sit in the off-screen neighbour panes. +function EmailPaneSkeleton({ styles }: { styles: ReturnType }) { + const pulse = React.useRef(new Animated.Value(0.55)).current; + React.useEffect(() => { + const loop = Animated.loop( + Animated.sequence([ + Animated.timing(pulse, { toValue: 1, duration: 600, easing: Easing.inOut(Easing.ease), useNativeDriver: true }), + Animated.timing(pulse, { toValue: 0.55, duration: 600, easing: Easing.inOut(Easing.ease), useNativeDriver: true }), + ]), + ); + loop.start(); + return () => loop.stop(); + }, [pulse]); + + const bodyLineWidths = ['92%', '100%', '85%', '96%', '60%', '88%', '74%', '40%'] as const; + + return ( + + + + + + + + + + + + + + + + + {bodyLineWidths.map((w, i) => ( + + ))} + + + ); +} + interface MoreMenuSheetProps { visible: boolean; onClose: () => void; @@ -1288,6 +1325,23 @@ function makeStyles(c: ThemePalette) { backgroundColor: c.background, }, + // Loading skeleton + skeletonBone: { + backgroundColor: c.surfaceHover, + borderRadius: radius.xs, + }, + skeletonAvatar: { + width: componentSizes.avatarMd, + height: componentSizes.avatarMd, + borderRadius: radius.full, + backgroundColor: c.surfaceHover, + }, + skeletonBody: { + paddingHorizontal: spacing.lg, + paddingVertical: spacing.lg, + gap: spacing.sm, + }, + // Bottom bar bottomBar: { position: 'absolute',