mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b10f61a642 | ||
|
|
752aa719d8 | ||
|
|
5884a18979 | ||
|
|
3ccc83e7ce | ||
|
|
5dc4ea1a45 | ||
|
|
1bd476e052 | ||
|
|
41793455f7 | ||
|
|
d99cbf1dbb | ||
|
|
1afd2185d5 | ||
|
|
5aa1c88b1e | ||
|
|
ac95a2bc39 | ||
|
|
e75aefeb4f | ||
|
|
70863e0923 | ||
|
|
b6ecf07369 | ||
|
|
bf5d75d5e9 | ||
|
|
53520a4236 |
@@ -1,5 +1,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
||||
@@ -21,6 +22,12 @@
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="bulwarkmobile"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service android:name=".BulwarkMessagingService" android:exported="false">
|
||||
<intent-filter>
|
||||
|
||||
+12
-1
@@ -46,7 +46,18 @@ module.exports = {
|
||||
web: {
|
||||
favicon: './assets/favicon.png',
|
||||
},
|
||||
plugins: ['expo-secure-store', '@react-native-community/datetimepicker', 'expo-localization'],
|
||||
plugins: [
|
||||
'expo-secure-store',
|
||||
'@react-native-community/datetimepicker',
|
||||
'expo-localization',
|
||||
[
|
||||
'expo-camera',
|
||||
{
|
||||
cameraPermission:
|
||||
'Bulwark Mail uses the camera to scan sign-in QR codes shown in webmail.',
|
||||
},
|
||||
],
|
||||
],
|
||||
extra: {
|
||||
commit: COMMIT,
|
||||
},
|
||||
|
||||
Generated
+23
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.47",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.47",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -17,6 +17,7 @@
|
||||
"@react-navigation/native-stack": "^7.14.10",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~54.0.33",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
@@ -5211,6 +5212,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-camera": {
|
||||
"version": "17.0.10",
|
||||
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.10.tgz",
|
||||
"integrity": "sha512-w1RBw83mAGVk4BPPwNrCZyFop0VLiVSRE3c2V9onWbdFwonpRhzmB4drygG8YOUTl1H3wQvALJHyMPTbgsK1Jg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"invariant": "^2.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*",
|
||||
"react-native-web": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-native-web": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-constants": {
|
||||
"version": "18.0.13",
|
||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.47",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -23,6 +23,7 @@
|
||||
"@react-navigation/native-stack": "^7.14.10",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~54.0.33",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { View, StyleSheet, Linking, Platform, Text, Pressable } from 'react-nati
|
||||
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
|
||||
import { Image as ImageIcon, ShieldCheck } from 'lucide-react-native';
|
||||
import type { Email } from '../api/types';
|
||||
import { wrapEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, extractCidRefs, hasRemoteContent, hasMeaningfulHtmlBody } from '../lib/email-html';
|
||||
import { wrapEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, extractCidRefs, hasRemoteContent, hasMeaningfulHtmlBody, hasNativeDarkMode } from '../lib/email-html';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { useContactsStore } from '../stores/contacts-store';
|
||||
@@ -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 {
|
||||
@@ -45,29 +54,188 @@ function extractTextBody(email: Email): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Dark-mode re-inversion, mirroring the webmail's handleIframeLoad pass. The
|
||||
// body's `filter: invert(1)` flips colored emoji (yellow smiley -> blue, red
|
||||
// heart -> cyan) and stylesheet-defined background images, which the static CSS
|
||||
// attribute selectors can't reach. This walks the DOM once and wraps/re-inverts
|
||||
// those so they keep their original colors. Only injected when inversion is on.
|
||||
const DARK_REINVERT_SCRIPT = `
|
||||
(function () {
|
||||
if (document.__rnDarkInvertDone) return;
|
||||
var doc = document;
|
||||
var win = window;
|
||||
if (!doc.body) return;
|
||||
document.__rnDarkInvertDone = true;
|
||||
|
||||
// Re-invert elements with stylesheet-defined background images (CSS attribute
|
||||
// selectors only catch inline styles, not rules that survive in <style>).
|
||||
doc.body.querySelectorAll('*').forEach(function (el) {
|
||||
// Skip elements already handled by CSS attribute selectors.
|
||||
if (el.style.backgroundImage || el.style.background ||
|
||||
el.hasAttribute('background') || el.hasAttribute('bgcolor')) return;
|
||||
// Skip leaf media elements (already re-inverted by CSS).
|
||||
var tag = el.tagName;
|
||||
if (['IMG','VIDEO','SVG','CANVAS','OBJECT','EMBED'].indexOf(tag) !== -1) return;
|
||||
var computed = win.getComputedStyle(el);
|
||||
if (computed.backgroundImage && computed.backgroundImage !== 'none') {
|
||||
// Only re-invert if this container doesn't have media children.
|
||||
if (!el.querySelector('img, video, svg, canvas, object, embed')) {
|
||||
el.style.filter = 'invert(1) hue-rotate(180deg)';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Re-invert emoji glyphs so they keep their original colors. Wrap each emoji
|
||||
// run in a span that re-inverts. Only act when the ancestor invert depth is
|
||||
// odd - emojis inside a double-inverted bgcolor container already render at
|
||||
// their original colors.
|
||||
var emojiRe;
|
||||
try {
|
||||
emojiRe = new RegExp('\\\\p{RGI_Emoji}', 'gv');
|
||||
} catch (e) {
|
||||
emojiRe = /\\p{Extended_Pictographic}(?:\\uFE0F)?(?:\\u200D\\p{Extended_Pictographic}(?:\\uFE0F)?)*/gu;
|
||||
}
|
||||
var emojiTestRe = /\\p{Extended_Pictographic}/u;
|
||||
var SKIP_TAGS = { SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, TEXTAREA: 1, IFRAME: 1 };
|
||||
|
||||
function isOddInvertDepth(start) {
|
||||
var count = 0;
|
||||
var n = start;
|
||||
while (n) {
|
||||
if (n === doc.body) { count++; break; }
|
||||
var cs = win.getComputedStyle(n);
|
||||
if (cs.filter && cs.filter.indexOf('invert') !== -1) count++;
|
||||
n = n.parentElement;
|
||||
}
|
||||
return count % 2 === 1;
|
||||
}
|
||||
|
||||
var walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: function (node) {
|
||||
var p = node.parentElement;
|
||||
while (p) {
|
||||
if (SKIP_TAGS[p.tagName]) return NodeFilter.FILTER_REJECT;
|
||||
p = p.parentElement;
|
||||
}
|
||||
return emojiTestRe.test(node.nodeValue || '')
|
||||
? NodeFilter.FILTER_ACCEPT
|
||||
: NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
});
|
||||
|
||||
var emojiTextNodes = [];
|
||||
var cur;
|
||||
while ((cur = walker.nextNode())) emojiTextNodes.push(cur);
|
||||
|
||||
emojiTextNodes.forEach(function (textNode) {
|
||||
var parent = textNode.parentElement;
|
||||
if (!parent || !isOddInvertDepth(parent)) return;
|
||||
var text = textNode.nodeValue || '';
|
||||
emojiRe.lastIndex = 0;
|
||||
var frag = doc.createDocumentFragment();
|
||||
var lastIndex = 0;
|
||||
var m;
|
||||
while ((m = emojiRe.exec(text)) !== null) {
|
||||
if (m.index > lastIndex) {
|
||||
frag.appendChild(doc.createTextNode(text.slice(lastIndex, m.index)));
|
||||
}
|
||||
var span = doc.createElement('span');
|
||||
span.style.cssText = 'filter:invert(1) hue-rotate(180deg)';
|
||||
span.textContent = m[0];
|
||||
frag.appendChild(span);
|
||||
lastIndex = m.index + m[0].length;
|
||||
}
|
||||
if (lastIndex === 0) return;
|
||||
if (lastIndex < text.length) {
|
||||
frag.appendChild(doc.createTextNode(text.slice(lastIndex)));
|
||||
}
|
||||
parent.replaceChild(frag, textNode);
|
||||
});
|
||||
})();
|
||||
`;
|
||||
|
||||
const HEIGHT_REPORTER = `
|
||||
(function () {
|
||||
// One-time: wrap body contents in a block-level div we can measure reliably.
|
||||
// body.scrollHeight on Android WebView is pinned to the container viewport
|
||||
// height, so we can't trust it. A dedicated wrapper gives a deterministic
|
||||
// bounding box.
|
||||
var wrapperEl = null;
|
||||
function ensureWrapper() {
|
||||
var b = document.body;
|
||||
if (!b) return null;
|
||||
if (wrapperEl && wrapperEl.isConnected) return wrapperEl;
|
||||
var wrap = document.getElementById('__rn_email_body_wrap__');
|
||||
if (!wrap) {
|
||||
wrap = document.createElement('div');
|
||||
wrap.id = '__rn_email_body_wrap__';
|
||||
wrap.style.display = 'block';
|
||||
wrap.style.width = '100%';
|
||||
wrap.style.transformOrigin = 'top left';
|
||||
while (b.firstChild) wrap.appendChild(b.firstChild);
|
||||
b.appendChild(wrap);
|
||||
}
|
||||
wrapperEl = wrap;
|
||||
return wrap;
|
||||
}
|
||||
// The width available for the email content (body content-box).
|
||||
function availWidth() {
|
||||
var b = document.body;
|
||||
var cs = window.getComputedStyle(b);
|
||||
var padL = parseFloat(cs.paddingLeft) || 0;
|
||||
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
|
||||
// the screen — the way native mail clients do. No sideways scrolling.
|
||||
function fitToWidth(w) {
|
||||
if (!w) return;
|
||||
var avail = availWidth();
|
||||
if (avail <= 0) return;
|
||||
// Reset to full width so wrapping content reports its true overflow extent
|
||||
// (the widest unbreakable element) rather than a previously-scaled box.
|
||||
w.style.width = '100%';
|
||||
var content = w.scrollWidth;
|
||||
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;
|
||||
// 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();
|
||||
var b = document.body;
|
||||
var cs = window.getComputedStyle(b);
|
||||
@@ -117,7 +285,16 @@ const HEIGHT_REPORTER = `
|
||||
if (document.body) ro.observe(document.body);
|
||||
} catch (e) {}
|
||||
try {
|
||||
var mo = new MutationObserver(report);
|
||||
var mo = new MutationObserver(function (records) {
|
||||
for (var i = 0; i < records.length; i++) {
|
||||
// Ignore the style mutations fitToWidth() applies to the wrapper itself,
|
||||
// otherwise scaling would re-trigger a measure and loop indefinitely.
|
||||
var rec = records[i];
|
||||
if (rec.target === wrapperEl && rec.type === 'attributes') continue;
|
||||
report();
|
||||
return;
|
||||
}
|
||||
});
|
||||
if (document.body) mo.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true });
|
||||
} catch (e) {}
|
||||
try {
|
||||
@@ -126,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);
|
||||
@@ -273,8 +600,36 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
|
||||
};
|
||||
}, [rawHtml, text, email.preview, shouldBlock, cidMap, renderAsDark]);
|
||||
|
||||
// Inversion is applied for HTML bodies in dark mode unless the email ships its
|
||||
// own dark-mode CSS. When it's on, prepend the DOM re-inversion pass (emoji +
|
||||
// stylesheet backgrounds) so it runs before the height reporter measures.
|
||||
const applyInversion =
|
||||
!!rawHtml && renderAsDark && !hasNativeDarkMode(rawHtml);
|
||||
const injectedJs = React.useMemo(
|
||||
() =>
|
||||
(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));
|
||||
};
|
||||
@@ -316,16 +671,25 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
|
||||
originWhitelist={['about:blank']}
|
||||
source={source}
|
||||
javaScriptEnabled
|
||||
injectedJavaScript={HEIGHT_REPORTER}
|
||||
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.
|
||||
// `injectedJavaScript` runs too early to see the final layout. The
|
||||
// re-inversion pass guards itself (__rnDarkInvertDone) so running it
|
||||
// again here is a no-op once the body has been processed.
|
||||
setTimeout(() => {
|
||||
webviewRef.current?.injectJavaScript(HEIGHT_REPORTER);
|
||||
webviewRef.current?.injectJavaScript(injectedJs);
|
||||
}, 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}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Pressable, StyleSheet, Modal, Platform } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
|
||||
import { X } from 'lucide-react-native';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import Button from './Button';
|
||||
|
||||
interface QrScanModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
// Fires once with the raw decoded QR string. The parent is responsible for
|
||||
// closing the modal and interpreting the payload.
|
||||
onScanned: (data: string) => void;
|
||||
}
|
||||
|
||||
export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
// Guards against the camera firing onBarcodeScanned dozens of times for the
|
||||
// same code before the modal tears down.
|
||||
const handled = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) handled.current = false;
|
||||
}, [visible]);
|
||||
|
||||
const handleBarcode = React.useCallback(
|
||||
(result: BarcodeScanningResult) => {
|
||||
if (handled.current) return;
|
||||
handled.current = true;
|
||||
onScanned(result.data);
|
||||
},
|
||||
[onScanned],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||
<View style={styles.container}>
|
||||
{permission?.granted ? (
|
||||
<CameraView
|
||||
style={StyleSheet.absoluteFill}
|
||||
facing="back"
|
||||
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||
onBarcodeScanned={handleBarcode}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<SafeAreaView style={styles.overlay}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={onClose} hitSlop={10} style={styles.closeButton}>
|
||||
<X size={24} color="#ffffff" />
|
||||
</Pressable>
|
||||
<Text style={styles.title}>Scan QR code</Text>
|
||||
<View style={styles.closeButton} />
|
||||
</View>
|
||||
|
||||
{permission?.granted ? (
|
||||
<View style={styles.frameWrap}>
|
||||
<View style={styles.frame} />
|
||||
<Text style={styles.hint}>
|
||||
Point your camera at the QR code shown on the webmail settings screen.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.permissionWrap}>
|
||||
<Text style={styles.permissionText}>
|
||||
{permission && !permission.canAskAgain
|
||||
? 'Camera access is disabled. Enable it in Settings to scan a sign-in QR code.'
|
||||
: 'Bulwark Mail needs camera access to scan a sign-in QR code.'}
|
||||
</Text>
|
||||
<Button variant="default" size="md" onPress={() => void requestPermission()}>
|
||||
Allow camera access
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#000000' },
|
||||
overlay: { flex: 1, justifyContent: 'flex-start' },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: Platform.OS === 'android' ? spacing.xl : spacing.sm,
|
||||
paddingBottom: spacing.md,
|
||||
},
|
||||
closeButton: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
|
||||
title: { ...typography.h3, color: '#ffffff' },
|
||||
frameWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.lg },
|
||||
frame: {
|
||||
width: 240,
|
||||
height: 240,
|
||||
borderWidth: 3,
|
||||
borderColor: '#ffffff',
|
||||
borderRadius: radius.lg,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
hint: {
|
||||
...typography.caption,
|
||||
color: '#ffffff',
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: spacing.xxl,
|
||||
},
|
||||
permissionWrap: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.lg,
|
||||
paddingHorizontal: spacing.xxl,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
permissionText: { ...typography.body, color: c.text, textAlign: 'center' },
|
||||
});
|
||||
}
|
||||
@@ -79,7 +79,8 @@ function makeStyles(c: ThemePalette) {
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: c.card,
|
||||
borderRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
borderBottomRightRadius: radius.lg,
|
||||
marginBottom: spacing.sm,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
|
||||
@@ -8,8 +8,9 @@ import type { ContactCard, Email, CalendarEvent } from '../../api/types';
|
||||
import type { RootStackParamList } from '../../navigation/types';
|
||||
import { getEmails, queryEmailsByFilter } from '../../api/email';
|
||||
import { useCalendarStore } from '../../stores/calendar-store';
|
||||
import { getEventColor } from '../../lib/calendar-utils';
|
||||
import SenderAvatar from '../SenderAvatar';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { radius, spacing, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
const EMAIL_LIMIT = 5;
|
||||
@@ -87,6 +88,7 @@ export function ContactActivity({ contact }: Props) {
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const navigation = useNavigation<Nav>();
|
||||
const calendarEvents = useCalendarStore((s) => s.events);
|
||||
const calendars = useCalendarStore((s) => s.calendars);
|
||||
|
||||
const addresses = React.useMemo(() => getContactEmails(contact), [contact]);
|
||||
const addressKey = addresses.join(',');
|
||||
@@ -145,74 +147,85 @@ export function ContactActivity({ contact }: Props) {
|
||||
<View style={styles.wrap}>
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<MailIcon size={14} color={c.primary} />
|
||||
<Text style={styles.sectionTitle}>Recent emails</Text>
|
||||
</View>
|
||||
{emailsLoading ? (
|
||||
<View style={styles.loading}>
|
||||
<ActivityIndicator size="small" color={c.primary} />
|
||||
<View style={styles.sectionIcon}>
|
||||
<MailIcon size={16} color={c.textMuted} />
|
||||
</View>
|
||||
) : emailsError ? (
|
||||
<Text style={styles.emptyText}>Couldn't load emails.</Text>
|
||||
) : !emails || emails.length === 0 ? (
|
||||
<Text style={styles.emptyText}>No emails with this contact.</Text>
|
||||
) : (
|
||||
emails.map((email) => {
|
||||
const sender = email.from?.[0];
|
||||
const senderName = sender?.name || sender?.email || 'Unknown';
|
||||
return (
|
||||
<Pressable
|
||||
key={email.id}
|
||||
onPress={() =>
|
||||
navigation.navigate('EmailThread', {
|
||||
emailId: email.id,
|
||||
threadId: email.threadId,
|
||||
subject: email.subject ?? undefined,
|
||||
})
|
||||
}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
>
|
||||
<SenderAvatar name={senderName} email={sender?.email} size={32} />
|
||||
<View style={styles.rowContent}>
|
||||
<View style={styles.rowTop}>
|
||||
<Text style={styles.rowSender} numberOfLines={1}>{senderName}</Text>
|
||||
<Text style={styles.rowDate}>{formatRelativeDate(email.receivedAt)}</Text>
|
||||
<Text style={styles.sectionLabel}>Recent emails</Text>
|
||||
</View>
|
||||
<View style={styles.sectionBody}>
|
||||
{emailsLoading ? (
|
||||
<View style={styles.loading}>
|
||||
<ActivityIndicator size="small" color={c.primary} />
|
||||
</View>
|
||||
) : emailsError ? (
|
||||
<Text style={styles.emptyText}>Couldn't load emails.</Text>
|
||||
) : !emails || emails.length === 0 ? (
|
||||
<Text style={styles.emptyText}>No emails with this contact.</Text>
|
||||
) : (
|
||||
emails.map((email) => {
|
||||
const sender = email.from?.[0];
|
||||
const senderName = sender?.name || sender?.email || 'Unknown';
|
||||
return (
|
||||
<Pressable
|
||||
key={email.id}
|
||||
onPress={() =>
|
||||
navigation.navigate('EmailThread', {
|
||||
emailId: email.id,
|
||||
threadId: email.threadId,
|
||||
subject: email.subject ?? undefined,
|
||||
})
|
||||
}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
>
|
||||
<SenderAvatar name={senderName} email={sender?.email} size={32} />
|
||||
<View style={styles.rowContent}>
|
||||
<View style={styles.rowTop}>
|
||||
<Text style={styles.rowSender} numberOfLines={1}>{senderName}</Text>
|
||||
<Text style={styles.rowDate}>{formatRelativeDate(email.receivedAt)}</Text>
|
||||
</View>
|
||||
<Text style={styles.rowSubject} numberOfLines={1}>
|
||||
{email.subject || '(no subject)'}
|
||||
</Text>
|
||||
{email.preview ? (
|
||||
<Text style={styles.rowPreview} numberOfLines={1}>{email.preview}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={styles.rowSubject} numberOfLines={1}>
|
||||
{email.subject || '(no subject)'}
|
||||
</Text>
|
||||
{email.preview ? (
|
||||
<Text style={styles.rowPreview} numberOfLines={1}>{email.preview}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<CalendarDays size={14} color={c.primary} />
|
||||
<Text style={styles.sectionTitle}>Upcoming events</Text>
|
||||
<View style={styles.sectionIcon}>
|
||||
<CalendarDays size={16} color={c.textMuted} />
|
||||
</View>
|
||||
<Text style={styles.sectionLabel}>Upcoming events</Text>
|
||||
</View>
|
||||
{upcomingEvents.length === 0 ? (
|
||||
<Text style={styles.emptyText}>No upcoming events.</Text>
|
||||
) : (
|
||||
upcomingEvents.map((event) => (
|
||||
<View key={event.id} style={styles.eventRow}>
|
||||
<Text style={styles.eventTime}>{formatEventTime(event)}</Text>
|
||||
<View style={styles.eventInfo}>
|
||||
<Text style={styles.eventTitle} numberOfLines={1}>
|
||||
{event.title || '(no title)'}
|
||||
</Text>
|
||||
<Text style={styles.eventDate}>
|
||||
{formatRelativeDate(event.utcStart || event.start)}
|
||||
</Text>
|
||||
<View style={styles.sectionBody}>
|
||||
{upcomingEvents.length === 0 ? (
|
||||
<Text style={styles.emptyText}>No upcoming events.</Text>
|
||||
) : (
|
||||
upcomingEvents.map((event) => (
|
||||
<View key={event.id} style={styles.eventRow}>
|
||||
<View
|
||||
style={[styles.eventDot, { backgroundColor: getEventColor(event, calendars) }]}
|
||||
/>
|
||||
<Text style={styles.eventTime}>{formatEventTime(event)}</Text>
|
||||
<View style={styles.eventInfo}>
|
||||
<Text style={styles.eventTitle} numberOfLines={1}>
|
||||
{event.title || '(no title)'}
|
||||
</Text>
|
||||
<Text style={styles.eventDate}>
|
||||
{formatRelativeDate(event.utcStart || event.start)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -220,18 +233,18 @@ export function ContactActivity({ contact }: Props) {
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
wrap: { gap: spacing.md, paddingHorizontal: spacing.lg, marginBottom: spacing.lg },
|
||||
section: {
|
||||
backgroundColor: c.card,
|
||||
borderRadius: radius.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
gap: spacing.sm,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: c.primary,
|
||||
wrap: { gap: spacing.lg },
|
||||
section: { gap: spacing.sm },
|
||||
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
sectionIcon: { width: 16 },
|
||||
sectionLabel: {
|
||||
...typography.bodyMedium,
|
||||
color: c.textSecondary,
|
||||
textTransform: 'uppercase',
|
||||
fontSize: 11,
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs },
|
||||
sectionTitle: { ...typography.bodySemibold, color: c.text },
|
||||
sectionBody: { paddingLeft: spacing.lg + spacing.xs, gap: spacing.xs },
|
||||
loading: { paddingVertical: spacing.sm, alignItems: 'flex-start' },
|
||||
emptyText: { ...typography.caption, color: c.textMuted },
|
||||
|
||||
@@ -251,10 +264,16 @@ function makeStyles(c: ThemePalette) {
|
||||
rowPreview: { ...typography.caption, color: c.textMuted },
|
||||
|
||||
eventRow: { flexDirection: 'row', gap: spacing.sm, alignItems: 'baseline', paddingVertical: 4 },
|
||||
eventDot: {
|
||||
width: componentSizes.eventDot,
|
||||
height: componentSizes.eventDot,
|
||||
borderRadius: componentSizes.eventDot / 2,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
eventTime: {
|
||||
...typography.caption,
|
||||
color: c.textMuted,
|
||||
width: 64,
|
||||
width: 52,
|
||||
},
|
||||
eventInfo: { flex: 1, minWidth: 0 },
|
||||
eventTitle: { ...typography.body, color: c.text },
|
||||
|
||||
@@ -23,7 +23,12 @@ export default function TagAssignSheet({
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const keywords = useContactsStore(selectKeywordsUsed);
|
||||
// Subscribe to the stable `contacts` array and derive the keyword list in a
|
||||
// memo. Passing `selectKeywordsUsed` straight to the store would return a
|
||||
// fresh array on every call, which trips useSyncExternalStore's snapshot
|
||||
// check and spins into an infinite render loop.
|
||||
const contacts = useContactsStore((s) => s.contacts);
|
||||
const keywords = React.useMemo(() => selectKeywordsUsed(contacts), [contacts]);
|
||||
|
||||
const [input, setInput] = React.useState('');
|
||||
|
||||
|
||||
@@ -22,7 +22,14 @@ export function ContactsSettings() {
|
||||
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
|
||||
const setGroupByLetter = useSettingsStore((s) => s.setGroupContactsByLetter);
|
||||
const contacts = useContactsStore((s) => s.contacts);
|
||||
const books = useContactsStore(selectAddressBooksWithCount);
|
||||
const addressBooks = useContactsStore((s) => s.addressBooks);
|
||||
// Derive in a memo from stable store fields. Subscribing with
|
||||
// `selectAddressBooksWithCount` directly returns a new array each call and
|
||||
// sends useSyncExternalStore into an infinite render loop.
|
||||
const books = React.useMemo(
|
||||
() => selectAddressBooksWithCount(addressBooks, contacts),
|
||||
[addressBooks, contacts],
|
||||
);
|
||||
const fetchAddressBooks = useContactsStore((s) => s.fetchAddressBooks);
|
||||
const fetchContacts = useContactsStore((s) => s.fetchContacts);
|
||||
const createAddressBook = useContactsStore((s) => s.createAddressBook);
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { ArrowRight, ArrowLeft } from 'lucide-react-native';
|
||||
import { SettingsSection, RadioGroup } from './settings-section';
|
||||
import { ArrowRight, ArrowLeft, MoveVertical } from 'lucide-react-native';
|
||||
import { SettingsSection, RadioGroup, Select } from './settings-section';
|
||||
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore, type SwipeAction, type SwipeMode } from '../../stores/settings-store';
|
||||
import {
|
||||
useSettingsStore,
|
||||
normalizeBottomQuickActions,
|
||||
REPLY_QUICK_ACTIONS,
|
||||
ALL_QUICK_ACTIONS,
|
||||
type SwipeAction,
|
||||
type SwipeMode,
|
||||
type QuickAction,
|
||||
} from '../../stores/settings-store';
|
||||
|
||||
const SWIPE_OPTIONS: { value: SwipeAction; label: string }[] = [
|
||||
{ value: 'none', label: 'None' },
|
||||
@@ -22,6 +30,24 @@ const SWIPE_MODE_OPTIONS: { value: SwipeMode; label: string }[] = [
|
||||
{ value: 'reveal', label: 'Reveal (swipe, then tap)' },
|
||||
];
|
||||
|
||||
const QUICK_ACTION_LABELS: Record<QuickAction, string> = {
|
||||
reply: 'Reply',
|
||||
replyAll: 'Reply All',
|
||||
forward: 'Forward',
|
||||
delete: 'Delete',
|
||||
archive: 'Archive',
|
||||
markUnread: 'Mark Read/Unread',
|
||||
star: 'Star',
|
||||
move: 'Move to folder',
|
||||
spam: 'Spam',
|
||||
tag: 'Tag',
|
||||
};
|
||||
|
||||
const QUICK_ACTION_OPTIONS = ALL_QUICK_ACTIONS.map((value) => ({
|
||||
value,
|
||||
label: QUICK_ACTION_LABELS[value],
|
||||
}));
|
||||
|
||||
export function LayoutSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
@@ -31,11 +57,27 @@ export function LayoutSettings() {
|
||||
const setSwipeRightAction = useSettingsStore((s) => s.setSwipeRightAction);
|
||||
const swipeMode = useSettingsStore((s) => s.swipeMode);
|
||||
const setSwipeMode = useSettingsStore((s) => s.setSwipeMode);
|
||||
const bottomQuickActionsRaw = useSettingsStore((s) => s.bottomQuickActions);
|
||||
const update = useSettingsStore((s) => s.updateSetting);
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
|
||||
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
|
||||
|
||||
const bottomActions = normalizeBottomQuickActions(bottomQuickActionsRaw);
|
||||
// Pick an action for a bar slot. If the chosen action already occupies
|
||||
// another slot, swap the two so the three slots stay unique.
|
||||
const setQuickActionSlot = (index: number, value: QuickAction) => {
|
||||
const next = [...bottomActions];
|
||||
const existing = next.indexOf(value);
|
||||
if (existing !== -1 && existing !== index) {
|
||||
next[existing] = next[index];
|
||||
}
|
||||
next[index] = value;
|
||||
update('bottomQuickActions', next);
|
||||
};
|
||||
const relocated = REPLY_QUICK_ACTIONS.filter((a) => !bottomActions.includes(a));
|
||||
|
||||
return (
|
||||
<SettingsSection title="Layout" description="Tune the email list interactions for mobile.">
|
||||
<View style={{ gap: spacing.sm }}>
|
||||
@@ -81,6 +123,34 @@ export function LayoutSettings() {
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={{ gap: spacing.sm, marginTop: spacing.lg }}>
|
||||
<View style={styles.row}>
|
||||
<MoveVertical size={14} color={c.mutedForeground} />
|
||||
<Text style={styles.rowLabel}>Reader quick actions</Text>
|
||||
</View>
|
||||
<Text style={styles.rowDescription}>
|
||||
The three buttons shown between Prev/Next at the bottom of an open
|
||||
email. Defaults to Reply, Reply All and Forward.
|
||||
</Text>
|
||||
{bottomActions.map((action, index) => (
|
||||
<View key={index} style={styles.slotRow}>
|
||||
<Text style={styles.slotLabel}>Slot {index + 1}</Text>
|
||||
<Select
|
||||
value={action}
|
||||
onChange={(v) => setQuickActionSlot(index, v as QuickAction)}
|
||||
options={QUICK_ACTION_OPTIONS}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
{relocated.length > 0 && (
|
||||
<Text style={styles.rowDescription}>
|
||||
{relocated.map((a) => QUICK_ACTION_LABELS[a]).join(', ')}
|
||||
{relocated.length === 1 ? ' is' : ' are'} moved to the top toolbar so
|
||||
you can still use {relocated.length === 1 ? 'it' : 'them'}.
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -90,5 +160,13 @@ function makeStyles(c: ThemePalette) {
|
||||
row: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||
rowLabel: { ...typography.bodyMedium, color: c.text },
|
||||
rowDescription: { ...typography.caption, color: c.mutedForeground },
|
||||
slotRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.md,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
slotLabel: { ...typography.body, color: c.text },
|
||||
});
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -123,6 +123,90 @@ export async function runWebmailHandoff(webmailUrl: string): Promise<HandoffResu
|
||||
throw new HandoffError(`Unknown sign-in flow: ${flow ?? 'missing'}`);
|
||||
}
|
||||
|
||||
// QR login payloads. A QR scanned on the login screen either bootstraps the
|
||||
// server URL for the normal webmail handoff (`connect`), or carries a one-time
|
||||
// cross-device pairing code minted by an already-signed-in webmail (`pair`).
|
||||
// The payload never contains credentials — the `pair` code is redeemed for
|
||||
// tokens over the network, once.
|
||||
export type QrLoginPayload =
|
||||
| { kind: 'connect'; webmailUrl: string }
|
||||
| { kind: 'pair'; webmailUrl: string; code: string };
|
||||
|
||||
export function parseQrLoginPayload(raw: string): QrLoginPayload | null {
|
||||
const trimmed = raw.trim();
|
||||
|
||||
// Custom scheme: bulwarkmail://connect?server=... | bulwarkmail://pair?server=...&code=...
|
||||
const match = /^bulwarkmail:\/\/(connect|pair)\?(.*)$/i.exec(trimmed);
|
||||
if (match) {
|
||||
const kind = match[1].toLowerCase();
|
||||
const params = new URLSearchParams(match[2]);
|
||||
const server = params.get('server');
|
||||
if (!server || !/^https?:\/\//i.test(server)) return null;
|
||||
if (kind === 'pair') {
|
||||
const code = params.get('code');
|
||||
if (!code) return null;
|
||||
return { kind: 'pair', webmailUrl: server, code };
|
||||
}
|
||||
return { kind: 'connect', webmailUrl: server };
|
||||
}
|
||||
|
||||
// A bare https URL is treated as a server-bootstrap target so admins can
|
||||
// hand out a plain webmail URL QR without the custom-scheme wrapper.
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
return { kind: 'connect', webmailUrl: trimmed };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cross-device pairing redemption. Posts the scanned code to the webmail that
|
||||
// minted it and maps the token bundle into the same shape the in-browser OAuth
|
||||
// handoff produces, so the caller can reuse the existing connectWithOAuth path.
|
||||
export async function redeemPairingCode(webmailUrl: string, code: string): Promise<HandoffResult> {
|
||||
const base = webmailUrl.replace(/\/+$/, '');
|
||||
let response: Response;
|
||||
try {
|
||||
response = await secureFetch(`${base}/api/auth/pair/redeem`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ pairing_code: code }),
|
||||
});
|
||||
} catch {
|
||||
throw new HandoffError('Could not reach the server to complete pairing');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// The code was unknown, already used, or expired (server returns 400) —
|
||||
// or something else went wrong. Either way the user needs a fresh QR.
|
||||
throw new HandoffError('Pairing code is invalid or has expired');
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
server_url?: string;
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
token_endpoint?: string;
|
||||
client_id?: string;
|
||||
};
|
||||
|
||||
if (!data.server_url || !data.access_token || !data.token_endpoint || !data.client_id) {
|
||||
throw new HandoffError('Pairing response missing token material');
|
||||
}
|
||||
|
||||
return {
|
||||
flow: 'oauth',
|
||||
serverUrl: data.server_url,
|
||||
tokens: {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token ?? undefined,
|
||||
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
|
||||
tokenEndpoint: data.token_endpoint,
|
||||
clientId: data.client_id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// OAuth refresh — exchanges the refresh token at the original token endpoint
|
||||
// for a new access token. Returns the updated bundle so the caller can
|
||||
// persist it.
|
||||
|
||||
@@ -593,7 +593,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
<Pressable onPress={() => setDrawerOpen(true)} style={styles.headerButton}>
|
||||
<Menu size={20} color={c.textMuted} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle}>{currentMailbox?.name ?? 'Inbox'}</Text>
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>{currentMailbox?.name ?? 'Inbox'}</Text>
|
||||
<View style={{ flex: 1 }} />
|
||||
<Pressable
|
||||
onPress={() => { void handleImport(); }}
|
||||
@@ -1075,7 +1075,7 @@ function makeStyles(c: ThemePalette) {
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
headerTitle: { ...typography.h3, color: c.text, flex: 1 }, // text-lg font-semibold
|
||||
headerTitle: { ...typography.h3, color: c.text, flexShrink: 1 }, // text-lg font-semibold
|
||||
headerLogo: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
|
||||
+542
-212
@@ -1,8 +1,9 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, ActivityIndicator,
|
||||
Modal, useWindowDimensions, Animated, Easing, Alert,
|
||||
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 {
|
||||
@@ -18,7 +19,12 @@ import { CalendarInvitationBanner } from '../components/email/CalendarInvitation
|
||||
import SenderAvatar from '../components/SenderAvatar';
|
||||
import { MoveSheet } from '../components/MoveSheet';
|
||||
import { useEmailStore } from '../stores/email-store';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import {
|
||||
useSettingsStore,
|
||||
normalizeBottomQuickActions,
|
||||
REPLY_QUICK_ACTIONS,
|
||||
type QuickAction,
|
||||
} from '../stores/settings-store';
|
||||
import { setEmailKeywords } from '../api/email';
|
||||
import { shareEmailEml, shareAttachment, downloadAttachment } from '../lib/email-export';
|
||||
import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store';
|
||||
@@ -30,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',
|
||||
});
|
||||
}
|
||||
@@ -54,7 +65,11 @@ function plainTextBody(email: Email): string {
|
||||
export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const { emailId, subject: subjectParam, jmapAccountId } = route.params;
|
||||
const { jmapAccountId } = route.params;
|
||||
// The displayed email is tracked in local state (not a route param) so that
|
||||
// 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 insets = useSafeAreaInsets();
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
const getEmailDetail = useEmailStore((s) => s.getEmailDetail);
|
||||
@@ -66,40 +81,115 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
const currentMailboxId = useEmailStore((s) => s.currentMailboxId);
|
||||
const emails = useEmailStore((s) => s.emails);
|
||||
|
||||
const currentIndex = emails.findIndex((e) => e.id === emailId);
|
||||
const currentIndex = emails.findIndex((e) => e.id === activeEmailId);
|
||||
const prevEmail = currentIndex > 0 ? emails[currentIndex - 1] : null;
|
||||
const nextEmail = currentIndex >= 0 && currentIndex < emails.length - 1 ? emails[currentIndex + 1] : null;
|
||||
const goToEmail = (target: { id: string; threadId: string; subject?: string }) => {
|
||||
navigation.replace('EmailThread', {
|
||||
emailId: target.id,
|
||||
threadId: target.threadId,
|
||||
subject: target.subject ?? '',
|
||||
});
|
||||
};
|
||||
|
||||
const [email, setEmail] = React.useState<Email | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
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. 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],
|
||||
);
|
||||
|
||||
// --- 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)),
|
||||
);
|
||||
|
||||
// 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]);
|
||||
|
||||
// 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]);
|
||||
|
||||
// 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]);
|
||||
|
||||
// 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]);
|
||||
|
||||
// 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);
|
||||
const markAsReadDelay = useSettingsStore((s) => s.markAsReadDelay);
|
||||
const hideInlineImageAttachments = useSettingsStore((s) => s.hideInlineImageAttachments);
|
||||
const bottomQuickActionsRaw = useSettingsStore((s) => s.bottomQuickActions);
|
||||
const bottomActions = React.useMemo(
|
||||
() => normalizeBottomQuickActions(bottomQuickActionsRaw),
|
||||
[bottomQuickActionsRaw],
|
||||
);
|
||||
// Any reply-family action the user pulled out of the bottom bar is surfaced
|
||||
// in the top toolbar so it stays reachable.
|
||||
const relocatedActions = React.useMemo(
|
||||
() => REPLY_QUICK_ACTIONS.filter((a) => !bottomActions.includes(a)),
|
||||
[bottomActions],
|
||||
);
|
||||
|
||||
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));
|
||||
@@ -107,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);
|
||||
@@ -175,35 +207,34 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
let readTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
setLoading(true);
|
||||
// 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(emailId, jmapAccountId);
|
||||
if (cancelled) return;
|
||||
setEmail(fetched);
|
||||
if (fetched && !fetched.keywords?.$seen) {
|
||||
if (markAsReadDelay > 0) {
|
||||
readTimer = setTimeout(() => {
|
||||
if (!cancelled) void markRead(emailId, jmapAccountId);
|
||||
}, markAsReadDelay);
|
||||
} else {
|
||||
void markRead(emailId, 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) {
|
||||
if (!cancelled) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load email');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (readTimer) clearTimeout(readTimer);
|
||||
};
|
||||
}, [emailId, jmapAccountId, getEmailDetail, markRead, markAsReadDelay]);
|
||||
}, [activeEmailId, ensureDetail, markRead, markAsReadDelay, jmapAccountId, detailCache]);
|
||||
|
||||
const starred = !!email?.keywords?.$flagged;
|
||||
const unread = !!email && !email.keywords?.$seen;
|
||||
@@ -222,8 +253,13 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
);
|
||||
const isInJunk = !!(junkMailbox && email?.mailboxIds?.[junkMailbox.id]);
|
||||
|
||||
const updateLocalKeywords = (next: Record<string, boolean>) => {
|
||||
setEmail((prev) => (prev ? { ...prev, keywords: next } : prev));
|
||||
// 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) => {
|
||||
@@ -231,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);
|
||||
}
|
||||
};
|
||||
@@ -312,8 +352,78 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
});
|
||||
};
|
||||
|
||||
const subject = email?.subject ?? subjectParam ?? '(no subject)';
|
||||
const from = email?.from?.[0];
|
||||
// Registry of every action that can live in the bottom quick-action bar (or
|
||||
// be relocated to the top toolbar). `icon` is a factory so the same action
|
||||
// can render at the toolbar (18px) and bottom-bar (20px) sizes.
|
||||
const quickActionRegistry: Record<
|
||||
QuickAction,
|
||||
{ label: string; icon: (size: number, color: string) => React.ReactNode; onPress: () => void; available: boolean }
|
||||
> = {
|
||||
reply: {
|
||||
label: 'Reply',
|
||||
icon: (s, col) => <Reply size={s} color={col} />,
|
||||
onPress: () => navigateCompose('reply'),
|
||||
available: true,
|
||||
},
|
||||
replyAll: {
|
||||
label: 'Reply All',
|
||||
icon: (s, col) => <ReplyAll size={s} color={col} />,
|
||||
onPress: () => navigateCompose('replyAll'),
|
||||
available: true,
|
||||
},
|
||||
forward: {
|
||||
label: 'Forward',
|
||||
icon: (s, col) => <Forward size={s} color={col} />,
|
||||
onPress: () => navigateCompose('forward'),
|
||||
available: true,
|
||||
},
|
||||
delete: {
|
||||
label: 'Delete',
|
||||
icon: (s, col) => <Trash2 size={s} color={col} />,
|
||||
onPress: onDelete,
|
||||
available: true,
|
||||
},
|
||||
archive: {
|
||||
label: 'Archive',
|
||||
icon: (s, col) => <Archive size={s} color={col} />,
|
||||
onPress: onArchive,
|
||||
available: !!archiveMailbox && currentMailboxId !== archiveMailbox?.id,
|
||||
},
|
||||
markUnread: {
|
||||
label: unread ? 'Read' : 'Unread',
|
||||
icon: (s, col) => (unread ? <MailOpen size={s} color={col} /> : <Mail size={s} color={col} />),
|
||||
onPress: onToggleUnread,
|
||||
available: true,
|
||||
},
|
||||
star: {
|
||||
label: starred ? 'Unstar' : 'Star',
|
||||
icon: (s, col) => (
|
||||
<Star size={s} color={starred ? c.starred : col} fill={starred ? c.starred : 'transparent'} />
|
||||
),
|
||||
onPress: onToggleStar,
|
||||
available: true,
|
||||
},
|
||||
move: {
|
||||
label: 'Move',
|
||||
icon: (s, col) => <FolderInput size={s} color={col} />,
|
||||
onPress: () => setMoveMenuOpen(true),
|
||||
available: mailboxes.length > 0,
|
||||
},
|
||||
spam: {
|
||||
label: isInJunk ? 'Not spam' : 'Spam',
|
||||
icon: (s, col) =>
|
||||
isInJunk ? <ShieldCheck size={s} color={c.success} /> : <ShieldAlert size={s} color={col} />,
|
||||
onPress: onToggleSpam,
|
||||
available: !!junkMailbox || isInJunk,
|
||||
},
|
||||
tag: {
|
||||
label: 'Tag',
|
||||
icon: (s, col) => <Tag size={s} color={col} />,
|
||||
onPress: () => setTagMenuOpen(true),
|
||||
available: keywordDefs.length > 0,
|
||||
},
|
||||
};
|
||||
|
||||
const bottomBarHeight = 60 + Math.max(insets.bottom, 4);
|
||||
|
||||
// Show extras in priority order. Each toolbar button is ~64px wide (icon+label+padding);
|
||||
@@ -329,6 +439,17 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
<ArrowLeft size={22} color={c.text} />
|
||||
</Pressable>
|
||||
<View style={styles.toolbarActions}>
|
||||
{relocatedActions.map((id) => {
|
||||
const def = quickActionRegistry[id];
|
||||
return (
|
||||
<ToolbarButton
|
||||
key={id}
|
||||
icon={def.icon(18, c.textSecondary)}
|
||||
label={def.label}
|
||||
onPress={def.onPress}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<ToolbarButton
|
||||
icon={<Trash2 size={18} color={c.textSecondary} />}
|
||||
label="Delete"
|
||||
@@ -373,110 +494,82 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{loading ? (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator color={c.primary} />
|
||||
</View>
|
||||
) : error ? (
|
||||
{error && !email ? (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
) : email ? (
|
||||
) : (
|
||||
<>
|
||||
<ScrollView
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 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 ? () => goToEmail(prevEmail) : undefined}
|
||||
onPress={prevEmail ? () => goToIndex(currentIndex - 1) : undefined}
|
||||
disabled={!prevEmail}
|
||||
/>
|
||||
<BottomBarButton
|
||||
icon={<Reply size={20} color={c.textSecondary} />}
|
||||
label="Reply"
|
||||
onPress={() => navigateCompose('reply')}
|
||||
/>
|
||||
<BottomBarButton
|
||||
icon={<ReplyAll size={20} color={c.textSecondary} />}
|
||||
label="Reply All"
|
||||
onPress={() => navigateCompose('replyAll')}
|
||||
/>
|
||||
<BottomBarButton
|
||||
icon={<Forward size={20} color={c.textSecondary} />}
|
||||
label="Forward"
|
||||
onPress={() => navigateCompose('forward')}
|
||||
/>
|
||||
{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="Next"
|
||||
onPress={nextEmail ? () => goToEmail(nextEmail) : undefined}
|
||||
onPress={nextEmail ? () => goToIndex(currentIndex + 1) : undefined}
|
||||
disabled={!nextEmail}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
<MoreMenuSheet
|
||||
visible={moreMenuOpen}
|
||||
@@ -535,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;
|
||||
@@ -856,6 +1172,8 @@ function makeStyles(c: ThemePalette) {
|
||||
padding: spacing.lg,
|
||||
},
|
||||
errorText: { ...typography.body, color: c.error, textAlign: 'center' },
|
||||
// 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
|
||||
@@ -871,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,
|
||||
},
|
||||
@@ -888,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: {
|
||||
@@ -932,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: {
|
||||
@@ -990,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',
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Pressable, StyleSheet, KeyboardAvoidingView, Platform, Image } from 'react-native';
|
||||
import { View, Text, Pressable, StyleSheet, KeyboardAvoidingView, Platform, Image, Alert } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import Constants from 'expo-constants';
|
||||
import { Eye, EyeOff, X } from 'lucide-react-native';
|
||||
import { Eye, EyeOff, X, QrCode } from 'lucide-react-native';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { Button, Input } from '../components';
|
||||
import { QrScanModal } from '../components/QrScanModal';
|
||||
import { parseQrLoginPayload } from '../lib/oauth';
|
||||
import { useAuthStore } from '../stores/auth-store';
|
||||
|
||||
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
|
||||
@@ -21,6 +23,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const login = useAuthStore((state) => state.login);
|
||||
const loginViaWebmail = useAuthStore((state) => state.loginViaWebmail);
|
||||
const loginViaPairing = useAuthStore((state) => state.loginViaPairing);
|
||||
const isLoading = useAuthStore((state) => state.isLoading);
|
||||
const error = useAuthStore((state) => state.error);
|
||||
const clearError = useAuthStore((state) => state.clearError);
|
||||
@@ -29,6 +32,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
|
||||
const [email, setEmail] = React.useState('');
|
||||
const [password, setPassword] = React.useState('');
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
const [scannerVisible, setScannerVisible] = React.useState(false);
|
||||
|
||||
const canSubmit = Boolean(serverUrl.trim() && email.trim() && password);
|
||||
const canHandoff = Boolean(serverUrl.trim());
|
||||
@@ -65,6 +69,34 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
|
||||
}
|
||||
};
|
||||
|
||||
const handleScanned = async (data: string) => {
|
||||
setScannerVisible(false);
|
||||
const payload = parseQrLoginPayload(data);
|
||||
if (!payload) {
|
||||
Alert.alert('Unrecognized QR code', "That QR code isn't a Bulwark Mail sign-in code.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const before = useAuthStore.getState().isAuthenticated;
|
||||
if (payload.kind === 'connect') {
|
||||
// Server-bootstrap QR: fill the field and run the normal browser
|
||||
// handoff so the user still authenticates in the webmail.
|
||||
setServerUrl(payload.webmailUrl);
|
||||
await loginViaWebmail(payload.webmailUrl, { addAccount: isAddMode });
|
||||
} else {
|
||||
// Cross-device pairing QR: redeem the one-time code for tokens, no
|
||||
// browser round-trip needed.
|
||||
await loginViaPairing(payload.webmailUrl, payload.code, { addAccount: isAddMode });
|
||||
}
|
||||
const after = useAuthStore.getState().isAuthenticated;
|
||||
if ((after && !before) || (isAddMode && after)) {
|
||||
onLogin?.();
|
||||
}
|
||||
} catch {
|
||||
// Store state already contains the user-facing error.
|
||||
}
|
||||
};
|
||||
|
||||
const updateField = React.useCallback(
|
||||
(setter: React.Dispatch<React.SetStateAction<string>>) => (value: string) => {
|
||||
if (useAuthStore.getState().error) {
|
||||
@@ -182,6 +214,15 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
|
||||
>
|
||||
Sign in via webmail
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="md"
|
||||
onPress={() => setScannerVisible(true)}
|
||||
disabled={isLoading}
|
||||
icon={<QrCode size={18} color={c.text} />}
|
||||
>
|
||||
Scan QR code
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -192,6 +233,14 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
<QrScanModal
|
||||
visible={scannerVisible}
|
||||
onClose={() => setScannerVisible(false)}
|
||||
onScanned={(data) => {
|
||||
void handleScanned(data);
|
||||
}}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
+72
-26
@@ -7,7 +7,7 @@ import { useContactsStore } from './contacts-store';
|
||||
import { useCalendarStore } from './calendar-store';
|
||||
import { useFilterStore } from './filter-store';
|
||||
import { generateAccountId } from '../lib/account-utils';
|
||||
import { runWebmailHandoff, HandoffCancelledError } from '../lib/oauth';
|
||||
import { runWebmailHandoff, redeemPairingCode, HandoffCancelledError, type HandoffResult } from '../lib/oauth';
|
||||
import {
|
||||
teardownPushNotifications,
|
||||
teardownPushNotificationsForAccount,
|
||||
@@ -41,6 +41,7 @@ export interface AuthState {
|
||||
|
||||
login: (serverUrl: string, username: string, password: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
||||
loginViaWebmail: (webmailUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
||||
loginViaPairing: (webmailUrl: string, code: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
logoutAll: () => Promise<void>;
|
||||
switchAccount: (accountId: string) => Promise<void>;
|
||||
@@ -97,6 +98,43 @@ function refetchFeatureStores(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Shared tail of the OAuth sign-in flows (browser handoff and cross-device QR
|
||||
// pairing both end here). Bootstraps a JMAP session from the token bundle,
|
||||
// registers the account, and flips the store to connected. Throws on failure
|
||||
// so the caller can surface a flow-specific error.
|
||||
async function completeOAuthHandoff(
|
||||
set: (partial: Partial<AuthState>) => void,
|
||||
get: () => AuthState,
|
||||
result: Extract<HandoffResult, { flow: 'oauth' }>,
|
||||
opts?: { addAccount?: boolean },
|
||||
): Promise<void> {
|
||||
if (opts?.addAccount && get().isAuthenticated) {
|
||||
jmapClient.reset();
|
||||
useContactsStore.getState().reset();
|
||||
useCalendarStore.getState().reset();
|
||||
}
|
||||
|
||||
const { session, username, accountId } = await jmapClient.connectWithOAuth(
|
||||
result.serverUrl,
|
||||
result.tokens,
|
||||
);
|
||||
|
||||
const accountStore = useAccountStore.getState();
|
||||
accountStore.addAccount({
|
||||
serverUrl: result.serverUrl.replace(/\/+$/, ''),
|
||||
username,
|
||||
displayName: username,
|
||||
email: username,
|
||||
lastLoginAt: Date.now(),
|
||||
isConnected: true,
|
||||
hasError: false,
|
||||
});
|
||||
accountStore.setActiveAccount(accountId);
|
||||
useEmailStore.getState().setActiveAccount(accountId);
|
||||
|
||||
applyConnectedState(set, session, result.serverUrl.replace(/\/+$/, ''), username, accountId);
|
||||
}
|
||||
|
||||
function applyConnectedState(
|
||||
set: (partial: Partial<AuthState>) => void,
|
||||
session: JMAPSession,
|
||||
@@ -200,31 +238,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
// token bundle. Bootstrap the JMAP session with Bearer auth and let
|
||||
// ensure/forceRefreshToken keep it alive going forward.
|
||||
try {
|
||||
if (opts?.addAccount && get().isAuthenticated) {
|
||||
jmapClient.reset();
|
||||
useContactsStore.getState().reset();
|
||||
useCalendarStore.getState().reset();
|
||||
}
|
||||
|
||||
const { session, username, accountId } = await jmapClient.connectWithOAuth(
|
||||
result.serverUrl,
|
||||
result.tokens,
|
||||
);
|
||||
|
||||
const accountStore = useAccountStore.getState();
|
||||
accountStore.addAccount({
|
||||
serverUrl: result.serverUrl.replace(/\/+$/, ''),
|
||||
username,
|
||||
displayName: username,
|
||||
email: username,
|
||||
lastLoginAt: Date.now(),
|
||||
isConnected: true,
|
||||
hasError: false,
|
||||
});
|
||||
accountStore.setActiveAccount(accountId);
|
||||
useEmailStore.getState().setActiveAccount(accountId);
|
||||
|
||||
applyConnectedState(set, session, result.serverUrl.replace(/\/+$/, ''), username, accountId);
|
||||
await completeOAuthHandoff(set, get, result, opts);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof AuthenticationError
|
||||
@@ -237,6 +251,38 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
loginViaPairing: async (webmailUrl, code, opts) => {
|
||||
set({ isLoading: true, error: null });
|
||||
let result;
|
||||
try {
|
||||
result = await redeemPairingCode(webmailUrl, code);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Pairing failed';
|
||||
set({ isLoading: false, error: message });
|
||||
throw err;
|
||||
}
|
||||
|
||||
// redeemPairingCode only ever yields the OAuth flow, but guard anyway so a
|
||||
// future server change can't silently mis-route credentials here.
|
||||
if (result.flow !== 'oauth') {
|
||||
set({ isLoading: false, error: 'Unexpected pairing response' });
|
||||
throw new Error('Unexpected pairing response');
|
||||
}
|
||||
|
||||
try {
|
||||
await completeOAuthHandoff(set, get, result, opts);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof AuthenticationError
|
||||
? 'Authentication rejected by server'
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: 'Pairing sign-in failed';
|
||||
set({ isLoading: false, error: message });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
const accountStore = useAccountStore.getState();
|
||||
const currentId = get().activeAccountId;
|
||||
|
||||
@@ -376,9 +376,9 @@ export function selectGroups(state: ContactsState): ContactCard[] {
|
||||
return state.contacts.filter(isGroup);
|
||||
}
|
||||
|
||||
export function selectKeywordsUsed(state: ContactsState): Array<{ keyword: string; count: number }> {
|
||||
export function selectKeywordsUsed(contacts: ContactCard[]): Array<{ keyword: string; count: number }> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const contact of state.contacts) {
|
||||
for (const contact of contacts) {
|
||||
for (const kw of getContactKeywords(contact)) {
|
||||
counts.set(kw, (counts.get(kw) || 0) + 1);
|
||||
}
|
||||
@@ -388,10 +388,13 @@ export function selectKeywordsUsed(state: ContactsState): Array<{ keyword: strin
|
||||
.sort((a, b) => a.keyword.localeCompare(b.keyword));
|
||||
}
|
||||
|
||||
export function selectAddressBooksWithCount(state: ContactsState): Array<AddressBook & { count: number }> {
|
||||
return state.addressBooks.map((book) => ({
|
||||
export function selectAddressBooksWithCount(
|
||||
addressBooks: AddressBook[],
|
||||
contacts: ContactCard[],
|
||||
): Array<AddressBook & { count: number }> {
|
||||
return addressBooks.map((book) => ({
|
||||
...book,
|
||||
count: state.contacts.filter((c) => !isGroup(c) && c.addressBookIds?.[book.id]).length,
|
||||
count: contacts.filter((c) => !isGroup(c) && c.addressBookIds?.[book.id]).length,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,55 @@ export type SwipeAction =
|
||||
| 'pin'
|
||||
| 'move';
|
||||
export type SwipeMode = 'instant' | 'reveal';
|
||||
// Actions that can be placed in the email reader's bottom quick-action bar.
|
||||
// The first three are the reply family (the default bar); any reply-family
|
||||
// action the user removes from the bar is relocated to the top toolbar so it
|
||||
// stays reachable.
|
||||
export type QuickAction =
|
||||
| 'reply'
|
||||
| 'replyAll'
|
||||
| 'forward'
|
||||
| 'delete'
|
||||
| 'archive'
|
||||
| 'markUnread'
|
||||
| 'star'
|
||||
| 'move'
|
||||
| 'spam'
|
||||
| 'tag';
|
||||
|
||||
export const REPLY_QUICK_ACTIONS: QuickAction[] = ['reply', 'replyAll', 'forward'];
|
||||
|
||||
export const ALL_QUICK_ACTIONS: QuickAction[] = [
|
||||
'reply',
|
||||
'replyAll',
|
||||
'forward',
|
||||
'delete',
|
||||
'archive',
|
||||
'markUnread',
|
||||
'star',
|
||||
'move',
|
||||
'spam',
|
||||
'tag',
|
||||
];
|
||||
|
||||
// The reader bottom bar always shows exactly three quick actions (between the
|
||||
// prev/next nav buttons). Coerce any persisted value into three unique, valid
|
||||
// ids, backfilling from the reply-family default when entries are missing.
|
||||
export function normalizeBottomQuickActions(value: unknown): QuickAction[] {
|
||||
const out: QuickAction[] = [];
|
||||
if (Array.isArray(value)) {
|
||||
for (const a of value) {
|
||||
if (ALL_QUICK_ACTIONS.includes(a as QuickAction) && !out.includes(a as QuickAction)) {
|
||||
out.push(a as QuickAction);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const d of REPLY_QUICK_ACTIONS) {
|
||||
if (out.length >= 3) break;
|
||||
if (!out.includes(d)) out.push(d);
|
||||
}
|
||||
return out.slice(0, 3);
|
||||
}
|
||||
export type ArchiveMode = 'single' | 'year' | 'month';
|
||||
export type CalendarView = 'month' | 'week' | 'day' | 'agenda';
|
||||
export type FirstDayOfWeek = 0 | 1;
|
||||
@@ -103,6 +152,10 @@ interface PersistedSettings {
|
||||
swipeRightAction: SwipeAction;
|
||||
swipeMode: SwipeMode;
|
||||
|
||||
// Email reader's bottom quick-action bar (3 slots). Defaults to the reply
|
||||
// family; reply-family actions removed from here move to the top toolbar.
|
||||
bottomQuickActions: QuickAction[];
|
||||
|
||||
// Archive
|
||||
archiveMode: ArchiveMode;
|
||||
|
||||
@@ -210,6 +263,8 @@ const DEFAULT_PERSISTED: PersistedSettings = {
|
||||
swipeRightAction: 'read',
|
||||
swipeMode: 'instant',
|
||||
|
||||
bottomQuickActions: ['reply', 'replyAll', 'forward'],
|
||||
|
||||
archiveMode: 'single',
|
||||
|
||||
calendarDefaultView: 'month',
|
||||
|
||||
Reference in New Issue
Block a user