Compare commits

...
4 Commits
Author SHA1 Message Date
Linus Rath 49d1fc62a8 chore: update version number to 0.1.12 2026-04-27 17:14:32 +02:00
Linus Rath d5a336519a feat: add rich text editor component and enhance email thread screen with attachment handling
- Implemented a new RichTextEditor component for composing and editing rich text emails.
- Enhanced EmailThreadScreen to support downloading attachments with loading indicators.
- Added functionality to expand/collapse attachment list in EmailThreadScreen.
- Created utility functions for building initial HTML for email composition and converting HTML to plain text.
- Added tests for HTML composition and conversion utilities.
2026-04-27 17:14:22 +02:00
Linus Rath 9c7654acb2 chore: update version number to 0.1.11 2026-04-27 13:55:25 +02:00
Linus Rath 346a71de84 feat: enhance email composition with markdown support and attachment handling
- Implement markdown formatting in the email composer, allowing users to use bold, italic, lists, and blockquotes.
- Add functionality to upload and manage attachments, including inline images.
- Introduce a new compose-format utility for converting markdown to HTML.
- Update the ComposeScreen to handle attachments and formatting actions.
- Refactor ContactDetailScreen and EmailListScreen for improved code clarity and performance.
- Add tests for markdown formatting and selection wrapping functionalities.
2026-04-27 13:55:16 +02:00
29 changed files with 3497 additions and 921 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.10
0.1.12
+3
View File
@@ -454,6 +454,9 @@
"discard_draft_title": "Discard draft?",
"discard_draft_confirm": "You have unsaved changes. Do you want to discard this draft?",
"saving": "Saving...",
"sending": "Sending...",
"add_link": "Add link",
"link_url_prompt": "Enter the URL",
"draft_saved": "Draft saved",
"save_failed": "Failed to save",
"to_placeholder": "Recipient email addresses",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.10",
"version": "0.1.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.10",
"version": "0.1.12",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.10",
"version": "0.1.12",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
+28 -11
View File
@@ -164,6 +164,15 @@ export async function queryEmailsByFilter(
return res.methodResponses[0][1].ids;
}
export interface OutgoingAttachment {
blobId: string;
type: string;
name: string;
size?: number;
disposition?: 'attachment' | 'inline';
cid?: string;
}
export async function sendEmail(
email: {
from: EmailAddress[];
@@ -173,7 +182,7 @@ export async function sendEmail(
subject: string;
htmlBody?: string;
textBody?: string;
attachments?: Array<{ blobId: string; type: string; name: string }>;
attachments?: OutgoingAttachment[];
inReplyTo?: string;
references?: string;
},
@@ -191,21 +200,29 @@ export async function sendEmail(
keywords: { $seen: true },
};
const bodyValues: Record<string, { value: string }> = {};
if (email.htmlBody) {
emailCreate.htmlBody = [{ partId: 'html', type: 'text/html' }];
emailCreate.bodyValues = { html: { value: email.htmlBody } };
} else {
emailCreate.textBody = [{ partId: 'text', type: 'text/plain' }];
emailCreate.bodyValues = { text: { value: email.textBody ?? '' } };
bodyValues.html = { value: email.htmlBody };
}
if (email.textBody || !email.htmlBody) {
emailCreate.textBody = [{ partId: 'text', type: 'text/plain' }];
bodyValues.text = { value: email.textBody ?? '' };
}
emailCreate.bodyValues = bodyValues;
if (email.attachments?.length) {
emailCreate.attachments = email.attachments.map((a) => ({
blobId: a.blobId,
type: a.type,
name: a.name,
disposition: 'attachment',
}));
emailCreate.attachments = email.attachments.map((a) => {
const part: Record<string, unknown> = {
blobId: a.blobId,
type: a.type,
name: a.name,
disposition: a.disposition ?? 'attachment',
};
if (a.size != null) part.size = a.size;
if (a.cid) part.cid = a.cid;
return part;
});
}
if (email.inReplyTo) {
+38 -12
View File
@@ -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;
}
/**
+24
View File
@@ -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;
}
+1 -1
View File
@@ -9,7 +9,7 @@ type ButtonSize = 'sm' | 'md' | 'lg' | 'icon';
interface ButtonProps {
variant?: ButtonVariant;
size?: ButtonSize;
children: React.ReactNode;
children?: React.ReactNode;
onPress?: () => void;
disabled?: boolean;
loading?: boolean;
+461
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
var safeUrl = url.replace(/"/g, '&quot;');
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, '&quot;');
var safeAlt = (alt || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;');
var safeCid = cid ? cid.replace(/"/g, '&quot;') : '';
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',
},
});
}
+5 -5
View File
@@ -49,17 +49,17 @@ function iconFor(
return Folder;
}
const ROLE_COLOR: Record<string, string> = {
const ROLE_COLOR_FIXED: Record<string, string> = {
inbox: '#60a5fa',
sent: '#4ade80',
drafts: '#a78bfa',
trash: c.textMuted,
junk: '#f87171',
archive: '#fbbf24',
};
function iconColor(role: string | null | undefined, isSelected: boolean): string {
if (role && ROLE_COLOR[role]) return ROLE_COLOR[role];
function iconColor(c: ThemePalette, role: string | null | undefined, isSelected: boolean): string {
if (role === 'trash') return c.textMuted;
if (role && ROLE_COLOR_FIXED[role]) return ROLE_COLOR_FIXED[role];
return isSelected ? c.text : c.textSecondary;
}
@@ -450,7 +450,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
return (
<SidebarRow
key={node.id}
icon={<Icon size={16} color={iconColor(node.role, isSelected)} />}
icon={<Icon size={16} color={iconColor(c, node.role, isSelected)} />}
label={node.name}
depth={node.depth}
isSelected={isSelected}
+1 -1
View File
@@ -174,6 +174,6 @@ function makeStyles(c: ThemePalette) {
paddingHorizontal: 16,
},
bandInner: { alignItems: 'center', gap: 4 },
bandLabel: { color: '#fff', fontWeight: '600', ...typography.caption },
bandLabel: { ...typography.caption, color: '#fff', fontWeight: '600' },
});
}
+3 -1
View File
@@ -1,5 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Download, X } from 'lucide-react-native';
import { useUpdatesStore } from '../stores/updates-store';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
@@ -8,6 +9,7 @@ import { useColors } from '../theme/colors';
export function UpdateBanner(): React.ReactElement | null {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
const dismissedTag = useUpdatesStore((s) => s.dismissedTag);
const installing = useUpdatesStore((s) => s.installing);
@@ -20,7 +22,7 @@ export function UpdateBanner(): React.ReactElement | null {
if (!cachedLatest?.apkAsset) return null;
return (
<View style={styles.banner}>
<View style={[styles.banner, { paddingTop: spacing.md + insets.top }]}>
<Download size={16} color={c.primaryForeground} />
<View style={{ flex: 1 }}>
<Text style={styles.title}>Update available</Text>
+2 -2
View File
@@ -238,7 +238,7 @@ export function EventDetailSheet({
style={[
styles.participantDot,
{
backgroundColor: statusColor(p.participationStatus),
backgroundColor: statusColor(c, p.participationStatus),
},
]}
/>
@@ -334,7 +334,7 @@ function ActionButton({
);
}
function statusColor(status?: string): string {
function statusColor(c: ThemePalette, status?: string): string {
switch (status) {
case 'accepted':
return c.success;
+3 -3
View File
@@ -58,7 +58,7 @@ function formatSize(b: number) {
return `${(b / (1024 * 1024)).toFixed(1)} MB`;
}
function getIcon(file: SampleFile, colored: boolean, sz: number) {
function getIcon(c: ThemePalette, file: SampleFile, colored: boolean, sz: number) {
if (file.isFolder) return <Folder size={sz} color={colored ? '#60a5fa' : c.mutedForeground} />;
const ext = file.name.split('.').pop()?.toLowerCase();
switch (ext) {
@@ -96,7 +96,7 @@ function FilesPreview({ prefs }: { prefs: FilesPrefs }) {
<View style={styles.gridWrap}>
{files.map((f) => (
<View key={f.name} style={[styles.gridItem, f.hidden && { opacity: 0.5 }]}>
{prefs.showIcons ? getIcon(f, prefs.coloredIcons, 24) : <View style={{ width: 24, height: 24 }} />}
{prefs.showIcons ? getIcon(c, f, prefs.coloredIcons, 24) : <View style={{ width: 24, height: 24 }} />}
<Text style={styles.gridName} numberOfLines={1}>{f.name}</Text>
</View>
))}
@@ -114,7 +114,7 @@ function FilesPreview({ prefs }: { prefs: FilesPrefs }) {
</View>
{files.map((f) => (
<View key={f.name} style={[styles.listRow, f.hidden && { opacity: 0.5 }]}>
{prefs.showIcons && getIcon(f, prefs.coloredIcons, 14)}
{prefs.showIcons && getIcon(c, f, prefs.coloredIcons, 14)}
<Text
style={[styles.listName, f.isFolder && { fontWeight: '500' }]}
numberOfLines={1}
+10 -8
View File
@@ -20,13 +20,15 @@ interface Plugin {
forceEnabled?: boolean;
}
const STATUS_STYLE: Record<Plugin['status'], { bg: string; fg: string }> = {
installed: { bg: c.muted, fg: c.mutedForeground },
enabled: { bg: 'rgba(59, 130, 246, 0.15)', fg: '#60a5fa' },
running: { bg: 'rgba(34, 197, 94, 0.15)', fg: '#4ade80' },
disabled: { bg: c.muted, fg: c.mutedForeground },
error: { bg: 'rgba(239, 68, 68, 0.15)', fg: '#f87171' },
};
function statusStyle(c: ThemePalette, status: Plugin['status']): { bg: string; fg: string } {
switch (status) {
case 'installed': return { bg: c.muted, fg: c.mutedForeground };
case 'enabled': return { bg: 'rgba(59, 130, 246, 0.15)', fg: '#60a5fa' };
case 'running': return { bg: 'rgba(34, 197, 94, 0.15)', fg: '#4ade80' };
case 'disabled': return { bg: c.muted, fg: c.mutedForeground };
case 'error': return { bg: 'rgba(239, 68, 68, 0.15)', fg: '#f87171' };
}
}
export function PluginsSettings() {
const c = useColors();
@@ -63,7 +65,7 @@ export function PluginsSettings() {
<View style={{ gap: spacing.sm }}>
{plugins.map((plugin) => {
const expanded = expandedId === plugin.id;
const status = STATUS_STYLE[plugin.status];
const status = statusStyle(c, plugin.status);
return (
<View
key={plugin.id}
+41 -37
View File
@@ -121,6 +121,8 @@ interface AppFormProps {
}
function AppForm({ initial, onSave, onCancel }: AppFormProps) {
const c = useColors();
const formStyles = React.useMemo(() => makeFormStyles(c), [c]);
const [name, setName] = useState(initial?.name ?? '');
const [url, setUrl] = useState(initial?.url ?? '');
const [openMode, setOpenMode] = useState<'tab' | 'inline'>(initial?.openMode ?? 'tab');
@@ -254,40 +256,42 @@ function makeStyles(c: ThemePalette) {
});
}
const formStyles = StyleSheet.create({
form: {
gap: spacing.md,
padding: spacing.lg,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.muted,
},
label: { ...typography.captionMedium, color: c.text },
input: {
marginTop: 4,
paddingHorizontal: spacing.md,
paddingVertical: 8,
borderRadius: radius.sm,
backgroundColor: c.background,
borderWidth: 1,
borderColor: c.border,
color: c.text,
...typography.body,
},
modeBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: spacing.md,
paddingVertical: 8,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
},
modeBtnActive: {
borderColor: c.primary,
backgroundColor: 'rgba(59, 130, 246, 0.1)',
},
modeBtnText: { ...typography.caption, color: c.text },
});
function makeFormStyles(c: ThemePalette) {
return StyleSheet.create({
form: {
gap: spacing.md,
padding: spacing.lg,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.muted,
},
label: { ...typography.captionMedium, color: c.text },
input: {
marginTop: 4,
paddingHorizontal: spacing.md,
paddingVertical: 8,
borderRadius: radius.sm,
backgroundColor: c.background,
borderWidth: 1,
borderColor: c.border,
color: c.text,
...typography.body,
},
modeBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: spacing.md,
paddingVertical: 8,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
},
modeBtnActive: {
borderColor: c.primary,
backgroundColor: 'rgba(59, 130, 246, 0.1)',
},
modeBtnText: { ...typography.caption, color: c.text },
});
}
+6 -6
View File
@@ -119,18 +119,18 @@ export function SmimeSettings() {
description="Public certificates used to encrypt outgoing mail."
>
<View style={{ gap: spacing.sm }}>
{certs.map((c) => {
const expired = isExpired(c.notAfter);
{certs.map((cert) => {
const expired = isExpired(cert.notAfter);
return (
<View key={c.id} style={styles.certRow}>
<View key={cert.id} style={styles.certRow}>
<View style={styles.certLeft}>
<View style={[styles.certIcon, styles.certIconMuted]}>
<Users size={16} color={c.mutedForeground} />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.certName} numberOfLines={1}>{c.email || c.subject}</Text>
<Text style={styles.certName} numberOfLines={1}>{cert.email || cert.subject}</Text>
<Text style={styles.certMeta}>
{c.issuer} · {c.source}
{cert.issuer} · {cert.source}
{expired && <Text style={styles.expiredText}> (Expired)</Text>}
</Text>
</View>
@@ -139,7 +139,7 @@ export function SmimeSettings() {
<Pressable style={styles.iconBtn}>
<Eye size={16} color={c.text} />
</Pressable>
<Pressable style={styles.iconBtn} onPress={() => deleteCert(c.id)}>
<Pressable style={styles.iconBtn} onPress={() => deleteCert(cert.id)}>
<Trash2 size={16} color={c.error} />
</Pressable>
</View>
+51 -15
View File
@@ -1,5 +1,5 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet, Linking, Alert } from 'react-native';
import { View, Text, StyleSheet, Linking } from 'react-native';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
@@ -30,9 +30,8 @@ function formatProgressLabel(p: InstallProgress): string {
function installButtonLabel(
installing: boolean,
progress: InstallProgress | null,
apkAsset: unknown,
): string {
if (!installing) return apkAsset ? 'Install' : 'Open release';
if (!installing) return 'Install';
if (progress?.phase === 'installing') return 'Installing…';
if (progress?.progress != null) return `Downloading ${Math.round(progress.progress * 100)}%`;
return 'Downloading…';
@@ -62,14 +61,17 @@ export function UpdatesSettings() {
const updateAvailable = hasUpdate();
const apkAsset = cachedLatest?.apkAsset ?? null;
const apkPending = updateAvailable && !apkAsset;
useEffect(() => {
if (!apkPending) return;
const id = setInterval(() => {
void checkNow({ force: true });
}, 30_000);
return () => clearInterval(id);
}, [apkPending, checkNow]);
const onInstall = () => {
if (!apkAsset) {
if (cachedLatest?.htmlUrl) {
void Linking.openURL(cachedLatest.htmlUrl);
}
return;
}
void installLatest();
};
@@ -99,19 +101,38 @@ export function UpdatesSettings() {
</SettingItem>
<SettingItem
label={updateAvailable ? 'Update available' : 'Check for updates'}
description={updateAvailable
? `Tap install to download v${cachedLatest?.tag} and open it with Android's package installer.`
: undefined}
label={
apkPending
? 'Update building'
: updateAvailable
? 'Update available'
: 'Check for updates'
}
description={
apkPending
? `v${cachedLatest?.tag} was published, but the APK is still being built. This page will check again every 30 seconds.`
: updateAvailable
? `Tap install to download v${cachedLatest?.tag} and open it with Android's package installer.`
: undefined
}
>
{updateAvailable ? (
{apkPending ? (
<Button
variant="outline"
size="sm"
onPress={() => void checkNow({ force: true })}
disabled={checking}
>
{checking ? 'Checking…' : 'Check again'}
</Button>
) : updateAvailable ? (
<Button
variant="default"
size="sm"
onPress={onInstall}
disabled={installing}
>
{installButtonLabel(installing, installProgress, apkAsset)}
{installButtonLabel(installing, installProgress)}
</Button>
) : (
<Button
@@ -125,6 +146,21 @@ export function UpdatesSettings() {
)}
</SettingItem>
{apkPending && cachedLatest?.htmlUrl ? (
<SettingItem
label="Release page"
description="Watch the build progress or download the APK manually."
>
<Button
variant="ghost"
size="sm"
onPress={() => void Linking.openURL(cachedLatest.htmlUrl)}
>
Open on GitHub
</Button>
</SettingItem>
) : null}
{installing && installProgress ? (
<View style={styles.progressBox}>
<View style={styles.progressTrack}>
+124
View File
@@ -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 &lt;a@b.com&gt; 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 &lt;b@c.com&gt;');
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('&lt;script&gt;x&lt;/script&gt;');
});
});
describe('htmlToPlainText', () => {
it('returns empty string for empty input', () => {
expect(htmlToPlainText('')).toBe('');
});
it('strips tags and decodes entities', () => {
expect(htmlToPlainText('<p>hello &amp; 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 &nbsp; to space', () => {
expect(htmlToPlainText('hello&nbsp;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']);
});
});
+134
View File
@@ -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(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/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) };
}
+54
View File
@@ -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;
+30 -1
View File
@@ -6,11 +6,40 @@ import { getDownloadUrl } from '../api/blob';
const RFC822 = 'message/rfc822';
function authHeader(): string {
const h = (jmapClient as { authHeader?: string }).authHeader;
const h = (jmapClient as unknown as { authHeader?: string }).authHeader;
if (!h) throw new Error('Not connected');
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() } });
+50 -3
View File
@@ -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
+616 -86
View File
@@ -1,19 +1,28 @@
import React from 'react';
import {
View, Text, StyleSheet, TextInput, Pressable, ScrollView,
KeyboardAvoidingView, Platform, ActivityIndicator, Alert,
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,
Link2, Image as ImageIcon, MoreHorizontal,
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';
import { isGroup } from '../lib/contact-utils';
import {
getContactDisplayName,
@@ -21,7 +30,10 @@ import {
matchesContactSearch,
} from '../lib/contact-utils';
import { getIdentities } from '../api/identity';
import { sendEmail } from '../api/email';
import { sendEmail, type OutgoingAttachment } from '../api/email';
import { uploadBlob } from '../api/blob';
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';
@@ -32,7 +44,21 @@ interface Recipient {
email: string;
}
type AttachmentEntry = {
localId: string;
name: string;
type: string;
size: number;
uri: string;
inline: boolean;
cid?: string;
blobId?: string;
uploading: boolean;
error?: string;
};
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const URL_RE = /^https?:\/\/.+/i;
function parseRecipients(input: string): Recipient[] {
return input
@@ -42,6 +68,31 @@ function parseRecipients(input: string): Recipient[] {
.map((email) => ({ name: '', email }));
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
function genCid(): string {
return `${Date.now().toString(36)}.${Math.random().toString(36).slice(2, 10)}@bulwark.local`;
}
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]);
@@ -93,11 +144,72 @@ function SuggestionList({
);
}
function AttachmentChip({
attachment, onRemove,
}: {
attachment: AttachmentEntry;
onRemove: () => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={styles.attachmentChip}>
{attachment.uploading ? (
<ActivityIndicator size="small" color={c.primary} />
) : attachment.error ? (
<X size={14} color={c.error} />
) : attachment.type.startsWith('image/') ? (
<ImageIcon size={14} color={c.textSecondary} />
) : (
<FileText size={14} color={c.textSecondary} />
)}
<View style={styles.attachmentMeta}>
<Text style={styles.attachmentName} numberOfLines={1}>
{attachment.name}
</Text>
<Text style={styles.attachmentSize}>
{attachment.error
? attachment.error
: attachment.uploading
? 'Uploading...'
: formatBytes(attachment.size)}
</Text>
</View>
<Pressable onPress={onRemove} hitSlop={8} style={styles.attachmentRemove}>
<X size={14} color={c.textMuted} />
</Pressable>
</View>
);
}
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(
@@ -119,7 +231,6 @@ export default function ComposeScreen({ route, navigation }: Props) {
return [];
}
if (mode === 'forward') return [];
// reply + replyAll both include the original sender
const base: Recipient[] = replyTo.from.email
? [{ name: replyTo.from.name ?? '', email: replyTo.from.email }]
: [];
@@ -149,14 +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 [bodyHtml, setBodyHtml] = React.useState(initialBodyHtml);
const [activeField, setActiveField] = React.useState<'to' | 'cc' | null>(null);
const [attachments, setAttachments] = React.useState<AttachmentEntry[]>([]);
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]);
@@ -216,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 = () => {
@@ -235,24 +372,253 @@ export default function ComposeScreen({ route, navigation }: Props) {
};
const { finalTo, finalCc } = commitTyped();
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 onClose = () => navigation.goBack();
const onClose = () => {
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.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' },
{
text: t('email_composer.discard', 'Discard'),
style: 'destructive',
onPress: () => navigation.goBack(),
},
],
);
return;
}
navigation.goBack();
};
// ── 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 (removed?.inline && removed.cid) {
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 addUploadEntry = (asset: {
name: string;
type: string;
size: number;
uri: string;
inline: boolean;
cid?: string;
}): AttachmentEntry => {
const localId = genLocalId();
const entry: AttachmentEntry = {
localId,
name: asset.name,
type: asset.type,
size: asset.size,
uri: asset.uri,
inline: asset.inline,
cid: asset.cid,
uploading: true,
};
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(entry.uri, entry.type);
updateAttachment(entry.localId, {
blobId,
type: type || entry.type,
size: size || entry.size,
uploading: false,
});
} catch (e) {
const message = e instanceof Error ? e.message : 'Upload failed';
updateAttachment(entry.localId, { uploading: false, error: message });
Alert.alert(
t('email_composer.upload_failed', 'Failed to upload {filename}').replace('{filename}', entry.name),
message,
);
}
};
const pickAttachment = 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;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images', 'videos'],
allowsMultipleSelection: true,
quality: 0.9,
exif: false,
});
if (result.canceled) return;
for (const asset of result.assets) {
const fallbackName = asset.fileName
|| `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: false,
});
void startUpload(entry);
}
};
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;
}
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 bodyWithQuote = replyTo?.body
? `${body}\n\n---\nOn ${new Date().toLocaleDateString()}, ${replyTo.from.name || replyTo.from.email} wrote:\n${replyTo.body.split('\n').map((l) => `> ${l}`).join('\n')}`
: body;
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 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,
}));
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: 'attachment',
}));
const outgoing = [...inlineFromBody, ...fileAttachments];
await sendEmail(
{
@@ -262,7 +628,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
? finalCc.map((r) => ({ name: r.name || undefined, email: r.email }))
: undefined,
subject,
textBody: bodyWithQuote,
htmlBody: finalHtml,
textBody: finalText,
attachments: outgoing.length ? outgoing : undefined,
inReplyTo: replyTo?.inReplyTo,
references: replyTo?.references,
},
@@ -271,12 +639,21 @@ export default function ComposeScreen({ route, navigation }: Props) {
);
navigation.goBack();
} catch (e) {
Alert.alert('Send failed', e instanceof Error ? e.message : 'Failed to send email');
Alert.alert(
t('email_composer.send_failed', 'Send failed'),
e instanceof Error ? e.message : 'Failed to send email',
);
} finally {
setSending(false);
}
};
const titleKey =
mode === 'forward' ? 'email_composer.forward'
: mode === 'replyAll' ? 'email_composer.reply_all'
: replyTo ? 'email_composer.reply'
: 'email_composer.new_message';
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}>
@@ -284,10 +661,14 @@ export default function ComposeScreen({ route, navigation }: Props) {
<X size={22} color={c.text} />
</Pressable>
<Text style={styles.headerTitle}>
{mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply All' : replyTo ? 'Reply' : 'New Message'}
{t(titleKey, mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply All' : replyTo ? 'Reply' : 'New Message')}
</Text>
<View style={styles.headerRight}>
<Pressable style={styles.headerBtn}>
<Pressable
onPress={() => { void pickAttachment(); }}
style={styles.headerBtn}
hitSlop={8}
>
<Paperclip size={20} color={c.text} />
</Pressable>
<Button
@@ -307,7 +688,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
}
style={!canSend ? styles.sendButtonDisabled : undefined}
>
{sending ? 'Sending...' : 'Send'}
{sending ? t('email_composer.sending', 'Sending...') : t('email_composer.send', 'Send')}
</Button>
</View>
</View>
@@ -316,9 +697,13 @@ 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}>From</Text>
<Text style={styles.fieldLabel}>{t('email_composer.from', 'From')}</Text>
<View style={styles.fieldContent}>
<Text style={styles.fromText} numberOfLines={1}>
{primaryIdentity?.email ?? (identityError ? 'identity unavailable' : 'Loading...')}
@@ -328,7 +713,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
</View>
<View style={styles.fieldRow}>
<Text style={styles.fieldLabel}>To</Text>
<Text style={styles.fieldLabel}>{t('email_composer.to', 'To')}</Text>
<View style={styles.recipientField}>
{toRecipients.map((r, i) => (
<RecipientChip
@@ -339,7 +724,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
))}
<TextInput
style={styles.recipientInput}
placeholder="Add recipient..."
placeholder={t('email_composer.to_placeholder', 'Recipient email addresses')}
placeholderTextColor={c.textMuted}
value={toInput}
onChangeText={setToInput}
@@ -359,14 +744,14 @@ export default function ComposeScreen({ route, navigation }: Props) {
)}
{!ccVisible && (
<Pressable onPress={() => setCcVisible(true)} style={styles.ccToggle}>
<Text style={styles.ccToggleText}>Cc</Text>
<Text style={styles.ccToggleText}>{t('email_composer.cc', 'Cc')}</Text>
</Pressable>
)}
</View>
{ccVisible && (
<View style={styles.fieldRow}>
<Text style={styles.fieldLabel}>Cc</Text>
<Text style={styles.fieldLabel}>{t('email_composer.cc', 'Cc')}</Text>
<View style={styles.recipientField}>
{ccRecipients.map((r, i) => (
<RecipientChip
@@ -377,7 +762,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
))}
<TextInput
style={styles.recipientInput}
placeholder="Add Cc..."
placeholder={t('email_composer.cc_placeholder', 'Cc recipients')}
placeholderTextColor={c.textMuted}
value={ccInput}
onChangeText={setCcInput}
@@ -399,64 +784,147 @@ export default function ComposeScreen({ route, navigation }: Props) {
)}
<View style={styles.fieldRow}>
<Text style={styles.fieldLabel}>Subject</Text>
<Text style={styles.fieldLabel}>{t('email_composer.subject', 'Subject')}</Text>
<TextInput
style={styles.subjectInput}
placeholder="Email subject"
placeholder={t('email_composer.subject_placeholder', 'Subject')}
placeholderTextColor={c.textMuted}
value={subject}
onChangeText={setSubject}
/>
</View>
<TextInput
style={styles.bodyInput}
placeholder="Write your message..."
placeholderTextColor={c.textMuted}
value={body}
onChangeText={setBody}
multiline
textAlignVertical="top"
/>
{replyTo?.body && (
<View style={styles.quoteBlock}>
<View style={styles.quoteBorder} />
<View style={styles.quoteContent}>
<Text style={styles.quoteMeta}>
On {new Date().toLocaleDateString()}, {replyTo.from.name || replyTo.from.email} wrote:
</Text>
<Text style={styles.quoteText} numberOfLines={6}>
{replyTo.body}
</Text>
</View>
{attachments.length > 0 && (
<View style={styles.attachmentList}>
{attachments.map((a) => (
<AttachmentChip
key={a.localId}
attachment={a}
onRemove={() => removeAttachment(a.localId)}
/>
))}
</View>
)}
<RichTextEditor
ref={editorRef}
initialHtml={initialBodyHtml}
placeholder={t('email_composer.body_placeholder', 'Write your message...')}
onChange={setBodyHtml}
onSelectionChange={setSelState}
/>
</ScrollView>
<View style={styles.formatBar}>
<View style={styles.formatActions}>
<Pressable style={styles.formatBtn}>
<Bold size={18} color={c.textSecondary} />
</Pressable>
<Pressable style={styles.formatBtn}>
<Italic size={18} color={c.textSecondary} />
</Pressable>
<Pressable style={styles.formatBtn}>
<List size={18} color={c.textSecondary} />
</Pressable>
<Pressable style={styles.formatBtn}>
<Link2 size={18} color={c.textSecondary} />
</Pressable>
<Pressable style={styles.formatBtn}>
<ImageIcon size={18} color={c.textSecondary} />
</Pressable>
<Pressable style={styles.formatBtn}>
<MoreHorizontal size={18} color={c.textSecondary} />
</Pressable>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.formatBar}
contentContainerStyle={styles.formatActions}
keyboardShouldPersistTaps="always"
>
<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} />
<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} />
<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>
</KeyboardAvoidingView>
</Modal>
</SafeAreaView>
);
}
@@ -465,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',
@@ -576,36 +1045,39 @@ function makeStyles(c: ThemePalette) {
paddingVertical: 10,
},
bodyInput: {
...typography.body,
color: c.text,
attachmentList: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
minHeight: 200,
lineHeight: 22,
paddingVertical: spacing.sm,
gap: spacing.xs,
borderBottomWidth: 1,
borderBottomColor: c.borderLight,
},
quoteBlock: {
attachmentChip: {
flexDirection: 'row',
paddingHorizontal: spacing.lg,
marginTop: spacing.md,
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: radius.sm,
backgroundColor: c.surfaceActive,
},
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 },
attachmentMeta: { flex: 1, minWidth: 0 },
attachmentName: { ...typography.bodyMedium, color: c.text },
attachmentSize: { ...typography.caption, color: c.textMuted },
attachmentRemove: { padding: 4 },
formatBar: {
borderTopWidth: 1,
borderTopColor: c.border,
backgroundColor: c.surface,
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
flexGrow: 0,
},
formatActions: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
},
formatBtn: {
width: componentSizes.buttonMd,
@@ -614,5 +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 },
});
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -129,7 +129,11 @@ const EmailRow = React.memo(function EmailRow({
);
});
const EmailRowSeparator = () => <View style={styles.separator} />;
function EmailRowSeparator() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return <View style={styles.separator} />;
}
const emailKeyExtractor = (item: Email) => item.id;
@@ -239,7 +243,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
else void markUnread(id);
break;
case 'star':
void toggleStar(id);
void toggleStar(id, !isStarred(email));
break;
}
}, [
@@ -820,6 +824,8 @@ function TriToggle({
value: boolean | undefined;
onPress: () => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const state =
value === true ? 'on' : value === false ? 'off' : 'unset';
return (
+82 -12
View File
@@ -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';
@@ -78,11 +78,11 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
const currentIndex = emails.findIndex((e) => e.id === emailId);
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 }) => {
const goToEmail = (target: { id: string; threadId: string; subject?: string }) => {
navigation.replace('EmailThread', {
emailId: target.id,
threadId: target.threadId,
subject: target.subject,
subject: target.subject ?? '',
});
};
@@ -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: {
+5 -1
View File
@@ -7,6 +7,7 @@ import { downloadAndInstallApk, type InstallProgress } from '../lib/install-upda
const STORAGE_KEY = 'webmail:updates:v1';
const MIN_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
const PENDING_APK_INTERVAL_MS = 2 * 60 * 1000;
interface PersistedUpdates {
autoCheck: boolean;
@@ -82,7 +83,10 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
const s = get();
if (s.checking) return;
const now = Date.now();
if (!opts?.force && now - s.lastCheckedAt < MIN_CHECK_INTERVAL_MS) return;
const waitingForApk =
s.cachedLatest != null && !s.cachedLatest.apkAsset && get().hasUpdate();
const interval = waitingForApk ? PENDING_APK_INTERVAL_MS : MIN_CHECK_INTERVAL_MS;
if (!opts?.force && now - s.lastCheckedAt < interval) return;
set({ checking: true, error: null });
try {
const latest = await fetchLatestRelease();