mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b10f61a642 | ||
|
|
752aa719d8 | ||
|
|
5884a18979 | ||
|
|
3ccc83e7ce | ||
|
|
5dc4ea1a45 | ||
|
|
1bd476e052 | ||
|
|
41793455f7 | ||
|
|
d99cbf1dbb | ||
|
|
1afd2185d5 |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.47",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.47",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.47",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -27,6 +27,15 @@ 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;
|
||||
// Pinch-zoom state from inside the page: `pinching` while two fingers are
|
||||
// down, `zoomed` while the content is scaled in. The hosts use it to freeze
|
||||
// the surrounding vertical scroll / horizontal pager so a zoom gesture can't
|
||||
// scroll the pane away or switch mails.
|
||||
onZoomChange?: (zoom: { pinching: boolean; zoomed: boolean }) => void;
|
||||
}
|
||||
|
||||
function extractHtmlBody(email: Email): string | null {
|
||||
@@ -177,6 +186,30 @@ const HEIGHT_REPORTER = `
|
||||
var padR = parseFloat(cs.paddingRight) || 0;
|
||||
return b.clientWidth - padL - padR;
|
||||
}
|
||||
// Shared zoom state, mutated by the pinch-zoom handler injected after this
|
||||
// script. "fit" is the shrink-to-fit scale, "scale" the user's pinch factor
|
||||
// on top of it, "tx" pans the zoomed overflow into view horizontally.
|
||||
// Vertical panning needs no state here: the height reports below carry the
|
||||
// scaled height, so the surrounding native ScrollView grows and scrolls.
|
||||
var Z = window.__rnZoom = window.__rnZoom || {
|
||||
scale: 1, tx: 0, pinching: false, fit: 1, layoutW: 0, avail: 0,
|
||||
};
|
||||
// Apply the combined fit × pinch transform using the cached layout numbers
|
||||
// (cheap — no reflow), clamping the pan so content edges pin to the viewport.
|
||||
function applyTransform(w) {
|
||||
var s = Z.fit * Z.scale;
|
||||
if (Z.scale > 1.001 || Z.fit < 0.999) {
|
||||
var minTx = Math.min(0, Z.avail - Z.layoutW * s);
|
||||
if (Z.tx < minTx) Z.tx = minTx;
|
||||
if (Z.tx > 0) Z.tx = 0;
|
||||
w.style.width = Z.layoutW + 'px';
|
||||
w.style.transform = 'translateX(' + Z.tx + 'px) scale(' + s + ')';
|
||||
} else {
|
||||
Z.tx = 0;
|
||||
w.style.width = '100%';
|
||||
w.style.transform = 'none';
|
||||
}
|
||||
}
|
||||
// Shrink-to-fit: emails authored at a fixed width (e.g. 600px tables) overflow
|
||||
// a phone viewport and force horizontal scrolling. Lay the content out at its
|
||||
// natural width, then scale the whole block down so its widest element fits
|
||||
@@ -189,18 +222,18 @@ const HEIGHT_REPORTER = `
|
||||
// (the widest unbreakable element) rather than a previously-scaled box.
|
||||
w.style.width = '100%';
|
||||
var content = w.scrollWidth;
|
||||
if (content > avail + 1) {
|
||||
var scale = avail / content;
|
||||
w.style.width = content + 'px';
|
||||
w.style.transform = 'scale(' + scale + ')';
|
||||
} else {
|
||||
w.style.transform = 'none';
|
||||
}
|
||||
Z.avail = avail;
|
||||
Z.layoutW = content > avail + 1 ? content : avail;
|
||||
Z.fit = avail / Z.layoutW;
|
||||
applyTransform(w);
|
||||
}
|
||||
function measure() {
|
||||
var w = ensureWrapper();
|
||||
if (!w) return 0;
|
||||
fitToWidth(w);
|
||||
// While a pinch is in flight only re-apply the transform — a full re-fit
|
||||
// resets the wrapper width and would thrash layout on every frame.
|
||||
if (Z.pinching) applyTransform(w);
|
||||
else fitToWidth(w);
|
||||
// getBoundingClientRect reflects the applied transform, so r.height is the
|
||||
// already-scaled visual height of the content.
|
||||
var r = w.getBoundingClientRect();
|
||||
@@ -270,11 +303,161 @@ const HEIGHT_REPORTER = `
|
||||
img.addEventListener('error', report);
|
||||
});
|
||||
} catch (e) {}
|
||||
// Hooks for the pinch-zoom handler script.
|
||||
window.__rnApplyZoom = function () { var w = ensureWrapper(); if (w) applyTransform(w); };
|
||||
window.__rnReport = report;
|
||||
true;
|
||||
})();
|
||||
`;
|
||||
|
||||
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;
|
||||
// While the content is pinch-zoomed, horizontal flicks pan the zoomed
|
||||
// content — never switch mails.
|
||||
if (window.__rnZoom && (window.__rnZoom.pinching || window.__rnZoom.scale > 1.001)) return;
|
||||
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;
|
||||
})();
|
||||
`;
|
||||
|
||||
// Pinch zoom + pan for the email content, driven entirely in the page: native
|
||||
// WebView zoom can't work here because the WebView is auto-height inside a
|
||||
// native ScrollView (viewport meta pins it to scale 1). A two-finger pinch
|
||||
// scales the measured wrapper (on top of the shrink-to-fit scale the height
|
||||
// reporter maintains) anchored at the fingers' horizontal midpoint; while
|
||||
// zoomed, one-finger horizontal drags pan via translateX and vertical drags
|
||||
// still scroll the surrounding ScrollView, which sees the scaled height
|
||||
// through the regular height reports. Pinch moves call preventDefault so the
|
||||
// engine consumes the gesture and Android parents don't intercept it; `zoom`
|
||||
// messages let the RN side freeze the pane scroll / pager as a second guard.
|
||||
const PINCH_ZOOM = `
|
||||
(function () {
|
||||
if (window.__rnPinchInit) return;
|
||||
window.__rnPinchInit = true;
|
||||
var Z = window.__rnZoom = window.__rnZoom || {
|
||||
scale: 1, tx: 0, pinching: false, fit: 1, layoutW: 0, avail: 0,
|
||||
};
|
||||
var MAX_ZOOM = 4;
|
||||
var pinch = null; // { d0, s0, cx0, tx0 }
|
||||
var pan = null; // { x0, tx0 }
|
||||
var rafPending = false;
|
||||
|
||||
function post(msg) {
|
||||
if (window.ReactNativeWebView) {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
function apply() {
|
||||
if (window.__rnApplyZoom) window.__rnApplyZoom();
|
||||
}
|
||||
// Height reports are rAF-throttled during the gesture so the RN container
|
||||
// keeps up with the growing content without re-measuring on every move.
|
||||
function scheduleReport() {
|
||||
if (rafPending) return;
|
||||
rafPending = true;
|
||||
requestAnimationFrame(function () {
|
||||
rafPending = false;
|
||||
if (window.__rnReport) window.__rnReport();
|
||||
});
|
||||
}
|
||||
function dist(t0, t1) {
|
||||
var dx = t1.clientX - t0.clientX, dy = t1.clientY - t0.clientY;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
document.addEventListener('touchstart', function (e) {
|
||||
if (e.touches.length === 2) {
|
||||
var t0 = e.touches[0], t1 = e.touches[1];
|
||||
pinch = {
|
||||
d0: dist(t0, t1) || 1,
|
||||
s0: Z.scale,
|
||||
cx0: (t0.clientX + t1.clientX) / 2,
|
||||
tx0: Z.tx,
|
||||
};
|
||||
pan = null;
|
||||
if (!Z.pinching) {
|
||||
Z.pinching = true;
|
||||
post({ type: 'zoom', pinching: true, scale: Z.scale });
|
||||
}
|
||||
e.preventDefault();
|
||||
} else if (e.touches.length === 1 && Z.scale > 1.001) {
|
||||
pan = { x0: e.touches[0].clientX, tx0: Z.tx };
|
||||
} else {
|
||||
pan = null;
|
||||
}
|
||||
}, { passive: false, capture: true });
|
||||
|
||||
document.addEventListener('touchmove', function (e) {
|
||||
if (pinch && e.touches.length === 2) {
|
||||
e.preventDefault();
|
||||
var t0 = e.touches[0], t1 = e.touches[1];
|
||||
var z = pinch.s0 * (dist(t0, t1) / pinch.d0);
|
||||
if (z < 1) z = 1;
|
||||
if (z > MAX_ZOOM) z = MAX_ZOOM;
|
||||
// Keep the content under the fingers' midpoint stationary horizontally:
|
||||
// the wrapper-local point that started under cx0 must map back to the
|
||||
// current midpoint at the new scale. (applyTransform clamps the result.)
|
||||
var cx = (t0.clientX + t1.clientX) / 2;
|
||||
var local = (pinch.cx0 - pinch.tx0) / (Z.fit * pinch.s0);
|
||||
Z.scale = z;
|
||||
Z.tx = cx - local * (Z.fit * z);
|
||||
apply();
|
||||
scheduleReport();
|
||||
} else if (pan && e.touches.length === 1 && Z.scale > 1.001) {
|
||||
// No preventDefault: vertical-dominant drags must keep scrolling the
|
||||
// native ScrollView around the WebView. Horizontal-dominant drags stay
|
||||
// inside the WebView and pan here.
|
||||
Z.tx = pan.tx0 + (e.touches[0].clientX - pan.x0);
|
||||
apply();
|
||||
}
|
||||
}, { passive: false, capture: true });
|
||||
|
||||
function onTouchEnd(e) {
|
||||
if (pinch && e.touches.length < 2) {
|
||||
pinch = null;
|
||||
// Snap back to the fitted layout when the pinch ends near scale 1.
|
||||
if (Z.scale < 1.02) { Z.scale = 1; Z.tx = 0; }
|
||||
Z.pinching = false;
|
||||
apply();
|
||||
if (window.__rnReport) window.__rnReport();
|
||||
post({ type: 'zoom', pinching: false, scale: Z.scale });
|
||||
// A finger left on screen continues as a pan.
|
||||
if (e.touches.length === 1 && Z.scale > 1.001) {
|
||||
pan = { x0: e.touches[0].clientX, tx0: Z.tx };
|
||||
}
|
||||
}
|
||||
if (e.touches.length === 0) pan = null;
|
||||
}
|
||||
document.addEventListener('touchend', onTouchEnd, { passive: true, capture: true });
|
||||
document.addEventListener('touchcancel', onTouchEnd, { passive: true, capture: true });
|
||||
true;
|
||||
})();
|
||||
`;
|
||||
|
||||
export default function EmailBodyView({ email, senderEmail, onSwipe, onZoomChange }: EmailBodyViewProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
|
||||
@@ -423,12 +606,30 @@ 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 + PINCH_ZOOM,
|
||||
[applyInversion],
|
||||
);
|
||||
|
||||
const onMessage = (e: WebViewMessageEvent) => {
|
||||
const parsed = parseInt(e.nativeEvent.data, 10);
|
||||
const data = e.nativeEvent.data;
|
||||
// Swipe/zoom 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);
|
||||
} else if (msg.type === 'zoom') {
|
||||
onZoomChange?.({
|
||||
pinching: !!msg.pinching,
|
||||
zoomed: typeof msg.scale === 'number' && msg.scale > 1.01,
|
||||
});
|
||||
}
|
||||
} 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));
|
||||
};
|
||||
@@ -473,6 +674,10 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
|
||||
injectedJavaScript={injectedJs}
|
||||
onMessage={onMessage}
|
||||
onLoadEnd={() => {
|
||||
// A (re)load resets the page's zoom state to 1 — drop any host-side
|
||||
// scroll locks so they can't go stale (e.g. cid images arriving
|
||||
// reload the source mid-zoom).
|
||||
onZoomChange?.({ pinching: false, zoomed: false });
|
||||
// Re-inject after load to cover Android cases where
|
||||
// `injectedJavaScript` runs too early to see the final layout. The
|
||||
// re-inversion pass guards itself (__rnDarkInvertDone) so running it
|
||||
@@ -482,6 +687,9 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
|
||||
}, 50);
|
||||
}}
|
||||
scrollEnabled={false}
|
||||
// Native Android zoom would scale the viewport inside the fixed-size
|
||||
// container; pinch zoom is implemented in the page instead.
|
||||
setBuiltInZoomControls={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
setSupportMultipleWindows={false}
|
||||
|
||||
Binary file not shown.
+417
-340
@@ -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 {
|
||||
@@ -35,8 +36,13 @@ type Props = NativeStackScreenProps<RootStackParamList, 'EmailThread'>;
|
||||
|
||||
function formatHeaderDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(undefined, {
|
||||
return d.toLocaleDateString(undefined, {
|
||||
weekday: 'short', year: 'numeric', month: 'short', day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function formatHeaderTime(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString(undefined, {
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
@@ -64,7 +70,6 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
// swiping / Prev-Next can switch messages in place without remounting the
|
||||
// screen — which is what produced the loading flash on every change.
|
||||
const [activeEmailId, setActiveEmailId] = React.useState(route.params.emailId);
|
||||
const [activeSubject, setActiveSubject] = React.useState(route.params.subject ?? '');
|
||||
const insets = useSafeAreaInsets();
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
const getEmailDetail = useEmailStore((s) => s.getEmailDetail);
|
||||
@@ -80,121 +85,85 @@ 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 [email, setEmail] = React.useState<Email | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
// True only while a Prev/Next/swipe transition is waiting on an
|
||||
// not-yet-prefetched neighbour. Drives a lightweight overlay spinner so the
|
||||
// brief gap shows feedback instead of a blank pane — without unmounting the
|
||||
// (still-animating) slide pane the way the full-screen `loading` state would.
|
||||
const [switching, setSwitching] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [moreMenuOpen, setMoreMenuOpen] = React.useState(false);
|
||||
const [moveMenuOpen, setMoveMenuOpen] = React.useState(false);
|
||||
const [tagMenuOpen, setTagMenuOpen] = React.useState(false);
|
||||
const [attachmentsExpanded, setAttachmentsExpanded] = React.useState(false);
|
||||
const [downloadingBlobId, setDownloadingBlobId] = React.useState<string | null>(null);
|
||||
|
||||
// In-memory cache of fetched message details keyed by id. Lets us show a
|
||||
// neighbour instantly (no spinner) when switching, and prefetch adjacent
|
||||
// messages so a swipe lands on ready content.
|
||||
// In-memory cache of fetched message details keyed by id. This is the single
|
||||
// source of truth for every rendered pane: the active message *and* its
|
||||
// prefetched neighbours all read their detail from here, so a swipe lands on
|
||||
// ready content with no spinner and no late content-swap. `cacheVersion` is
|
||||
// bumped whenever an entry changes so the panes re-render.
|
||||
const detailCache = React.useRef(new Map<string, Email>()).current;
|
||||
const [cacheVersion, setCacheVersion] = React.useState(0);
|
||||
const bumpCache = React.useCallback(() => setCacheVersion((v) => v + 1), []);
|
||||
const email = React.useMemo(
|
||||
() => detailCache.get(activeEmailId) ?? null,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[activeEmailId, cacheVersion, detailCache],
|
||||
);
|
||||
|
||||
// --- Swipe / slide navigation between adjacent emails -------------------
|
||||
// translateX drives both the finger-follow drag and the slide-out/slide-in
|
||||
// transition. Refs keep the (memoised once) PanResponder reading the latest
|
||||
// neighbours / dimensions without being recreated every render.
|
||||
const translateX = React.useRef(new Animated.Value(0)).current;
|
||||
const scrollRef = React.useRef<ScrollView>(null);
|
||||
const animatingRef = 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<FlatList<Email>>(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;
|
||||
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<string, Promise<Email | null>>()).current;
|
||||
const ensureDetail = React.useCallback((id: string): Promise<Email | null> => {
|
||||
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]);
|
||||
|
||||
// Make sure the target's detail is in the cache *before* we swap it into
|
||||
// view. That way the load effect takes its no-spinner cached path and the
|
||||
// full-screen `loading` state never fires — which is what unmounted the
|
||||
// slide pane mid-animation and produced the blank-then-jump flash.
|
||||
// Neighbours are usually prefetched, so this resolves immediately.
|
||||
const ready: Promise<unknown> = detailCache.has(target.id)
|
||||
? Promise.resolve()
|
||||
: getEmailDetail(target.id, jmapAccountId)
|
||||
.then((e) => { detailCache.set(target.id, e); })
|
||||
.catch(() => { /* fall back to the load effect's own fetch */ });
|
||||
// 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]);
|
||||
|
||||
// Swap content + slide the new message in from the opposite edge.
|
||||
const swapAndSlideIn = () => {
|
||||
setSwitching(false);
|
||||
setActiveEmailId(target.id);
|
||||
setActiveSubject(target.subject ?? '');
|
||||
scrollRef.current?.scrollTo({ y: 0, animated: false });
|
||||
translateX.setValue(-out);
|
||||
Animated.timing(translateX, {
|
||||
toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true,
|
||||
}).start(() => { animatingRef.current = false; });
|
||||
};
|
||||
// Adopt the centred page as the active message once a swipe settles.
|
||||
const onMomentumEnd = React.useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const index = Math.round(e.nativeEvent.contentOffset.x / windowWidth);
|
||||
const target = emails[index];
|
||||
if (target && target.id !== activeEmailId) setActiveEmailId(target.id);
|
||||
}, [emails, windowWidth, activeEmailId]);
|
||||
|
||||
// Slide the current message the rest of the way out, then swap once the
|
||||
// target is ready. If it isn't cached yet, show the overlay spinner during
|
||||
// the (usually brief) wait rather than leaving an empty pane on screen.
|
||||
Animated.timing(translateX, {
|
||||
toValue: out, duration: 170, easing: Easing.out(Easing.cubic), useNativeDriver: true,
|
||||
}).start(() => {
|
||||
if (detailCache.has(target.id)) {
|
||||
swapAndSlideIn();
|
||||
} else {
|
||||
setSwitching(true);
|
||||
void ready.then(swapAndSlideIn);
|
||||
}
|
||||
});
|
||||
}, [translateX, detailCache, getEmailDetail, jmapAccountId]);
|
||||
const switchEmailRef = React.useRef(switchEmail);
|
||||
switchEmailRef.current = switchEmail;
|
||||
// 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(() => {
|
||||
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 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;
|
||||
}
|
||||
translateX.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(translateX, { toValue: 0, useNativeDriver: true, speed: 20, bounciness: 6 }).start();
|
||||
}
|
||||
},
|
||||
onPanResponderTerminate: () => {
|
||||
Animated.spring(translateX, { toValue: 0, useNativeDriver: true, speed: 20, bounciness: 6 }).start();
|
||||
},
|
||||
onPanResponderTerminationRequest: () => true,
|
||||
}),
|
||||
).current;
|
||||
// While the body is pinch-zoomed (or a pinch is in flight) horizontal
|
||||
// gestures pan the zoomed content, so the pager must not treat them as page
|
||||
// swipes. Prev/Next stay usable: programmatic scrollToOffset ignores
|
||||
// scrollEnabled. Reset when the active message changes — the zoomed pane's
|
||||
// WebView can unmount without reporting back.
|
||||
const [pagerLocked, setPagerLocked] = React.useState(false);
|
||||
React.useEffect(() => { setPagerLocked(false); }, [activeEmailId]);
|
||||
|
||||
const mailAttachmentAction = useSettingsStore((s) => s.mailAttachmentAction);
|
||||
const attachmentPosition = useSettingsStore((s) => s.attachmentPosition);
|
||||
@@ -213,14 +182,14 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
);
|
||||
|
||||
const onPressAttachment = React.useCallback(
|
||||
async (blobId: string, name: string | undefined, type: string | undefined) => {
|
||||
async (target: Email, blobId: string, name: string | undefined, type: string | undefined) => {
|
||||
if (downloadingBlobId) return;
|
||||
setDownloadingBlobId(blobId);
|
||||
try {
|
||||
if (mailAttachmentAction === 'download') {
|
||||
await downloadAttachment(blobId, name, type, email);
|
||||
await downloadAttachment(blobId, name, type, target);
|
||||
} else {
|
||||
await shareAttachment(blobId, name, type, email);
|
||||
await shareAttachment(blobId, name, type, target);
|
||||
}
|
||||
} catch (e) {
|
||||
Alert.alert('Download failed', e instanceof Error ? e.message : String(e));
|
||||
@@ -228,66 +197,8 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
setDownloadingBlobId(null);
|
||||
}
|
||||
},
|
||||
[downloadingBlobId, mailAttachmentAction, email],
|
||||
[downloadingBlobId, mailAttachmentAction],
|
||||
);
|
||||
|
||||
const renderAttachments = () => {
|
||||
const all = email?.attachments;
|
||||
if (!all || all.length === 0) return null;
|
||||
// hideInlineImageAttachments: drop chips for images already shown inline
|
||||
// in the body (any image attachment with a cid:). Non-image inline
|
||||
// attachments stay visible because the user wouldn't see them otherwise.
|
||||
const atts = hideInlineImageAttachments
|
||||
? all.filter((att) => !(att.cid && (att.type ?? '').startsWith('image/')))
|
||||
: all;
|
||||
if (atts.length === 0) return null;
|
||||
const visible = attachmentsExpanded ? atts : atts.slice(0, 3);
|
||||
return (
|
||||
<View style={styles.attachmentsBlock}>
|
||||
<View style={styles.attachmentsRow}>
|
||||
{visible.map((att, idx) => {
|
||||
const isDownloading = downloadingBlobId === att.blobId;
|
||||
return (
|
||||
<Pressable
|
||||
key={att.blobId ?? idx}
|
||||
style={({ pressed }) => [
|
||||
styles.attachmentChip,
|
||||
pressed && styles.attachmentChipPressed,
|
||||
]}
|
||||
onPress={() => onPressAttachment(att.blobId, att.name, att.type)}
|
||||
disabled={!!downloadingBlobId}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<ActivityIndicator size="small" color={c.textMuted} />
|
||||
) : (
|
||||
<Paperclip size={14} color={c.textMuted} />
|
||||
)}
|
||||
<Text style={styles.attachmentName} numberOfLines={1}>
|
||||
{att.name || 'attachment'}
|
||||
</Text>
|
||||
<Text style={styles.attachmentSize}>{formatSize(att.size)}</Text>
|
||||
<Download size={14} color={c.textMuted} />
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
{atts.length > 3 && (
|
||||
<Pressable
|
||||
onPress={() => setAttachmentsExpanded((v) => !v)}
|
||||
style={({ pressed }) => [
|
||||
styles.attachmentsToggle,
|
||||
pressed && styles.attachmentsTogglePressed,
|
||||
]}
|
||||
hitSlop={6}
|
||||
>
|
||||
<Text style={styles.attachmentsToggleText}>
|
||||
{attachmentsExpanded ? 'Show less' : `Show all (${atts.length})`}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
const keywordDefs = useKeywordsStore((s) => s.keywords);
|
||||
const hydrateKeywords = useKeywordsStore((s) => s.hydrate);
|
||||
const keywordsHydrated = useKeywordsStore((s) => s.hydrated);
|
||||
@@ -296,60 +207,34 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
let readTimer: ReturnType<typeof setTimeout> | 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) {
|
||||
setEmail(cached);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
} else {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
// 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);
|
||||
setEmail(fetched);
|
||||
if (fetched && !fetched.keywords?.$seen) {
|
||||
if (markAsReadDelay > 0) {
|
||||
readTimer = setTimeout(() => {
|
||||
if (!cancelled) void markRead(activeEmailId, jmapAccountId);
|
||||
}, markAsReadDelay);
|
||||
} else {
|
||||
void markRead(activeEmailId, jmapAccountId);
|
||||
}
|
||||
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);
|
||||
}
|
||||
} 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');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (readTimer) clearTimeout(readTimer);
|
||||
};
|
||||
}, [activeEmailId, jmapAccountId, getEmailDetail, markRead, markAsReadDelay, detailCache]);
|
||||
|
||||
// Prefetch the adjacent messages so a swipe / Prev-Next lands on ready
|
||||
// content instead of a spinner. Best-effort and silent.
|
||||
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))
|
||||
.catch(() => { /* ignore — prefetch is best-effort */ });
|
||||
}
|
||||
}
|
||||
}, [prevEmail, nextEmail, getEmailDetail, jmapAccountId, detailCache]);
|
||||
}, [activeEmailId, ensureDetail, markRead, markAsReadDelay, jmapAccountId, detailCache]);
|
||||
|
||||
const starred = !!email?.keywords?.$flagged;
|
||||
const unread = !!email && !email.keywords?.$seen;
|
||||
@@ -368,14 +253,13 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
);
|
||||
const isInJunk = !!(junkMailbox && email?.mailboxIds?.[junkMailbox.id]);
|
||||
|
||||
const updateLocalKeywords = (next: Record<string, boolean>) => {
|
||||
setEmail((prev) => {
|
||||
if (!prev) return prev;
|
||||
const updated = { ...prev, keywords: next };
|
||||
// Keep the cached copy in sync so swiping back shows the toggled state.
|
||||
detailCache.set(prev.id, updated);
|
||||
return updated;
|
||||
});
|
||||
// Optimistically write a message's keywords into the cache (the single source
|
||||
// of truth for every pane) and bump the version so the panes re-render.
|
||||
const updateLocalKeywords = (id: string, next: Record<string, boolean>) => {
|
||||
const prev = detailCache.get(id);
|
||||
if (!prev) return;
|
||||
detailCache.set(id, { ...prev, keywords: next });
|
||||
bumpCache();
|
||||
};
|
||||
|
||||
const onToggleKeyword = (token: string) => {
|
||||
@@ -383,28 +267,32 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
const next = { ...email.keywords };
|
||||
if (next[token]) delete next[token];
|
||||
else next[token] = true;
|
||||
updateLocalKeywords(next);
|
||||
updateLocalKeywords(email.id, next);
|
||||
void setEmailKeywords(email.id, next);
|
||||
};
|
||||
|
||||
const onToggleStar = () => {
|
||||
if (!email) return;
|
||||
const next = { ...email.keywords };
|
||||
if (starred) delete next.$flagged;
|
||||
// Toggle the star on a specific message — used both by the toolbar (current
|
||||
// message) and by each pane's own subject star.
|
||||
const toggleStarFor = React.useCallback((target: Email) => {
|
||||
const next = { ...target.keywords };
|
||||
if (next.$flagged) delete next.$flagged;
|
||||
else next.$flagged = true;
|
||||
updateLocalKeywords(next);
|
||||
void setEmailKeywords(email.id, next);
|
||||
};
|
||||
const prev = detailCache.get(target.id);
|
||||
if (prev) { detailCache.set(target.id, { ...prev, keywords: next }); bumpCache(); }
|
||||
void setEmailKeywords(target.id, next);
|
||||
}, [detailCache, bumpCache]);
|
||||
|
||||
const onToggleStar = () => { if (email) toggleStarFor(email); };
|
||||
|
||||
const onToggleUnread = () => {
|
||||
if (!email) return;
|
||||
if (unread) {
|
||||
void markRead(email.id);
|
||||
updateLocalKeywords({ ...email.keywords, $seen: true });
|
||||
updateLocalKeywords(email.id, { ...email.keywords, $seen: true });
|
||||
} else {
|
||||
const next = { ...email.keywords };
|
||||
delete next.$seen;
|
||||
updateLocalKeywords(next);
|
||||
updateLocalKeywords(email.id, next);
|
||||
void setEmailKeywords(email.id, next);
|
||||
}
|
||||
};
|
||||
@@ -536,8 +424,6 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
},
|
||||
};
|
||||
|
||||
const subject = email?.subject ?? activeSubject ?? '(no subject)';
|
||||
const from = email?.from?.[0];
|
||||
const bottomBarHeight = 60 + Math.max(insets.bottom, 4);
|
||||
|
||||
// Show extras in priority order. Each toolbar button is ~64px wide (icon+label+padding);
|
||||
@@ -608,98 +494,59 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{loading && !email ? (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator color={c.primary} />
|
||||
</View>
|
||||
) : error && !email ? (
|
||||
{error && !email ? (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
) : email ? (
|
||||
) : (
|
||||
<>
|
||||
<Animated.View
|
||||
style={[styles.slidePane, { transform: [{ translateX }] }]}
|
||||
{...panResponder.panHandlers}
|
||||
>
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={{ paddingBottom: bottomBarHeight + spacing.lg }}
|
||||
>
|
||||
{/* Subject block */}
|
||||
<View style={styles.subjectBlock}>
|
||||
<View style={styles.subjectRow}>
|
||||
<View style={styles.subjectMain}>
|
||||
<Pressable onPress={onToggleStar} hitSlop={8} style={styles.subjectStar}>
|
||||
<Star
|
||||
size={18}
|
||||
color={starred ? c.starred : c.textMuted}
|
||||
fill={starred ? c.starred : 'transparent'}
|
||||
/>
|
||||
</Pressable>
|
||||
<Text style={styles.subjectText}>{subject}</Text>
|
||||
</View>
|
||||
<View style={styles.subjectMeta}>
|
||||
<Text style={styles.subjectDate}>{formatHeaderDate(email.receivedAt)}</Text>
|
||||
{email.size > 0 && (
|
||||
<Text style={styles.subjectSize}>{formatSize(email.size)}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Sender info */}
|
||||
<View style={styles.senderBlock}>
|
||||
<View style={styles.senderRow}>
|
||||
<SenderAvatar
|
||||
name={from?.name}
|
||||
email={from?.email}
|
||||
size={componentSizes.avatarMd}
|
||||
{/* 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. */}
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
style={styles.pagerViewport}
|
||||
data={emails}
|
||||
extraData={cacheVersion}
|
||||
keyExtractor={(item) => item.id}
|
||||
horizontal
|
||||
pagingEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
initialScrollIndex={initialIndexRef.current}
|
||||
getItemLayout={(_, index) => ({ length: windowWidth, offset: windowWidth * index, index })}
|
||||
windowSize={3}
|
||||
initialNumToRender={1}
|
||||
maxToRenderPerBatch={2}
|
||||
removeClippedSubviews
|
||||
scrollEnabled={!pagerLocked}
|
||||
onMomentumScrollEnd={onMomentumEnd}
|
||||
renderItem={({ item, index }) => (
|
||||
<View style={{ width: windowWidth }}>
|
||||
<EmailPane
|
||||
id={item.id}
|
||||
email={detailCache.get(item.id) ?? null}
|
||||
ensureDetail={ensureDetail}
|
||||
c={c}
|
||||
styles={styles}
|
||||
bottomBarHeight={bottomBarHeight}
|
||||
attachmentPosition={attachmentPosition}
|
||||
hideInlineImageAttachments={hideInlineImageAttachments}
|
||||
downloadingBlobId={downloadingBlobId}
|
||||
onToggleStar={toggleStarFor}
|
||||
onPressAttachment={onPressAttachment}
|
||||
onSwipe={(dir) => goToIndex(dir === 'next' ? index + 1 : index - 1)}
|
||||
onZoomChange={(z) => setPagerLocked(z.pinching || z.zoomed)}
|
||||
/>
|
||||
<View style={styles.senderInfo}>
|
||||
<Text style={styles.senderName} numberOfLines={1}>
|
||||
{from?.name || from?.email || 'Unknown sender'}
|
||||
</Text>
|
||||
{from?.name && from?.email ? (
|
||||
<Text style={styles.senderEmail} numberOfLines={1}>{from.email}</Text>
|
||||
) : null}
|
||||
<Text style={styles.senderRecipients} numberOfLines={1}>
|
||||
<Text style={styles.senderRecipientsLabel}>to </Text>
|
||||
{email.to?.map((t) => t.name || t.email).join(', ') || '-'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{attachmentPosition === 'beside-sender' && renderAttachments()}
|
||||
</View>
|
||||
|
||||
{/* Attachments chips (full-width below header) */}
|
||||
{attachmentPosition === 'below-header' && renderAttachments()}
|
||||
|
||||
{/* Calendar invitation (auto-detected .ics) */}
|
||||
<CalendarInvitationBanner email={email} />
|
||||
|
||||
{/* Body */}
|
||||
<View style={styles.bodyBlock}>
|
||||
<EmailBodyView email={email} senderEmail={from?.email} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
|
||||
{/* Transient spinner while a switch waits on an un-prefetched
|
||||
neighbour — sits above the (slid-out) pane, not in place of it. */}
|
||||
{switching && (
|
||||
<View style={styles.switchOverlay} pointerEvents="none">
|
||||
<ActivityIndicator color={c.primary} />
|
||||
</View>
|
||||
)}
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Bottom action bar */}
|
||||
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 4) }]}>
|
||||
<BottomBarButton
|
||||
icon={<ChevronLeft size={20} color={c.textMuted} />}
|
||||
label="Prev"
|
||||
onPress={prevEmail ? () => switchEmail('prev') : undefined}
|
||||
onPress={prevEmail ? () => goToIndex(currentIndex - 1) : undefined}
|
||||
disabled={!prevEmail}
|
||||
/>
|
||||
{bottomActions.map((id) => {
|
||||
@@ -717,12 +564,12 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
<BottomBarButton
|
||||
icon={<ChevronRight size={20} color={c.textMuted} />}
|
||||
label="Next"
|
||||
onPress={nextEmail ? () => switchEmail('next') : undefined}
|
||||
onPress={nextEmail ? () => goToIndex(currentIndex + 1) : undefined}
|
||||
disabled={!nextEmail}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
<MoreMenuSheet
|
||||
visible={moreMenuOpen}
|
||||
@@ -781,6 +628,229 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
interface EmailPaneProps {
|
||||
id: string;
|
||||
email: Email | null;
|
||||
ensureDetail: (id: string) => Promise<Email | null>;
|
||||
c: ThemePalette;
|
||||
styles: ReturnType<typeof makeStyles>;
|
||||
bottomBarHeight: number;
|
||||
attachmentPosition: 'beside-sender' | 'below-header';
|
||||
hideInlineImageAttachments: boolean;
|
||||
downloadingBlobId: string | null;
|
||||
onToggleStar: (email: Email) => void;
|
||||
onPressAttachment: (email: Email, blobId: string, name: string | undefined, type: string | undefined) => void;
|
||||
onSwipe: (direction: 'prev' | 'next') => void;
|
||||
onZoomChange: (zoom: { pinching: boolean; zoomed: boolean }) => void;
|
||||
}
|
||||
|
||||
// One swipeable page: the scrollable subject / sender / attachments / body for a
|
||||
// single message. The pager keeps three of these mounted (prev, current, next)
|
||||
// so a swipe slides ready content into view. Each pane owns its own vertical
|
||||
// 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({
|
||||
id, email, ensureDetail, c, styles, bottomBarHeight, attachmentPosition,
|
||||
hideInlineImageAttachments, downloadingBlobId, onToggleStar, onPressAttachment, onSwipe,
|
||||
onZoomChange,
|
||||
}: EmailPaneProps) {
|
||||
const [attachmentsExpanded, setAttachmentsExpanded] = React.useState(false);
|
||||
// Freeze the pane's vertical scroll while a pinch is in flight so a two-
|
||||
// finger zoom can't fling the page; while merely zoomed, vertical scrolling
|
||||
// stays on — it is how the user pans the (taller) zoomed content vertically.
|
||||
const [pinching, setPinching] = React.useState(false);
|
||||
|
||||
// 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 <EmailPaneSkeleton styles={styles} />;
|
||||
}
|
||||
|
||||
const from = email.from?.[0];
|
||||
const starred = !!email.keywords?.$flagged;
|
||||
const subject = email.subject || '(no subject)';
|
||||
|
||||
const renderAttachments = () => {
|
||||
const all = email.attachments;
|
||||
if (!all || all.length === 0) return null;
|
||||
// hideInlineImageAttachments: drop chips for images already shown inline
|
||||
// in the body (any image attachment with a cid:). Non-image inline
|
||||
// attachments stay visible because the user wouldn't see them otherwise.
|
||||
const atts = hideInlineImageAttachments
|
||||
? all.filter((att) => !(att.cid && (att.type ?? '').startsWith('image/')))
|
||||
: all;
|
||||
if (atts.length === 0) return null;
|
||||
const visible = attachmentsExpanded ? atts : atts.slice(0, 3);
|
||||
return (
|
||||
<View style={styles.attachmentsBlock}>
|
||||
<View style={styles.attachmentsRow}>
|
||||
{visible.map((att, idx) => {
|
||||
const isDownloading = downloadingBlobId === att.blobId;
|
||||
return (
|
||||
<Pressable
|
||||
key={att.blobId ?? idx}
|
||||
style={({ pressed }) => [
|
||||
styles.attachmentChip,
|
||||
pressed && styles.attachmentChipPressed,
|
||||
]}
|
||||
onPress={() => onPressAttachment(email, att.blobId, att.name, att.type)}
|
||||
disabled={!!downloadingBlobId}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<ActivityIndicator size="small" color={c.textMuted} />
|
||||
) : (
|
||||
<Paperclip size={14} color={c.textMuted} />
|
||||
)}
|
||||
<Text style={styles.attachmentName} numberOfLines={1}>
|
||||
{att.name || 'attachment'}
|
||||
</Text>
|
||||
<Text style={styles.attachmentSize}>{formatSize(att.size)}</Text>
|
||||
<Download size={14} color={c.textMuted} />
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
{atts.length > 3 && (
|
||||
<Pressable
|
||||
onPress={() => setAttachmentsExpanded((v) => !v)}
|
||||
style={({ pressed }) => [
|
||||
styles.attachmentsToggle,
|
||||
pressed && styles.attachmentsTogglePressed,
|
||||
]}
|
||||
hitSlop={6}
|
||||
>
|
||||
<Text style={styles.attachmentsToggleText}>
|
||||
{attachmentsExpanded ? 'Show less' : `Show all (${atts.length})`}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={{ paddingBottom: bottomBarHeight + spacing.lg }}
|
||||
scrollEnabled={!pinching}
|
||||
>
|
||||
{/* Subject block */}
|
||||
<View style={styles.subjectBlock}>
|
||||
<View style={styles.subjectRow}>
|
||||
<Text style={styles.subjectText}>{subject}</Text>
|
||||
<Pressable onPress={() => onToggleStar(email)} hitSlop={8} style={styles.subjectStar}>
|
||||
<Star
|
||||
size={18}
|
||||
color={starred ? c.starred : c.textMuted}
|
||||
fill={starred ? c.starred : 'transparent'}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Sender info */}
|
||||
<View style={styles.senderBlock}>
|
||||
<View style={styles.senderRow}>
|
||||
<SenderAvatar
|
||||
name={from?.name}
|
||||
email={from?.email}
|
||||
size={componentSizes.avatarMd}
|
||||
/>
|
||||
<View style={styles.senderInfo}>
|
||||
<Text style={styles.senderName} numberOfLines={1}>
|
||||
{from?.name || from?.email || 'Unknown sender'}
|
||||
</Text>
|
||||
{from?.name && from?.email ? (
|
||||
<Text style={styles.senderEmail} numberOfLines={1}>{from.email}</Text>
|
||||
) : null}
|
||||
<Text style={styles.senderRecipients} numberOfLines={1}>
|
||||
<Text style={styles.senderRecipientsLabel}>to </Text>
|
||||
{email.to?.map((t) => t.name || t.email).join(', ') || '-'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.senderMeta}>
|
||||
<Text style={styles.senderDate}>{formatHeaderDate(email.receivedAt)}</Text>
|
||||
<Text style={styles.senderTime}>
|
||||
{formatHeaderTime(email.receivedAt)}
|
||||
{email.size > 0 ? ` · ${formatSize(email.size)}` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{attachmentPosition === 'beside-sender' && renderAttachments()}
|
||||
</View>
|
||||
|
||||
{/* Attachments chips (full-width below header) */}
|
||||
{attachmentPosition === 'below-header' && renderAttachments()}
|
||||
|
||||
{/* Calendar invitation (auto-detected .ics) */}
|
||||
<CalendarInvitationBanner email={email} />
|
||||
|
||||
{/* Body */}
|
||||
<View style={styles.bodyBlock}>
|
||||
<EmailBodyView
|
||||
email={email}
|
||||
senderEmail={from?.email}
|
||||
onSwipe={onSwipe}
|
||||
onZoomChange={(z) => { setPinching(z.pinching); onZoomChange(z); }}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
// 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<typeof makeStyles> }) {
|
||||
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 (
|
||||
<Animated.View style={[styles.scroll, { opacity: pulse }]}>
|
||||
<View style={styles.subjectBlock}>
|
||||
<View style={[styles.skeletonBone, { height: 20, width: '88%' }]} />
|
||||
</View>
|
||||
<View style={styles.senderBlock}>
|
||||
<View style={styles.senderRow}>
|
||||
<View style={styles.skeletonAvatar} />
|
||||
<View style={styles.senderInfo}>
|
||||
<View style={[styles.skeletonBone, { height: 14, width: '55%' }]} />
|
||||
<View style={[styles.skeletonBone, { height: 11, width: '70%', marginTop: 6 }]} />
|
||||
<View style={[styles.skeletonBone, { height: 11, width: '45%', marginTop: 6 }]} />
|
||||
</View>
|
||||
<View style={styles.senderMeta}>
|
||||
<View style={[styles.skeletonBone, { height: 11, width: 86 }]} />
|
||||
<View style={[styles.skeletonBone, { height: 11, width: 64, marginTop: 6 }]} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.skeletonBody}>
|
||||
{bodyLineWidths.map((w, i) => (
|
||||
<View key={i} style={[styles.skeletonBone, { height: 12, width: w }]} />
|
||||
))}
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
interface MoreMenuSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
@@ -1102,13 +1172,8 @@ function makeStyles(c: ThemePalette) {
|
||||
padding: spacing.lg,
|
||||
},
|
||||
errorText: { ...typography.body, color: c.error, textAlign: 'center' },
|
||||
switchOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
slidePane: { flex: 1, backgroundColor: c.background },
|
||||
// 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
|
||||
@@ -1124,12 +1189,6 @@ function makeStyles(c: ThemePalette) {
|
||||
alignItems: 'flex-start',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
subjectMain: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
subjectStar: {
|
||||
paddingTop: 4,
|
||||
},
|
||||
@@ -1141,18 +1200,6 @@ function makeStyles(c: ThemePalette) {
|
||||
color: c.text,
|
||||
letterSpacing: -0.2,
|
||||
},
|
||||
subjectMeta: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
subjectDate: {
|
||||
...typography.caption,
|
||||
color: c.textSecondary,
|
||||
},
|
||||
subjectSize: {
|
||||
...typography.caption,
|
||||
color: c.textMuted,
|
||||
marginTop: 2,
|
||||
},
|
||||
|
||||
// Sender block
|
||||
senderBlock: {
|
||||
@@ -1185,6 +1232,19 @@ function makeStyles(c: ThemePalette) {
|
||||
senderRecipientsLabel: {
|
||||
color: c.textMuted,
|
||||
},
|
||||
senderMeta: {
|
||||
alignItems: 'flex-end',
|
||||
paddingTop: 1,
|
||||
},
|
||||
senderDate: {
|
||||
...typography.caption,
|
||||
color: c.textSecondary,
|
||||
},
|
||||
senderTime: {
|
||||
...typography.caption,
|
||||
color: c.textMuted,
|
||||
marginTop: 2,
|
||||
},
|
||||
|
||||
// Attachments
|
||||
attachmentsBlock: {
|
||||
@@ -1243,6 +1303,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',
|
||||
|
||||
Reference in New Issue
Block a user