mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49d1fc62a8 | ||
|
|
d5a336519a |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.11",
|
||||
"version": "0.1.12",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.11",
|
||||
"version": "0.1.12",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.11",
|
||||
"version": "0.1.12",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
+38
-12
@@ -24,34 +24,60 @@ export async function createPushSubscription(params: {
|
||||
deviceClientId: string;
|
||||
url: string;
|
||||
types: string[];
|
||||
// ISO date. Servers may clamp to their own ceiling - we send the maximum we
|
||||
// want and accept whatever Stalwart returns.
|
||||
expires?: string;
|
||||
}): Promise<string> {
|
||||
const created: Record<string, unknown> = {
|
||||
deviceClientId: params.deviceClientId,
|
||||
url: params.url,
|
||||
types: params.types,
|
||||
};
|
||||
if (params.expires) created.expires = params.expires;
|
||||
|
||||
const res = await jmapClient.request(
|
||||
[
|
||||
[
|
||||
'PushSubscription/set',
|
||||
{
|
||||
create: {
|
||||
new: {
|
||||
deviceClientId: params.deviceClientId,
|
||||
url: params.url,
|
||||
types: params.types,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ create: { new: created } },
|
||||
'0',
|
||||
],
|
||||
],
|
||||
[CAPABILITIES.CORE],
|
||||
);
|
||||
const [, body] = res.methodResponses[0] ?? [];
|
||||
const created = body?.created?.new as { id?: string } | undefined;
|
||||
if (!created?.id) {
|
||||
const result = body?.created?.new as { id?: string } | undefined;
|
||||
if (!result?.id) {
|
||||
const notCreated = body?.notCreated?.new;
|
||||
throw new Error(
|
||||
`PushSubscription/set create failed: ${JSON.stringify(notCreated ?? body)}`,
|
||||
);
|
||||
}
|
||||
return created.id;
|
||||
return result.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push the subscription's expiry forward (RFC 8620 §7.2.1). Returns false if
|
||||
* the server rejected the update (e.g. the subscription no longer exists),
|
||||
* which the caller treats as a signal to recreate.
|
||||
*/
|
||||
export async function updatePushSubscription(
|
||||
id: string,
|
||||
patch: { expires?: string; types?: string[] },
|
||||
): Promise<boolean> {
|
||||
const res = await jmapClient.request(
|
||||
[
|
||||
[
|
||||
'PushSubscription/set',
|
||||
{ update: { [id]: patch } },
|
||||
'0',
|
||||
],
|
||||
],
|
||||
[CAPABILITIES.CORE],
|
||||
);
|
||||
const [, body] = res.methodResponses[0] ?? [];
|
||||
if (body?.notUpdated?.[id]) return false;
|
||||
return body?.updated?.[id] !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -191,6 +191,8 @@ export interface ContactAddress {
|
||||
country?: string;
|
||||
countryCode?: string;
|
||||
fullAddress?: string;
|
||||
coordinates?: string;
|
||||
timeZone?: string;
|
||||
contexts?: Record<string, boolean>;
|
||||
label?: string;
|
||||
pref?: number;
|
||||
@@ -276,6 +278,23 @@ export interface ContactLink {
|
||||
pref?: number;
|
||||
}
|
||||
|
||||
export interface ContactCryptoKey {
|
||||
uri: string;
|
||||
mediaType?: string;
|
||||
contexts?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface ContactPronouns {
|
||||
pronouns: string;
|
||||
pref?: number;
|
||||
contexts?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface ContactSpeakToAs {
|
||||
grammaticalGender?: string;
|
||||
pronouns?: Record<string, ContactPronouns>;
|
||||
}
|
||||
|
||||
export interface ContactCard {
|
||||
id: string;
|
||||
originalId?: string;
|
||||
@@ -298,8 +317,13 @@ export interface ContactCard {
|
||||
media?: Record<string, ContactMedia>;
|
||||
relatedTo?: Record<string, ContactRelation>;
|
||||
links?: Record<string, ContactLink>;
|
||||
cryptoKeys?: Record<string, ContactCryptoKey>;
|
||||
keywords?: Record<string, boolean>;
|
||||
members?: Record<string, boolean>;
|
||||
speakToAs?: ContactSpeakToAs;
|
||||
calendarUri?: string;
|
||||
schedulingUri?: string;
|
||||
freeBusyUri?: string;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, View, Platform } from 'react-native';
|
||||
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
|
||||
import { useColors } from '../theme/colors';
|
||||
import type { ThemePalette } from '../theme/tokens';
|
||||
|
||||
// Minimum visible editor height (px). The editor auto-grows beyond this as the
|
||||
// user types, and the parent ScrollView handles overflow.
|
||||
const MIN_HEIGHT = 220;
|
||||
|
||||
export type RichTextCommand =
|
||||
| 'bold'
|
||||
| 'italic'
|
||||
| 'underline'
|
||||
| 'strikeThrough'
|
||||
| 'insertUnorderedList'
|
||||
| 'insertOrderedList'
|
||||
| 'formatBlock:H1'
|
||||
| 'formatBlock:H2'
|
||||
| 'formatBlock:BLOCKQUOTE'
|
||||
| 'formatBlock:P'
|
||||
| 'justifyLeft'
|
||||
| 'justifyCenter'
|
||||
| 'justifyRight'
|
||||
| 'removeFormat'
|
||||
| 'undo'
|
||||
| 'redo';
|
||||
|
||||
export interface RichTextSelectionState {
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
underline: boolean;
|
||||
strikeThrough: boolean;
|
||||
ul: boolean;
|
||||
ol: boolean;
|
||||
blockquote: boolean;
|
||||
h1: boolean;
|
||||
h2: boolean;
|
||||
alignLeft: boolean;
|
||||
alignCenter: boolean;
|
||||
alignRight: boolean;
|
||||
link: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_STATE: RichTextSelectionState = {
|
||||
bold: false, italic: false, underline: false, strikeThrough: false,
|
||||
ul: false, ol: false, blockquote: false, h1: false, h2: false,
|
||||
alignLeft: false, alignCenter: false, alignRight: false, link: false,
|
||||
};
|
||||
|
||||
export interface RichTextEditorHandle {
|
||||
exec(command: RichTextCommand): void;
|
||||
insertLink(url: string, label?: string): void;
|
||||
unsetLink(): void;
|
||||
insertImage(src: string, cid?: string, alt?: string): void;
|
||||
setHtml(html: string): void;
|
||||
focus(): void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
initialHtml?: string;
|
||||
placeholder?: string;
|
||||
onChange?: (html: string) => void;
|
||||
onSelectionChange?: (state: RichTextSelectionState) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
function buildEditorHtml(opts: {
|
||||
initialHtml: string;
|
||||
placeholder: string;
|
||||
c: ThemePalette;
|
||||
}): string {
|
||||
const { initialHtml, placeholder, c } = opts;
|
||||
// Inject initial content as a JSON-encoded string so any HTML/quotes inside
|
||||
// are safely embedded (no template literal collision with the script body).
|
||||
const initialJson = JSON.stringify(initialHtml);
|
||||
const placeholderJson = JSON.stringify(placeholder);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||
<style>
|
||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
html, body { margin: 0; padding: 0; background: ${c.background}; color: ${c.text}; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
overflow-y: hidden;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
#editor {
|
||||
outline: none;
|
||||
padding: 12px 16px 24px;
|
||||
min-height: ${MIN_HEIGHT}px;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
#editor[data-empty="true"]::before {
|
||||
content: attr(data-placeholder);
|
||||
color: ${c.textMuted};
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
#editor p { margin: 0 0 12px 0; }
|
||||
#editor p:last-child { margin-bottom: 0; }
|
||||
#editor h1 { font-size: 1.4em; font-weight: 700; margin: 0 0 12px 0; line-height: 1.2; }
|
||||
#editor h2 { font-size: 1.2em; font-weight: 700; margin: 0 0 10px 0; line-height: 1.3; }
|
||||
#editor ul, #editor ol { margin: 0 0 12px 0; padding-left: 24px; }
|
||||
#editor li { margin-bottom: 4px; }
|
||||
#editor blockquote {
|
||||
margin: 0 0 12px 0;
|
||||
padding: 4px 0 4px 12px;
|
||||
border-left: 3px solid ${c.border};
|
||||
color: ${c.textSecondary};
|
||||
}
|
||||
#editor a { color: ${c.primary}; text-decoration: underline; }
|
||||
#editor img { max-width: 100%; height: auto; border-radius: 4px; }
|
||||
#editor pre, #editor code {
|
||||
background: ${c.surfaceActive};
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
#editor pre { padding: 8px 12px; overflow-x: auto; }
|
||||
::selection { background: ${c.primary}33; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="editor" contenteditable="true" data-placeholder=${placeholderJson}></div>
|
||||
<script>
|
||||
(function () {
|
||||
var editor = document.getElementById('editor');
|
||||
var lastHtml = null;
|
||||
var lastHeight = 0;
|
||||
|
||||
function refreshEmpty() {
|
||||
var html = editor.innerHTML;
|
||||
var isEmpty =
|
||||
html === '' ||
|
||||
html === '<br>' ||
|
||||
html === '<p></p>' ||
|
||||
html === '<p><br></p>' ||
|
||||
editor.textContent.trim() === '' && editor.querySelectorAll('img').length === 0;
|
||||
editor.setAttribute('data-empty', isEmpty ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function post(type, payload) {
|
||||
if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({ type: type, payload: payload }));
|
||||
}
|
||||
}
|
||||
|
||||
function reportHeight() {
|
||||
var h = Math.max(${MIN_HEIGHT}, editor.scrollHeight + 8);
|
||||
if (Math.abs(h - lastHeight) < 2) return;
|
||||
lastHeight = h;
|
||||
post('height', h);
|
||||
}
|
||||
|
||||
function reportChange() {
|
||||
refreshEmpty();
|
||||
var html = editor.innerHTML;
|
||||
if (html === lastHtml) return;
|
||||
lastHtml = html;
|
||||
post('change', html);
|
||||
}
|
||||
|
||||
function reportSelection() {
|
||||
function active(name) {
|
||||
try { return document.queryCommandState(name); } catch (e) { return false; }
|
||||
}
|
||||
function inBlock(tag) {
|
||||
var sel = window.getSelection();
|
||||
if (!sel || !sel.rangeCount) return false;
|
||||
var node = sel.anchorNode;
|
||||
while (node && node !== editor) {
|
||||
if (node.nodeType === 1 && node.tagName === tag) return true;
|
||||
node = node.parentNode;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
var state = {
|
||||
bold: active('bold'),
|
||||
italic: active('italic'),
|
||||
underline: active('underline'),
|
||||
strikeThrough: active('strikeThrough'),
|
||||
ul: active('insertUnorderedList'),
|
||||
ol: active('insertOrderedList'),
|
||||
blockquote: inBlock('BLOCKQUOTE'),
|
||||
h1: inBlock('H1'),
|
||||
h2: inBlock('H2'),
|
||||
alignLeft: active('justifyLeft'),
|
||||
alignCenter: active('justifyCenter'),
|
||||
alignRight: active('justifyRight'),
|
||||
link: inBlock('A'),
|
||||
};
|
||||
post('selection', state);
|
||||
}
|
||||
|
||||
// ── Initial content ────────────────────────────────────────────────
|
||||
var initial = ${initialJson};
|
||||
if (initial && typeof initial === 'string') editor.innerHTML = initial;
|
||||
refreshEmpty();
|
||||
lastHtml = editor.innerHTML;
|
||||
|
||||
editor.addEventListener('input', function () {
|
||||
reportChange();
|
||||
reportHeight();
|
||||
});
|
||||
editor.addEventListener('blur', function () { post('blur', null); });
|
||||
editor.addEventListener('focus', function () { post('focus', null); });
|
||||
document.addEventListener('selectionchange', function () {
|
||||
if (document.activeElement === editor) reportSelection();
|
||||
});
|
||||
|
||||
// ── Bridge: receive commands from RN ───────────────────────────────
|
||||
window.__rne = {
|
||||
exec: function (command, value) {
|
||||
editor.focus();
|
||||
try {
|
||||
if (command === 'formatBlock' && value) {
|
||||
// Toggle: if we're already in that block, switch to <p>.
|
||||
var sel = window.getSelection();
|
||||
if (sel && sel.rangeCount) {
|
||||
var node = sel.anchorNode;
|
||||
while (node && node !== editor) {
|
||||
if (node.nodeType === 1 && node.tagName === value.toUpperCase()) {
|
||||
document.execCommand('formatBlock', false, '<p>');
|
||||
reportChange();
|
||||
reportHeight();
|
||||
reportSelection();
|
||||
return;
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
}
|
||||
document.execCommand('formatBlock', false, '<' + value.toLowerCase() + '>');
|
||||
} else {
|
||||
document.execCommand(command, false, value);
|
||||
}
|
||||
} catch (e) {}
|
||||
reportChange();
|
||||
reportHeight();
|
||||
reportSelection();
|
||||
},
|
||||
setHtml: function (html) {
|
||||
editor.innerHTML = html || '';
|
||||
lastHtml = editor.innerHTML;
|
||||
refreshEmpty();
|
||||
reportChange();
|
||||
reportHeight();
|
||||
},
|
||||
insertHtml: function (html) {
|
||||
editor.focus();
|
||||
try { document.execCommand('insertHTML', false, html); } catch (e) {}
|
||||
reportChange();
|
||||
reportHeight();
|
||||
},
|
||||
insertLink: function (url, label) {
|
||||
editor.focus();
|
||||
var sel = window.getSelection();
|
||||
var hasSelection = sel && sel.rangeCount && !sel.getRangeAt(0).collapsed;
|
||||
if (hasSelection) {
|
||||
try { document.execCommand('createLink', false, url); } catch (e) {}
|
||||
// execCommand doesn't set rel; tag every anchor that points at this URL.
|
||||
Array.prototype.forEach.call(editor.getElementsByTagName('a'), function (a) {
|
||||
if (a.getAttribute('href') === url) {
|
||||
a.setAttribute('rel', 'noopener noreferrer nofollow');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
var text = label && label.trim() ? label : url;
|
||||
var safe = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
var safeUrl = url.replace(/"/g, '"');
|
||||
var html = '<a href="' + safeUrl + '" rel="noopener noreferrer nofollow">' + safe + '</a>';
|
||||
try { document.execCommand('insertHTML', false, html); } catch (e) {}
|
||||
}
|
||||
reportChange();
|
||||
reportHeight();
|
||||
reportSelection();
|
||||
},
|
||||
unsetLink: function () {
|
||||
editor.focus();
|
||||
try { document.execCommand('unlink', false, null); } catch (e) {}
|
||||
reportChange();
|
||||
reportSelection();
|
||||
},
|
||||
insertImage: function (src, cid, alt) {
|
||||
editor.focus();
|
||||
var safeSrc = (src || '').replace(/"/g, '"');
|
||||
var safeAlt = (alt || '').replace(/&/g, '&').replace(/"/g, '"');
|
||||
var safeCid = cid ? cid.replace(/"/g, '"') : '';
|
||||
var html = '<img src="' + safeSrc + '" alt="' + safeAlt + '"' +
|
||||
(safeCid ? ' data-cid="' + safeCid + '"' : '') + ' />';
|
||||
try { document.execCommand('insertHTML', false, html); } catch (e) {}
|
||||
reportChange();
|
||||
reportHeight();
|
||||
},
|
||||
focus: function () { editor.focus(); },
|
||||
};
|
||||
|
||||
reportHeight();
|
||||
reportChange();
|
||||
|
||||
// Re-measure after async layout (image loads, font swap).
|
||||
window.addEventListener('load', function () {
|
||||
setTimeout(reportHeight, 50);
|
||||
setTimeout(reportHeight, 200);
|
||||
setTimeout(reportHeight, 600);
|
||||
});
|
||||
if (window.ResizeObserver) {
|
||||
new ResizeObserver(reportHeight).observe(editor);
|
||||
}
|
||||
})();
|
||||
true;
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function RichTextEditor(
|
||||
{ initialHtml = '', placeholder = '', onChange, onSelectionChange, onFocus, onBlur },
|
||||
ref,
|
||||
) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const webViewRef = React.useRef<WebView>(null);
|
||||
const [height, setHeight] = React.useState(MIN_HEIGHT);
|
||||
const onChangeRef = React.useRef(onChange);
|
||||
const onSelectionRef = React.useRef(onSelectionChange);
|
||||
const onFocusRef = React.useRef(onFocus);
|
||||
const onBlurRef = React.useRef(onBlur);
|
||||
onChangeRef.current = onChange;
|
||||
onSelectionRef.current = onSelectionChange;
|
||||
onFocusRef.current = onFocus;
|
||||
onBlurRef.current = onBlur;
|
||||
|
||||
// Build the source HTML once. Theming changes won't auto-rebuild the page,
|
||||
// which is fine: themes change rarely and the user can recompose if needed.
|
||||
const html = React.useMemo(
|
||||
() => buildEditorHtml({ initialHtml, placeholder, c }),
|
||||
// c reference changes per theme switch but we don't want to lose user
|
||||
// edits on a theme change. Bake in the initial colors only.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const call = React.useCallback((script: string) => {
|
||||
webViewRef.current?.injectJavaScript(`${script}; true;`);
|
||||
}, []);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
exec: (command) => {
|
||||
const [name, value] = command.split(':');
|
||||
if (value) call(`window.__rne && window.__rne.exec(${JSON.stringify(name)}, ${JSON.stringify(value)})`);
|
||||
else call(`window.__rne && window.__rne.exec(${JSON.stringify(name)})`);
|
||||
},
|
||||
insertLink: (url, label) => {
|
||||
call(`window.__rne && window.__rne.insertLink(${JSON.stringify(url)}, ${JSON.stringify(label ?? '')})`);
|
||||
},
|
||||
unsetLink: () => {
|
||||
call(`window.__rne && window.__rne.unsetLink()`);
|
||||
},
|
||||
insertImage: (src, cid, alt) => {
|
||||
call(`window.__rne && window.__rne.insertImage(${JSON.stringify(src)}, ${JSON.stringify(cid ?? '')}, ${JSON.stringify(alt ?? '')})`);
|
||||
},
|
||||
setHtml: (next) => {
|
||||
call(`window.__rne && window.__rne.setHtml(${JSON.stringify(next)})`);
|
||||
},
|
||||
focus: () => {
|
||||
call(`window.__rne && window.__rne.focus()`);
|
||||
},
|
||||
}), [call]);
|
||||
|
||||
const onMessage = (event: WebViewMessageEvent) => {
|
||||
let data: { type: string; payload: unknown };
|
||||
try {
|
||||
data = JSON.parse(event.nativeEvent.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
switch (data.type) {
|
||||
case 'change':
|
||||
onChangeRef.current?.(typeof data.payload === 'string' ? data.payload : '');
|
||||
break;
|
||||
case 'height': {
|
||||
const h = typeof data.payload === 'number' ? data.payload : Number(data.payload);
|
||||
if (!Number.isNaN(h) && h > 0) setHeight(Math.max(MIN_HEIGHT, h));
|
||||
break;
|
||||
}
|
||||
case 'selection':
|
||||
onSelectionRef.current?.((data.payload as RichTextSelectionState) ?? EMPTY_STATE);
|
||||
break;
|
||||
case 'focus':
|
||||
onFocusRef.current?.();
|
||||
break;
|
||||
case 'blur':
|
||||
onBlurRef.current?.();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { height }]}>
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
originWhitelist={['about:*']}
|
||||
source={{ html }}
|
||||
onMessage={onMessage}
|
||||
javaScriptEnabled
|
||||
domStorageEnabled={false}
|
||||
scrollEnabled={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
keyboardDisplayRequiresUserAction={false}
|
||||
hideKeyboardAccessoryView
|
||||
setSupportMultipleWindows={false}
|
||||
thirdPartyCookiesEnabled={false}
|
||||
sharedCookiesEnabled={false}
|
||||
allowFileAccess={false}
|
||||
allowFileAccessFromFileURLs={false}
|
||||
allowUniversalAccessFromFileURLs={false}
|
||||
allowsBackForwardNavigationGestures={false}
|
||||
incognito
|
||||
cacheEnabled={false}
|
||||
mixedContentMode="never"
|
||||
androidLayerType={Platform.OS === 'android' ? 'hardware' : undefined}
|
||||
style={styles.webview}
|
||||
containerStyle={styles.webviewContainer}
|
||||
onShouldStartLoadWithRequest={(request) => {
|
||||
// Allow only the initial about:blank doc + data URIs we emit ourselves.
|
||||
const url = request.url;
|
||||
return !url || url === 'about:blank' || url.startsWith('data:') || url.startsWith('about:');
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
export default RichTextEditor;
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
webview: {
|
||||
backgroundColor: 'transparent',
|
||||
flex: 1,
|
||||
},
|
||||
webviewContainer: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
markdownToHtml,
|
||||
wrapSelection,
|
||||
toggleLinePrefix,
|
||||
formatReplyQuote,
|
||||
} from '../compose-format';
|
||||
|
||||
describe('markdownToHtml', () => {
|
||||
it('escapes HTML in plain paragraphs', () => {
|
||||
expect(markdownToHtml('hi <script>alert(1)</script>')).toBe(
|
||||
'<p>hi <script>alert(1)</script></p>',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders bold and italic', () => {
|
||||
expect(markdownToHtml('see **this** and _that_')).toContain('<b>this</b>');
|
||||
expect(markdownToHtml('see **this** and _that_')).toContain('<i>that</i>');
|
||||
});
|
||||
|
||||
it('renders bullet list', () => {
|
||||
const html = markdownToHtml('- one\n- two\n- three');
|
||||
expect(html).toBe('<ul><li>one</li><li>two</li><li>three</li></ul>');
|
||||
});
|
||||
|
||||
it('renders ordered list', () => {
|
||||
const html = markdownToHtml('1. one\n2. two');
|
||||
expect(html).toBe('<ol><li>one</li><li>two</li></ol>');
|
||||
});
|
||||
|
||||
it('renders blockquotes', () => {
|
||||
expect(markdownToHtml('> quoted\n> more')).toBe(
|
||||
'<blockquote>quoted<br />more</blockquote>',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves multiple paragraphs separated by blank lines', () => {
|
||||
const html = markdownToHtml('first line\n\nsecond para');
|
||||
expect(html).toBe('<p>first line</p>\n<p>second para</p>');
|
||||
});
|
||||
|
||||
it('preserves single line breaks within a paragraph as <br>', () => {
|
||||
const html = markdownToHtml('one\ntwo');
|
||||
expect(html).toBe('<p>one<br />two</p>');
|
||||
});
|
||||
|
||||
it('renders markdown links', () => {
|
||||
const html = markdownToHtml('Click [here](https://example.com) please');
|
||||
expect(html).toContain('<a href="https://example.com" rel="noopener noreferrer">here</a>');
|
||||
});
|
||||
|
||||
it('rejects javascript: links', () => {
|
||||
const html = markdownToHtml('[evil](javascript:alert(1))');
|
||||
expect(html).not.toContain('href="javascript:');
|
||||
});
|
||||
|
||||
it('auto-links bare URLs', () => {
|
||||
const html = markdownToHtml('go to https://x.test now');
|
||||
expect(html).toContain('<a href="https://x.test"');
|
||||
});
|
||||
|
||||
it('renders inline cid images', () => {
|
||||
const html = markdownToHtml('');
|
||||
expect(html).toContain('<img src="cid:abc@x" alt="pic.png" />');
|
||||
});
|
||||
});
|
||||
|
||||
describe('wrapSelection', () => {
|
||||
it('wraps the selected range with markers', () => {
|
||||
const r = wrapSelection('hello world', { start: 6, end: 11 }, '**', '**');
|
||||
expect(r.text).toBe('hello **world**');
|
||||
expect(r.selection).toEqual({ start: 8, end: 13 });
|
||||
});
|
||||
|
||||
it('inserts placeholder when nothing is selected', () => {
|
||||
const r = wrapSelection('hi ', { start: 3, end: 3 }, '**', '**', 'bold');
|
||||
expect(r.text).toBe('hi **bold**');
|
||||
expect(r.selection).toEqual({ start: 5, end: 9 });
|
||||
});
|
||||
|
||||
it('handles reversed selection (end before start)', () => {
|
||||
const r = wrapSelection('abc', { start: 3, end: 0 }, '_', '_');
|
||||
expect(r.text).toBe('_abc_');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleLinePrefix', () => {
|
||||
it('adds prefix to a single line', () => {
|
||||
const r = toggleLinePrefix('hello', { start: 2, end: 2 }, '- ');
|
||||
expect(r.text).toBe('- hello');
|
||||
});
|
||||
|
||||
it('adds prefix to every line in selection', () => {
|
||||
const r = toggleLinePrefix('one\ntwo\nthree', { start: 0, end: 13 }, '- ');
|
||||
expect(r.text).toBe('- one\n- two\n- three');
|
||||
});
|
||||
|
||||
it('removes prefix when every line already has it (toggle)', () => {
|
||||
const r = toggleLinePrefix('- one\n- two', { start: 0, end: 11 }, '- ');
|
||||
expect(r.text).toBe('one\ntwo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatReplyQuote', () => {
|
||||
it('prefixes original body lines with "> "', () => {
|
||||
const out = formatReplyQuote('hello\nworld', {
|
||||
senderName: 'Alice',
|
||||
date: new Date('2026-04-27'),
|
||||
});
|
||||
expect(out).toContain('> hello');
|
||||
expect(out).toContain('> world');
|
||||
expect(out).toContain('Alice wrote:');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildInitialHtml, htmlToPlainText, rewriteInlineImages } from '../compose-html';
|
||||
|
||||
describe('buildInitialHtml', () => {
|
||||
it('returns empty paragraph for plain compose', () => {
|
||||
expect(buildInitialHtml('compose')).toBe('<p><br></p>');
|
||||
});
|
||||
|
||||
it('returns empty paragraph when reply has no body', () => {
|
||||
expect(buildInitialHtml('reply', { from: { email: 'a@b.com' } })).toBe('<p><br></p>');
|
||||
});
|
||||
|
||||
it('builds reply with quoted plain-text body and sender label', () => {
|
||||
const html = buildInitialHtml('reply', {
|
||||
from: { name: 'Alice', email: 'a@b.com' },
|
||||
subject: 'Hello',
|
||||
body: 'line one\nline two',
|
||||
receivedAt: '2026-04-27T10:00:00Z',
|
||||
});
|
||||
expect(html).toContain('<p><br></p>');
|
||||
expect(html).toContain('Alice <a@b.com> wrote:');
|
||||
expect(html).toContain('<blockquote');
|
||||
expect(html).toContain('line one<br>line two');
|
||||
});
|
||||
|
||||
it('uses HTML body when present, stripping dangerous tags', () => {
|
||||
const html = buildInitialHtml('reply', {
|
||||
from: { email: 'x@y.com' },
|
||||
body: 'fallback',
|
||||
htmlBody: '<p>safe</p><script>alert(1)</script>',
|
||||
});
|
||||
expect(html).toContain('<p>safe</p>');
|
||||
expect(html).not.toContain('<script');
|
||||
});
|
||||
|
||||
it('forward: includes "Forwarded message" header and From/Subject lines', () => {
|
||||
const html = buildInitialHtml('forward', {
|
||||
from: { name: 'Bob', email: 'b@c.com' },
|
||||
subject: 'Original Subject',
|
||||
body: 'forwarded body',
|
||||
receivedAt: '2026-04-27T10:00:00Z',
|
||||
});
|
||||
expect(html).toContain('Forwarded message');
|
||||
expect(html).toContain('From: Bob <b@c.com>');
|
||||
expect(html).toContain('Subject: Original Subject');
|
||||
expect(html).toContain('forwarded body');
|
||||
});
|
||||
|
||||
it('escapes HTML in plain-text quoted body', () => {
|
||||
const html = buildInitialHtml('reply', {
|
||||
from: { email: 'a@b.com' },
|
||||
body: '<script>x</script>',
|
||||
});
|
||||
expect(html).not.toContain('<script>x</script>');
|
||||
expect(html).toContain('<script>x</script>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('htmlToPlainText', () => {
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(htmlToPlainText('')).toBe('');
|
||||
});
|
||||
|
||||
it('strips tags and decodes entities', () => {
|
||||
expect(htmlToPlainText('<p>hello & world</p>')).toBe('hello & world');
|
||||
});
|
||||
|
||||
it('converts <br> to newline', () => {
|
||||
expect(htmlToPlainText('a<br>b<br/>c')).toBe('a\nb\nc');
|
||||
});
|
||||
|
||||
it('treats block boundaries as newlines', () => {
|
||||
expect(htmlToPlainText('<p>a</p><p>b</p>')).toBe('a\n\nb');
|
||||
});
|
||||
|
||||
it('renders list items with bullet prefix', () => {
|
||||
expect(htmlToPlainText('<ul><li>one</li><li>two</li></ul>')).toBe('• one\n• two');
|
||||
});
|
||||
|
||||
it('collapses excessive blank lines', () => {
|
||||
expect(htmlToPlainText('<p>a</p><br><br><br><p>b</p>')).toBe('a\n\nb');
|
||||
});
|
||||
|
||||
it('decodes to space', () => {
|
||||
expect(htmlToPlainText('hello world')).toBe('hello world');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rewriteInlineImages', () => {
|
||||
it('returns input unchanged when no data-cid is present', () => {
|
||||
const html = '<p>hi <img src="https://x"></p>';
|
||||
const out = rewriteInlineImages(html);
|
||||
expect(out.html).toBe(html);
|
||||
expect(out.usedCids).toEqual([]);
|
||||
});
|
||||
|
||||
it('rewrites data-cid images to cid: src and reports the CID', () => {
|
||||
const html = '<p><img src="data:image/png;base64,xxx" data-cid="abc@x" alt="pic"></p>';
|
||||
const out = rewriteInlineImages(html);
|
||||
expect(out.html).toContain('src="cid:abc@x"');
|
||||
expect(out.html).not.toContain('data-cid');
|
||||
expect(out.html).not.toContain('data:image/png');
|
||||
expect(out.usedCids).toEqual(['abc@x']);
|
||||
});
|
||||
|
||||
it('handles single-quoted attributes', () => {
|
||||
const html = `<img src='data:image/png' data-cid='zzz' alt='x'>`;
|
||||
const out = rewriteInlineImages(html);
|
||||
expect(out.html).toContain('src="cid:zzz"');
|
||||
expect(out.usedCids).toEqual(['zzz']);
|
||||
});
|
||||
|
||||
it('preserves alt attribute on rewritten images', () => {
|
||||
const html = '<img src="data:" alt="my pic" data-cid="m1">';
|
||||
const out = rewriteInlineImages(html);
|
||||
expect(out.html).toContain('alt="my pic"');
|
||||
});
|
||||
|
||||
it('reports each cid only once even if image referenced multiple times', () => {
|
||||
const html = '<img src="data:" data-cid="dup"><img src="data:" data-cid="dup">';
|
||||
const out = rewriteInlineImages(html);
|
||||
expect(out.usedCids).toEqual(['dup']);
|
||||
});
|
||||
});
|
||||
@@ -1,175 +0,0 @@
|
||||
// Lightweight markdown-style formatting for the native composer.
|
||||
//
|
||||
// The compose body is plain text the user types into a TextInput. The toolbar
|
||||
// inserts markdown-like markers around the current selection (** for bold,
|
||||
// _ for italic, "- " line prefix for lists). On send we render that text to
|
||||
// HTML for `htmlBody` and keep the source as `textBody`.
|
||||
//
|
||||
// This is intentionally NOT a full markdown parser - it covers the formats
|
||||
// the toolbar produces plus a few common conveniences (auto-link bare URLs,
|
||||
// preserve line breaks, blockquote `> `).
|
||||
|
||||
import { escapeHtml } from './email-html';
|
||||
|
||||
const BOLD_RE = /\*\*([^*\n]+)\*\*/g;
|
||||
const ITALIC_RE = /(^|[^*\w])_([^_\n]+)_(?=$|[^*\w])/g;
|
||||
const CID_IMG_RE = /!\[([^\]]*)\]\(cid:([^)\s]+)\)/g;
|
||||
// Combined matcher: either a markdown link `[label](url)` or a bare URL.
|
||||
// Handling both in one pass means URLs inside a markdown link's parentheses
|
||||
// don't get auto-linked a second time.
|
||||
const LINK_OR_URL_RE =
|
||||
/\[([^\]]+)\]\((https?:\/\/[^)\s]+|mailto:[^)\s]+)\)|(\bhttps?:\/\/[^\s<>()"']+)/gi;
|
||||
|
||||
function inlineFormat(text: string): string {
|
||||
let out = escapeHtml(text);
|
||||
out = out.replace(CID_IMG_RE, (_m, alt: string, cid: string) =>
|
||||
`<img src="cid:${cid}" alt="${alt}" />`,
|
||||
);
|
||||
out = out.replace(LINK_OR_URL_RE, (full, label, mdUrl, bareUrl) => {
|
||||
if (label !== undefined && mdUrl) {
|
||||
return `<a href="${mdUrl}" rel="noopener noreferrer">${label}</a>`;
|
||||
}
|
||||
if (bareUrl) {
|
||||
return `<a href="${bareUrl}" rel="noopener noreferrer">${bareUrl}</a>`;
|
||||
}
|
||||
return full;
|
||||
});
|
||||
out = out.replace(BOLD_RE, '<b>$1</b>');
|
||||
out = out.replace(ITALIC_RE, '$1<i>$2</i>');
|
||||
return out;
|
||||
}
|
||||
|
||||
type Block =
|
||||
| { kind: 'p'; lines: string[] }
|
||||
| { kind: 'ul'; items: string[] }
|
||||
| { kind: 'ol'; items: string[] }
|
||||
| { kind: 'quote'; lines: string[] };
|
||||
|
||||
export function markdownToHtml(source: string): string {
|
||||
if (!source) return '';
|
||||
const lines = source.replace(/\r\n/g, '\n').split('\n');
|
||||
const blocks: Block[] = [];
|
||||
let para: string[] = [];
|
||||
|
||||
const flushPara = () => {
|
||||
if (para.length) {
|
||||
blocks.push({ kind: 'p', lines: para });
|
||||
para = [];
|
||||
}
|
||||
};
|
||||
|
||||
for (const raw of lines) {
|
||||
const line = raw;
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed === '') {
|
||||
flushPara();
|
||||
continue;
|
||||
}
|
||||
|
||||
const ulMatch = /^[-*]\s+(.+)$/.exec(trimmed);
|
||||
if (ulMatch) {
|
||||
flushPara();
|
||||
const last = blocks[blocks.length - 1];
|
||||
if (last && last.kind === 'ul') last.items.push(ulMatch[1]);
|
||||
else blocks.push({ kind: 'ul', items: [ulMatch[1]] });
|
||||
continue;
|
||||
}
|
||||
|
||||
const olMatch = /^\d+\.\s+(.+)$/.exec(trimmed);
|
||||
if (olMatch) {
|
||||
flushPara();
|
||||
const last = blocks[blocks.length - 1];
|
||||
if (last && last.kind === 'ol') last.items.push(olMatch[1]);
|
||||
else blocks.push({ kind: 'ol', items: [olMatch[1]] });
|
||||
continue;
|
||||
}
|
||||
|
||||
const quoteMatch = /^>\s?(.*)$/.exec(trimmed);
|
||||
if (quoteMatch) {
|
||||
flushPara();
|
||||
const last = blocks[blocks.length - 1];
|
||||
if (last && last.kind === 'quote') last.lines.push(quoteMatch[1]);
|
||||
else blocks.push({ kind: 'quote', lines: [quoteMatch[1]] });
|
||||
continue;
|
||||
}
|
||||
|
||||
para.push(line);
|
||||
}
|
||||
flushPara();
|
||||
|
||||
const out: string[] = [];
|
||||
for (const b of blocks) {
|
||||
if (b.kind === 'p') {
|
||||
out.push(`<p>${b.lines.map(inlineFormat).join('<br />')}</p>`);
|
||||
} else if (b.kind === 'ul') {
|
||||
out.push(`<ul>${b.items.map((i) => `<li>${inlineFormat(i)}</li>`).join('')}</ul>`);
|
||||
} else if (b.kind === 'ol') {
|
||||
out.push(`<ol>${b.items.map((i) => `<li>${inlineFormat(i)}</li>`).join('')}</ol>`);
|
||||
} else {
|
||||
out.push(`<blockquote>${b.lines.map(inlineFormat).join('<br />')}</blockquote>`);
|
||||
}
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
// Wrap the current selection of `text` in `before`/`after`. If nothing is
|
||||
// selected, insert the markers at the caret. Returns the new text plus the
|
||||
// new selection range.
|
||||
export function wrapSelection(
|
||||
text: string,
|
||||
selection: { start: number; end: number },
|
||||
before: string,
|
||||
after: string,
|
||||
placeholder = '',
|
||||
): { text: string; selection: { start: number; end: number } } {
|
||||
const start = Math.min(selection.start, selection.end);
|
||||
const end = Math.max(selection.start, selection.end);
|
||||
const middle = text.slice(start, end) || placeholder;
|
||||
const next = `${text.slice(0, start)}${before}${middle}${after}${text.slice(end)}`;
|
||||
const cursorStart = start + before.length;
|
||||
const cursorEnd = cursorStart + middle.length;
|
||||
return {
|
||||
text: next,
|
||||
selection: { start: cursorStart, end: cursorEnd },
|
||||
};
|
||||
}
|
||||
|
||||
// Add a line prefix (e.g. "- " or "> ") to the line(s) covered by selection.
|
||||
// If a line already starts with the prefix, it's removed (toggle behavior).
|
||||
export function toggleLinePrefix(
|
||||
text: string,
|
||||
selection: { start: number; end: number },
|
||||
prefix: string,
|
||||
): { text: string; selection: { start: number; end: number } } {
|
||||
const start = Math.min(selection.start, selection.end);
|
||||
const end = Math.max(selection.start, selection.end);
|
||||
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
|
||||
let lineEnd = text.indexOf('\n', end);
|
||||
if (lineEnd === -1) lineEnd = text.length;
|
||||
|
||||
const block = text.slice(lineStart, lineEnd);
|
||||
const lines = block.split('\n');
|
||||
const allHave = lines.every((l) => l.startsWith(prefix));
|
||||
const next = lines
|
||||
.map((l) => (allHave ? l.slice(prefix.length) : `${prefix}${l}`))
|
||||
.join('\n');
|
||||
const newText = `${text.slice(0, lineStart)}${next}${text.slice(lineEnd)}`;
|
||||
const delta = next.length - block.length;
|
||||
return {
|
||||
text: newText,
|
||||
selection: { start: lineStart, end: lineEnd + delta },
|
||||
};
|
||||
}
|
||||
|
||||
// Format the quoted reply prefix the composer adds before the original body
|
||||
// when sending a reply / forward. Used both for textBody and as input to the
|
||||
// htmlBody renderer.
|
||||
export function formatReplyQuote(
|
||||
body: string,
|
||||
meta: { senderName: string; date: Date },
|
||||
): string {
|
||||
const date = meta.date.toLocaleDateString();
|
||||
const lines = body.split('\n').map((l) => `> ${l}`).join('\n');
|
||||
return `\n\n---\nOn ${date}, ${meta.senderName} wrote:\n${lines}`;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Build the initial HTML body for a new compose / reply / forward, mirroring
|
||||
// what the webmail composer (`components/email/email-composer.tsx`) produces.
|
||||
// All values are HTML-escaped before insertion into the document.
|
||||
|
||||
import { escapeHtml, stripDangerousTags } from './email-html';
|
||||
|
||||
export interface ReplyMeta {
|
||||
from?: { email?: string; name?: string };
|
||||
to?: Array<{ email?: string; name?: string }>;
|
||||
cc?: Array<{ email?: string; name?: string }>;
|
||||
subject?: string;
|
||||
body?: string; // plain-text body (preferred when htmlBody absent)
|
||||
htmlBody?: string;
|
||||
receivedAt?: string;
|
||||
}
|
||||
|
||||
function escapeForHtmlBody(text: string): string {
|
||||
return escapeHtml(text).replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
function senderLabel(from?: { email?: string; name?: string }): string {
|
||||
if (!from) return '';
|
||||
if (from.name && from.email) return `${from.name} <${from.email}>`;
|
||||
return from.name || from.email || '';
|
||||
}
|
||||
|
||||
function formatDate(iso?: string): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the seed HTML for a new compose / reply / forward. Returns an empty
|
||||
* paragraph for plain compose (so the contenteditable has a caret target).
|
||||
*/
|
||||
export function buildInitialHtml(
|
||||
mode: 'compose' | 'reply' | 'replyAll' | 'forward',
|
||||
reply?: ReplyMeta | null,
|
||||
): string {
|
||||
if (!reply || mode === 'compose') return '<p><br></p>';
|
||||
|
||||
const date = formatDate(reply.receivedAt);
|
||||
const fromStr = senderLabel(reply.from) || 'Unknown';
|
||||
const subject = reply.subject ?? '';
|
||||
|
||||
const quotedBody = reply.htmlBody
|
||||
? stripDangerousTags(reply.htmlBody)
|
||||
: reply.body
|
||||
? escapeForHtmlBody(reply.body)
|
||||
: '';
|
||||
|
||||
if (!quotedBody) return '<p><br></p>';
|
||||
|
||||
if (mode === 'forward') {
|
||||
return [
|
||||
'<p><br></p>',
|
||||
'<div>---------- Forwarded message ----------<br>',
|
||||
`From: ${escapeHtml(fromStr)}<br>`,
|
||||
date ? `Date: ${escapeHtml(date)}<br>` : '',
|
||||
subject ? `Subject: ${escapeHtml(subject)}<br>` : '',
|
||||
reply.to && reply.to.length
|
||||
? `To: ${escapeHtml(reply.to.map(senderLabel).filter(Boolean).join(', '))}<br>`
|
||||
: '',
|
||||
'</div>',
|
||||
`<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${quotedBody}</blockquote>`,
|
||||
].join('');
|
||||
}
|
||||
|
||||
// reply / replyAll
|
||||
return [
|
||||
'<p><br></p>',
|
||||
'<div>',
|
||||
`On ${escapeHtml(date || 'a previous date')}, ${escapeHtml(fromStr)} wrote:`,
|
||||
'</div>',
|
||||
`<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${quotedBody}</blockquote>`,
|
||||
].join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip HTML tags and decode common entities for a plain-text fallback body.
|
||||
* Mirrors the webmail's `htmlToPlainText` (DOMParser-based) since we don't
|
||||
* have DOMParser in the RN runtime.
|
||||
*/
|
||||
export function htmlToPlainText(html: string): string {
|
||||
if (!html) return '';
|
||||
let s = html;
|
||||
// Treat block-level boundaries and <br> as newlines before stripping tags.
|
||||
s = s.replace(/<br\s*\/?>/gi, '\n');
|
||||
// Paragraph-level closes get a blank line; list/heading/cell closes a single
|
||||
// newline so they stack tightly.
|
||||
s = s.replace(/<\/(p|div|blockquote|pre)>/gi, '\n\n');
|
||||
s = s.replace(/<\/(li|h[1-6]|tr)>/gi, '\n');
|
||||
s = s.replace(/<li[^>]*>/gi, '• ');
|
||||
s = s.replace(/<[^>]+>/g, '');
|
||||
// Decode the most common entities; leave the rest as-is.
|
||||
s = s
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/gi, "'");
|
||||
// Collapse runs of >2 newlines and trim trailing whitespace per line.
|
||||
s = s.replace(/\n{3,}/g, '\n\n').replace(/[ \t]+\n/g, '\n');
|
||||
return s.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the editor HTML, replacing `<img data-cid="X" ...>` with `<img src="cid:X">`
|
||||
* (and dropping the data-cid attribute). Returns the rewritten HTML and the
|
||||
* set of CIDs actually referenced so the sender can include exactly the
|
||||
* needed inline parts.
|
||||
*/
|
||||
export function rewriteInlineImages(html: string): { html: string; usedCids: string[] } {
|
||||
if (!html || !/data-cid=/i.test(html)) return { html, usedCids: [] };
|
||||
const used = new Set<string>();
|
||||
const out = html.replace(
|
||||
/<img\b([^>]*?)\sdata-cid=("([^"]*)"|'([^']*)')([^>]*)>/gi,
|
||||
(_full, before: string, _quoted: string, dq: string | undefined, sq: string | undefined, after: string) => {
|
||||
const cid = dq ?? sq ?? '';
|
||||
if (!cid) return _full;
|
||||
used.add(cid);
|
||||
// Drop any existing src= and replace with cid: form.
|
||||
const merged = (before + after)
|
||||
.replace(/\ssrc=("[^"]*"|'[^']*')/gi, '')
|
||||
.trim();
|
||||
const space = merged ? ' ' + merged : '';
|
||||
return `<img src="cid:${cid}"${space}>`;
|
||||
},
|
||||
);
|
||||
return { html: out, usedCids: Array.from(used) };
|
||||
}
|
||||
@@ -119,6 +119,60 @@ export function getBirthday(contact: ContactCard): AnniversaryDate | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getDateParts(dateInput: AnniversaryDate): { year?: number; month?: number; day?: number } {
|
||||
if (typeof dateInput === 'object' && dateInput !== null) {
|
||||
if ((dateInput as Timestamp)['@type'] === 'Timestamp') {
|
||||
const ts = dateInput as Timestamp;
|
||||
const d = new Date(ts.utc);
|
||||
if (!isNaN(d.getTime())) {
|
||||
return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
const pd = dateInput as PartialDate;
|
||||
return { year: pd.year, month: pd.month, day: pd.day };
|
||||
}
|
||||
const s = String(dateInput);
|
||||
if (s.startsWith('--')) {
|
||||
const parts = s.substring(2).split('-');
|
||||
return { month: parseInt(parts[0], 10), day: parts[1] ? parseInt(parts[1], 10) : undefined };
|
||||
}
|
||||
const d = new Date(s);
|
||||
if (!isNaN(d.getTime())) {
|
||||
return { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate() };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function getCompletedYears(dateInput: AnniversaryDate): number | null {
|
||||
const { year, month, day } = getDateParts(dateInput);
|
||||
if (!year) return null;
|
||||
const now = new Date();
|
||||
let years = now.getFullYear() - year;
|
||||
const m = month ?? 1;
|
||||
const d = day ?? 1;
|
||||
const nowM = now.getMonth() + 1;
|
||||
const nowD = now.getDate();
|
||||
if (nowM < m || (nowM === m && nowD < d)) years -= 1;
|
||||
if (years < 0) return null;
|
||||
return years;
|
||||
}
|
||||
|
||||
export function getPhoneFeatures(features?: Record<string, boolean>): string[] {
|
||||
if (!features) return [];
|
||||
return Object.keys(features).filter((k) => features[k]);
|
||||
}
|
||||
|
||||
export function getActiveContexts(contexts?: Record<string, boolean>): string[] {
|
||||
if (!contexts) return [];
|
||||
return Object.keys(contexts).filter((k) => contexts[k]);
|
||||
}
|
||||
|
||||
export function getPrimaryNickname(contact: ContactCard): string | undefined {
|
||||
if (!contact.nicknames) return undefined;
|
||||
return Object.values(contact.nicknames)[0]?.name;
|
||||
}
|
||||
|
||||
export function formatAddress(address: {
|
||||
components?: Array<{ kind: string; value: string }>;
|
||||
full?: string;
|
||||
|
||||
@@ -11,6 +11,35 @@ function authHeader(): string {
|
||||
return h;
|
||||
}
|
||||
|
||||
function safeAttachmentName(name: string | undefined, type: string | undefined): string {
|
||||
const fallbackExt = type?.split('/')[1]?.replace(/[^a-z0-9]/gi, '').slice(0, 8) || 'bin';
|
||||
const cleaned = (name ?? '').replace(/[\\/:*?"<>|]/g, '_').trim();
|
||||
if (cleaned) return cleaned.slice(0, 120);
|
||||
return `attachment.${fallbackExt}`;
|
||||
}
|
||||
|
||||
export async function shareAttachment(
|
||||
blobId: string,
|
||||
name: string | undefined,
|
||||
type: string | undefined,
|
||||
): Promise<void> {
|
||||
const filename = safeAttachmentName(name, type);
|
||||
const mimeType = type || 'application/octet-stream';
|
||||
const dest = new File(Paths.cache, filename);
|
||||
if (dest.exists) dest.delete();
|
||||
const url = getDownloadUrl(blobId, filename, mimeType);
|
||||
const downloaded = await File.downloadFileAsync(url, dest, {
|
||||
headers: { Authorization: authHeader() },
|
||||
});
|
||||
if (!(await Sharing.isAvailableAsync())) {
|
||||
throw new Error('Sharing is not available on this device');
|
||||
}
|
||||
await Sharing.shareAsync(downloaded.uri, {
|
||||
mimeType,
|
||||
dialogTitle: filename,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchRawEmail(blobId: string): Promise<string> {
|
||||
const url = getDownloadUrl(blobId, 'email.eml', RFC822);
|
||||
const r = await fetch(url, { headers: { Authorization: authHeader() } });
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createPushSubscription,
|
||||
destroyPushSubscription,
|
||||
listPushSubscriptions,
|
||||
updatePushSubscription,
|
||||
verifyPushSubscription,
|
||||
} from '../api/push';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
@@ -26,6 +27,20 @@ export const DEFAULT_RELAY_BASE_URL = 'https://notifications.relay.bulwarkmail.o
|
||||
// outgoing mail state changes don't belong in a user-visible push.
|
||||
const PUSH_TYPES = ['Email', 'EmailDelivery', 'Mailbox'] as const;
|
||||
|
||||
// Maximum expires we ask the server for. Stalwart (and other JMAP servers)
|
||||
// may clamp this down; whatever they return is what we get. Without this,
|
||||
// the server picks its own (often short) default and the subscription
|
||||
// silently expires between app updates - so push stops arriving until the
|
||||
// user re-enables it from settings.
|
||||
const SUBSCRIPTION_EXPIRES_DAYS = 90;
|
||||
// When an existing subscription has less than this much lifetime left, push
|
||||
// expires forward on the next app start.
|
||||
const SUBSCRIPTION_REFRESH_THRESHOLD_DAYS = 7;
|
||||
|
||||
function expiresFromNow(days: number): string {
|
||||
return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
type BulwarkFcmNative = {
|
||||
getToken(): Promise<string>;
|
||||
deleteToken(): Promise<void>;
|
||||
@@ -186,12 +201,20 @@ export async function setupPushNotifications(
|
||||
accountLabel: params.accountLabel,
|
||||
});
|
||||
|
||||
// Reuse the previous JMAP subscription when the server still has it.
|
||||
// Reuse the previous JMAP subscription when the server still has it, but
|
||||
// push the expiry forward so it doesn't time out before the next app start.
|
||||
const storedServerId = await AsyncStorage.getItem(SUBSCRIPTION_ID_KEY);
|
||||
if (storedServerId) {
|
||||
const existing = await listPushSubscriptions().catch(() => []);
|
||||
if (existing.some((s) => s.id === storedServerId)) {
|
||||
return { subscriptionId: storedServerId, verified: true };
|
||||
const match = existing.find((s) => s.id === storedServerId);
|
||||
if (match) {
|
||||
const refreshed = await refreshSubscriptionExpires(match);
|
||||
if (refreshed) {
|
||||
return { subscriptionId: storedServerId, verified: true };
|
||||
}
|
||||
// Server rejected the refresh (likely the subscription was already
|
||||
// deleted server-side) - drop the stale id and recreate below.
|
||||
await destroyPushSubscription(storedServerId).catch(() => undefined);
|
||||
}
|
||||
await AsyncStorage.removeItem(SUBSCRIPTION_ID_KEY);
|
||||
}
|
||||
@@ -200,6 +223,7 @@ export async function setupPushNotifications(
|
||||
deviceClientId,
|
||||
url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`),
|
||||
types: [...PUSH_TYPES],
|
||||
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
|
||||
});
|
||||
|
||||
const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId);
|
||||
@@ -210,6 +234,29 @@ export async function setupPushNotifications(
|
||||
return { subscriptionId: serverAssignedId, verified: true };
|
||||
}
|
||||
|
||||
// Push the subscription's expires forward when it's getting close to the
|
||||
// server's ceiling. Returns false if the server rejects the update, which the
|
||||
// caller treats as "recreate".
|
||||
async function refreshSubscriptionExpires(
|
||||
sub: { id: string; expires?: string | null },
|
||||
): Promise<boolean> {
|
||||
if (sub.expires) {
|
||||
const remainingMs = new Date(sub.expires).getTime() - Date.now();
|
||||
const thresholdMs = SUBSCRIPTION_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000;
|
||||
if (Number.isFinite(remainingMs) && remainingMs > thresholdMs) {
|
||||
// Plenty of life left - skip the update round-trip.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await updatePushSubscription(sub.id, {
|
||||
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down push on logout / disable. Removes the server-side JMAP
|
||||
* subscription, tells the relay to drop the mapping, and clears the local
|
||||
|
||||
+378
-233
@@ -1,19 +1,25 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, TextInput, Pressable, ScrollView,
|
||||
KeyboardAvoidingView, Platform, ActivityIndicator, Alert,
|
||||
type NativeSyntheticEvent, type TextInputSelectionChangeEventData,
|
||||
KeyboardAvoidingView, Platform, ActivityIndicator, Alert, Modal,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import {
|
||||
X, Send, Paperclip, ChevronDown, Bold, Italic, List, ListOrdered,
|
||||
Link2, Image as ImageIcon, Quote, FileText,
|
||||
X, Send, Paperclip, ChevronDown, Bold, Italic, Underline, Strikethrough,
|
||||
List, ListOrdered, Link2, Link2Off, Image as ImageIcon, Quote,
|
||||
Heading1, Heading2, AlignLeft, AlignCenter, AlignRight, RemoveFormatting,
|
||||
Undo2, Redo2, FileText,
|
||||
} from 'lucide-react-native';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { Button } from '../components';
|
||||
import RichTextEditor, {
|
||||
type RichTextEditorHandle,
|
||||
type RichTextSelectionState,
|
||||
} from '../components/RichTextEditor';
|
||||
import { useEmailStore } from '../stores/email-store';
|
||||
import { useContactsStore } from '../stores/contacts-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
@@ -26,12 +32,8 @@ import {
|
||||
import { getIdentities } from '../api/identity';
|
||||
import { sendEmail, type OutgoingAttachment } from '../api/email';
|
||||
import { uploadBlob } from '../api/blob';
|
||||
import {
|
||||
markdownToHtml,
|
||||
wrapSelection,
|
||||
toggleLinePrefix,
|
||||
formatReplyQuote,
|
||||
} from '../lib/compose-format';
|
||||
import { buildInitialHtml, htmlToPlainText, rewriteInlineImages } from '../lib/compose-html';
|
||||
import { stripDangerousTags } from '../lib/email-html';
|
||||
import type { EmailAddress, Identity, ContactCard } from '../api/types';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
|
||||
@@ -80,6 +82,17 @@ function genLocalId(): string {
|
||||
return `${Date.now().toString(36)}.${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
async function readUriAsDataUrl(uri: string, mime: string): Promise<string | null> {
|
||||
try {
|
||||
const base64 = await FileSystem.readAsStringAsync(uri, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
return `data:${mime};base64,${base64}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function RecipientChip({ recipient, onRemove }: { recipient: Recipient; onRemove: () => void }) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
@@ -169,12 +182,34 @@ function AttachmentChip({
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
active, onPress, icon, disabled,
|
||||
}: {
|
||||
active?: boolean;
|
||||
onPress: () => void;
|
||||
icon: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
hitSlop={4}
|
||||
disabled={disabled}
|
||||
style={[styles.formatBtn, active && styles.formatBtnActive, disabled && styles.formatBtnDisabled]}
|
||||
>
|
||||
{icon}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ComposeScreen({ route, navigation }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const replyTo = route.params?.replyTo;
|
||||
const mode = route.params?.mode;
|
||||
const mode = route.params?.mode ?? 'compose';
|
||||
const prefillTo = route.params?.prefillTo;
|
||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const sentMailbox = React.useMemo(
|
||||
@@ -225,22 +260,39 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
return /^re:/i.test(s) ? s : `Re: ${s}`;
|
||||
}, [replyTo, mode]);
|
||||
|
||||
const initialBodyHtml = React.useMemo(
|
||||
() => buildInitialHtml(mode, replyTo
|
||||
? {
|
||||
from: { name: replyTo.from.name, email: replyTo.from.email },
|
||||
to: replyTo.to,
|
||||
cc: replyTo.cc,
|
||||
subject: replyTo.subject,
|
||||
body: replyTo.body,
|
||||
}
|
||||
: null,
|
||||
),
|
||||
[mode, replyTo],
|
||||
);
|
||||
|
||||
const [toRecipients, setToRecipients] = React.useState<Recipient[]>(initialTo);
|
||||
const [ccRecipients, setCcRecipients] = React.useState<Recipient[]>(initialCc);
|
||||
const [ccVisible, setCcVisible] = React.useState(initialCc.length > 0);
|
||||
const [toInput, setToInput] = React.useState('');
|
||||
const [ccInput, setCcInput] = React.useState('');
|
||||
const [subject, setSubject] = React.useState(initialSubject);
|
||||
const [body, setBody] = React.useState('');
|
||||
const [bodySelection, setBodySelection] = React.useState({ start: 0, end: 0 });
|
||||
// Carry the next caret position from a programmatic edit so we can apply it
|
||||
// imperatively after the body text has flushed. We avoid driving the
|
||||
// TextInput's `selection` prop because that would pin the caret on every
|
||||
// render and fight the user when they tap to move the cursor.
|
||||
const pendingSelectionRef = React.useRef<{ start: number; end: number } | null>(null);
|
||||
const [bodyHtml, setBodyHtml] = React.useState(initialBodyHtml);
|
||||
const [activeField, setActiveField] = React.useState<'to' | 'cc' | null>(null);
|
||||
const [attachments, setAttachments] = React.useState<AttachmentEntry[]>([]);
|
||||
const bodyInputRef = React.useRef<TextInput>(null);
|
||||
const [selState, setSelState] = React.useState<RichTextSelectionState>({
|
||||
bold: false, italic: false, underline: false, strikeThrough: false,
|
||||
ul: false, ol: false, blockquote: false, h1: false, h2: false,
|
||||
alignLeft: false, alignCenter: false, alignRight: false, link: false,
|
||||
});
|
||||
|
||||
const editorRef = React.useRef<RichTextEditorHandle>(null);
|
||||
// Track inline-image placeholders that haven't yet been rewritten to cid:
|
||||
// until send time. Maps cid → blobId/type/name/size.
|
||||
const inlineRegistryRef = React.useRef<Map<string, AttachmentEntry>>(new Map());
|
||||
|
||||
const allContacts = useContactsStore((s) => s.contacts);
|
||||
const individuals = React.useMemo(() => allContacts.filter((c) => !isGroup(c)), [allContacts]);
|
||||
@@ -300,9 +352,10 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
const commitTyped = () => {
|
||||
const extraTo = parseRecipients(toInput);
|
||||
const extraCc = parseRecipients(ccInput);
|
||||
const finalTo = [...toRecipients, ...extraTo];
|
||||
const finalCc = [...ccRecipients, ...extraCc];
|
||||
return { finalTo, finalCc };
|
||||
return {
|
||||
finalTo: [...toRecipients, ...extraTo],
|
||||
finalCc: [...ccRecipients, ...extraCc],
|
||||
};
|
||||
};
|
||||
|
||||
const addTyped = () => {
|
||||
@@ -322,39 +375,29 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
const hasUploadInFlight = attachments.some((a) => a.uploading);
|
||||
const hasUploadError = attachments.some((a) => !!a.error);
|
||||
const hasValidRecipients = finalTo.every((r) => EMAIL_RE.test(r.email)) && finalTo.length > 0;
|
||||
const bodyPlain = React.useMemo(() => htmlToPlainText(bodyHtml), [bodyHtml]);
|
||||
const hasBodyContent = bodyPlain.trim().length > 0
|
||||
|| attachments.some((a) => a.blobId && !a.error);
|
||||
const canSend =
|
||||
!sending &&
|
||||
!hasUploadInFlight &&
|
||||
!hasUploadError &&
|
||||
hasValidRecipients &&
|
||||
subject.trim().length > 0 &&
|
||||
hasBodyContent &&
|
||||
!!primaryIdentity &&
|
||||
!!sentMailbox;
|
||||
|
||||
const onBodySelectionChange = (e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
|
||||
setBodySelection(e.nativeEvent.selection);
|
||||
};
|
||||
|
||||
// Apply caret moves imperatively once the body text has flushed - using
|
||||
// setNativeProps means we don't render the TextInput as selection-controlled
|
||||
// and the user keeps free cursor control.
|
||||
React.useLayoutEffect(() => {
|
||||
const target = pendingSelectionRef.current;
|
||||
if (!target || !bodyInputRef.current) return;
|
||||
pendingSelectionRef.current = null;
|
||||
bodyInputRef.current.setNativeProps({ selection: target });
|
||||
setBodySelection(target);
|
||||
});
|
||||
|
||||
const applyEdit = (edit: { text: string; selection: { start: number; end: number } }) => {
|
||||
pendingSelectionRef.current = edit.selection;
|
||||
setBody(edit.text);
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
if (body.trim() || subject.trim() || attachments.length > 0) {
|
||||
const isDirty =
|
||||
bodyPlain.trim().length > 0
|
||||
|| subject.trim().length > 0
|
||||
|| toRecipients.length > 0
|
||||
|| ccRecipients.length > 0
|
||||
|| attachments.length > 0;
|
||||
if (isDirty) {
|
||||
Alert.alert(
|
||||
t('email_composer.close_draft_title', 'Discard draft?'),
|
||||
t('email_composer.discard_draft_title', 'Discard draft?'),
|
||||
t('email_composer.discard_draft_confirm', 'You have unsaved changes. Do you want to discard this draft?'),
|
||||
[
|
||||
{ text: t('email_composer.cancel', 'Cancel'), style: 'cancel' },
|
||||
@@ -370,36 +413,45 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
navigation.goBack();
|
||||
};
|
||||
|
||||
const addAttachmentEntry = (entry: AttachmentEntry) => {
|
||||
setAttachments((prev) => [...prev, entry]);
|
||||
};
|
||||
|
||||
// ── Attachments ──────────────────────────────────────────────────────
|
||||
const updateAttachment = (localId: string, patch: Partial<AttachmentEntry>) => {
|
||||
setAttachments((prev) => prev.map((a) => (a.localId === localId ? { ...a, ...patch } : a)));
|
||||
if (patch.blobId !== undefined || patch.error !== undefined) {
|
||||
const cid = inlineRegistryRef.current;
|
||||
const entry = Array.from(cid.values()).find((e) => e.localId === localId);
|
||||
if (entry) cid.set(entry.cid!, { ...entry, ...patch });
|
||||
}
|
||||
};
|
||||
|
||||
const removeAttachment = (localId: string) => {
|
||||
setAttachments((prev) => {
|
||||
const removed = prev.find((a) => a.localId === localId);
|
||||
// If an inline image is removed, also drop its markdown reference from
|
||||
// the body so the user doesn't ship a dangling cid: link.
|
||||
if (removed?.inline && removed.cid) {
|
||||
const ref = ``;
|
||||
if (body.includes(ref)) setBody((b) => b.replace(ref, '').replace(/\n{3,}/g, '\n\n'));
|
||||
inlineRegistryRef.current.delete(removed.cid);
|
||||
// Strip the editor's <img data-cid="…"> for this cid.
|
||||
const escCid = removed.cid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const stripped = bodyHtml.replace(
|
||||
new RegExp(`<img\\b[^>]*\\sdata-cid=("${escCid}"|'${escCid}')[^>]*>`, 'gi'),
|
||||
'',
|
||||
);
|
||||
if (stripped !== bodyHtml) {
|
||||
setBodyHtml(stripped);
|
||||
editorRef.current?.setHtml(stripped);
|
||||
}
|
||||
}
|
||||
return prev.filter((a) => a.localId !== localId);
|
||||
});
|
||||
};
|
||||
|
||||
const startUpload = async (asset: {
|
||||
const addUploadEntry = (asset: {
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
uri: string;
|
||||
inline: boolean;
|
||||
}) => {
|
||||
cid?: string;
|
||||
}): AttachmentEntry => {
|
||||
const localId = genLocalId();
|
||||
const cid = asset.inline ? genCid() : undefined;
|
||||
const entry: AttachmentEntry = {
|
||||
localId,
|
||||
name: asset.name,
|
||||
@@ -407,37 +459,34 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
size: asset.size,
|
||||
uri: asset.uri,
|
||||
inline: asset.inline,
|
||||
cid,
|
||||
cid: asset.cid,
|
||||
uploading: true,
|
||||
};
|
||||
addAttachmentEntry(entry);
|
||||
|
||||
if (asset.inline && cid) {
|
||||
// Insert a markdown reference at the caret so the user can move it.
|
||||
const ref = `\n\n`;
|
||||
const next = wrapSelection(body, bodySelection, ref, '', '');
|
||||
applyEdit(next);
|
||||
}
|
||||
setAttachments((prev) => [...prev, entry]);
|
||||
if (asset.inline && asset.cid) inlineRegistryRef.current.set(asset.cid, entry);
|
||||
return entry;
|
||||
};
|
||||
|
||||
const startUpload = async (entry: AttachmentEntry) => {
|
||||
try {
|
||||
const { blobId, size, type } = await uploadBlob(asset.uri, asset.type);
|
||||
updateAttachment(localId, {
|
||||
const { blobId, size, type } = await uploadBlob(entry.uri, entry.type);
|
||||
updateAttachment(entry.localId, {
|
||||
blobId,
|
||||
type: type || asset.type,
|
||||
size: size || asset.size,
|
||||
type: type || entry.type,
|
||||
size: size || entry.size,
|
||||
uploading: false,
|
||||
});
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : 'Upload failed';
|
||||
updateAttachment(localId, { uploading: false, error: message });
|
||||
updateAttachment(entry.localId, { uploading: false, error: message });
|
||||
Alert.alert(
|
||||
t('email_composer.send_failed', 'Failed to send email'),
|
||||
t('email_composer.upload_failed', 'Failed to upload {filename}').replace('{filename}', asset.name),
|
||||
t('email_composer.upload_failed', 'Failed to upload {filename}').replace('{filename}', entry.name),
|
||||
message,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const pickAttachment = async (inline: boolean) => {
|
||||
const pickAttachment = async () => {
|
||||
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!perm.granted) {
|
||||
Alert.alert(
|
||||
@@ -447,117 +496,130 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
return;
|
||||
}
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: inline ? ['images'] : ['images', 'videos'],
|
||||
allowsMultipleSelection: !inline,
|
||||
mediaTypes: ['images', 'videos'],
|
||||
allowsMultipleSelection: true,
|
||||
quality: 0.9,
|
||||
exif: false,
|
||||
});
|
||||
if (result.canceled) return;
|
||||
for (const asset of result.assets) {
|
||||
const fallbackName = asset.fileName
|
||||
|| `${inline ? 'image' : 'attachment'}-${Date.now()}.${(asset.mimeType ?? 'image/jpeg').split('/')[1] ?? 'bin'}`;
|
||||
void startUpload({
|
||||
|| `attachment-${Date.now()}.${(asset.mimeType ?? 'application/octet-stream').split('/')[1] ?? 'bin'}`;
|
||||
const entry = addUploadEntry({
|
||||
name: fallbackName,
|
||||
type: asset.mimeType ?? 'application/octet-stream',
|
||||
size: asset.fileSize ?? 0,
|
||||
uri: asset.uri,
|
||||
inline,
|
||||
inline: false,
|
||||
});
|
||||
void startUpload(entry);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Format toolbar handlers ────────────────────────────────────────────
|
||||
const formatBold = () => {
|
||||
applyEdit(wrapSelection(body, bodySelection, '**', '**', 'bold'));
|
||||
bodyInputRef.current?.focus();
|
||||
};
|
||||
const formatItalic = () => {
|
||||
applyEdit(wrapSelection(body, bodySelection, '_', '_', 'italic'));
|
||||
bodyInputRef.current?.focus();
|
||||
};
|
||||
const formatBullet = () => {
|
||||
applyEdit(toggleLinePrefix(body, bodySelection, '- '));
|
||||
bodyInputRef.current?.focus();
|
||||
};
|
||||
const formatNumbered = () => {
|
||||
applyEdit(toggleLinePrefix(body, bodySelection, '1. '));
|
||||
bodyInputRef.current?.focus();
|
||||
};
|
||||
const formatQuote = () => {
|
||||
applyEdit(toggleLinePrefix(body, bodySelection, '> '));
|
||||
bodyInputRef.current?.focus();
|
||||
};
|
||||
const insertLink = (url: string) => {
|
||||
const target = URL_RE.test(url) ? url : `https://${url}`;
|
||||
const start = Math.min(bodySelection.start, bodySelection.end);
|
||||
const end = Math.max(bodySelection.start, bodySelection.end);
|
||||
const selected = body.slice(start, end);
|
||||
const label = selected || target;
|
||||
const insertion = `[${label}](${target})`;
|
||||
const next = `${body.slice(0, start)}${insertion}${body.slice(end)}`;
|
||||
const cursor = start + insertion.length;
|
||||
applyEdit({ text: next, selection: { start: cursor, end: cursor } });
|
||||
bodyInputRef.current?.focus();
|
||||
};
|
||||
|
||||
const formatLink = () => {
|
||||
if (Platform.OS === 'ios' && typeof Alert.prompt === 'function') {
|
||||
Alert.prompt(
|
||||
t('email_composer.add_link', 'Add link'),
|
||||
t('email_composer.link_url_prompt', 'Enter the URL'),
|
||||
(url) => { if (url) insertLink(url); },
|
||||
'plain-text',
|
||||
'',
|
||||
'url',
|
||||
const insertInlineImage = async () => {
|
||||
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!perm.granted) {
|
||||
Alert.alert(
|
||||
t('email_composer.attach', 'Attach'),
|
||||
'Photo library permission is required to attach images.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Android / web: insert an editable stub with the URL portion selected.
|
||||
const stub = '[link text](https://)';
|
||||
const start = Math.min(bodySelection.start, bodySelection.end);
|
||||
const end = Math.max(bodySelection.start, bodySelection.end);
|
||||
const next = `${body.slice(0, start)}${stub}${body.slice(end)}`;
|
||||
const urlStart = start + '[link text]('.length;
|
||||
const urlEnd = start + stub.length - 1;
|
||||
applyEdit({ text: next, selection: { start: urlStart, end: urlEnd } });
|
||||
bodyInputRef.current?.focus();
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ['images'],
|
||||
allowsMultipleSelection: false,
|
||||
quality: 0.9,
|
||||
exif: false,
|
||||
});
|
||||
if (result.canceled || !result.assets[0]) return;
|
||||
const asset = result.assets[0];
|
||||
const mime = asset.mimeType ?? 'image/jpeg';
|
||||
const fallbackName = asset.fileName || `image-${Date.now()}.${mime.split('/')[1] ?? 'jpg'}`;
|
||||
const cid = genCid();
|
||||
|
||||
// Read the picked image as a data URL so it shows up immediately in the
|
||||
// editor. At send time the data URL is rewritten to `cid:<id>` and the
|
||||
// matching inline part is added via the registry.
|
||||
const dataUrl = await readUriAsDataUrl(asset.uri, mime);
|
||||
if (!dataUrl) {
|
||||
Alert.alert(t('email_composer.attach', 'Attach'), 'Could not load image');
|
||||
return;
|
||||
}
|
||||
editorRef.current?.insertImage(dataUrl, cid, fallbackName);
|
||||
const entry = addUploadEntry({
|
||||
name: fallbackName,
|
||||
type: mime,
|
||||
size: asset.fileSize ?? 0,
|
||||
uri: asset.uri,
|
||||
inline: true,
|
||||
cid,
|
||||
});
|
||||
void startUpload(entry);
|
||||
};
|
||||
|
||||
// ── Link prompt (Modal) ──────────────────────────────────────────────
|
||||
const [linkPromptVisible, setLinkPromptVisible] = React.useState(false);
|
||||
const [linkPromptValue, setLinkPromptValue] = React.useState('');
|
||||
|
||||
const openLinkPrompt = () => {
|
||||
setLinkPromptValue('https://');
|
||||
setLinkPromptVisible(true);
|
||||
};
|
||||
|
||||
const submitLinkPrompt = () => {
|
||||
const trimmed = linkPromptValue.trim();
|
||||
setLinkPromptVisible(false);
|
||||
if (!trimmed || trimmed === 'https://' || trimmed === 'http://') return;
|
||||
const url = URL_RE.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||
editorRef.current?.insertLink(url);
|
||||
};
|
||||
|
||||
const onLinkPress = () => {
|
||||
if (selState.link) {
|
||||
editorRef.current?.unsetLink();
|
||||
return;
|
||||
}
|
||||
openLinkPrompt();
|
||||
};
|
||||
|
||||
// ── Send ─────────────────────────────────────────────────────────────
|
||||
const onSend = async () => {
|
||||
if (!canSend || !primaryIdentity || !sentMailbox) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const from: EmailAddress[] = [{ name: primaryIdentity.name, email: primaryIdentity.email }];
|
||||
|
||||
const quote = replyTo?.body
|
||||
? formatReplyQuote(replyTo.body, {
|
||||
senderName: replyTo.from.name || replyTo.from.email,
|
||||
date: new Date(),
|
||||
})
|
||||
: '';
|
||||
const { html: rewrittenHtml, usedCids } = rewriteInlineImages(bodyHtml);
|
||||
// Belt-and-suspenders sanitization: the editor uses execCommand which
|
||||
// can preserve pasted <script>/<style>/etc. Strip them before sending.
|
||||
const safeHtml = stripDangerousTags(rewrittenHtml);
|
||||
const finalHtml = `<div>${safeHtml}</div>`;
|
||||
const finalText = htmlToPlainText(safeHtml);
|
||||
|
||||
const sourceText = body + quote;
|
||||
const htmlBody = markdownToHtml(sourceText);
|
||||
const inlineFromBody = usedCids
|
||||
.map((cid) => inlineRegistryRef.current.get(cid))
|
||||
.filter((e): e is AttachmentEntry => !!e && !!e.blobId && !e.error)
|
||||
.map<OutgoingAttachment>((e) => ({
|
||||
blobId: e.blobId!,
|
||||
type: e.type,
|
||||
name: e.name,
|
||||
size: e.size,
|
||||
disposition: 'inline',
|
||||
cid: e.cid,
|
||||
}));
|
||||
|
||||
// Plain text fallback: keep the user's source as-is. Strip our basic
|
||||
// markdown markers so receivers without HTML get clean text.
|
||||
const plainText = sourceText
|
||||
.replace(/!\[([^\]]*)\]\(cid:[^)]+\)/g, '[$1]')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)')
|
||||
.replace(/\*\*([^*\n]+)\*\*/g, '$1')
|
||||
.replace(/(^|[^*\w])_([^_\n]+)_(?=$|[^*\w])/g, '$1$2');
|
||||
|
||||
const outgoing: OutgoingAttachment[] = attachments
|
||||
.filter((a) => a.blobId && !a.error)
|
||||
.map((a) => ({
|
||||
const fileAttachments = attachments
|
||||
.filter((a) => !a.inline && a.blobId && !a.error)
|
||||
.map<OutgoingAttachment>((a) => ({
|
||||
blobId: a.blobId!,
|
||||
type: a.type,
|
||||
name: a.name,
|
||||
size: a.size,
|
||||
disposition: a.inline ? 'inline' : 'attachment',
|
||||
cid: a.cid,
|
||||
disposition: 'attachment',
|
||||
}));
|
||||
|
||||
const outgoing = [...inlineFromBody, ...fileAttachments];
|
||||
|
||||
await sendEmail(
|
||||
{
|
||||
from,
|
||||
@@ -566,8 +628,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
? finalCc.map((r) => ({ name: r.name || undefined, email: r.email }))
|
||||
: undefined,
|
||||
subject,
|
||||
htmlBody,
|
||||
textBody: plainText,
|
||||
htmlBody: finalHtml,
|
||||
textBody: finalText,
|
||||
attachments: outgoing.length ? outgoing : undefined,
|
||||
inReplyTo: replyTo?.inReplyTo,
|
||||
references: replyTo?.references,
|
||||
@@ -603,7 +665,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
</Text>
|
||||
<View style={styles.headerRight}>
|
||||
<Pressable
|
||||
onPress={() => { void pickAttachment(false); }}
|
||||
onPress={() => { void pickAttachment(); }}
|
||||
style={styles.headerBtn}
|
||||
hitSlop={8}
|
||||
>
|
||||
@@ -635,7 +697,11 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
style={styles.flex}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<ScrollView style={styles.flex} keyboardShouldPersistTaps="handled">
|
||||
<ScrollView
|
||||
style={styles.flex}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
<View style={styles.fieldRow}>
|
||||
<Text style={styles.fieldLabel}>{t('email_composer.from', 'From')}</Text>
|
||||
<View style={styles.fieldContent}>
|
||||
@@ -740,34 +806,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
ref={bodyInputRef}
|
||||
style={styles.bodyInput}
|
||||
<RichTextEditor
|
||||
ref={editorRef}
|
||||
initialHtml={initialBodyHtml}
|
||||
placeholder={t('email_composer.body_placeholder', 'Write your message...')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
value={body}
|
||||
onChangeText={setBody}
|
||||
onSelectionChange={onBodySelectionChange}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
autoCapitalize="sentences"
|
||||
onChange={setBodyHtml}
|
||||
onSelectionChange={setSelState}
|
||||
/>
|
||||
|
||||
{replyTo?.body && (
|
||||
<View style={styles.quoteBlock}>
|
||||
<View style={styles.quoteBorder} />
|
||||
<View style={styles.quoteContent}>
|
||||
<Text style={styles.quoteMeta}>
|
||||
{t('email_composer.quote.reply_header', 'On {date}, {sender} wrote:')
|
||||
.replace('{date}', new Date().toLocaleDateString())
|
||||
.replace('{sender}', replyTo.from.name || replyTo.from.email)}
|
||||
</Text>
|
||||
<Text style={styles.quoteText} numberOfLines={6}>
|
||||
{replyTo.body}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<ScrollView
|
||||
@@ -775,44 +820,111 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.formatBar}
|
||||
contentContainerStyle={styles.formatActions}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardShouldPersistTaps="always"
|
||||
>
|
||||
<Pressable style={styles.formatBtn} onPress={formatBold} hitSlop={4}>
|
||||
<Bold size={18} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
<Pressable style={styles.formatBtn} onPress={formatItalic} hitSlop={4}>
|
||||
<Italic size={18} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
<ToolbarButton active={selState.bold} onPress={() => editorRef.current?.exec('bold')}
|
||||
icon={<Bold size={18} color={selState.bold ? c.primary : c.textSecondary} />} />
|
||||
<ToolbarButton active={selState.italic} onPress={() => editorRef.current?.exec('italic')}
|
||||
icon={<Italic size={18} color={selState.italic ? c.primary : c.textSecondary} />} />
|
||||
<ToolbarButton active={selState.underline} onPress={() => editorRef.current?.exec('underline')}
|
||||
icon={<Underline size={18} color={selState.underline ? c.primary : c.textSecondary} />} />
|
||||
<ToolbarButton active={selState.strikeThrough} onPress={() => editorRef.current?.exec('strikeThrough')}
|
||||
icon={<Strikethrough size={18} color={selState.strikeThrough ? c.primary : c.textSecondary} />} />
|
||||
|
||||
<View style={styles.formatSep} />
|
||||
<Pressable style={styles.formatBtn} onPress={formatBullet} hitSlop={4}>
|
||||
<List size={18} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
<Pressable style={styles.formatBtn} onPress={formatNumbered} hitSlop={4}>
|
||||
<ListOrdered size={18} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
<Pressable style={styles.formatBtn} onPress={formatQuote} hitSlop={4}>
|
||||
<Quote size={18} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
|
||||
<ToolbarButton active={selState.h1} onPress={() => editorRef.current?.exec('formatBlock:H1')}
|
||||
icon={<Heading1 size={18} color={selState.h1 ? c.primary : c.textSecondary} />} />
|
||||
<ToolbarButton active={selState.h2} onPress={() => editorRef.current?.exec('formatBlock:H2')}
|
||||
icon={<Heading2 size={18} color={selState.h2 ? c.primary : c.textSecondary} />} />
|
||||
|
||||
<View style={styles.formatSep} />
|
||||
<Pressable style={styles.formatBtn} onPress={formatLink} hitSlop={4}>
|
||||
<Link2 size={18} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.formatBtn}
|
||||
onPress={() => { void pickAttachment(true); }}
|
||||
hitSlop={4}
|
||||
>
|
||||
<ImageIcon size={18} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.formatBtn}
|
||||
onPress={() => { void pickAttachment(false); }}
|
||||
hitSlop={4}
|
||||
>
|
||||
<Paperclip size={18} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
|
||||
<ToolbarButton active={selState.ul} onPress={() => editorRef.current?.exec('insertUnorderedList')}
|
||||
icon={<List size={18} color={selState.ul ? c.primary : c.textSecondary} />} />
|
||||
<ToolbarButton active={selState.ol} onPress={() => editorRef.current?.exec('insertOrderedList')}
|
||||
icon={<ListOrdered size={18} color={selState.ol ? c.primary : c.textSecondary} />} />
|
||||
<ToolbarButton active={selState.blockquote} onPress={() => editorRef.current?.exec('formatBlock:BLOCKQUOTE')}
|
||||
icon={<Quote size={18} color={selState.blockquote ? c.primary : c.textSecondary} />} />
|
||||
|
||||
<View style={styles.formatSep} />
|
||||
|
||||
<ToolbarButton active={selState.alignLeft} onPress={() => editorRef.current?.exec('justifyLeft')}
|
||||
icon={<AlignLeft size={18} color={selState.alignLeft ? c.primary : c.textSecondary} />} />
|
||||
<ToolbarButton active={selState.alignCenter} onPress={() => editorRef.current?.exec('justifyCenter')}
|
||||
icon={<AlignCenter size={18} color={selState.alignCenter ? c.primary : c.textSecondary} />} />
|
||||
<ToolbarButton active={selState.alignRight} onPress={() => editorRef.current?.exec('justifyRight')}
|
||||
icon={<AlignRight size={18} color={selState.alignRight ? c.primary : c.textSecondary} />} />
|
||||
|
||||
<View style={styles.formatSep} />
|
||||
|
||||
<ToolbarButton active={selState.link} onPress={onLinkPress}
|
||||
icon={selState.link
|
||||
? <Link2Off size={18} color={c.primary} />
|
||||
: <Link2 size={18} color={c.textSecondary} />} />
|
||||
<ToolbarButton onPress={() => { void insertInlineImage(); }}
|
||||
icon={<ImageIcon size={18} color={c.textSecondary} />} />
|
||||
<ToolbarButton onPress={() => editorRef.current?.exec('removeFormat')}
|
||||
icon={<RemoveFormatting size={18} color={c.textSecondary} />} />
|
||||
|
||||
<View style={styles.formatSep} />
|
||||
|
||||
<ToolbarButton onPress={() => editorRef.current?.exec('undo')}
|
||||
icon={<Undo2 size={18} color={c.textSecondary} />} />
|
||||
<ToolbarButton onPress={() => editorRef.current?.exec('redo')}
|
||||
icon={<Redo2 size={18} color={c.textSecondary} />} />
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
<Modal
|
||||
visible={linkPromptVisible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setLinkPromptVisible(false)}
|
||||
>
|
||||
<View style={styles.modalBackdrop}>
|
||||
<View style={styles.modalCard}>
|
||||
<Text style={styles.modalTitle}>
|
||||
{t('email_composer.add_link', 'Add link')}
|
||||
</Text>
|
||||
<Text style={styles.modalLabel}>
|
||||
{t('email_composer.link_url_prompt', 'Enter the URL')}
|
||||
</Text>
|
||||
<TextInput
|
||||
style={styles.modalInput}
|
||||
value={linkPromptValue}
|
||||
onChangeText={setLinkPromptValue}
|
||||
placeholder="https://example.com"
|
||||
placeholderTextColor={c.textMuted}
|
||||
keyboardType="url"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
onSubmitEditing={submitLinkPrompt}
|
||||
/>
|
||||
<View style={styles.modalActions}>
|
||||
<Pressable
|
||||
style={styles.modalCancel}
|
||||
onPress={() => setLinkPromptVisible(false)}
|
||||
hitSlop={4}
|
||||
>
|
||||
<Text style={styles.modalCancelText}>
|
||||
{t('email_composer.cancel', 'Cancel')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.modalConfirm}
|
||||
onPress={submitLinkPrompt}
|
||||
hitSlop={4}
|
||||
>
|
||||
<Text style={styles.modalConfirmText}>
|
||||
{t('confirm_dialog.confirm', 'Confirm')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -821,6 +933,7 @@ function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: c.background },
|
||||
flex: { flex: 1 },
|
||||
scrollContent: { flexGrow: 1 },
|
||||
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
@@ -953,26 +1066,6 @@ function makeStyles(c: ThemePalette) {
|
||||
attachmentSize: { ...typography.caption, color: c.textMuted },
|
||||
attachmentRemove: { padding: 4 },
|
||||
|
||||
bodyInput: {
|
||||
...typography.body,
|
||||
color: c.text,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
minHeight: 200,
|
||||
lineHeight: 22,
|
||||
},
|
||||
|
||||
quoteBlock: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: spacing.lg,
|
||||
marginTop: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
quoteBorder: { width: 3, backgroundColor: c.border, borderRadius: radius.xs },
|
||||
quoteContent: { flex: 1 },
|
||||
quoteMeta: { ...typography.caption, color: c.textMuted, marginBottom: spacing.xs },
|
||||
quoteText: { ...typography.caption, color: c.textSecondary, lineHeight: 18 },
|
||||
|
||||
formatBar: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: c.border,
|
||||
@@ -993,11 +1086,63 @@ function makeStyles(c: ThemePalette) {
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
formatBtnActive: {
|
||||
backgroundColor: c.primaryBg,
|
||||
},
|
||||
formatBtnDisabled: { opacity: 0.4 },
|
||||
formatSep: {
|
||||
width: 1,
|
||||
height: 20,
|
||||
backgroundColor: c.borderLight,
|
||||
marginHorizontal: 2,
|
||||
},
|
||||
|
||||
modalBackdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: spacing.lg,
|
||||
},
|
||||
modalCard: {
|
||||
width: '100%',
|
||||
maxWidth: 420,
|
||||
backgroundColor: c.background,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
padding: spacing.lg,
|
||||
gap: spacing.md,
|
||||
},
|
||||
modalTitle: { ...typography.h3, color: c.text },
|
||||
modalLabel: { ...typography.caption, color: c.textSecondary },
|
||||
modalInput: {
|
||||
...typography.body,
|
||||
color: c.text,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.borderLight,
|
||||
backgroundColor: c.surface,
|
||||
},
|
||||
modalActions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
modalCancel: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
modalCancelText: { ...typography.bodyMedium, color: c.textSecondary },
|
||||
modalConfirm: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.primary,
|
||||
},
|
||||
modalConfirmText: { ...typography.bodyMedium, color: c.primaryForeground },
|
||||
});
|
||||
}
|
||||
|
||||
+682
-321
File diff suppressed because it is too large
Load Diff
+1031
-393
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ import EmailBodyView from '../components/EmailBodyView';
|
||||
import SenderAvatar from '../components/SenderAvatar';
|
||||
import { useEmailStore } from '../stores/email-store';
|
||||
import { setEmailKeywords } from '../api/email';
|
||||
import { shareEmailEml } from '../lib/email-export';
|
||||
import { shareEmailEml, shareAttachment } from '../lib/email-export';
|
||||
import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store';
|
||||
import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree';
|
||||
import { useSheetDrag } from '../lib/use-sheet-drag';
|
||||
@@ -92,6 +92,23 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
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);
|
||||
|
||||
const onPressAttachment = React.useCallback(
|
||||
async (blobId: string, name: string | undefined, type: string | undefined) => {
|
||||
if (downloadingBlobId) return;
|
||||
setDownloadingBlobId(blobId);
|
||||
try {
|
||||
await shareAttachment(blobId, name, type);
|
||||
} catch (e) {
|
||||
Alert.alert('Download failed', e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setDownloadingBlobId(null);
|
||||
}
|
||||
},
|
||||
[downloadingBlobId],
|
||||
);
|
||||
const keywordDefs = useKeywordsStore((s) => s.keywords);
|
||||
const hydrateKeywords = useKeywordsStore((s) => s.hydrate);
|
||||
const keywordsHydrated = useKeywordsStore((s) => s.hydrated);
|
||||
@@ -350,16 +367,51 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
{email.attachments && email.attachments.length > 0 && (
|
||||
<View style={styles.attachmentsBlock}>
|
||||
<View style={styles.attachmentsRow}>
|
||||
{email.attachments.map((att, idx) => (
|
||||
<View key={att.blobId ?? idx} style={styles.attachmentChip}>
|
||||
<Paperclip size={14} color={c.textMuted} />
|
||||
<Text style={styles.attachmentName} numberOfLines={1}>
|
||||
{att.name || 'attachment'}
|
||||
</Text>
|
||||
<Text style={styles.attachmentSize}>{formatSize(att.size)}</Text>
|
||||
</View>
|
||||
))}
|
||||
{(attachmentsExpanded
|
||||
? email.attachments
|
||||
: email.attachments.slice(0, 3)
|
||||
).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>
|
||||
{email.attachments.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 (${email.attachments.length})`}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -972,6 +1024,10 @@ function makeStyles(c: ThemePalette) {
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
attachmentChipPressed: {
|
||||
backgroundColor: c.surfaceHover,
|
||||
opacity: 0.85,
|
||||
},
|
||||
attachmentName: {
|
||||
...typography.caption,
|
||||
color: c.text,
|
||||
@@ -981,6 +1037,20 @@ function makeStyles(c: ThemePalette) {
|
||||
...typography.small,
|
||||
color: c.textMuted,
|
||||
},
|
||||
attachmentsToggle: {
|
||||
marginTop: spacing.xs,
|
||||
alignSelf: 'flex-start',
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: spacing.xs,
|
||||
},
|
||||
attachmentsTogglePressed: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
attachmentsToggleText: {
|
||||
...typography.caption,
|
||||
color: c.primary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
|
||||
// Body
|
||||
bodyBlock: {
|
||||
|
||||
Reference in New Issue
Block a user